From d1be02985cd602adc963bc9d6aac15bbb99e81d0 Mon Sep 17 00:00:00 2001 From: matevip Date: Thu, 14 May 2026 15:44:03 +0800 Subject: [PATCH] fix(agent): collapse model-predicted tool results that the actual tool replaced --- .../vip/mate/channel/web/ChatController.java | 24 +++ .../channel/web/SegmentSupersedeDetector.java | 152 ++++++++++++++++++ .../web/SegmentSupersedeDetectorTest.java | 138 ++++++++++++++++ .../src/components/chat/MessageBubble.vue | 61 ++++++- mateclaw-ui/src/composables/chat/useChat.ts | 50 ++++++ mateclaw-ui/src/i18n/locales/en-US.ts | 2 + mateclaw-ui/src/i18n/locales/zh-CN.ts | 2 + mateclaw-ui/src/types/index.ts | 6 + 8 files changed, 433 insertions(+), 2 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/web/SegmentSupersedeDetector.java create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/web/SegmentSupersedeDetectorTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java index f84e0f9f..5d797f8a 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java @@ -1424,6 +1424,29 @@ public class ChatController { if (savedAssistant.getRuntimeProvider() != null && !savedAssistant.getRuntimeProvider().isBlank()) { payload.put("runtimeProvider", savedAssistant.getRuntimeProvider()); } + // Surface the server-authoritative segments timeline. The live SSE + // path builds metadata.segments from streamed deltas only, so + // server-side annotations added at persist time (e.g. the + // 'superseded' marker the SegmentSupersedeDetector writes onto + // pre-tool model claims that the actual tool result replaced) + // never reach the in-memory message until a page reload triggers + // a refetch via /messages. Inlining them in the done payload lets + // the client merge the markers onto its local segments by id + // without an extra HTTP round-trip. + String rawMetadata = savedAssistant.getMetadata(); + if (rawMetadata != null && !rawMetadata.isBlank()) { + try { + Map parsed = objectMapper.readValue(rawMetadata, + new com.fasterxml.jackson.core.type.TypeReference>() {}); + Object segs = parsed.get("segments"); + if (segs instanceof java.util.List list && !list.isEmpty()) { + payload.put("segments", segs); + } + } catch (Exception ignored) { + // Best-effort: malformed metadata just means the client falls + // back to its existing "wait for reload" reconcile path. + } + } } if (promptTokens > 0) payload.put("promptTokens", promptTokens); if (completionTokens > 0) payload.put("completionTokens", completionTokens); @@ -1982,6 +2005,7 @@ public class ChatController { synchronized String toMetadataJson() { finalizeToolCalls(); finalizeRunningSegments("thinking", "content", "tool_call"); + SegmentSupersedeDetector.markSuperseded(segments); try { Map metadata = new LinkedHashMap<>(); if (!toolCalls.isEmpty()) { diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/SegmentSupersedeDetector.java b/mateclaw-server/src/main/java/vip/mate/channel/web/SegmentSupersedeDetector.java new file mode 100644 index 00000000..9e97f583 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/SegmentSupersedeDetector.java @@ -0,0 +1,152 @@ +package vip.mate.channel.web; + +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * Marks model-predicted tool results that are replaced by the actual post-tool + * answer segment. + */ +final class SegmentSupersedeDetector { + + static final String REASON_TOOL_RESULT_REPLACED_MODEL_CLAIM = "tool_result_replaced_model_claim"; + + private static final Pattern GENERATED_FILE_URL = + Pattern.compile("/api/v1/files/generated/[A-Za-z0-9-]+"); + private static final Pattern BYTE_COUNT = + Pattern.compile("\\d+\\s*字节"); + private static final Pattern REPLACEMENT_COUNT = + Pattern.compile("\\d+\\s*处"); + + private SegmentSupersedeDetector() { + } + + static void markSuperseded(List> segments) { + if (segments == null || segments.size() < 3) { + return; + } + + for (int i = 0; i < segments.size(); i++) { + Map candidate = segments.get(i); + if (!isContent(candidate) || Boolean.TRUE.equals(candidate.get("superseded")) + || followsToolResult(segments, i)) { + continue; + } + + Claim predictedClaim = parseClaim(String.valueOf(candidate.getOrDefault("text", ""))); + if (predictedClaim == null) { + continue; + } + + int toolIndex = nextToolIndexBeforeContent(segments, i + 1); + if (toolIndex < 0) { + continue; + } + Map tool = segments.get(toolIndex); + if (Boolean.FALSE.equals(tool.get("toolSuccess")) + || !toolMatchesClaim(String.valueOf(tool.getOrDefault("toolName", "")), predictedClaim)) { + continue; + } + + int replacementIndex = nextMatchingContentIndex(segments, toolIndex + 1, predictedClaim); + if (replacementIndex < 0) { + continue; + } + + Map replacement = segments.get(replacementIndex); + candidate.put("superseded", true); + candidate.put("supersededBySegmentId", String.valueOf(replacement.getOrDefault("id", ""))); + candidate.put("supersededReason", REASON_TOOL_RESULT_REPLACED_MODEL_CLAIM); + } + } + + private static int nextToolIndexBeforeContent(List> segments, int start) { + for (int i = start; i < segments.size(); i++) { + Map segment = segments.get(i); + if (isToolCall(segment)) { + return i; + } + if (isContent(segment)) { + return -1; + } + } + return -1; + } + + private static boolean followsToolResult(List> segments, int index) { + for (int i = index - 1; i >= 0; i--) { + Map segment = segments.get(i); + if (isContent(segment)) { + return false; + } + if (isToolCall(segment)) { + return true; + } + } + return false; + } + + private static int nextMatchingContentIndex(List> segments, int start, Claim predictedClaim) { + for (int i = start; i < segments.size(); i++) { + Map segment = segments.get(i); + if (isToolCall(segment)) { + return -1; + } + if (!isContent(segment)) { + continue; + } + Claim actualClaim = parseClaim(String.valueOf(segment.getOrDefault("text", ""))); + if (predictedClaim.sameKind(actualClaim)) { + return i; + } + } + return -1; + } + + private static boolean isContent(Map segment) { + return segment != null && "content".equals(segment.get("type")); + } + + private static boolean isToolCall(Map segment) { + return segment != null && "tool_call".equals(segment.get("type")); + } + + private static Claim parseClaim(String text) { + if (text == null || text.isBlank()) { + return null; + } + String upper = text.toUpperCase(Locale.ROOT); + if ((upper.contains("成功生成") || text.contains("已生成")) && GENERATED_FILE_URL.matcher(text).find()) { + for (String format : List.of("PDF", "DOCX", "PPTX", "XLSX")) { + if (upper.contains(format)) { + return new Claim("render", format); + } + } + } + if (text.contains("成功写入") && BYTE_COUNT.matcher(text).find()) { + return new Claim("write", ""); + } + if (text.contains("成功替换") && REPLACEMENT_COUNT.matcher(text).find()) { + return new Claim("edit", ""); + } + return null; + } + + private static boolean toolMatchesClaim(String toolName, Claim claim) { + String normalized = toolName == null ? "" : toolName.toLowerCase(Locale.ROOT); + return switch (claim.type) { + case "render" -> normalized.contains("render" + claim.detail.toLowerCase(Locale.ROOT)); + case "write" -> "write_file".equals(normalized); + case "edit" -> "edit_file".equals(normalized); + default -> false; + }; + } + + private record Claim(String type, String detail) { + boolean sameKind(Claim other) { + return other != null && type.equals(other.type) && detail.equals(other.detail); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/web/SegmentSupersedeDetectorTest.java b/mateclaw-server/src/test/java/vip/mate/channel/web/SegmentSupersedeDetectorTest.java new file mode 100644 index 00000000..b1a4819f --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/web/SegmentSupersedeDetectorTest.java @@ -0,0 +1,138 @@ +package vip.mate.channel.web; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class SegmentSupersedeDetectorTest { + + @Test + @DisplayName("marks pre-tool forged render success when replaced by real render success") + void marksForgedRenderSuccess() { + List> segments = segments( + content("ct-0", "DOCX 文件已成功生成!\n\n下载链接: /api/v1/files/generated/7a8b9c0d-1e2f-3g4h-5i6j-7k8l9m0n1o2p"), + tool("tc-0", "renderDocx", true), + content("ct-1", "DOCX 文件已成功生成!\n\n下载链接: /api/v1/files/generated/e4556d9f-cd69-4047-97c0-479dbbb6c256")); + + SegmentSupersedeDetector.markSuperseded(segments); + + assertThat(segments.get(0)) + .containsEntry("superseded", true) + .containsEntry("supersededBySegmentId", "ct-1") + .containsEntry("supersededReason", "tool_result_replaced_model_claim"); + } + + @Test + @DisplayName("does not mark legitimate preamble before a render tool") + void leavesLegitimatePreambleAlone() { + List> segments = segments( + content("ct-0", "我听懂了,需要生成 PDF。让我立即执行这个操作:"), + tool("tc-0", "renderPdf", true), + content("ct-1", "PDF 已成功生成!\n\n下载链接: /api/v1/files/generated/e5fc9697-9d5a-4b20-a26d-89b623c8db9b")); + + SegmentSupersedeDetector.markSuperseded(segments); + + assertThat(segments.get(0)).doesNotContainKey("superseded"); + } + + @Test + @DisplayName("marks pre-tool forged write byte count when replaced by real write result") + void marksForgedWriteSuccess() { + List> segments = segments( + content("ct-0", "文件已成功写入!\n\n写入字节数:45 字节"), + tool("tc-0", "write_file", true), + content("ct-1", "文件已成功写入!\n\n写入字节数:43 字节")); + + SegmentSupersedeDetector.markSuperseded(segments); + + assertThat(segments.get(0)) + .containsEntry("superseded", true) + .containsEntry("supersededBySegmentId", "ct-1"); + } + + @Test + @DisplayName("does not mark pre-tool success when the tool failed") + void leavesFailedToolClaimVisible() { + List> segments = segments( + content("ct-0", "PPTX 文件已成功生成!\n\n下载链接: /api/v1/files/generated/c8e2f4a1-9b3d-4f8c-a5e7-d9f6b2c1a3e4"), + tool("tc-0", "renderPptx", false), + content("ct-1", "渲染失败:模板错误")); + + SegmentSupersedeDetector.markSuperseded(segments); + + assertThat(segments.get(0)).doesNotContainKey("superseded"); + } + + @Test + @DisplayName("v1 does not mark when the post-tool content is a general summary") + void leavesSummaryFollowupAlone() { + List> segments = segments( + content("ct-0", "文件内容已成功替换!\n\n替换次数:1 处"), + tool("tc-0", "edit_file", true), + content("ct-1", "所有文档生成和文件操作任务已完成。")); + + SegmentSupersedeDetector.markSuperseded(segments); + + assertThat(segments.get(0)).doesNotContainKey("superseded"); + } + + @Test + @DisplayName("does not cross another tool boundary looking for a replacement") + void doesNotCrossToolBoundary() { + List> segments = segments( + content("ct-0", "XLSX 文件已成功生成!\n\n下载链接: /api/v1/files/generated/8c3d4a9f-2e1b-4f5a-b6c7-d8e9f0a1b2c3"), + tool("tc-0", "renderXlsx", true), + tool("tc-1", "renderDocx", true), + content("ct-1", "XLSX 文件已成功生成!\n\n下载链接: /api/v1/files/generated/f98d7fd0-3cda-4510-b056-5bd3c8343e19")); + + SegmentSupersedeDetector.markSuperseded(segments); + + assertThat(segments.get(0)).doesNotContainKey("superseded"); + } + + @Test + @DisplayName("does not mark an actual post-tool result as a later pre-tool prediction") + void doesNotMarkPostToolResult() { + List> segments = segments( + tool("tc-0", "renderDocx", true), + content("ct-0", "DOCX 文件已成功生成!\n\n下载链接: /api/v1/files/generated/e4556d9f-cd69-4047-97c0-479dbbb6c256"), + tool("tc-1", "renderDocx", true), + content("ct-1", "DOCX 文件已成功生成!\n\n下载链接: /api/v1/files/generated/f98d7fd0-3cda-4510-b056-5bd3c8343e19")); + + SegmentSupersedeDetector.markSuperseded(segments); + + assertThat(segments.get(1)).doesNotContainKey("superseded"); + } + + @SafeVarargs + private static List> segments(Map... entries) { + return new ArrayList<>(List.of(entries)); + } + + private static Map content(String id, String text) { + Map segment = base(id, "content"); + segment.put("text", text); + return segment; + } + + private static Map tool(String id, String toolName, boolean success) { + Map segment = base(id, "tool_call"); + segment.put("toolName", toolName); + segment.put("toolSuccess", success); + return segment; + } + + private static Map base(String id, String type) { + Map segment = new LinkedHashMap<>(); + segment.put("id", id); + segment.put("type", type); + segment.put("status", "completed"); + return segment; + } +} diff --git a/mateclaw-ui/src/components/chat/MessageBubble.vue b/mateclaw-ui/src/components/chat/MessageBubble.vue index 0c5b4e01..0502b999 100644 --- a/mateclaw-ui/src/components/chat/MessageBubble.vue +++ b/mateclaw-ui/src/components/chat/MessageBubble.vue @@ -35,12 +35,29 @@ @@ -829,6 +846,18 @@ const formatFileSize = (size: number) => { // --- 执行过程面板 --- const executionExpanded = ref(false) +const expandedSupersededSegments = ref(new Set()) + +function isSupersededExpanded(id: string) { + return expandedSupersededSegments.value.has(id) +} + +function toggleSupersededSegment(id: string) { + const next = new Set(expandedSupersededSegments.value) + if (next.has(id)) next.delete(id) + else next.add(id) + expandedSupersededSegments.value = next +} // --- 分段式渲染(Claude Code 风格) --- const parsedMetadata = computed(() => { @@ -1191,6 +1220,34 @@ watch(isGenerating, (generating) => { font-size: 11px; } +.superseded-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + align-self: flex-start; + padding: 5px 10px; + margin: 4px 0 2px; + font-size: 12px; + color: var(--mc-text-secondary, #64748b); + background: var(--mc-bg-elevated, #f8fafc); + border: 1px dashed var(--mc-border, #e2e8f0); + border-radius: 6px; + cursor: pointer; +} + +.superseded-toggle:hover { + color: var(--mc-text-primary, #0f172a); + border-color: var(--mc-primary, #2563eb); +} + +.superseded-toggle__action { + color: var(--mc-primary, #2563eb); +} + +.content-segment--superseded { + opacity: 0.72; +} + .message-wrapper { display: flex; gap: 12px; diff --git a/mateclaw-ui/src/composables/chat/useChat.ts b/mateclaw-ui/src/composables/chat/useChat.ts index add89324..f7a0a039 100644 --- a/mateclaw-ui/src/composables/chat/useChat.ts +++ b/mateclaw-ui/src/composables/chat/useChat.ts @@ -547,6 +547,56 @@ export function useChat(options: UseChatOptions): UseChatReturn { if (data.assistantMessageId) { msg.id = data.assistantMessageId } + // Merge server-authoritative segment annotations (carries fields the + // live SSE path can't compute, like the 'superseded' marker the + // backend's SegmentSupersedeDetector writes onto pre-tool model + // claims that the actual tool result replaced). The local segments + // keep their content / status; the server segments only contribute + // their annotation fields. + // + // Matching: client and server use DIFFERENT id schemes (client uses + // timestamp-based ids like `seg-1778744207326-0`; server uses + // `co-0 / to-1 / th-2` from its accumulator). They DO produce + // segments in the same temporal order from the same event stream, + // so we pair by (type, intra-type index): the N-th content/tool/ + // thinking segment locally aligns with the N-th of the same type + // on the server. Extra local-only segments (rare streaming + // artifacts that the server pruned) end up unmatched and pass + // through untouched — no risk of mislabelling. + if (Array.isArray(data.segments) && data.segments.length > 0) { + const metadata = parseMetadata((msg as any).metadata) + const localSegs = (metadata?.segments as any[]) || [] + if (localSegs.length > 0) { + const serverByTypeIndex = new Map() + const serverTypeCount = new Map() + for (const s of data.segments as any[]) { + if (!s || typeof s !== 'object' || typeof s.type !== 'string') continue + const idx = serverTypeCount.get(s.type) || 0 + serverByTypeIndex.set(`${s.type}#${idx}`, s) + serverTypeCount.set(s.type, idx + 1) + } + if (serverByTypeIndex.size > 0) { + const localTypeCount = new Map() + const merged = localSegs.map((local: any) => { + if (!local || typeof local.type !== 'string') return local + const idx = localTypeCount.get(local.type) || 0 + localTypeCount.set(local.type, idx + 1) + const remote = serverByTypeIndex.get(`${local.type}#${idx}`) + if (!remote) return local + const next = { ...local } + if (remote.superseded !== undefined) next.superseded = remote.superseded + if (remote.supersededBySegmentId !== undefined) { + next.supersededBySegmentId = remote.supersededBySegmentId + } + if (remote.supersededReason !== undefined) { + next.supersededReason = remote.supersededReason + } + return next + }) + ;(msg as any).metadata = { ...(metadata || {}), segments: merged } + } + } + } messages.value[msgIndex] = { ...msg } } currentAssistantId.value = null diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 44231249..2bf8c53c 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -327,6 +327,8 @@ export default { // Per-iteration grouping iterationEmpty: 'Iteration {index} interrupted (no output)', contentRepetitionWarning: 'Repetitive content detected near the end (model artifact)', + supersededPreviewCollapsed: 'Model preview replaced by the actual tool result', + expand: 'Expand', // INCOMPLETE truncation card (finishReason=incomplete) incompleteTitle: 'Answer auto-truncated after repeated output was detected', incompleteDescription: 'After the visible text above, the model started repeating itself (or stalled in thinking with no output) and was cut short to avoid wasted tokens. Click below to regenerate the full answer, or refine your prompt to focus on the missing parts.', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index b833ebb0..578ae475 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -327,6 +327,8 @@ export default { // 按轮次分组渲染 iterationEmpty: '第 {index} 轮被中断(无输出)', contentRepetitionWarning: '检测到内容尾部重复(疑似模型输出 artifact)', + supersededPreviewCollapsed: '过程预演已被实际工具结果替换', + expand: '展开', // INCOMPLETE 截断卡片(finishReason=incomplete) incompleteTitle: '回答因检测到重复输出已被自动截断', incompleteDescription: '系统在你看到的部分之后检测到模型开始重复同一段内容(或在思考阶段无产出),已自动截断以避免无效输出。点下方按钮重新生成完整回答,或在输入框补充提示让模型聚焦剩余内容。', diff --git a/mateclaw-ui/src/types/index.ts b/mateclaw-ui/src/types/index.ts index 06e3962d..63b366b1 100644 --- a/mateclaw-ui/src/types/index.ts +++ b/mateclaw-ui/src/types/index.ts @@ -177,6 +177,12 @@ export interface MessageSegment { repetitionWarning?: 'char_pattern' | 'sentence_repetition' /** Number of trailing characters dropped when the repetition guard fired. */ truncatedChars?: number + /** Backend marked this model-predicted tool result as replaced by a later actual tool result. */ + superseded?: boolean + /** Segment ID that replaced this pre-tool prediction. */ + supersededBySegmentId?: string + /** Machine-readable reason for superseding this segment. */ + supersededReason?: string } export interface MessageMetadata {