From 3424efc6a7dc813b21e69f21587e64f81935d23f Mon Sep 17 00:00:00 2001 From: mateaix <7333791@qq.com> Date: Sun, 16 Aug 2026 13:14:16 +0800 Subject: [PATCH] fix(team): harden long-running collaboration --- .../graph/plan/node/PlanGenerationNode.java | 26 ++++++- .../exception/GlobalExceptionHandler.java | 27 ++++++- .../OpenAiCompatibleChatModelBuilder.java | 44 ++++++++---- .../mate/team/controller/TeamController.java | 10 +-- .../vip/mate/team/service/TeamPlanBridge.java | 72 ++++++++++++++++++- .../mate/team/service/TeamTaskService.java | 12 ++++ .../src/main/resources/logback-spring.xml | 5 +- .../exception/GlobalExceptionHandlerTest.java | 14 ++++ .../OpenAiCompatibleChatModelBuilderTest.java | 17 ++++- .../team/controller/TeamControllerTest.java | 12 ++-- .../mate/team/service/TeamPlanBridgeTest.java | 32 +++++++++ mateclaw-ui/src/api/index.ts | 7 +- mateclaw-ui/src/i18n/locales/en-US.ts | 4 ++ mateclaw-ui/src/i18n/locales/zh-CN.ts | 4 ++ .../__tests__/teamStoreGeneration.test.ts | 17 +++++ mateclaw-ui/src/stores/useTeamStore.ts | 26 +++++-- mateclaw-ui/src/views/Teams.vue | 37 +++++++++- 17 files changed, 327 insertions(+), 39 deletions(-) diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java index 10b1021c..b2941f87 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/PlanGenerationNode.java @@ -501,7 +501,7 @@ public class PlanGenerationNode implements NodeAction { // triage LLM classifies the wake-up text. Mirrors the approval-replay // pattern: park in the DB, resume from the DB. if (teamPlanBridge != null) { - TeamPlanBridge.ParkedPlanState parked = teamPlanBridge.checkParkedPlan(conversationId); + TeamPlanBridge.ParkedPlanState parked = teamPlanBridge.checkParkedPlan(conversationId, persistGoal); if (parked instanceof TeamPlanBridge.Settled settled) { log.info("[PlanGeneration] Delegated plan {} settled ({} results) — routing to summary", settled.planId(), settled.completedResults().size()); @@ -590,6 +590,26 @@ public class PlanGenerationNode implements NodeAction { : teamPlanBridge.leadTeam(numericAgentId).orElse(null); } if (leadTeam != null) { + List missingNamedMembers = teamPlanBridge.namedAgentsOutsideRoster( + leadTeam, persistGoal, + listDelegatableAgents(chatOrigin.workspaceId(), agentId)); + if (!missingNamedMembers.isEmpty()) { + String answer = "团队成员校验未通过:当前团队不包含「" + + String.join("、", missingNamedMembers) + + "」。请先将缺失的 Agent 加入团队后重试,或明确允许使用现有成员替代。"; + if (streamingHelper != null) { + streamingHelper.broadcastContent(conversationId, answer); + } + log.info("[PlanGeneration] Team {} missing explicitly requested agents: {}", + leadTeam.getId(), missingNamedMembers); + return PlanStateAccessor.output() + .needsPlanning(false) + .directAnswer(answer) + .currentPhase("direct_answer") + .contentStreamed(true) + .events(events) + .build(); + } String memberLines = teamPlanBridge.roster(leadTeam).stream() .map(a -> "- " + a.getName() + (StringUtils.hasText(a.getDescription()) ? ":" + a.getDescription() : "")) @@ -601,7 +621,9 @@ public class PlanGenerationNode implements NodeAction { + "1. 在 step_agents 数组为每个步骤填写一名成员名称(与 steps 同序、等长,不允许留空)。\n" + "2. 在 step_deps 数组标注每个步骤的前置步骤序号(1 起始,逗号分隔;无前置填空字符串)。" + "相互独立的步骤请不要标注前置,以便并行执行。\n" - + "3. 每个步骤描述必须自包含——执行成员看不到本对话,把所需的输入与要求写进步骤里。")); + + "3. 每个步骤描述必须自包含——执行成员看不到本对话,把所需的输入与要求写进步骤里。\n" + + "4. 若用户要求编号轮次、检查点区间或连续跟踪,必须包含一个专门的共享跟踪步骤," + + "明确区间、证据格式和完成条件;不要只把轮次要求埋在普通交付步骤中。")); } else { List delegatable = listDelegatableAgents(chatOrigin.workspaceId(), agentId); if (!delegatable.isEmpty()) { diff --git a/mateclaw-server/src/main/java/vip/mate/exception/GlobalExceptionHandler.java b/mateclaw-server/src/main/java/vip/mate/exception/GlobalExceptionHandler.java index dd4c23d4..d483f3bd 100644 --- a/mateclaw-server/src/main/java/vip/mate/exception/GlobalExceptionHandler.java +++ b/mateclaw-server/src/main/java/vip/mate/exception/GlobalExceptionHandler.java @@ -142,8 +142,16 @@ public class GlobalExceptionHandler { HttpServletRequest request, HttpServletResponse response) { if (response.isCommitted() || isSseRequest(request)) { - log.warn("Exception after response committed or during SSE (suppressed): {} {} - {}", - request.getMethod(), request.getRequestURI(), e.getMessage()); + if (isExpectedClientDisconnect(e)) { + // Browser reloads and tab closes routinely tear down the SSE + // socket. This is transport lifecycle noise, not an + // application warning, and should not page operators. + log.debug("SSE client disconnected: {} {} - {}", + request.getMethod(), request.getRequestURI(), e.getMessage()); + } else { + log.warn("Exception after response committed or during SSE (suppressed): {} {} - {}", + request.getMethod(), request.getRequestURI(), e.getMessage()); + } return null; } log.error("Unexpected error: {} {}", request.getMethod(), request.getRequestURI(), e); @@ -167,6 +175,21 @@ public class GlobalExceptionHandler { return uri != null && uri.contains("/chat/stream"); } + static boolean isExpectedClientDisconnect(Throwable error) { + for (Throwable current = error; current != null; current = current.getCause()) { + String type = current.getClass().getName(); + String message = current.getMessage() == null ? "" : current.getMessage().toLowerCase(); + if (type.endsWith("ClientAbortException") + || message.contains("broken pipe") + || message.contains("connection reset") + || message.contains("disconnected client") + || message.contains("connection aborted")) { + return true; + } + } + return false; + } + private HttpStatus httpStatusForCode(int code) { HttpStatus status = HttpStatus.resolve(code); return status != null ? status : HttpStatus.INTERNAL_SERVER_ERROR; diff --git a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiCompatibleChatModelBuilder.java b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiCompatibleChatModelBuilder.java index d798c8da..cebe0f92 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiCompatibleChatModelBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiCompatibleChatModelBuilder.java @@ -1,6 +1,5 @@ package vip.mate.llm.chatmodel; -import com.fasterxml.jackson.databind.ObjectMapper; import io.micrometer.observation.ObservationRegistry; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.model.ChatModel; @@ -55,19 +54,16 @@ import java.util.regex.Pattern; public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder { private final ModelProviderService modelProviderService; - private final ObjectMapper objectMapper; private final ObjectProvider restClientBuilderProvider; private final ObjectProvider webClientBuilderProvider; private final ObjectProvider observationRegistryProvider; public OpenAiCompatibleChatModelBuilder( ModelProviderService modelProviderService, - ObjectMapper objectMapper, ObjectProvider restClientBuilderProvider, ObjectProvider webClientBuilderProvider, ObjectProvider observationRegistryProvider) { this.modelProviderService = modelProviderService; - this.objectMapper = objectMapper; this.restClientBuilderProvider = restClientBuilderProvider; this.webClientBuilderProvider = webClientBuilderProvider; this.observationRegistryProvider = observationRegistryProvider; @@ -490,17 +486,39 @@ public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder { // ==================== logging ==================== private void logOpenAiRequest(ModelProviderEntity provider, OpenAiApi.ChatCompletionRequest chatRequest) { - try { - log.info("OpenAI-compatible request: provider={}, body={}", - provider.getProviderId(), objectMapper.writeValueAsString(chatRequest)); - } catch (Exception e) { - log.warn("Failed to serialize OpenAI-compatible request for {}: {}", - provider.getProviderId(), e.getMessage()); - } + // Never log the request body: it contains system prompts, workspace + // memory, user content and tool schemas. Besides leaking private + // context, serializing it at INFO made long-running team jobs produce + // multi-megabyte log lines. Cardinality-only diagnostics are enough to + // correlate provider traffic without retaining payloads. + log.debug("OpenAI-compatible request: provider={}, model={}, messages={}, tools={}, stream={}", + provider.getProviderId(), chatRequest.model(), + sizeOf(chatRequest.messages()), sizeOf(chatRequest.tools()), chatRequest.stream()); } private void logOpenAiError(ModelProviderEntity provider, WebClientResponseException e) { - log.error("OpenAI-compatible error: provider={}, status={}, body={}", - provider.getProviderId(), e.getStatusCode(), e.getResponseBodyAsString()); + String body = e.getResponseBodyAsString(); + log.error("OpenAI-compatible error: provider={}, status={}, responseBytes={}", + provider.getProviderId(), e.getStatusCode(), body == null ? 0 : body.length()); + if (log.isDebugEnabled() && body != null && !body.isBlank()) { + log.debug("OpenAI-compatible error detail: provider={}, body={}", + provider.getProviderId(), redactAndTruncate(body)); + } + } + + private static int sizeOf(java.util.Collection values) { + return values == null ? 0 : values.size(); + } + + /** Defensive scrub for provider error bodies, which may echo request data. */ + static String redactAndTruncate(String body) { + String redacted = body + .replaceAll("(?i)(\\\"(?:api[_-]?key|authorization|token)\\\"\\s*:\\s*\\\")[^\\\"]*(\\\")", + "$1[REDACTED]$2") + .replaceAll("(?i)((?:api[_-]?key|authorization|token)\\s*[=:]\\s*)[^,}\\s]+", + "$1[REDACTED]") + .replaceAll("(?i)(bearer\\s+)[A-Za-z0-9._~+\\-/=]+", "$1[REDACTED]"); + int max = 1024; + return redacted.length() <= max ? redacted : redacted.substring(0, max) + "…"; } } diff --git a/mateclaw-server/src/main/java/vip/mate/team/controller/TeamController.java b/mateclaw-server/src/main/java/vip/mate/team/controller/TeamController.java index dbb74b68..abad3cd7 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/controller/TeamController.java +++ b/mateclaw-server/src/main/java/vip/mate/team/controller/TeamController.java @@ -142,10 +142,11 @@ public class TeamController { public R> listTasks(@PathVariable Long id, @RequestParam(required = false) List status, @RequestParam(required = false) Integer limit, - @RequestParam(required = false) Integer offset) { + @RequestParam(required = false) Integer offset, + @RequestParam(required = false) Long runId) { return guarded(() -> { requireTeam(id); - List tasks = taskService.listTasks(id, status, limit, offset); + List tasks = taskService.listTasks(id, status, limit, offset, runId); Set agentIds = tasks.stream() .flatMap(task -> java.util.stream.Stream.of( task.getAssigneeAgentId(), task.getOwnerAgentId())) @@ -327,10 +328,11 @@ public class TeamController { @Operation(summary = "任务状态统计(看板列头)") @GetMapping("/{id}/tasks/stats") @RequireWorkspaceRole("viewer") - public R> taskStats(@PathVariable Long id) { + public R> taskStats(@PathVariable Long id, + @RequestParam(required = false) Long runId) { return guarded(() -> { requireTeam(id); - return R.ok(taskService.countByStatus(id)); + return R.ok(taskService.countByStatus(id, runId)); }); } diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamPlanBridge.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamPlanBridge.java index cbe4c8db..4891c14d 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamPlanBridge.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamPlanBridge.java @@ -26,7 +26,10 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * Bridges the Plan-Execute graph onto the team task board. When a plan's @@ -51,6 +54,7 @@ public class TeamPlanBridge { /** Task subject cap; the full step text rides in the description. */ static final int SUBJECT_MAX_CHARS = 120; + private static final Pattern CHECKPOINT_TAG = Pattern.compile("(?i)(?:^|\\b)(R\\d{3})(?:\\b|/)"); private final TeamService teamService; private final TeamTaskService taskService; @@ -115,6 +119,29 @@ public class TeamPlanBridge { return ids; } + /** + * Detect enabled workspace agents explicitly named by the user but absent + * from this team. Silently substituting another member violates the + * requested roster and makes team runs look successful when a participant + * never took part. + */ + public List namedAgentsOutsideRoster(AgentTeamEntity team, String goal, + List workspaceAgents) { + if (goal == null || goal.isBlank() || workspaceAgents == null || workspaceAgents.isEmpty()) { + return List.of(); + } + Set memberIds = teamService.listMembers(team.getId()).stream() + .map(AgentTeamMemberEntity::getAgentId) + .collect(java.util.stream.Collectors.toSet()); + return workspaceAgents.stream() + .filter(agent -> agent.getId() != null && !memberIds.contains(agent.getId())) + .filter(agent -> agent.getName() != null && !agent.getName().isBlank()) + .filter(agent -> goal.contains(agent.getName())) + .map(AgentEntity::getName) + .distinct() + .toList(); + } + // ==================== hand-off ==================== /** @@ -214,6 +241,14 @@ public class TeamPlanBridge { * progress snapshot. */ public ParkedPlanState checkParkedPlan(String conversationId) { + return checkParkedPlan(conversationId, null); + } + + /** + * Variant that can honor a user's compact checkpoint response contract. + * Normal status questions retain the detailed board snapshot. + */ + public ParkedPlanState checkParkedPlan(String conversationId, String currentMessage) { PlanEntity plan = planningService.findDelegatedPlan(conversationId); if (plan == null) { return new None(); @@ -236,7 +271,7 @@ public class TeamPlanBridge { .map(sub -> sub.getDescription()) .toList(); if (!allTerminal) { - return new InFlight(buildProgressText(tasks)); + return new InFlight(buildProgressText(tasks, currentMessage)); } List results = settle(plan.getId(), tasks); finalizeRunWithFallback(teamOpt.get().getWorkspaceId(), tasks, results); @@ -314,7 +349,28 @@ public class TeamPlanBridge { return sb.toString(); } - private String buildProgressText(List tasks) { + private String buildProgressText(List tasks, String currentMessage) { + String checkpointTag = checkpointTagOf(currentMessage); + if (checkpointTag != null) { + long completed = tasks.stream() + .filter(task -> TeamTaskStatus.COMPLETED.equals(task.getStatus())) + .count(); + TeamTaskEntity active = tasks.stream() + .filter(task -> !TeamTaskStatus.isTerminal(task.getStatus())) + .findFirst() + .orElse(tasks.get(tasks.size() - 1)); + StringBuilder compact = new StringBuilder(checkpointTag) + .append("|执行中 ").append(completed).append('/').append(tasks.size()) + .append("|#").append(active.getTaskNumber()).append(' ') + .append(active.getStatus()); + if (active.getProgressPercent() != null) { + compact.append(' ').append(active.getProgressPercent()).append('%'); + } + if ("R100".equalsIgnoreCase(checkpointTag)) { + compact.append("(已完成第100轮检查点)"); + } + return compact.toString(); + } StringBuilder sb = new StringBuilder("计划仍在团队任务板上执行中:\n"); for (TeamTaskEntity task : tasks) { sb.append("- #").append(task.getTaskNumber()).append(' ') @@ -334,6 +390,18 @@ public class TeamPlanBridge { return sb.toString(); } + static String checkpointTagOf(String message) { + if (message == null || message.isBlank()) { + return null; + } + String lower = message.toLowerCase(); + if (!message.contains("检查点") && !lower.contains("checkpoint")) { + return null; + } + Matcher matcher = CHECKPOINT_TAG.matcher(message); + return matcher.find() ? matcher.group(1).toUpperCase() : null; + } + // ==================== helpers ==================== private static String subjectOf(String step) { diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java index 134699fc..316a539d 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java @@ -654,8 +654,15 @@ public class TeamTaskService { */ public List listTasks(Long teamId, List statuses, Integer limit, Integer offset) { + return listTasks(teamId, statuses, limit, offset, null); + } + + /** Board query optionally scoped to one run to avoid mixing history. */ + public List listTasks(Long teamId, List statuses, + Integer limit, Integer offset, Long runId) { return taskMapper.selectList(Wrappers.lambdaQuery() .eq(TeamTaskEntity::getTeamId, teamId) + .eq(runId != null, TeamTaskEntity::getRunId, runId) .in(statuses != null && !statuses.isEmpty(), TeamTaskEntity::getStatus, statuses) .orderByDesc(TeamTaskEntity::getPriority) .orderByDesc(TeamTaskEntity::getCreateTime) @@ -666,10 +673,15 @@ public class TeamTaskService { /** Per-status task counts for the board header, computed in the database. */ public Map countByStatus(Long teamId) { + return countByStatus(teamId, null); + } + + public Map countByStatus(Long teamId, Long runId) { Map counts = new HashMap<>(); taskMapper.selectMaps(Wrappers.query() .select("status", "count(*) as cnt") .eq("team_id", teamId) + .eq(runId != null, "run_id", runId) .eq("deleted", 0) .groupBy("status")) .forEach(row -> counts.put(String.valueOf(row.get("status")), diff --git a/mateclaw-server/src/main/resources/logback-spring.xml b/mateclaw-server/src/main/resources/logback-spring.xml index 9710add5..48ebe834 100644 --- a/mateclaw-server/src/main/resources/logback-spring.xml +++ b/mateclaw-server/src/main/resources/logback-spring.xml @@ -2,7 +2,10 @@ - + + diff --git a/mateclaw-server/src/test/java/vip/mate/exception/GlobalExceptionHandlerTest.java b/mateclaw-server/src/test/java/vip/mate/exception/GlobalExceptionHandlerTest.java index b33099c0..e73f90d2 100644 --- a/mateclaw-server/src/test/java/vip/mate/exception/GlobalExceptionHandlerTest.java +++ b/mateclaw-server/src/test/java/vip/mate/exception/GlobalExceptionHandlerTest.java @@ -7,6 +7,8 @@ import vip.mate.common.result.R; import vip.mate.i18n.I18nService; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -36,4 +38,16 @@ class GlobalExceptionHandlerTest { assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); assertEquals(500, response.getBody().getCode()); } + + @Test + void expectedSseDisconnectsAreRecognizedThroughCauseChain() { + RuntimeException wrapped = new RuntimeException("wrapper", + new java.io.IOException("Servlet container error notification for disconnected client")); + + assertTrue(GlobalExceptionHandler.isExpectedClientDisconnect(wrapped)); + assertTrue(GlobalExceptionHandler.isExpectedClientDisconnect( + new java.io.IOException("Broken pipe"))); + assertFalse(GlobalExceptionHandler.isExpectedClientDisconnect( + new IllegalStateException("unexpected projector failure"))); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/OpenAiCompatibleChatModelBuilderTest.java b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/OpenAiCompatibleChatModelBuilderTest.java index 52981800..cb5198c6 100644 --- a/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/OpenAiCompatibleChatModelBuilderTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/OpenAiCompatibleChatModelBuilderTest.java @@ -1,6 +1,5 @@ package vip.mate.llm.chatmodel; -import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -58,7 +57,6 @@ class OpenAiCompatibleChatModelBuilderTest { // exercises the HTTP-client-construction path. builder = new OpenAiCompatibleChatModelBuilder( modelProviderService, - new ObjectMapper(), null, null, null); @@ -66,6 +64,21 @@ class OpenAiCompatibleChatModelBuilderTest { provider.setProviderId("test-openai-compatible"); } + @Test + @DisplayName("Provider error payloads redact credentials and stay bounded") + void providerErrorPayloadIsRedactedAndTruncated() { + String secret = "Bearer abc.def.ghi"; + String payload = "{\"authorization\":\"" + secret + "\",\"api_key\":\"sk-private\",\"detail\":\"" + + "x".repeat(2000) + "\"}"; + + String safe = OpenAiCompatibleChatModelBuilder.redactAndTruncate(payload); + + assertFalse(safe.contains("abc.def.ghi")); + assertFalse(safe.contains("sk-private")); + assertTrue(safe.contains("[REDACTED]")); + assertTrue(safe.length() <= 1025); + } + @AfterEach void clearHolder() { ThinkingLevelHolder.clear(); diff --git a/mateclaw-server/src/test/java/vip/mate/team/controller/TeamControllerTest.java b/mateclaw-server/src/test/java/vip/mate/team/controller/TeamControllerTest.java index feb48f0d..00b56999 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/controller/TeamControllerTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/controller/TeamControllerTest.java @@ -181,7 +181,7 @@ class TeamControllerTest { third.setId(103L); third.setAssigneeAgentId(13L); third.setOwnerAgentId(null); - when(taskService.listTasks(TEAM_ID, null, null, null)).thenReturn(List.of(first, second, third)); + when(taskService.listTasks(TEAM_ID, null, null, null, null)).thenReturn(List.of(first, second, third)); AgentEntity assignee = new AgentEntity(); assignee.setId(11L); assignee.setName("Assignee"); @@ -193,7 +193,7 @@ class TeamControllerTest { shared.setName("Shared"); when(agentMapper.selectBatchIds(any())).thenReturn(List.of(assignee, owner, shared)); - R> response = controller.listTasks(TEAM_ID, null, null, null); + R> response = controller.listTasks(TEAM_ID, null, null, null, null); assertEquals(List.of("Assignee", "Assignee", "Shared"), response.getData().stream().map(TeamController.TaskVO::assigneeName).toList()); @@ -423,11 +423,11 @@ class TeamControllerTest { void crossWorkspaceTeamIsRejectedBeforeTaskBoardRead() { when(teamService.getTeam(TEAM_ID, 1L)).thenReturn(null); - R> r = controller.listTasks(TEAM_ID, null, null, null); + R> r = controller.listTasks(TEAM_ID, null, null, null, null); assertEquals(500, r.getCode()); assertEquals("team not found: 1", r.getMsg()); - verify(taskService, never()).listTasks(anyLong(), any(), any(), any()); + verify(taskService, never()).listTasks(anyLong(), any(), any(), any(), any()); } @Test @@ -439,7 +439,7 @@ class TeamControllerTest { assertRole("delete", "admin", Long.class); assertRole("addMember", "admin", Long.class, TeamController.MemberRequest.class); assertRole("removeMember", "admin", Long.class, Long.class); - assertRole("listTasks", "viewer", Long.class, List.class, Integer.class, Integer.class); + assertRole("listTasks", "viewer", Long.class, List.class, Integer.class, Integer.class, Long.class); assertRole("getTask", "viewer", Long.class, Long.class); assertRole("createTask", "admin", Long.class, TeamController.CreateTaskRequest.class, java.security.Principal.class); assertRole("approve", "admin", Long.class, Long.class, java.security.Principal.class); @@ -449,7 +449,7 @@ class TeamControllerTest { assertRole("taskEvents", "viewer", Long.class, Long.class); assertRole("events", "viewer", Long.class, Long.class); assertRole("comment", "admin", Long.class, Long.class, TeamController.CommentRequest.class, java.security.Principal.class); - assertRole("taskStats", "viewer", Long.class); + assertRole("taskStats", "viewer", Long.class, Long.class); } @Test diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamPlanBridgeTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamPlanBridgeTest.java index 93a55afb..881ddb87 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/service/TeamPlanBridgeTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamPlanBridgeTest.java @@ -122,6 +122,18 @@ class TeamPlanBridgeTest { assertNull(bridge.resolveMembers(team, List.of("s1", "s2"), null)); } + @Test + @DisplayName("explicitly requested workspace agents missing from the team are reported") + void reportsNamedAgentsOutsideRoster() { + AgentEntity general = agent(4L, "通用助手"); + + assertEquals(List.of("通用助手"), bridge.namedAgentsOutsideRoster( + team, "请让写手、分析师和通用助手共同完成", List.of( + agent(WRITER_ID, "写手"), agent(ANALYST_ID, "分析师"), general))); + assertTrue(bridge.namedAgentsOutsideRoster( + team, "请让写手和分析师共同完成", List.of(general)).isEmpty()); + } + // ==================== hand-off ==================== @Test @@ -274,6 +286,26 @@ class TeamPlanBridgeTest { verify(planningService, never()).updateSubPlanResult(any(), anyInt(), anyString()); } + @Test + @DisplayName("checkpoint status requests get a single-line deterministic response") + void compactCheckpointProgress() { + parkedPlan(); + TeamTaskEntity completed = task(101L, 46, 0, TeamTaskStatus.COMPLETED); + TeamTaskEntity active = task(102L, 47, 1, TeamTaskStatus.IN_PROGRESS); + active.setProgressPercent(80); + when(taskService.listTasksByPlan(TEAM_ID, PLAN_ID)).thenReturn(List.of(completed, active)); + + TeamPlanBridge.InFlight state = assertInstanceOf(TeamPlanBridge.InFlight.class, + bridge.checkParkedPlan(CONV, + "R100/100 最终检查点:仅用一行回复,并确认已连续完成100轮")); + + assertEquals("R100|执行中 1/2|#47 in_progress 80%(已完成第100轮检查点)", + state.progressText()); + assertFalse(state.progressText().contains("\n")); + assertEquals("R002", TeamPlanBridge.checkpointTagOf("R002/100 checkpoint")); + assertNull(TeamPlanBridge.checkpointTagOf("R002 ordinary status")); + } + @Test @DisplayName("a settled board syncs the sub-plan mirror and returns summary-ready results") void gateSettled() { diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 18344d85..58984ccf 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -966,15 +966,18 @@ export const teamApi = { addMember: (id: string, agentId: string, role: string) => http.post(`/teams/${id}/members`, { agentId, role }), removeMember: (id: string, agentId: string) => http.delete(`/teams/${id}/members/${agentId}`), - listTasks: (id: string, status?: string[], opts?: { limit?: number; offset?: number }) => + listTasks: (id: string, status?: string[], opts?: { limit?: number; offset?: number; runId?: string }) => http.get(`/teams/${id}/tasks`, { params: { ...(status?.length ? { status: status.join(',') } : {}), ...(opts?.limit != null ? { limit: opts.limit } : {}), ...(opts?.offset != null ? { offset: opts.offset } : {}), + ...(opts?.runId ? { runId: opts.runId } : {}), }, }), - taskStats: (id: string) => http.get(`/teams/${id}/tasks/stats`), + taskStats: (id: string, runId?: string) => http.get(`/teams/${id}/tasks/stats`, { + params: runId ? { runId } : {}, + }), getTask: (id: string, taskId: string) => http.get(`/teams/${id}/tasks/${taskId}`), createTask: ( id: string, diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index c8414b03..5f7474e6 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -15,6 +15,8 @@ export default { back: 'Back', runs: 'Runs', board: 'Task Board', + boardScope: 'Task board run scope', + boardAllRuns: 'All historical tasks', members: 'Members', addMember: 'Add Member', memberName: 'Member', @@ -73,6 +75,7 @@ export default { column: { todo: 'To Do', in_progress: 'In Progress', + settling: 'Finalizing', in_review: 'In Review', completed: 'Completed', closed: 'Closed', @@ -158,6 +161,7 @@ export default { loading: 'Loading...', processing: 'Processing...', success: 'Done', + failed: 'Operation failed', revoked: 'Revoked', enabled: 'Enabled', disabled: 'Disabled', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index ff76c9ed..15802370 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -15,6 +15,8 @@ export default { back: '返回', runs: '运行记录', board: '任务板', + boardScope: '任务板运行范围', + boardAllRuns: '全部历史任务', members: '成员', addMember: '添加成员', memberName: '成员', @@ -73,6 +75,7 @@ export default { column: { todo: '待处理', in_progress: '进行中', + settling: '正在结算', in_review: '待审核', completed: '已完成', closed: '已终止', @@ -158,6 +161,7 @@ export default { loading: '加载中...', processing: '处理中...', success: '操作成功', + failed: '操作失败', revoked: '已撤销', enabled: '启用', disabled: '停用', diff --git a/mateclaw-ui/src/stores/__tests__/teamStoreGeneration.test.ts b/mateclaw-ui/src/stores/__tests__/teamStoreGeneration.test.ts index ff7093c4..225a1b9e 100644 --- a/mateclaw-ui/src/stores/__tests__/teamStoreGeneration.test.ts +++ b/mateclaw-ui/src/stores/__tests__/teamStoreGeneration.test.ts @@ -89,4 +89,21 @@ describe('useTeamStore request generation', () => { expect(store.tasks).toEqual([]) expect(store.taskStats).toEqual({}) }) + + it('scopes every board request to the selected run', async () => { + api.get.mockResolvedValue(detail('A')) + api.listTasks.mockResolvedValue({ data: [] }) + api.taskStats.mockResolvedValue({ data: {} }) + const store = useTeamStore() + await store.openTeam('A') + vi.clearAllMocks() + + await store.setTaskRunId('A', '9007199254740993') + + expect(api.listTasks).toHaveBeenCalledTimes(3) + for (const call of api.listTasks.mock.calls) { + expect(call[2]).toMatchObject({ runId: '9007199254740993' }) + } + expect(api.taskStats).toHaveBeenCalledWith('A', '9007199254740993') + }) }) diff --git a/mateclaw-ui/src/stores/useTeamStore.ts b/mateclaw-ui/src/stores/useTeamStore.ts index 1fcc7f07..2ade5303 100644 --- a/mateclaw-ui/src/stores/useTeamStore.ts +++ b/mateclaw-ui/src/stores/useTeamStore.ts @@ -20,6 +20,8 @@ export const useTeamStore = defineStore('team', () => { const currentTeam = ref(null) const members = ref([]) const boardLoading = ref(false) + /** Null = aggregate history; otherwise the board is scoped to one run. */ + const taskRunId = ref(null) /** Statuses that mean the board is still moving and worth polling. */ const ACTIVE_STATUSES = ['pending', 'in_progress', 'in_review', 'blocked'] @@ -76,6 +78,7 @@ export const useTeamStore = defineStore('team', () => { completedTasks.value = [] closedTasks.value = [] taskStats.value = {} + taskRunId.value = null await fetchTasks(teamId, generation) } @@ -88,6 +91,7 @@ export const useTeamStore = defineStore('team', () => { completedTasks.value = [] closedTasks.value = [] taskStats.value = {} + taskRunId.value = null boardLoading.value = false } @@ -103,10 +107,10 @@ export const useTeamStore = defineStore('team', () => { const completedLimit = Math.max(TERMINAL_PAGE, completedTasks.value.length) const closedLimit = Math.max(TERMINAL_PAGE, closedTasks.value.length) const [active, completed, closed, stats] = (await Promise.all([ - teamApi.listTasks(teamId, ACTIVE_STATUSES), - teamApi.listTasks(teamId, COMPLETED_STATUSES, { limit: completedLimit, offset: 0 }), - teamApi.listTasks(teamId, CLOSED_STATUSES, { limit: closedLimit, offset: 0 }), - teamApi.taskStats(teamId), + teamApi.listTasks(teamId, ACTIVE_STATUSES, { runId: taskRunId.value ?? undefined }), + teamApi.listTasks(teamId, COMPLETED_STATUSES, { limit: completedLimit, offset: 0, runId: taskRunId.value ?? undefined }), + teamApi.listTasks(teamId, CLOSED_STATUSES, { limit: closedLimit, offset: 0, runId: taskRunId.value ?? undefined }), + teamApi.taskStats(teamId, taskRunId.value ?? undefined), ])) as any[] if (expectedGeneration !== teamGeneration || requestSequence !== boardRequestSequence @@ -131,6 +135,7 @@ export const useTeamStore = defineStore('team', () => { const res: any = await teamApi.listTasks(teamId, COMPLETED_STATUSES, { limit: TERMINAL_PAGE, offset: completedTasks.value.length, + runId: taskRunId.value ?? undefined, }) if (generation !== teamGeneration || String(currentTeam.value?.team.id ?? '') !== teamId) return completedTasks.value = [...completedTasks.value, ...(res.data || [])] @@ -141,6 +146,7 @@ export const useTeamStore = defineStore('team', () => { const res: any = await teamApi.listTasks(teamId, CLOSED_STATUSES, { limit: TERMINAL_PAGE, offset: closedTasks.value.length, + runId: taskRunId.value ?? undefined, }) if (generation !== teamGeneration || String(currentTeam.value?.team.id ?? '') !== teamId) return closedTasks.value = [...closedTasks.value, ...(res.data || [])] @@ -156,6 +162,16 @@ export const useTeamStore = defineStore('team', () => { await fetchTeams() } + async function setTaskRunId(teamId: string, runId: string | null) { + if (taskRunId.value === runId) return + taskRunId.value = runId + activeTasks.value = [] + completedTasks.value = [] + closedTasks.value = [] + taskStats.value = {} + await fetchTasks(teamId) + } + async function deleteTeam(teamId: string) { await teamApi.delete(teamId) if (currentTeam.value?.team.id === teamId) { @@ -172,6 +188,7 @@ export const useTeamStore = defineStore('team', () => { tasks, taskStats, boardLoading, + taskRunId, hasActiveTasks, completedTotal, closedTotal, @@ -183,6 +200,7 @@ export const useTeamStore = defineStore('team', () => { fetchTasks, loadMoreCompleted, loadMoreClosed, + setTaskRunId, createTeam, deleteTeam, } diff --git a/mateclaw-ui/src/views/Teams.vue b/mateclaw-ui/src/views/Teams.vue index 2c097a7d..97b3e0fb 100644 --- a/mateclaw-ui/src/views/Teams.vue +++ b/mateclaw-ui/src/views/Teams.vue @@ -97,6 +97,18 @@ @click="setActiveTab('members')" >{{ t('teams.members') }} +