From f449fb229ef19e2c07b4369c097849f4e4726c8c Mon Sep 17 00:00:00 2001 From: matevip Date: Thu, 22 Oct 2026 05:44:01 -0400 Subject: [PATCH] fix(team): ground worker progress and preserve runtime failure causes --- .../guard/TaskFinalizationCoordinator.java | 9 +++ .../graph/state/ActionExecutionLedger.java | 64 ++++++++++++++- .../team/service/TeamDispatchService.java | 28 ++++++- .../vip/mate/team/tool/TeamTasksTool.java | 26 +++--- .../TaskFinalizationCoordinatorTest.java | 12 +++ .../state/ActionExecutionLedgerTest.java | 80 ++++++++++++++++++- .../team/service/TeamDispatchServiceTest.java | 31 +++++++ .../vip/mate/team/tool/TeamTasksToolTest.java | 37 +++++++++ 8 files changed, 272 insertions(+), 15 deletions(-) diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/guard/TaskFinalizationCoordinator.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/guard/TaskFinalizationCoordinator.java index 310f4cd1..5e9250a0 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/guard/TaskFinalizationCoordinator.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/guard/TaskFinalizationCoordinator.java @@ -45,6 +45,15 @@ public final class TaskFinalizationCoordinator { describe finishing the task after failure; it does not establish original-operation recovery. Endorse recovery only for canonical supportedRecoveryCallIds; an empty list means none is established. Do not invent numeric results or reconstruct omitted results. + For literal existence checks, a substring anywhere in the complete observed output satisfies existence. + Do not invent a standalone line, whole-line equality, token-boundary or occurrence-count requirement + unless the CURRENT objective explicitly requires it. A quoted numeric marker such as + "130 checks passed and evidence retained" is not a claim that those tests were executed. + Distinguish reporting that a marker exists from endorsing the marker's embedded claim. + successfulProgressUpdates records successful team progress writes separately from substantive outputs: + check its taskId, percent and completedAt for requested milestone and read-before-progress constraints. + Their absence from successfulSubstantiveCalls does not mean progress was not reported. + Free-form progress prose alone cannot establish a successful progress write. Unsupported candidate prose requires REVISE with empty recoveryClaims and a specific correction. Unfinished executable objective work or unsatisfied requested state constraints require CONTINUE, not prose-only REVISE. Explicitly requested pending records are not unfinished executable work; compare them to the original constraints. diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/ActionExecutionLedger.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/ActionExecutionLedger.java index 117ebaf6..ed258910 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/ActionExecutionLedger.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/ActionExecutionLedger.java @@ -29,14 +29,24 @@ public final class ActionExecutionLedger { public enum Status { SUCCEEDED, FAILED } + public record ProgressUpdate(String taskId, int percent) {} + public record Receipt(String toolCallId, String toolName, Status status, - String resultSummary, long completedAt, String operationIdentity, int resultChars, boolean progressOnly) { + String resultSummary, long completedAt, String operationIdentity, int resultChars, boolean progressOnly, ProgressUpdate progressUpdate) { public Receipt { + if (!progressOnly || progressUpdate != null && (!validTaskId(progressUpdate.taskId()) + || progressUpdate.percent() < 0 || progressUpdate.percent() > 100)) progressUpdate = null; // Older serialized receipts have no original output length (primitive defaults to zero). if (resultSummary != null && !resultSummary.isEmpty() && resultChars == 0) resultChars = -1; if (operationIdentity == null || !operationIdentity.matches("sha256:[a-f0-9]{64}")) { operationIdentity = UNKNOWN_OPERATION; } + if (!"team_tasks".equals(toolName) || UNKNOWN_OPERATION.equals(operationIdentity)) progressUpdate = null; + } + + public Receipt(String toolCallId, String toolName, Status status, + String resultSummary, long completedAt, String operationIdentity, int resultChars, boolean progressOnly) { + this(toolCallId, toolName, status, resultSummary, completedAt, operationIdentity, resultChars, progressOnly, null); } public Receipt(String toolCallId, String toolName, Status status, @@ -98,6 +108,7 @@ public final class ActionExecutionLedger { Map operations = new LinkedHashMap<>(); Map operationTools = new LinkedHashMap<>(); Map progressCalls = new LinkedHashMap<>(); + Map progressUpdates = new LinkedHashMap<>(); for (GraphEventPublisher.GraphEvent event : events) { if (event == null || !GraphEventPublisher.EVENT_TOOL_START.equals(event.type())) continue; String id = String.valueOf(event.data().getOrDefault("toolCallId", "")); @@ -107,6 +118,8 @@ public final class ActionExecutionLedger { String.valueOf(event.data().getOrDefault("arguments", ""))); progressCalls.put(id, isTeamProgress(name, String.valueOf(event.data().getOrDefault("arguments", "")))); + progressUpdates.put(id, teamProgressUpdate(name, + String.valueOf(event.data().getOrDefault("arguments", "")))); operationTools.put(id, name); operations.merge(id, identity, (first, next) -> first.equals(next) ? first : UNKNOWN_OPERATION); } @@ -130,7 +143,11 @@ public final class ActionExecutionLedger { ? operations.getOrDefault(id, UNKNOWN_OPERATION) : UNKNOWN_OPERATION, resultChars, name.equals(operationTools.get(id)) && !UNKNOWN_OPERATION.equals(operations.getOrDefault(id, UNKNOWN_OPERATION)) - && Boolean.TRUE.equals(progressCalls.get(id)))); + && Boolean.TRUE.equals(progressCalls.get(id)), + name.equals(operationTools.get(id)) + && !UNKNOWN_OPERATION.equals(operations.getOrDefault(id, UNKNOWN_OPERATION)) + && success && acknowledgedProgress(String.valueOf(data.getOrDefault("result", ""))) + ? progressUpdates.get(id) : null)); } return receipts.isEmpty() ? empty() : new ActionExecutionLedger(receipts, fullResults); } @@ -147,6 +164,34 @@ public final class ActionExecutionLedger { } } + /** Native callbacks may return the plain acknowledgement or its MethodToolCallback JSON string. */ + private static boolean acknowledgedProgress(String result) { + if ("✓ Progress recorded.".equals(result.trim())) return true; + try { + var value = OPERATION_MAPPER.readTree(result); + return value != null && value.isTextual() && "✓ Progress recorded.".equals(value.textValue()); + } catch (java.io.IOException invalid) { return false; } + } + + private static boolean validTaskId(String value) { + if (value == null || !value.matches("[1-9][0-9]{0,18}")) return false; + try { return Long.parseLong(value) > 0; } catch (NumberFormatException invalid) { return false; } + } + + private static ProgressUpdate teamProgressUpdate(String toolName, String arguments) { + if (!"team_tasks".equals(toolName)) return null; + try { + var request = OPERATION_MAPPER.readTree(arguments); + if (request == null || !request.isObject() || !"progress".equals(request.path("action").asText())) return null; + var id = request.path("taskId"); + var percent = request.path("percent"); + if (!(id.isTextual() || id.isIntegralNumber()) || !validTaskId(id.asText()) + || !percent.isIntegralNumber() || !percent.canConvertToInt() + || percent.intValue() < 0 || percent.intValue() > 100) return null; + return new ProgressUpdate(id.asText(), percent.intValue()); + } catch (java.io.IOException invalid) { return null; } + } + /** * Per-run receipts survive model-history compaction. Inject these before generation * so the streamed answer and its persisted copy share the same factual context. @@ -301,6 +346,20 @@ public final class ActionExecutionLedger { } successful.add(output); } + // Progress is execution evidence for milestone constraints, never the latest substantive output. + var progress = facts.putArray("successfulProgressUpdates"); + Map latestProgressAt = new LinkedHashMap<>(); + receipts.values().stream().filter(r -> r.status() == Status.SUCCEEDED && r.progressUpdate() != null) + .forEach(r -> latestProgressAt.merge(r.progressUpdate().taskId(), r.completedAt(), Math::max)); + receipts.values().stream().filter(r -> r.status() == Status.SUCCEEDED && r.progressUpdate() != null + && r.completedAt() == latestProgressAt.get(r.progressUpdate().taskId())) + .sorted(Comparator.comparingLong(Receipt::completedAt).reversed().thenComparing(Receipt::toolCallId)) + .forEach(r -> { + var node = progress.addObject(); + node.put("toolCallId", identifierPreview(r.toolCallId())); + node.put("taskId", r.progressUpdate().taskId()); node.put("percent", r.progressUpdate().percent()); + node.put("completedAt", r.completedAt()); + }); facts.put("omittedFailedCallCount", failures.size() - failed.size()); facts.put("omittedSuccessfulSubstantiveCallCount", successes.size() - successful.size()); if (vip.mate.agent.context.TokenEstimator.estimateTokens(facts.toString()) > tokenBudget) { @@ -332,6 +391,7 @@ public final class ActionExecutionLedger { var node = OPERATION_MAPPER.createObjectNode(); node.put("toolCallId", identifierPreview(receipt.toolCallId())); node.put("toolName", identifierPreview(receipt.toolName())); + node.put("completedAt", receipt.completedAt()); node.put("resultSummary", receipt.resultSummary()); int omitted = receipt.resultChars() < 0 ? -1 : Math.max(0, receipt.resultChars() - MAX_RESULT_SUMMARY_CHARS); node.put("resultComplete", omitted == 0); diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java index e60219dc..c82db941 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java @@ -215,6 +215,7 @@ public class TeamDispatchService { WorkerDecisionAdapter.Snapshot snapshot = WorkerDecisionAdapter.Snapshot.capture(task).withConversation(childConvId); ScheduledFuture heartbeat = null; String reply = null; + String finishReason = null; Exception runFailure = null; try { try { @@ -244,6 +245,7 @@ public class TeamDispatchService { AgentService.ChatResult result = agentService.chatWithUsage( memberId, dispatchContent, childConvId); reply = result == null ? null : result.content(); + finishReason = result == null ? null : result.finishReason(); if (reply != null && !reply.isBlank()) { conversationService.saveMessage(childConvId, "assistant", reply); } @@ -278,7 +280,7 @@ public class TeamDispatchService { if (runFailure != null) { throw runFailure; } - settleOutcome(task, reply, snapshot); + settleOutcome(task, reply, snapshot, finishReason); } catch (Exception error) { settleRunFailure(teamId, task, error); } @@ -335,6 +337,10 @@ public class TeamDispatchService { } void settleOutcome(TeamTaskEntity task, String reply, WorkerDecisionAdapter.Snapshot snapshot) { + settleOutcome(task, reply, snapshot, null); + } + + void settleOutcome(TeamTaskEntity task, String reply, WorkerDecisionAdapter.Snapshot snapshot, String finishReason) { TeamTaskEntity current = taskService.getTask(task.getId()); if (current == null) { return; @@ -346,6 +352,26 @@ public class TeamDispatchService { return; } if (TeamTaskStatus.IN_PROGRESS.equals(current.getStatus())) { + // Runtime diagnostic prose is not a malformed business envelope. Explicit non-completion wins + // even if the retained candidate happens to contain a valid COMPLETED object. + if (finishReason != null && !finishReason.isBlank() + && !Set.of("normal", "summarized", "return_direct").contains(finishReason)) { + String reason = "WORKER_RUNTIME_NOT_COMPLETED: finishReason=" + + TeamResultExcerpt.label(finishReason, 80); + boolean failed; + if (decisionAdapter != null && decisionAdapter.enabled()) { + var judgment = decisionAdapter.judge(current, "WORKER_RUNTIME_NOT_COMPLETED", reply); + failed = taskService.failTask(task.getId(), reason, judgment.ticket(), snapshot); + } else { + failed = taskService.failTask(task.getId(), reason); + } + if (failed) { + TeamTaskEntity settled = taskService.getTask(task.getId()); + broadcast(settled != null ? settled : task, "team_task_failed", Map.of("reason", reason)); + announceService.announceTaskSettled(settled); + } + return; + } // Domain guard: a successful model invocation is not a completed business task. // OFF retains the legacy text contract; SHADOW records the corrected baseline. if (decisionAdapter != null && decisionAdapter.enabled()) { diff --git a/mateclaw-server/src/main/java/vip/mate/team/tool/TeamTasksTool.java b/mateclaw-server/src/main/java/vip/mate/team/tool/TeamTasksTool.java index 36bf8b8b..8c88eedd 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/tool/TeamTasksTool.java +++ b/mateclaw-server/src/main/java/vip/mate/team/tool/TeamTasksTool.java @@ -123,20 +123,20 @@ public class TeamTasksTool { String conversationId = ToolExecutionContext.conversationId(ctx); if (conversationId == null || conversationId.isBlank()) { - return "Error: no conversation context bound to this call."; + return failure(ctx, "Error: no conversation context bound to this call."); } ConversationEntity conversation = conversationService.findByConversationId(conversationId); if (conversation == null || conversation.getAgentId() == null) { - return "Error: cannot resolve the calling agent for this conversation."; + return failure(ctx, "Error: cannot resolve the calling agent for this conversation."); } if (conversation.getWorkspaceId() == null) { - return "Error: workspaceId is missing from conversation context."; + return failure(ctx, "Error: workspaceId is missing from conversation context."); } Long agentId = conversation.getAgentId(); Long workspaceId = conversation.getWorkspaceId(); Optional teamOpt = teamService.getTeamForAgent(agentId); if (teamOpt.isEmpty()) { - return "Error: you are not part of any agent team; team_tasks is unavailable."; + return failure(ctx, "Error: you are not part of any agent team; team_tasks is unavailable."); } AgentTeamEntity team = teamOpt.get(); boolean isLead = teamService.isLead(team, agentId); @@ -153,7 +153,7 @@ public class TeamTasksTool { requireApproval, conversationId); case "seal_run" -> sealRun(team, isLead, workspaceId, conversationId, runId); case "complete" -> completeTask(team, agentId, parseId(taskId, "taskId"), result); - case "progress" -> progress(team, agentId, parseId(taskId, "taskId"), percent, step); + case "progress" -> progress(team, agentId, parseId(taskId, "taskId"), percent, step, ctx); case "comment" -> comment(team, agentId, parseId(taskId, "taskId"), type, text); case "attach" -> attach(team, agentId, parseId(taskId, "taskId"), name, url); case "cancel" -> cancel(team, agentId, isLead, parseId(taskId, "taskId"), text); @@ -165,13 +165,13 @@ public class TeamTasksTool { } catch (IllegalArgumentException | IllegalStateException e) { DecisionRecordingException recording = DecisionRecordingException.find(e); if (recording != null) throw recording; - return "Error: " + e.getMessage(); + return failure(ctx, "Error: " + e.getMessage()); } catch (Exception e) { DecisionRecordingException recording = DecisionRecordingException.find(e); if (recording != null) throw recording; log.warn("team_tasks {} failed for team={} agent={}: {}", action, team.getId(), agentId, e.getMessage()); - return "Error: team_tasks failed — " + e.getMessage(); + return failure(ctx, "Error: team_tasks failed — " + e.getMessage()); } } @@ -275,11 +275,17 @@ public class TeamTasksTool { return sb.toString(); } + private static String failure(ToolContext ctx, String message) { + var capture = vip.mate.tool.mcp.runtime.McpToolResultCapture.from(ctx); + if (capture != null) capture.recordFailure(message); + return message; + } + private String progress(AgentTeamEntity team, Long agentId, Long taskId, - Integer percent, String step) { + Integer percent, String step, ToolContext ctx) { requireTaskInTeam(team, taskId); if (percent != null && (percent < 0 || percent > 100)) { - return "Error: percent must be between 0 and 100."; + return failure(ctx, "Error: percent must be between 0 and 100."); } boolean ok = taskService.updateProgress(taskId, agentId, percent, step); if (ok) { @@ -293,7 +299,7 @@ public class TeamTasksTool { eventChannel.publishTaskEvent(taskService.getTask(taskId), "team_task_progress", extra); } return ok ? "✓ Progress recorded." - : "Error: task is not in progress under your ownership; progress not recorded."; + : failure(ctx, "Error: task is not in progress under your ownership; progress not recorded."); } private String comment(AgentTeamEntity team, Long agentId, Long taskId, diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/guard/TaskFinalizationCoordinatorTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/guard/TaskFinalizationCoordinatorTest.java index 7fc63a7d..8a12e283 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/guard/TaskFinalizationCoordinatorTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/guard/TaskFinalizationCoordinatorTest.java @@ -53,6 +53,18 @@ class TaskFinalizationCoordinatorTest { assertEquals(TaskFinalizationGate.Outcome.ACCEPT,review.result().outcome()); assertEquals(1,review.calls()); } + @Test void existenceReviewDoesNotInventWholeLineOrExecutionCountRequirements() { + when(helper.streamCallSilent(any(), any(), anyString(), anyString())).thenAnswer(call -> { + String prompt = ((Prompt)call.getArgument(1)).getContents(); + assertTrue(prompt.contains("substring anywhere")); + assertTrue(prompt.contains("standalone line")); + assertTrue(prompt.contains("not a claim that those tests were executed")); + assertTrue(prompt.contains("successfulProgressUpdates")); + return result(json("ACCEPT", "[]")); + }); + assertEquals(TaskFinalizationGate.Outcome.ACCEPT, review(16000, 0, 0).result().outcome()); + } + @Test void revisionFeedbackRequestsSpecificOffendingClauseAndGroundedReplacement() { when(helper.streamCallSilent(any(),any(),anyString(),anyString())).thenAnswer(call->{ String prompt=((Prompt)call.getArgument(1)).getContents(); diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/state/ActionExecutionLedgerTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/state/ActionExecutionLedgerTest.java index a70a518e..a0eafafe 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/state/ActionExecutionLedgerTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/state/ActionExecutionLedgerTest.java @@ -32,7 +32,7 @@ class ActionExecutionLedgerTest { GraphEventPublisher.toolComplete("read", "read_file", body, true))); var progress = ActionExecutionLedger.fromEvents(List.of( GraphEventPublisher.toolStart("progress", "team_tasks", "{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":100}"), - GraphEventPublisher.toolComplete("progress", "team_tasks", "Progress recorded", true))); + GraphEventPublisher.toolComplete("progress", "team_tasks", "✓ Progress recorded.", true))); var merged = read.merge(progress); assertEquals(java.util.Map.of("read", body), merged.latestCompleteResult()); assertFalse(merged.receipts().get("progress").substantive()); @@ -46,6 +46,82 @@ class ActionExecutionLedgerTest { assertEquals(1, merged.merge(failed).failedCallCount()); } + @Test void canonicalTeamProgressFactsPreserveLatestPercentWithoutReplacingReadEvidence() throws Exception { + String body = "HEAD " + "evidence ".repeat(1000) + " 130 checks passed and evidence retained TAIL"; + var ledger = ActionExecutionLedger.fromEvents(List.of( + GraphEventPublisher.toolComplete("read", "read_file", body, true), + GraphEventPublisher.toolStart("progress", "team_tasks", "{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":100,\"step\":\"secret/freeform step\"}"), + GraphEventPublisher.toolComplete("progress", "team_tasks", "✓ Progress recorded.", true))); + var projection = ledger.projectAnswerFacts(10000, ledger.latestCompleteResult()); + assertTrue(projection.coreFactsFit()); + var facts = new com.fasterxml.jackson.databind.ObjectMapper().readTree(projection.text()); + var progress = facts.path("successfulProgressUpdates").get(0); + org.junit.jupiter.api.Assertions.assertNotNull(progress); + assertEquals("5", progress.path("taskId").asText()); + assertEquals(100, progress.path("percent").asInt()); + assertFalse(projection.text().contains("secret/freeform step")); + assertEquals(java.util.Map.of("read", body), ledger.latestCompleteResult()); + } + + @Test void onlyJoinedSuccessfulStrictProgressRequestsEstablishPercent() throws Exception { + var mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + for (String args : List.of("{\"action\":\"progress\",\"percent\":100}", + "{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":\"100\"}", + "{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":100.0}", + "{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":101}", + "{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":100,\"percent\":0}", + "{\"action\":\"progress\",\"taskId\":\"not-an-id\",\"percent\":100}")) { + var ledger = ActionExecutionLedger.fromEvents(List.of( + GraphEventPublisher.toolStart("p", "team_tasks", args), + GraphEventPublisher.toolComplete("p", "team_tasks", "✓ Progress recorded.", true))); + assertTrue(mapper.readTree(ledger.projectAnswerFacts(3000).text()).path("successfulProgressUpdates").isEmpty()); + } + for (var events : List.of( + List.of(GraphEventPublisher.toolComplete("p", "team_tasks", "✓ Progress recorded.", true)), + List.of(GraphEventPublisher.toolStart("p", "team_tasks", "{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":100}"), + GraphEventPublisher.toolComplete("p", "team_tasks", "Error", false)), + List.of(GraphEventPublisher.toolStart("p", "team_tasks", "{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":100}"), + GraphEventPublisher.toolStart("p", "team_tasks", "{\"action\":\"complete\",\"taskId\":\"5\"}"), + GraphEventPublisher.toolComplete("p", "team_tasks", "✓ Progress recorded.", true)))) { + var ledger = ActionExecutionLedger.fromEvents(events); + assertTrue(mapper.readTree(ledger.projectAnswerFacts(3000).text()).path("successfulProgressUpdates").isEmpty()); + } + } + + @Test void historicalSuccessFlagWithRejectedNativeTextCannotEstablishMilestone() throws Exception { + var ledger = ActionExecutionLedger.fromEvents(List.of( + GraphEventPublisher.toolStart("p", "team_tasks", "{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":100}"), + GraphEventPublisher.toolComplete("p", "team_tasks", "Error: progress not recorded", true))); + var facts = new com.fasterxml.jackson.databind.ObjectMapper().readTree(ledger.projectAnswerFacts(3000).text()); + assertTrue(facts.path("successfulProgressUpdates").isEmpty()); + } + + @Test void malformedOrErrorEncodedAcknowledgementsRemainUnproven() throws Exception { + var mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + for (String text : List.of("\"Error: progress not recorded\"", "\"✓ Progress recorded.\" {}", + "{\"result\":\"✓ Progress recorded.\"}", "\"\\\"✓ Progress recorded.\\\"\"")) { + var ledger = ActionExecutionLedger.fromEvents(List.of( + GraphEventPublisher.toolStart("p", "team_tasks", "{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":100}"), + GraphEventPublisher.toolComplete("p", "team_tasks", text, true))); + assertTrue(mapper.readTree(ledger.projectAnswerFacts(3000).text()).path("successfulProgressUpdates").isEmpty()); + } + } + + @Test void latestProgressPerTaskSurvivesSerializationWithoutReintroducingOlderPercent() throws Exception { + var events = List.of( + new GraphEventPublisher.GraphEvent(GraphEventPublisher.EVENT_TOOL_START, java.util.Map.of("toolCallId","p1", "toolName","team_tasks", "arguments","{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":100}"), 1), + new GraphEventPublisher.GraphEvent(GraphEventPublisher.EVENT_TOOL_COMPLETE, java.util.Map.of("toolCallId","p1", "toolName","team_tasks", "result","✓ Progress recorded.", "success",true), 2), + new GraphEventPublisher.GraphEvent(GraphEventPublisher.EVENT_TOOL_START, java.util.Map.of("toolCallId","p2", "toolName","team_tasks", "arguments","{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":25}"), 3), + new GraphEventPublisher.GraphEvent(GraphEventPublisher.EVENT_TOOL_COMPLETE, java.util.Map.of("toolCallId","p2", "toolName","team_tasks", "result","✓ Progress recorded.", "success",true), 4)); + var ledger = ActionExecutionLedger.fromEvents(events); + var mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + var restored = mapper.readValue(mapper.writeValueAsString(ledger.receipts().get("p2")), ActionExecutionLedger.Receipt.class); + assertEquals(25, restored.progressUpdate().percent()); + var progress = mapper.readTree(ledger.projectAnswerFacts(3000).text()).path("successfulProgressUpdates"); + assertEquals(1, progress.size()); assertEquals(25, progress.get(0).path("percent").asInt()); + assertEquals(4, progress.get(0).path("completedAt").asInt()); + } + @Test void ambiguousOrMutatingTeamCallsRemainSubstantive() { for (String arguments : List.of("{\"action\":\"complete\",\"taskId\":\"5\"}", "{\"action\":\"output\",\"taskId\":\"5\"}", @@ -56,7 +132,7 @@ class ActionExecutionLedgerTest { assertTrue(ledger.receipts().get("call").substantive()); } var missingStart = ActionExecutionLedger.fromEvents(List.of( - GraphEventPublisher.toolComplete("legacy", "team_tasks", "Progress recorded", true))); + GraphEventPublisher.toolComplete("legacy", "team_tasks", "✓ Progress recorded.", true))); assertTrue(missingStart.receipts().get("legacy").substantive()); } diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamDispatchServiceTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamDispatchServiceTest.java index 16fa93b1..46181c03 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/service/TeamDispatchServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamDispatchServiceTest.java @@ -222,6 +222,37 @@ class TeamDispatchServiceTest { verify(taskService, never()).completeTask(any(), any(), anyString()); } + @ParameterizedTest + @ValueSource(strings = {"incomplete", "error_fallback", "max_iterations_reached", "stopped", "evidence_insufficient", "action_unverified", "action_failed"}) + void incompleteRuntimeIsNotMisreportedAsInvalidWorkerJson(String finishReason) { + var adapter = mock(WorkerDecisionAdapter.class); + when(adapter.enabled()).thenReturn(true); + var ticket = new DecisionTicket(null, DecisionMode.SHADOW, new DecisionValue.BooleanValue(false)); + when(adapter.judge(any(), anyString(), anyString())).thenReturn(new WorkerDecisionAdapter.Judgment(ticket, false)); + service.setDecisionAdapter(adapter); + TeamTaskEntity assigned = task(1L, MEMBER_A); assigned.setStatus(TeamTaskStatus.IN_PROGRESS); + when(taskService.getTask(1L)).thenReturn(assigned); + when(agentService.chatWithUsage(eq(MEMBER_A), anyString(), anyString())).thenReturn( + new AgentService.ChatResult("任务尚未完成:文字修正已达上限", 10, 5, "model", "provider", finishReason)); + service.runTask(TEAM_ID, assigned); + verify(adapter).judge(eq(assigned), eq("WORKER_RUNTIME_NOT_COMPLETED"), anyString()); + verify(taskService).failTask(eq(1L), startsWith("WORKER_RUNTIME_NOT_COMPLETED:"), eq(ticket), any()); + verify(taskService, never()).completeTask(any(), any(), anyString(), any(), any()); + verify(adapter, never()).judge(any(), eq("INVALID_RESULT_CONTRACT"), anyString()); + } + + @Test + void validJsonCannotOverrideAnIncompleteRuntimeEvenWhenDecisionEnvelopeIsDisabled() { + TeamTaskEntity assigned = task(1L, MEMBER_A); assigned.setStatus(TeamTaskStatus.IN_PROGRESS); + when(taskService.getTask(1L)).thenReturn(assigned); + when(agentService.chatWithUsage(eq(MEMBER_A), anyString(), anyString())).thenReturn( + new AgentService.ChatResult("{\"status\":\"COMPLETED\",\"result\":\"done\",\"evidence\":\"checked\"}", + 10, 5, "model", "provider", "incomplete")); + service.runTask(TEAM_ID, assigned); + verify(taskService).failTask(eq(1L), startsWith("WORKER_RUNTIME_NOT_COMPLETED:")); + verify(taskService, never()).completeTask(any(), any(), anyString()); + } + @Test void childStreamClosesBeforeLeadReceivesSuccessfulResult() { TeamTaskEntity assigned = task(1L, MEMBER_A); diff --git a/mateclaw-server/src/test/java/vip/mate/team/tool/TeamTasksToolTest.java b/mateclaw-server/src/test/java/vip/mate/team/tool/TeamTasksToolTest.java index 4ee4b870..5ad1f18f 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/tool/TeamTasksToolTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/tool/TeamTasksToolTest.java @@ -113,6 +113,43 @@ class TeamTasksToolTest { null, null, null, null, null, null, null, null, null, null, null); } + @Test void methodCallbackEncodedProgressIsGroundedInActualSuccessfulWrite() throws Exception { + callerIs(MEMBER_ID); + when(taskService.getTask(5L)).thenReturn(task(5L, TeamTaskStatus.IN_PROGRESS)); + when(taskService.updateProgress(eq(5L), eq(MEMBER_ID), eq(100), anyString())).thenReturn(true); + var capture = new vip.mate.tool.mcp.runtime.McpToolResultCapture(); + var ctx = capture.attach(new ToolContext(java.util.Map.of())); + String args = "{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":100,\"step\":\"done\"}"; + var callback = org.springframework.ai.support.ToolCallbacks.from(tool)[0]; + String result = callback.call(args, ctx); + assertFalse(capture.isError()); + var ledger = vip.mate.agent.graph.state.ActionExecutionLedger.fromEvents(List.of( + vip.mate.agent.GraphEventPublisher.toolStart("p", "team_tasks", args), + vip.mate.agent.GraphEventPublisher.toolComplete("p", "team_tasks", result, !capture.isError()))); + var facts = new com.fasterxml.jackson.databind.ObjectMapper().readTree(ledger.projectAnswerFacts(3000).text()); + assertEquals(1, facts.path("successfulProgressUpdates").size()); + assertEquals(100, facts.path("successfulProgressUpdates").get(0).path("percent").asInt()); + verify(taskService).updateProgress(5L, MEMBER_ID, 100, "done"); + } + + @Test void rejectedNativeProgressCannotEstablishSuccessfulMilestone() throws Exception { + callerIs(MEMBER_ID); + when(taskService.getTask(5L)).thenReturn(task(5L, TeamTaskStatus.IN_PROGRESS)); + when(taskService.updateProgress(eq(5L), eq(MEMBER_ID), eq(100), anyString())).thenReturn(false); + var capture = new vip.mate.tool.mcp.runtime.McpToolResultCapture(); + var ctx = capture.attach(new ToolContext(java.util.Map.of())); + String result = tool.team_tasks("progress", "5", null, null, null, null, null, null, + null, null, null, null, 100, "done", null, null, null, null, ctx); + assertTrue(capture.isError()); + var ledger = vip.mate.agent.graph.state.ActionExecutionLedger.fromEvents(List.of( + vip.mate.agent.GraphEventPublisher.toolStart("p", "team_tasks", "{\"action\":\"progress\",\"taskId\":\"5\",\"percent\":100}"), + vip.mate.agent.GraphEventPublisher.toolComplete("p", "team_tasks", result, !capture.isError()))); + assertEquals(1, ledger.failedCallCount()); + var facts = new com.fasterxml.jackson.databind.ObjectMapper().readTree(ledger.projectAnswerFacts(3000).text()); + assertTrue(facts.path("successfulProgressUpdates").isEmpty()); + verify(eventChannel, never()).publishTaskEvent(any(), eq("team_task_progress"), any()); + } + private TeamRunEntity run(Long teamId, String conversationId, String status) { TeamRunEntity run = new TeamRunEntity(); run.setId(RUN_ID);