From bf3f4af913e9b36e1aced124cf8575a00995fe29 Mon Sep 17 00:00:00 2001 From: matevip Date: Thu, 14 May 2026 07:46:30 +0800 Subject: [PATCH] fix(agent): structured tool-call replay + final-answer-only content (#120) --- mateclaw-server/Dockerfile | 15 +- .../java/vip/mate/agent/AgentService.java | 41 +- .../main/java/vip/mate/agent/BaseAgent.java | 195 +++++- .../agent/graph/StateGraphReActAgent.java | 80 ++- .../vip/mate/channel/web/ChatController.java | 40 +- .../agent/BaseAgentToolCallReplayTest.java | 584 ++++++++++++++++++ ...aphReActAgentStreamedContentDeltaTest.java | 69 +++ mateclaw-ui/package.json | 3 +- mateclaw-ui/pnpm-lock.yaml | 3 + 9 files changed, 998 insertions(+), 32 deletions(-) create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/BaseAgentToolCallReplayTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/graph/StateGraphReActAgentStreamedContentDeltaTest.java diff --git a/mateclaw-server/Dockerfile b/mateclaw-server/Dockerfile index 72b2c054..d6ec4146 100644 --- a/mateclaw-server/Dockerfile +++ b/mateclaw-server/Dockerfile @@ -18,14 +18,17 @@ RUN pnpm install --frozen-lockfile COPY mateclaw-ui/ ./ # Override outDir: vite.config.ts writes to ../mateclaw-server/…/static which # is outside this container; call vite directly to control --outDir. -# The earlier escape hatch (NODE_OPTIONS=--max-old-space-size=6144) was -# retired after the workflow route adopted precise Monaco imports + async -# component loading + named manualChunks; the single-chunk peak now sits -# below ~1 GB so the default Node heap is enough. Re-add the flag if a -# future feature reintroduces a 4 MB+ chunk and the build OOMs. +# NODE_OPTIONS=--max-old-space-size=6144 keeps Rollup's `rendering chunks` +# phase from getting SIGKILL'd by the host kernel's OOM-killer on memory- +# constrained servers. The earlier removal of this flag relied on lazy- +# loading + manualChunks dropping the per-chunk peak, but Rollup still +# minifies several vendor chunks (monaco / mermaid / echarts) in parallel +# so the cumulative working set blows past Node's default ~1.5 GB heap +# and trips the OOM-killer mid-build. The fix is not the heap flag +# itself; it is keeping the build reproducible on smaller hosts. # Skipping vue-tsc here is intentional — type errors are caught in CI, not in # the production Docker image build. -RUN pnpm exec vite build --outDir /static --emptyOutDir +RUN NODE_OPTIONS=--max-old-space-size=6144 pnpm exec vite build --outDir /static --emptyOutDir # Stage 2 — Backend (Maven) FROM maven:3.9-eclipse-temurin-21 AS builder diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java index c101eb6c..d5f8a7a1 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java @@ -484,24 +484,55 @@ public class AgentService { // ==================== StreamDelta ==================== - public record StreamDelta(String content, String thinking, String eventType, Map eventData, boolean persistenceOnly) { + public record StreamDelta(String content, String thinking, String eventType, Map eventData, + boolean persistenceOnly, boolean segmentOnly) { // 兼容构造器(广播+持久化) public StreamDelta(String content, String thinking) { - this(content, thinking, null, null, false); + this(content, thinking, null, null, false, false); + } + + // 显式 5-参构造器:保留旧调用点对 (content, thinking, eventType, eventData, persistenceOnly) 的兼容 + public StreamDelta(String content, String thinking, String eventType, + Map eventData, boolean persistenceOnly) { + this(content, thinking, eventType, eventData, persistenceOnly, false); } /** 仅用于持久化,不再广播(内容已由 NodeStreamingChatHelper 实时广播过) */ public static StreamDelta persistOnly(String content, String thinking) { - return new StreamDelta(content, thinking, null, null, true); + return new StreamDelta(content, thinking, null, null, true, false); + } + + /** + * Per-iteration narrative routing for ReasoningNode / SummarizingNode output. + * + *

The accumulator should: + *

    + *
  • append the text to the in-flight {@code segments} entry so the UI's + * segmented view still renders the intermediate "I'll look it up…" + * narration between tool cards;
  • + *
  • NOT broadcast — already broadcast live by NodeStreamingChatHelper;
  • + *
  • NOT append to the top-level {@code content} StringBuilder, which is + * what gets persisted as {@code mate_message.content}. That field + * should hold the final-answer span only — otherwise multiple + * iterations stack into "我来…让我…然后…" walls that next-turn replay + * sees as unanswered chain-of-thought (issue #120 narration leg).
  • + *
+ * + *

Implies {@code persistenceOnly} (no broadcast) at the accumulator + * layer, but is a stricter promise: nothing reaches the top-level + * persisted content field via this flavor. + */ + public static StreamDelta segmentOnly(String content, String thinking) { + return new StreamDelta(content, thinking, null, null, true, true); } public static StreamDelta empty() { - return new StreamDelta(null, null, null, null, false); + return new StreamDelta(null, null, null, null, false, false); } public static StreamDelta event(String type, Map data) { - return new StreamDelta(null, null, type, data, false); + return new StreamDelta(null, null, type, data, false, false); } public boolean isEvent() { diff --git a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java index 4fae2e5a..d7dfca00 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java @@ -1,4 +1,6 @@ package vip.mate.agent; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.messages.AssistantMessage; @@ -27,6 +29,7 @@ import java.util.ArrayList; import java.util.EnumSet; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicReference; @@ -292,10 +295,7 @@ public abstract class BaseAgent { List messages = new ArrayList<>(limit); for (int i = 0; i < limit; i += 1) { - Message springMessage = sanitizeForLlm(history.get(i)); - if (springMessage != null) { - messages.add(springMessage); - } + messages.addAll(expandToSpringMessages(history.get(i))); } // Tail guard — orphan-user strip (issue #47). @@ -526,6 +526,193 @@ public abstract class BaseAgent { return toSpringMessage(entity); } + /** + * Replay a persisted message as 1..N Spring AI {@link Message}s. + * + *

Plain user/system/assistant rows expand to a single message via + * {@link #sanitizeForLlm}. Assistant rows that issued tool calls during + * the original turn expand to TWO messages: + *

    + *
  1. {@link AssistantMessage} carrying the persisted narration and + * the {@link AssistantMessage.ToolCall} list reconstructed from + * {@code metadata.toolCalls}.
  2. + *
  3. A single {@link ToolResponseMessage} bundling one + * {@link ToolResponseMessage.ToolResponse} per completed tool call, + * with the persisted {@code result} string as content.
  4. + *
+ * + *

Without this expansion the next turn would see a bare assistant text + * row containing chain-of-thought like "Let me try the browser..." but no + * tool calls and no observations. The LLM concludes the action wasn't + * actually performed and retries the same tool, looping until the iteration + * cap. Issuing the structured tool_call + tool_response pair lets the model + * see what already ran and reason from the result instead. + * + *

Only completed tool calls (status=completed AND result present) are + * replayed. Calls left in awaiting_approval or running state are dropped + * — replaying them without a paired response would produce a sequence the + * provider rejects (every tool_call_id must have a matching tool_response). + * + *

RFC-052 direct-tool rows take the existing scrub path unchanged; the + * placeholder text already names the originating tool and instructs the + * model to re-call if needed, which is the correct multi-turn signal for + * returnDirect tools. + */ + List expandToSpringMessages(MessageEntity entity) { + if (entity == null) return List.of(); + // Stages 0/1/1.5 in sanitizeForLlm drop cron-header system rows, + // approval-placeholder assistants, and error-status assistants. Those + // rows must NOT replay structured tool exchanges even if metadata still + // carries them — the underlying turn is broken or synthetic. Centralize + // that decision so we can apply it before the empty-content check. + if (shouldFullyFilter(entity)) return List.of(); + + // Non-assistant rows expand to exactly one message via the existing + // conversion path (which also handles user-message media injection). + if (!"assistant".equals(entity.getRole())) { + Message m = toSpringMessage(entity); + return m == null ? List.of() : List.of(m); + } + + // RFC-052 direct-tool rows: keep the existing placeholder-only path. + // The placeholder names the originating tool and tells the model to + // re-call it; resurrecting the original tool_call/tool_response pair + // would leak the very content RFC-052 elides. + if (!directToolNamesIn(entity).isEmpty()) { + Message m = toSpringMessage(entity); + return m == null ? List.of() : List.of(m); + } + + String renderedContent = conversationService.renderMessageContent(entity); + List persisted = extractCompletedToolCalls(entity); + + // No tool calls to replay → fall back to the legacy single-message + // path, which preserves the "drop on blank content" behavior for + // narration-only assistants. + if (persisted.isEmpty()) { + Message m = toSpringMessage(entity); + return m == null ? List.of() : List.of(m); + } + + // Have completed tool calls → emit the structured pair regardless of + // whether the rendered content is blank. A pure tool-call turn (LLM + // returned tool_calls with no preamble text) is the canonical case + // here: previously the row was dropped entirely because + // renderMessageContent collapsed to "" once the tool_call part was + // skipped, leaving the next turn with no record that the tools ran. + return buildToolExchange(entity, renderedContent == null ? "" : renderedContent, persisted); + } + + /** + * True when the persisted row must be dropped before any history reaches + * the LLM, irrespective of its tool-call payload. Mirrors stages 0/1/1.5 + * of {@link #sanitizeForLlm}: cron-run header system rows, approval + * placeholders, and error-status / "[错误] " assistants. Replaying tool + * exchanges from these would resurface UI scaffolding or self-replicate + * provider failures. + */ + static boolean shouldFullyFilter(MessageEntity entity) { + if (entity == null) return true; + String role = entity.getRole(); + if ("system".equals(role) + && entity.getContent() != null + && entity.getContent().startsWith("📋 ")) return true; + if ("assistant".equals(role) + && isApprovalPlaceholder(entity.getContent())) return true; + if ("assistant".equals(role) + && ("error".equals(entity.getStatus()) + || (entity.getContent() != null + && entity.getContent().startsWith("[错误] ")))) return true; + return false; + } + + /** + * Build the structured {@code [AssistantMessage(toolCalls), ToolResponseMessage]} + * pair from a persisted row. Pure: depends only on its arguments, so it + * can be exercised directly from unit tests without a BaseAgent fixture. + * + *

Both sides reuse the same id for each call so the in-prompt sequence + * validates with every provider's tool_call_id pairing rule. For legacy + * rows persisted before {@code toolCallId} was captured, synthesize a + * stable id from {@code entity.id + index}. The id never escapes this + * prompt; synthetic and real ids cannot collide downstream. + */ + static List buildToolExchange(MessageEntity entity, String content, + List persisted) { + List toolCalls = new ArrayList<>(persisted.size()); + List responses = new ArrayList<>(persisted.size()); + for (int i = 0; i < persisted.size(); i++) { + PersistedToolCall p = persisted.get(i); + String id = (p.toolCallId() == null || p.toolCallId().isEmpty()) + ? "legacy-" + entity.getId() + "-" + i + : p.toolCallId(); + toolCalls.add(new AssistantMessage.ToolCall(id, "function", p.name(), p.arguments())); + responses.add(new ToolResponseMessage.ToolResponse(id, p.name(), p.result())); + } + AssistantMessage rebuilt = AssistantMessage.builder() + .content(content == null ? "" : content) + .toolCalls(toolCalls) + .build(); + ToolResponseMessage toolResponses = ToolResponseMessage.builder() + .responses(responses) + .build(); + return List.of(rebuilt, toolResponses); + } + + /** + * Parse {@code metadata.toolCalls} into a list of completed entries that + * are safe to replay. Returns empty if metadata is missing, malformed, or + * carries no entry with both {@code status='completed'} and a non-null + * {@code result}. + * + *

Handles H2's JSON-column double-wrap (the column read can produce a + * JSON-encoded string of JSON) the same way + * {@code ConversationService#reconcileResolvedMessages} does. + */ + static List extractCompletedToolCalls(MessageEntity entity) { + if (entity == null) return List.of(); + String raw = entity.getMetadata(); + if (raw == null || raw.isBlank() || !raw.contains("toolCalls")) return List.of(); + try { + String json = raw.trim(); + if (json.startsWith("\"") && json.endsWith("\"")) { + json = HISTORY_METADATA_MAPPER.readValue(json, String.class); + } + Map meta = HISTORY_METADATA_MAPPER.readValue(json, + new TypeReference>() {}); + Object tc = meta.get("toolCalls"); + if (!(tc instanceof List list)) return List.of(); + List result = new ArrayList<>(list.size()); + for (Object entry : list) { + if (!(entry instanceof Map raw2)) continue; + @SuppressWarnings("unchecked") + Map call = (Map) raw2; + if (!"completed".equals(String.valueOf(call.get("status")))) continue; + Object resultField = call.get("result"); + if (resultField == null) continue; + String name = String.valueOf(call.getOrDefault("name", "")); + if (name.isBlank()) continue; + String args = String.valueOf(call.getOrDefault("arguments", "")); + String toolCallId = String.valueOf(call.getOrDefault("toolCallId", "")); + result.add(new PersistedToolCall(toolCallId, name, args, String.valueOf(resultField))); + } + return result; + } catch (Exception e) { + log.warn("[BaseAgent] Failed to parse metadata.toolCalls for replay (msgId={}): {}", + entity.getId(), e.getMessage()); + return List.of(); + } + } + + private static final ObjectMapper HISTORY_METADATA_MAPPER = new ObjectMapper(); + + /** + * A completed tool call recovered from {@code mate_message.metadata}, ready + * to be replayed as an {@link AssistantMessage.ToolCall} / matching + * {@link ToolResponseMessage.ToolResponse} pair. + */ + record PersistedToolCall(String toolCallId, String name, String arguments, String result) {} + /** * 判断消息是否为持久化的压缩摘要。 */ diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java index f845d9a3..79012837 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java @@ -214,15 +214,33 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC boolean contentAlreadyStreamed = output.state().value(CONTENT_STREAMED, false); boolean thinkingAlreadyStreamed = output.state().value(THINKING_STREAMED, false); - // 与 chatStructuredStream 一致:把每轮 STREAMED_CONTENT 用 persistOnly 推给 Accumulator, - // 否则中间叙述(reasoning narrative + summarize)只在 SSE 上出现一次,刷新后丢失。 + // Route per-iteration STREAMED_CONTENT (reasoning preamble + + // SummarizingNode output) into segments only — final-answer + // text arrives via the FINAL_ANSWER branch below. Pre-#120 + // this used persistOnly, which appended every iteration's + // narration into the persisted assistant content; next-turn + // replay then saw a chain of "Let me try X..." with no + // observations and looped retrying tools. + // + // Exception — evidence-insufficient terminal turn + // (ReasoningNode.java:617): when an answer is rejected for + // unsupported references, FINAL_ANSWER is replaced with a + // short "[证据不足]" warning and STREAMED_CONTENT carries the + // actual answer body the user/UI need to see. Falling back + // to persistOnly for that case keeps both the original + // answer text and the warning in mate_message.content; with + // pure segmentOnly the persisted content would shrink to + // just the warning, breaking single-segment renderers like + // copy / TTS / history reload (segments.length<=1 disables + // the segmented view in MessageBubble). + boolean isFinalAnswerTurn = hasFinalAnswer(output); String streamed = output.state().value(STREAMED_CONTENT).orElse(""); if (!streamed.isEmpty() && !streamed.equals(lastEmittedStreamedContent.get())) { lastEmittedStreamedContent.set(streamed); - deltas.add(AgentService.StreamDelta.persistOnly(streamed, null)); + deltas.add(streamedContentDelta(isFinalAnswerTurn, streamed)); } - if (hasFinalAnswer(output) && finalAnswerEmitted.compareAndSet(false, true)) { + if (isFinalAnswerTurn && finalAnswerEmitted.compareAndSet(false, true)) { String answer = extractFinalAnswer(output); if (answer != null && !answer.isEmpty()) { deltas.add(contentAlreadyStreamed @@ -347,17 +365,30 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC boolean thinkingAlreadyStreamed = output.state() .value(THINKING_STREAMED, false); - // 2a. 中间叙述内容持久化:每轮 ReasoningNode(带 tool_calls)和 SummarizingNode - // 都把当轮 LLM 输出写入 STREAMED_CONTENT。NodeStreamingChatHelper 已实时广播 - // 给前端,但 Accumulator 不在 SSE 订阅链路上,必须用 persistOnly StreamDelta - // 补一刀,否则刷新后正文文字全部丢失(只剩 final_answer + tool_call 卡片)。 + // 2a. Route per-iteration narrative into the segments timeline + // so the segmented UI view still shows "我来…" preludes + // between tool cards, but keep the top-level content + // field (= persisted mate_message.content) reserved for + // the final-answer span. NodeStreamingChatHelper already + // broadcast the live deltas; segmentOnly suppresses + // re-broadcast and skips content.append while still + // populating the segments[] entry. + // + // Exception — evidence-insufficient terminal turn + // (ReasoningNode.java:617): STREAMED_CONTENT carries + // the rejected answer body, FINAL_ANSWER is just the + // short "[证据不足]" warning. Use persistOnly there so + // mate_message.content keeps both the answer text and + // the warning — single-segment renderers (copy / TTS / + // history reload) read content, not segments. + boolean isFinalAnswerTurn = hasFinalAnswer(output); String streamed = output.state().value(STREAMED_CONTENT).orElse(""); if (!streamed.isEmpty() && !streamed.equals(lastEmittedStreamedContent.get())) { lastEmittedStreamedContent.set(streamed); - deltas.add(AgentService.StreamDelta.persistOnly(streamed, null)); + deltas.add(streamedContentDelta(isFinalAnswerTurn, streamed)); } - if (hasFinalAnswer(output) && finalAnswerEmitted.compareAndSet(false, true)) { + if (isFinalAnswerTurn && finalAnswerEmitted.compareAndSet(false, true)) { String answer = extractFinalAnswer(output); if (answer != null && !answer.isEmpty()) { deltas.add(contentAlreadyStreamed @@ -509,6 +540,35 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC return inputs; } + /** + * Pick the right {@link AgentService.StreamDelta} flavor for the per-iteration + * {@code STREAMED_CONTENT} the graph just emitted. + * + *

The contract: + *

    + *
  • Intermediate ReAct iterations (no {@code FINAL_ANSWER} yet) → + * {@code segmentOnly}. The content is reasoning preamble / mid-loop + * summary that belongs in the segments timeline, not in the persisted + * {@code mate_message.content}.
  • + *
  • Terminal turn where {@code FINAL_ANSWER} is set → + * {@code persistOnly}. This covers the evidence-insufficient path + * (ReasoningNode.java:617) where {@code STREAMED_CONTENT} carries the + * actual rejected answer body and {@code FINAL_ANSWER} is just a short + * "[证据不足]" warning. Persisting the streamed body keeps single-segment + * renderers (copy / TTS / history reload) showing the full text.
  • + *
+ * + *

Package-private so the unit test can pin the decision without standing + * up a full StateGraph fixture. Returning {@code null} for blank input is the + * caller's responsibility — this helper just decides flavor for non-blank + * content. + */ + static AgentService.StreamDelta streamedContentDelta(boolean isFinalAnswerTurn, String streamed) { + return isFinalAnswerTurn + ? AgentService.StreamDelta.persistOnly(streamed, null) + : AgentService.StreamDelta.segmentOnly(streamed, null); + } + private boolean hasFinalAnswer(NodeOutput output) { if (output == null || output.state() == null) { return false; 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 19c79358..f84e0f9f 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 @@ -1726,7 +1726,15 @@ public class ChatController { // content_delta if (delta.content() != null && !delta.content().isBlank()) { - content.append(delta.content()); + // segmentOnly deltas route per-iteration narration to the + // segments timeline only — the persisted top-level content + // field stays clean so it carries the final answer span, + // not "我来…让我…" concatenations across iterations (issue + // #120 narration leg). segmentOnly implies persistenceOnly, + // so no broadcast either. + if (!delta.segmentOnly()) { + content.append(delta.content()); + } streamTracker.updatePhase(conversationId, "drafting_answer"); if (!delta.persistenceOnly()) { broadcastEvent(conversationId, "content_delta", Map.of("delta", delta.content())); @@ -1745,7 +1753,9 @@ public class ChatController { // thinking_delta if (delta.thinking() != null && !delta.thinking().isBlank()) { - thinking.append(delta.thinking()); + if (!delta.segmentOnly()) { + thinking.append(delta.thinking()); + } if (!delta.persistenceOnly()) { broadcastEvent(conversationId, "thinking_delta", Map.of("delta", delta.thinking())); } @@ -1824,6 +1834,11 @@ public class ChatController { } else if ("tool_call_started".equals(eventType)) { // toolCalls(兼容) Map tc = new LinkedHashMap<>(); + // toolCallId is required for history replay to pair the persisted + // assistant tool_call with its tool_response — providers reject any + // sequence whose ids don't match. Always record it (empty string + // when the upstream event didn't carry one, e.g. forced tool calls). + tc.put("toolCallId", String.valueOf(data.getOrDefault("toolCallId", ""))); tc.put("name", data.getOrDefault("toolName", "")); tc.put("arguments", data.getOrDefault("arguments", "")); tc.put("status", "running"); @@ -1831,6 +1846,7 @@ public class ChatController { // segments: 关闭 running thinking/content,插入 tool_call finalizeRunningSegments("thinking", "content"); var seg = newSegment("tool_call"); + seg.put("toolCallId", String.valueOf(data.getOrDefault("toolCallId", ""))); seg.put("toolName", data.getOrDefault("toolName", "")); seg.put("toolArgs", data.getOrDefault("arguments", "")); segments.add(seg); @@ -1848,10 +1864,17 @@ public class ChatController { } } else if ("tool_call_completed".equals(eventType)) { String toolName = String.valueOf(data.getOrDefault("toolName", "")); - // toolCalls(兼容) + String toolCallId = String.valueOf(data.getOrDefault("toolCallId", "")); + // toolCalls(兼容)— prefer toolCallId match so parallel calls of + // the same tool don't collide on the running+toolName fallback. for (int i = toolCalls.size() - 1; i >= 0; i--) { Map tc = toolCalls.get(i); - if ("running".equals(tc.get("status")) && toolName.equals(tc.get("name"))) { + boolean matches = (!toolCallId.isEmpty() + && toolCallId.equals(String.valueOf(tc.getOrDefault("toolCallId", "")))) + || (toolCallId.isEmpty() + && "running".equals(tc.get("status")) + && toolName.equals(tc.get("name"))); + if (matches) { tc.put("result", data.getOrDefault("result", "")); tc.put("success", data.getOrDefault("success", true)); tc.put("status", "completed"); @@ -1861,8 +1884,13 @@ public class ChatController { // segments: 标记对应 tool_call 完成 for (int i = segments.size() - 1; i >= 0; i--) { var seg = segments.get(i); - if ("tool_call".equals(seg.get("type")) && "running".equals(seg.get("status")) - && toolName.equals(seg.get("toolName"))) { + if (!"tool_call".equals(seg.get("type"))) continue; + boolean matches = (!toolCallId.isEmpty() + && toolCallId.equals(String.valueOf(seg.getOrDefault("toolCallId", "")))) + || (toolCallId.isEmpty() + && "running".equals(seg.get("status")) + && toolName.equals(seg.get("toolName"))); + if (matches) { seg.put("status", "completed"); seg.put("toolResult", data.getOrDefault("result", "")); seg.put("toolSuccess", data.getOrDefault("success", true)); diff --git a/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentToolCallReplayTest.java b/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentToolCallReplayTest.java new file mode 100644 index 00000000..65e7eadb --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentToolCallReplayTest.java @@ -0,0 +1,584 @@ +package vip.mate.agent; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.ToolResponseMessage; +import org.springframework.ai.chat.messages.UserMessage; +import vip.mate.workspace.conversation.ConversationService; +import vip.mate.workspace.conversation.model.MessageEntity; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Issue #120 regression: persisted assistant rows that issued tool calls must + * replay as a structured {@code AssistantMessage(toolCalls)} + + * {@link ToolResponseMessage} pair, not as bare text. Without the pair the next + * turn sees chain-of-thought narration ("Let me try the browser…") with no + * matching observations and re-attempts the same tool, looping until the + * iteration cap. + * + *

These tests drive {@link BaseAgent#extractCompletedToolCalls(MessageEntity)} + * directly — {@code expandToSpringMessages} is instance-bound (its + * {@code sanitizeForLlm} call uses {@code conversationService.renderMessageContent}) + * and exercising it requires a full BaseAgent fixture covered by integration + * tests. The static extractor is the load-bearing parser; if it round-trips + * metadata correctly the structural replay is correct. + */ +class BaseAgentToolCallReplayTest { + + @Test + @DisplayName("completed toolCalls round-trip from metadata, preserving id+name+args+result") + void completedToolCalls_extractedInOrder() { + MessageEntity msg = new MessageEntity(); + msg.setRole("assistant"); + msg.setContent("Looked it up via the web."); + msg.setMetadata("{\"toolCalls\":[" + + "{\"toolCallId\":\"call_001\",\"name\":\"search\"," + + "\"arguments\":\"{\\\"q\\\":\\\"spring ai\\\"}\"," + + "\"status\":\"completed\",\"result\":\"No relevant hits.\",\"success\":true}," + + "{\"toolCallId\":\"call_002\",\"name\":\"browser_use\"," + + "\"arguments\":\"{\\\"url\\\":\\\"https://x\\\"}\"," + + "\"status\":\"completed\",\"result\":\"404\",\"success\":false}" + + "]}"); + + List calls = BaseAgent.extractCompletedToolCalls(msg); + + assertEquals(2, calls.size()); + assertEquals("call_001", calls.get(0).toolCallId()); + assertEquals("search", calls.get(0).name()); + assertEquals("{\"q\":\"spring ai\"}", calls.get(0).arguments()); + assertEquals("No relevant hits.", calls.get(0).result()); + assertEquals("call_002", calls.get(1).toolCallId()); + assertEquals("browser_use", calls.get(1).name()); + assertEquals("404", calls.get(1).result()); + } + + @Test + @DisplayName("running / awaiting_approval entries are skipped — replaying them produces orphan tool_call_ids") + void incompleteToolCalls_dropped() { + MessageEntity msg = new MessageEntity(); + msg.setRole("assistant"); + msg.setMetadata("{\"toolCalls\":[" + + "{\"toolCallId\":\"call_a\",\"name\":\"a\",\"status\":\"completed\",\"result\":\"ok\"}," + + "{\"toolCallId\":\"call_b\",\"name\":\"b\",\"status\":\"running\"}," + + "{\"toolCallId\":\"call_c\",\"name\":\"c\",\"status\":\"awaiting_approval\"}" + + "]}"); + + List calls = BaseAgent.extractCompletedToolCalls(msg); + + assertEquals(1, calls.size()); + assertEquals("call_a", calls.get(0).toolCallId()); + } + + @Test + @DisplayName("completed entry without a result is also skipped — there's nothing to feed back as observation") + void completedWithoutResult_dropped() { + MessageEntity msg = new MessageEntity(); + msg.setRole("assistant"); + msg.setMetadata("{\"toolCalls\":[" + + "{\"toolCallId\":\"x\",\"name\":\"t\",\"status\":\"completed\"}" + + "]}"); + + assertTrue(BaseAgent.extractCompletedToolCalls(msg).isEmpty()); + } + + @Test + @DisplayName("H2 double-wrap (JSON-encoded string of JSON) unwraps transparently") + void h2DoubleWrap_unwrapped() { + MessageEntity msg = new MessageEntity(); + msg.setRole("assistant"); + // What H2's JSON column read sometimes hands back through MyBatis + msg.setMetadata("\"{\\\"toolCalls\\\":[" + + "{\\\"toolCallId\\\":\\\"id-1\\\",\\\"name\\\":\\\"t\\\"," + + "\\\"status\\\":\\\"completed\\\",\\\"result\\\":\\\"ok\\\"}" + + "]}\""); + + List calls = BaseAgent.extractCompletedToolCalls(msg); + assertEquals(1, calls.size()); + assertEquals("id-1", calls.get(0).toolCallId()); + assertEquals("ok", calls.get(0).result()); + } + + @Test + @DisplayName("legacy rows without toolCallId still extract — caller synthesizes a stable id at replay time") + void legacyMissingToolCallId_extractsBlankId() { + MessageEntity msg = new MessageEntity(); + msg.setRole("assistant"); + msg.setMetadata("{\"toolCalls\":[" + + "{\"name\":\"search\",\"status\":\"completed\",\"result\":\"hit\"}" + + "]}"); + + List calls = BaseAgent.extractCompletedToolCalls(msg); + assertEquals(1, calls.size()); + assertEquals("", calls.get(0).toolCallId()); + assertEquals("search", calls.get(0).name()); + } + + @Test + @DisplayName("metadata without a toolCalls field returns empty (cheap exit, no JSON parse)") + void noToolCallsField_emptyShortCircuit() { + MessageEntity msg = new MessageEntity(); + msg.setRole("assistant"); + msg.setMetadata("{\"segments\":[{\"type\":\"text\"}]}"); + + assertTrue(BaseAgent.extractCompletedToolCalls(msg).isEmpty()); + } + + @Test + @DisplayName("null / blank / null entity safely returns empty") + void nullSafe() { + assertTrue(BaseAgent.extractCompletedToolCalls(null).isEmpty()); + + MessageEntity blank = new MessageEntity(); + assertTrue(BaseAgent.extractCompletedToolCalls(blank).isEmpty()); + + blank.setMetadata(""); + assertTrue(BaseAgent.extractCompletedToolCalls(blank).isEmpty()); + } + + @Test + @DisplayName("malformed JSON does not throw — it just yields an empty list") + void malformedJson_emptyAndNoThrow() { + MessageEntity msg = new MessageEntity(); + msg.setRole("assistant"); + // Looks like it has toolCalls (short-circuit lets us through) but the + // JSON itself is junk after that. + msg.setMetadata("{\"toolCalls\": [not actually json}"); + + assertTrue(BaseAgent.extractCompletedToolCalls(msg).isEmpty()); + } + + /** + * Smoke-test that the structured Spring AI primitives BaseAgent emits + * actually carry the data we expect. We assemble the same pair the + * production replay path does and read it back. + */ + @Test + @DisplayName("AssistantMessage + ToolResponseMessage pair carries matching tool_call ids") + void assistantPlusToolResponse_pairCarriesIds() { + AssistantMessage.ToolCall tc = new AssistantMessage.ToolCall( + "call_42", "function", "search", "{\"q\":\"hello\"}"); + AssistantMessage assistant = AssistantMessage.builder() + .content("Looking it up.") + .toolCalls(List.of(tc)) + .build(); + ToolResponseMessage responses = ToolResponseMessage.builder() + .responses(List.of(new ToolResponseMessage.ToolResponse("call_42", "search", "no hits"))) + .build(); + + assertNotNull(assistant.getToolCalls()); + assertEquals(1, assistant.getToolCalls().size()); + assertEquals("call_42", assistant.getToolCalls().get(0).id()); + assertEquals("search", assistant.getToolCalls().get(0).name()); + + assertEquals(1, responses.getResponses().size()); + assertEquals("call_42", responses.getResponses().get(0).id()); + assertEquals("no hits", responses.getResponses().get(0).responseData()); + } + + // ========== shouldFullyFilter — mirrors sanitizeForLlm stages 0/1/1.5 ========== + + @Test + @DisplayName("shouldFullyFilter: cron-header system row dropped before any tool replay") + void shouldFullyFilter_cronHeader() { + MessageEntity msg = new MessageEntity(); + msg.setRole("system"); + msg.setContent("📋 每日新闻 · 定时触发 · 2026-04-30T10:55"); + msg.setMetadata("{\"toolCalls\":[{\"toolCallId\":\"x\",\"name\":\"t\"," + + "\"status\":\"completed\",\"result\":\"y\"}]}"); + + assertTrue(BaseAgent.shouldFullyFilter(msg)); + } + + @Test + @DisplayName("shouldFullyFilter: approval-placeholder assistant dropped even with tool metadata") + void shouldFullyFilter_approvalPlaceholder() { + MessageEntity msg = new MessageEntity(); + msg.setRole("assistant"); + // Real marker from ApprovalPlaceholderUtil — see callers in BaseAgent / + // ConversationService. The point under test: even if metadata.toolCalls + // looks complete, an approval placeholder must not resurrect a tool + // exchange (the underlying tool never executed, was awaiting approval). + msg.setContent("[APPROVAL_PENDING] 工具调用 search 等待您的批准"); + msg.setMetadata("{\"toolCalls\":[{\"name\":\"t\",\"status\":\"completed\",\"result\":\"y\"}]}"); + + assertTrue(BaseAgent.isApprovalPlaceholder(msg.getContent()), + "sanity: content must match ApprovalPlaceholderUtil"); + assertTrue(BaseAgent.shouldFullyFilter(msg)); + } + + @Test + @DisplayName("shouldFullyFilter: error-status assistant dropped — replaying produces 400 loops") + void shouldFullyFilter_errorStatusAssistant() { + MessageEntity msg = new MessageEntity(); + msg.setRole("assistant"); + msg.setStatus("error"); + msg.setContent("anything"); + msg.setMetadata("{\"toolCalls\":[{\"name\":\"t\",\"status\":\"completed\",\"result\":\"y\"}]}"); + + assertTrue(BaseAgent.shouldFullyFilter(msg)); + } + + @Test + @DisplayName("shouldFullyFilter: '[错误] ' content-prefix assistant dropped (legacy persistence)") + void shouldFullyFilter_errorPrefixContent() { + MessageEntity msg = new MessageEntity(); + msg.setRole("assistant"); + msg.setContent("[错误] Bad request - reasoning_content"); + + assertTrue(BaseAgent.shouldFullyFilter(msg)); + } + + @Test + @DisplayName("shouldFullyFilter: ordinary assistant / user / null-entity rows pass through") + void shouldFullyFilter_normalRowsPass() { + MessageEntity ordinary = new MessageEntity(); + ordinary.setRole("assistant"); + ordinary.setContent("normal answer"); + assertFalse(BaseAgent.shouldFullyFilter(ordinary)); + + MessageEntity user = new MessageEntity(); + user.setRole("user"); + user.setContent("hi"); + assertFalse(BaseAgent.shouldFullyFilter(user)); + + assertTrue(BaseAgent.shouldFullyFilter(null), "null entity is filtered"); + } + + // ========== buildToolExchange — the load-bearing pair builder ========== + + @Test + @DisplayName("buildToolExchange: empty content + completed tool call still emits the structured pair (P1 fix)") + void buildToolExchange_emptyContentStillEmits() { + MessageEntity entity = new MessageEntity(); + entity.setId(1234567890L); + entity.setRole("assistant"); + entity.setContent(""); + + List calls = List.of( + new BaseAgent.PersistedToolCall("call_x", "search", "{\"q\":\"a\"}", "no hits")); + + List out = BaseAgent.buildToolExchange(entity, "", calls); + + assertEquals(2, out.size(), "pure tool-call turn must replay as [assistant, toolResponses]"); + assertTrue(out.get(0) instanceof AssistantMessage); + assertTrue(out.get(1) instanceof ToolResponseMessage); + + AssistantMessage am = (AssistantMessage) out.get(0); + assertEquals("", am.getText() == null ? "" : am.getText(), + "content stays blank — the tool exchange carries the semantics"); + assertEquals(1, am.getToolCalls().size()); + assertEquals("call_x", am.getToolCalls().get(0).id()); + + ToolResponseMessage trm = (ToolResponseMessage) out.get(1); + assertEquals(1, trm.getResponses().size()); + assertEquals("call_x", trm.getResponses().get(0).id(), + "tool_call.id must equal tool_response.id"); + } + + @Test + @DisplayName("buildToolExchange: non-empty content is preserved verbatim alongside the tool calls") + void buildToolExchange_contentPreserved() { + MessageEntity entity = new MessageEntity(); + entity.setId(42L); + entity.setRole("assistant"); + + List calls = List.of( + new BaseAgent.PersistedToolCall("id-1", "fetch", "{}", "200 OK")); + + List out = BaseAgent.buildToolExchange(entity, "I'll look it up.", calls); + + AssistantMessage am = (AssistantMessage) out.get(0); + assertEquals("I'll look it up.", am.getText()); + } + + @Test + @DisplayName("buildToolExchange: legacy row with missing toolCallId gets a stable 'legacy--' synthesis") + void buildToolExchange_legacyIdSynthesis() { + MessageEntity entity = new MessageEntity(); + entity.setId(99L); + entity.setRole("assistant"); + + List calls = List.of( + new BaseAgent.PersistedToolCall("", "search", "{}", "hit"), + new BaseAgent.PersistedToolCall(null, "fetch", "{}", "ok")); + + List out = BaseAgent.buildToolExchange(entity, "", calls); + + AssistantMessage am = (AssistantMessage) out.get(0); + ToolResponseMessage trm = (ToolResponseMessage) out.get(1); + assertEquals("legacy-99-0", am.getToolCalls().get(0).id()); + assertEquals("legacy-99-1", am.getToolCalls().get(1).id()); + assertEquals("legacy-99-0", trm.getResponses().get(0).id()); + assertEquals("legacy-99-1", trm.getResponses().get(1).id(), + "synthetic ids must match across the pair so provider tool_call_id validation passes"); + } + + @Test + @DisplayName("buildToolExchange: multiple tool calls preserve order and pair 1:1") + void buildToolExchange_multipleCallsOrdered() { + MessageEntity entity = new MessageEntity(); + entity.setId(7L); + entity.setRole("assistant"); + + List calls = List.of( + new BaseAgent.PersistedToolCall("a", "t1", "{}", "r1"), + new BaseAgent.PersistedToolCall("b", "t2", "{}", "r2"), + new BaseAgent.PersistedToolCall("c", "t3", "{}", "r3")); + + List out = BaseAgent.buildToolExchange(entity, "", calls); + AssistantMessage am = (AssistantMessage) out.get(0); + ToolResponseMessage trm = (ToolResponseMessage) out.get(1); + + for (int i = 0; i < calls.size(); i++) { + assertEquals(calls.get(i).toolCallId(), am.getToolCalls().get(i).id()); + assertEquals(calls.get(i).toolCallId(), trm.getResponses().get(i).id()); + assertEquals(calls.get(i).result(), trm.getResponses().get(i).responseData()); + } + } + + @Test + @DisplayName("emitted pair survives a 1:1 round-trip in a list ([assistant, toolResponses])") + void replayList_pairsAreContiguous() { + // What the BaseAgent.expandToSpringMessages path produces is a list of + // exactly these two Message subtypes in this order; the consumer + // (Spring AI chat client) iterates them as a single tool exchange. + AssistantMessage assistant = AssistantMessage.builder() + .content("ran X") + .toolCalls(List.of(new AssistantMessage.ToolCall("id-1", "function", "X", "{}"))) + .build(); + ToolResponseMessage responses = ToolResponseMessage.builder() + .responses(List.of(new ToolResponseMessage.ToolResponse("id-1", "X", "ok"))) + .build(); + List replay = List.of(assistant, responses); + + assertEquals(2, replay.size()); + assertTrue(replay.get(0) instanceof AssistantMessage); + assertTrue(replay.get(1) instanceof ToolResponseMessage); + AssistantMessage a = (AssistantMessage) replay.get(0); + ToolResponseMessage t = (ToolResponseMessage) replay.get(1); + assertEquals(a.getToolCalls().get(0).id(), t.getResponses().get(0).id(), + "tool_call.id must match its tool_response.id — providers reject mismatched pairs"); + } + + // ==================================================================== + // End-to-end orchestration: expandToSpringMessages with a TestAgent + // fixture so the conversationService.renderMessageContent interaction + // is exercised, not just the static helpers it composes (P3). + // ==================================================================== + + @Test + @DisplayName("E2E: pure tool-call turn (blank rendered content) still replays as structured pair") + void e2e_pureToolCallTurn() { + TestAgent agent = newTestAgent(); + MessageEntity entity = new MessageEntity(); + entity.setId(101L); + entity.setRole("assistant"); + entity.setContent(""); // pure tool call — no preamble text + entity.setMetadata("{\"toolCalls\":[" + + "{\"toolCallId\":\"call_zz\",\"name\":\"search\"," + + "\"status\":\"completed\",\"result\":\"hit\"}" + + "]}"); + when(agent.conversationService.renderMessageContent(entity)).thenReturn(""); + + List out = agent.callExpand(entity); + + assertEquals(2, out.size(), + "P1 regression: blank rendered content must NOT short-circuit the tool exchange"); + assertTrue(out.get(0) instanceof AssistantMessage); + assertTrue(out.get(1) instanceof ToolResponseMessage); + assertEquals(1, ((AssistantMessage) out.get(0)).getToolCalls().size()); + assertEquals("call_zz", ((AssistantMessage) out.get(0)).getToolCalls().get(0).id()); + } + + @Test + @DisplayName("E2E: narration-only assistant (no toolCalls) yields one AssistantMessage via toSpringMessage") + void e2e_narrativeOnly() { + TestAgent agent = newTestAgent(); + MessageEntity entity = new MessageEntity(); + entity.setId(102L); + entity.setRole("assistant"); + entity.setContent("All set."); + entity.setMetadata("{}"); + when(agent.conversationService.renderMessageContent(entity)).thenReturn("All set."); + + List out = agent.callExpand(entity); + + assertEquals(1, out.size()); + assertTrue(out.get(0) instanceof AssistantMessage); + assertEquals("All set.", ((AssistantMessage) out.get(0)).getText()); + assertTrue(((AssistantMessage) out.get(0)).getToolCalls() == null + || ((AssistantMessage) out.get(0)).getToolCalls().isEmpty(), + "no metadata.toolCalls present → no resurrected tool calls"); + } + + @Test + @DisplayName("E2E: narration-only assistant with blank rendered content is dropped (legacy behavior preserved)") + void e2e_blankNarrativeDropped() { + TestAgent agent = newTestAgent(); + MessageEntity entity = new MessageEntity(); + entity.setId(103L); + entity.setRole("assistant"); + entity.setContent(""); + entity.setMetadata("{}"); + when(agent.conversationService.renderMessageContent(entity)).thenReturn(""); + + List out = agent.callExpand(entity); + + assertTrue(out.isEmpty(), + "blank content + no toolCalls → drop, same as pre-#120 behavior"); + } + + @Test + @DisplayName("E2E: approval-placeholder assistant dropped even when metadata.toolCalls looks complete") + void e2e_approvalPlaceholderWithToolCalls() { + TestAgent agent = newTestAgent(); + MessageEntity entity = new MessageEntity(); + entity.setId(104L); + entity.setRole("assistant"); + entity.setContent("[APPROVAL_PENDING] 工具调用等待您的批准"); + entity.setMetadata("{\"toolCalls\":[" + + "{\"toolCallId\":\"x\",\"name\":\"t\"," + + "\"status\":\"completed\",\"result\":\"y\"}" + + "]}"); + // renderMessageContent should NEVER be consulted for approval placeholders — + // shouldFullyFilter cuts before we look at content. Stubbing it would mask a + // regression where the filter ordering flipped. + + List out = agent.callExpand(entity); + + assertTrue(out.isEmpty(), + "approval placeholder must not resurrect a tool exchange — the tool never actually ran"); + } + + @Test + @DisplayName("E2E: error-status assistant dropped — replaying produces provider 400 loops") + void e2e_errorAssistantWithToolCalls() { + TestAgent agent = newTestAgent(); + MessageEntity entity = new MessageEntity(); + entity.setId(105L); + entity.setRole("assistant"); + entity.setStatus("error"); + entity.setContent("anything"); + entity.setMetadata("{\"toolCalls\":[" + + "{\"toolCallId\":\"x\",\"name\":\"t\"," + + "\"status\":\"completed\",\"result\":\"y\"}" + + "]}"); + + List out = agent.callExpand(entity); + + assertTrue(out.isEmpty()); + } + + @Test + @DisplayName("E2E: cron-header system row dropped before any tool replay") + void e2e_cronHeaderDropped() { + TestAgent agent = newTestAgent(); + MessageEntity entity = new MessageEntity(); + entity.setId(106L); + entity.setRole("system"); + entity.setContent("📋 每日新闻 · 定时触发 · 2026-04-30T10:55"); + entity.setMetadata("{\"toolCalls\":[" + + "{\"toolCallId\":\"x\",\"name\":\"t\"," + + "\"status\":\"completed\",\"result\":\"y\"}" + + "]}"); + + List out = agent.callExpand(entity); + + assertTrue(out.isEmpty()); + } + + @Test + @DisplayName("E2E: RFC-052 direct-tool row replays as placeholder only — no tool exchange resurrected") + void e2e_directToolPlaceholderOnly() { + TestAgent agent = newTestAgent(); + MessageEntity entity = new MessageEntity(); + entity.setId(107L); + entity.setRole("assistant"); + entity.setContent("EMPLOYEE-SECRET-DATA"); + entity.setMetadata("{\"directToolNames\":[\"query_employee_salary\"]," + + "\"toolCalls\":[" + + "{\"toolCallId\":\"x\",\"name\":\"query_employee_salary\"," + + "\"status\":\"completed\",\"result\":\"SECRET-PAYLOAD\"}" + + "]}"); + when(agent.conversationService.renderMessageContent(entity)).thenReturn("EMPLOYEE-SECRET-DATA"); + + List out = agent.callExpand(entity); + + assertEquals(1, out.size(), + "RFC-052 direct-tool row must replay as ONE placeholder message, not as a tool exchange"); + assertTrue(out.get(0) instanceof AssistantMessage); + String text = ((AssistantMessage) out.get(0)).getText(); + assertFalse(text.contains("EMPLOYEE-SECRET-DATA"), + "direct-tool content must be scrubbed from history"); + assertFalse(text.contains("SECRET-PAYLOAD"), + "tool result must not leak via resurrected tool exchange either"); + assertTrue(text.contains("query_employee_salary"), + "placeholder names the originating tool so the model can re-call it on follow-up"); + } + + @Test + @DisplayName("E2E: non-assistant rows are delegated to toSpringMessage unchanged") + void e2e_userMessagePassThrough() { + TestAgent agent = newTestAgent(); + MessageEntity entity = new MessageEntity(); + entity.setId(108L); + entity.setRole("user"); + entity.setContent("hi"); + when(agent.conversationService.renderMessageContent(entity)).thenReturn("hi"); + when(agent.conversationService.parseMessageParts(any())).thenReturn(List.of()); + + List out = agent.callExpand(entity); + + assertEquals(1, out.size()); + assertTrue(out.get(0) instanceof UserMessage); + assertEquals("hi", ((UserMessage) out.get(0)).getText()); + } + + // ---------- Test scaffold ---------- + + private static TestAgent newTestAgent() { + ConversationService conv = mock(ConversationService.class); + TestAgent agent = new TestAgent(conv); + agent.agentName = "test-agent"; + agent.modelName = "test-model"; + return agent; + } + + /** + * Minimal concrete BaseAgent fixture so the package-private + * {@code expandToSpringMessages} entry point can be exercised end-to-end + * (renderMessageContent → sanitizeForLlm fork → buildToolExchange). + */ + static class TestAgent extends BaseAgent { + TestAgent(ConversationService conv) { + super(null, conv); + } + + List callExpand(MessageEntity entity) { + return expandToSpringMessages(entity); + } + + @Override public String chat(String userMessage, String conversationId) { + throw new UnsupportedOperationException(); + } + + @Override public reactor.core.publisher.Flux chatStream(String userMessage, String conversationId) { + throw new UnsupportedOperationException(); + } + + @Override public String execute(String goal, String conversationId) { + throw new UnsupportedOperationException(); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/StateGraphReActAgentStreamedContentDeltaTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/StateGraphReActAgentStreamedContentDeltaTest.java new file mode 100644 index 00000000..c9dd092a --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/StateGraphReActAgentStreamedContentDeltaTest.java @@ -0,0 +1,69 @@ +package vip.mate.agent.graph; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vip.mate.agent.AgentService; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pins the routing decision for {@code STREAMED_CONTENT} deltas emitted by + * {@link StateGraphReActAgent} during the structured-stream loop. + * + *

Background — issue #120 follow-up: the original fix routed every per-iteration + * {@code STREAMED_CONTENT} via {@link AgentService.StreamDelta#segmentOnly}, which + * keeps the persisted {@code mate_message.content} clean of mid-loop "我来…" preamble. + * That broke the evidence-insufficient terminal turn though + * ({@code ReasoningNode.java:617}): there {@code FINAL_ANSWER} is just a short + * "[证据不足]" warning while {@code STREAMED_CONTENT} carries the actual answer body + * the user/UI need to see. Single-segment renderers (copy / TTS / history reload) + * read {@code content}, not segments, so {@code segmentOnly} for that case would + * shrink the visible message to the warning alone. + * + *

The helper under test embodies the corrected contract. + */ +class StateGraphReActAgentStreamedContentDeltaTest { + + @Test + @DisplayName("intermediate iteration (no FINAL_ANSWER yet) → segmentOnly — narration stays out of content") + void intermediateIteration_routedToSegmentsOnly() { + AgentService.StreamDelta d = StateGraphReActAgent.streamedContentDelta( + /* isFinalAnswerTurn */ false, + "I'll search for X."); + + assertTrue(d.persistenceOnly(), + "segmentOnly implies persistenceOnly — no re-broadcast (NodeStreamingChatHelper already pushed it)"); + assertTrue(d.segmentOnly(), + "intermediate narration MUST set segmentOnly so content.append is skipped"); + // Sanity: the content payload survives the wrap. + org.junit.jupiter.api.Assertions.assertEquals("I'll search for X.", d.content()); + } + + @Test + @DisplayName("evidence-insufficient terminal turn (FINAL_ANSWER set) → persistOnly — answer body persists to content") + void evidenceInsufficientFinalTurn_routedToPersistOnly() { + // Regression: STREAMED_CONTENT here is the rejected answer body; FINAL_ANSWER + // is only the "[证据不足]" warning. Persisting the streamed body keeps + // mate_message.content readable through single-segment renderers. + AgentService.StreamDelta d = StateGraphReActAgent.streamedContentDelta( + /* isFinalAnswerTurn */ true, + "The answer is 42. References: [1] [2] [3]."); + + assertTrue(d.persistenceOnly(), + "persistOnly suppresses re-broadcast — content was already streamed live"); + assertFalse(d.segmentOnly(), + "persistOnly variant MUST NOT set segmentOnly — content.append needs to run"); + org.junit.jupiter.api.Assertions.assertEquals( + "The answer is 42. References: [1] [2] [3].", d.content()); + } + + @Test + @DisplayName("both flavors leave thinking null — STREAMED_CONTENT routing only carries text content") + void thinkingFieldNeverSet() { + org.junit.jupiter.api.Assertions.assertNull( + StateGraphReActAgent.streamedContentDelta(false, "x").thinking()); + org.junit.jupiter.api.Assertions.assertNull( + StateGraphReActAgent.streamedContentDelta(true, "x").thinking()); + } +} diff --git a/mateclaw-ui/package.json b/mateclaw-ui/package.json index 11888ff5..7a3178c2 100644 --- a/mateclaw-ui/package.json +++ b/mateclaw-ui/package.json @@ -6,7 +6,7 @@ "description": "MateClaw - Personal AI Assistant Web Console", "scripts": { "dev": "vite", - "build": "vue-tsc --noEmit && vite build", + "build": "node --max-old-space-size=6144 ./node_modules/vue-tsc/bin/vue-tsc.js --noEmit && node --max-old-space-size=6144 ./node_modules/vite/bin/vite.js build", "preview": "vite preview", "lint": "eslint src --ext .ts,.vue --fix" }, @@ -14,6 +14,7 @@ "@element-plus/icons-vue": "^2.3.1", "@google/model-viewer": "^4.2.0", "@guolao/vue-monaco-editor": "^1.6.0", + "@intlify/core-base": "9.14.4", "@vue-flow/background": "^1.3.2", "@vue-flow/controls": "^1.1.3", "@vue-flow/core": "^1.48.2", diff --git a/mateclaw-ui/pnpm-lock.yaml b/mateclaw-ui/pnpm-lock.yaml index 18c792f4..57d9e133 100644 --- a/mateclaw-ui/pnpm-lock.yaml +++ b/mateclaw-ui/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@guolao/vue-monaco-editor': specifier: ^1.6.0 version: 1.6.0(monaco-editor@0.55.1)(vue@3.5.31(typescript@5.7.3)) + '@intlify/core-base': + specifier: 9.14.4 + version: 9.14.4 '@vue-flow/background': specifier: ^1.3.2 version: 1.3.2(@vue-flow/core@1.48.2(vue@3.5.31(typescript@5.7.3)))(vue@3.5.31(typescript@5.7.3))