From 493910bf5ab0b012da86660e84e40a0d287522c6 Mon Sep 17 00:00:00 2001 From: matevip Date: Thu, 14 May 2026 09:30:43 +0800 Subject: [PATCH] =?UTF-8?q?release:=20v1.3.0=20(hotfix=20bundle=20?= =?UTF-8?q?=E2=80=94=20#120=20+=20UI/build=20fixes)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 6 + mateclaw-server/Dockerfile | 10 +- .../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 | 4 +- mateclaw-ui/pnpm-lock.yaml | 219 +++++++ mateclaw-ui/src/assets/main.css | 13 + .../components/workflow/StepPropertyPanel.vue | 10 +- .../components/workflow/WorkflowCanvas.vue | 19 + .../workflow/WorkflowJsonEditor.vue | 7 +- mateclaw-ui/src/i18n/index.ts | 20 + mateclaw-ui/src/i18n/locales/en-US.ts | 4 +- mateclaw-ui/src/i18n/locales/zh-CN.ts | 4 +- mateclaw-ui/src/utils/mcpCatalog.ts | 243 ++++++++ mateclaw-ui/src/views/Settings/Layout.vue | 33 +- mateclaw-ui/src/views/Workflows.vue | 296 +++++---- mateclaw-ui/vite.config.ts | 38 ++ 21 files changed, 1789 insertions(+), 146 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 create mode 100644 mateclaw-ui/src/utils/mcpCatalog.ts diff --git a/.gitignore b/.gitignore index dd12de0d..71b27cc3 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,12 @@ nbbuild/ nbdist/ .nb-gradle/ +### frontend build artifacts ### +# Vite's primary output goes to mateclaw-server/.../static; the only thing +# that lands here is rollup-plugin-visualizer's stats.html when running +# ANALYZE=1 pnpm build. +mateclaw-ui/dist/ + ### maven ### target/ *.war diff --git a/mateclaw-server/Dockerfile b/mateclaw-server/Dockerfile index 05ecfb94..d6ec4146 100644 --- a/mateclaw-server/Dockerfile +++ b/mateclaw-server/Dockerfile @@ -18,9 +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. +# 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 63216ca1..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": "node --max-old-space-size=6144 ./node_modules/vue-tsc/bin/vue-tsc.js && node --max-old-space-size=6144 ./node_modules/vite/bin/vite.js 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", @@ -47,6 +48,7 @@ "autoprefixer": "^10.4.20", "eslint": "^9.18.0", "eslint-plugin-vue": "^9.32.0", + "rollup-plugin-visualizer": "^7.0.1", "tailwindcss": "^4.0.6", "typescript": "~5.7.2", "vite": "^7.3.1", diff --git a/mateclaw-ui/pnpm-lock.yaml b/mateclaw-ui/pnpm-lock.yaml index 07dcdf84..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)) @@ -111,6 +114,9 @@ importers: eslint-plugin-vue: specifier: ^9.32.0 version: 9.33.0(eslint@9.39.4(jiti@2.6.1)) + rollup-plugin-visualizer: + specifier: ^7.0.1 + version: 7.0.1(rollup@4.60.1) tailwindcss: specifier: ^4.0.6 version: 4.2.2 @@ -948,10 +954,18 @@ packages: alien-signals@3.1.2: resolution: {integrity: sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -993,6 +1007,10 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -1017,6 +1035,10 @@ packages: resolution: {integrity: sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==} engines: {node: '>=22.0.0'} + cliui@9.0.1: + resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} + engines: {node: '>=20'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1238,6 +1260,18 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + delaunator@5.1.0: resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} @@ -1270,6 +1304,9 @@ packages: peerDependencies: vue: ^3.3.0 + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + enhanced-resolve@5.20.1: resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} engines: {node: '>=10.13.0'} @@ -1423,6 +1460,14 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -1505,6 +1550,11 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1513,6 +1563,15 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + is-promise@2.2.2: resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} @@ -1520,6 +1579,10 @@ packages: resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} engines: {node: '>=18'} + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1746,6 +1809,10 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1827,6 +1894,10 @@ packages: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -1856,6 +1927,19 @@ packages: robust-predicates@3.0.3: resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + rollup-plugin-visualizer@7.0.1: + resolution: {integrity: sha512-UJUT4+1Ho4OcWmPYU3sYXgUqI8B8Ayfe06MX7y0qCJ1K8aGoKtR/NDd/2nZqM7ADkrzny+I99Ul7GgyoiVNAgg==} + engines: {node: '>=22'} + hasBin: true + peerDependencies: + rolldown: 1.x || ^1.0.0-beta || ^1.0.0-rc + rollup: 2.x || 3.x || 4.x + peerDependenciesMeta: + rolldown: + optional: true + rollup: + optional: true + rollup@4.60.1: resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -1864,6 +1948,10 @@ packages: roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + rw@1.3.3: resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} @@ -1887,6 +1975,10 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + speakingurl@14.0.1: resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} engines: {node: '>=0.10.0'} @@ -1894,6 +1986,14 @@ packages: state-local@1.0.7: resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -2080,10 +2180,30 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + xml-name-validator@4.0.0: resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} engines: {node: '>=12'} + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yargs@18.0.0: + resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -2826,10 +2946,14 @@ snapshots: alien-signals@3.1.2: {} + ansi-regex@6.2.2: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + ansi-styles@6.2.3: {} + argparse@2.0.1: {} async-validator@4.2.5: {} @@ -2874,6 +2998,10 @@ snapshots: node-releases: 2.0.37 update-browserslist-db: 1.2.3(browserslist@4.28.2) + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -2901,6 +3029,12 @@ snapshots: '@chevrotain/types': 12.0.0 '@chevrotain/utils': 12.0.0 + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -3138,6 +3272,15 @@ snapshots: deep-is@0.1.4: {} + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + define-lazy-prop@3.0.0: {} + delaunator@5.1.0: dependencies: robust-predicates: 3.0.3 @@ -3188,6 +3331,8 @@ snapshots: transitivePeerDependencies: - typescript + emoji-regex@10.6.0: {} + enhanced-resolve@5.20.1: dependencies: graceful-fs: 4.2.11 @@ -3381,6 +3526,10 @@ snapshots: function-bind@1.1.2: {} + get-caller-file@2.0.5: {} + + get-east-asian-width@1.6.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -3454,16 +3603,28 @@ snapshots: internmap@2.0.3: {} + is-docker@3.0.0: {} + is-extglob@2.1.1: {} is-glob@4.0.3: dependencies: is-extglob: 2.1.1 + is-in-ssh@1.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + is-promise@2.2.2: {} is-what@5.5.0: {} + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + isexe@2.0.0: {} jiti@2.6.1: {} @@ -3673,6 +3834,15 @@ snapshots: dependencies: boolbase: 1.0.0 + open@11.0.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -3749,6 +3919,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + powershell-utils@0.1.0: {} + prelude-ls@1.2.1: {} promise-worker-transferable@1.0.4: @@ -3768,6 +3940,15 @@ snapshots: robust-predicates@3.0.3: {} + rollup-plugin-visualizer@7.0.1(rollup@4.60.1): + dependencies: + open: 11.0.0 + picomatch: 4.0.4 + source-map: 0.7.6 + yargs: 18.0.0 + optionalDependencies: + rollup: 4.60.1 + rollup@4.60.1: dependencies: '@types/estree': 1.0.8 @@ -3806,6 +3987,8 @@ snapshots: points-on-curve: 0.2.0 points-on-path: 0.2.1 + run-applescript@7.1.0: {} + rw@1.3.3: {} safer-buffer@2.1.2: {} @@ -3820,10 +4003,22 @@ snapshots: source-map-js@1.2.1: {} + source-map@0.7.6: {} + speakingurl@14.0.1: {} state-local@1.0.7: {} + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-json-comments@3.1.1: {} stylis@4.4.0: {} @@ -3960,8 +4155,32 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + wsl-utils@0.3.1: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 + xml-name-validator@4.0.0: {} + y18n@5.0.8: {} + + yargs-parser@22.0.0: {} + + yargs@18.0.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 7.2.0 + y18n: 5.0.8 + yargs-parser: 22.0.0 + yocto-queue@0.1.0: {} zrender@6.0.0: diff --git a/mateclaw-ui/src/assets/main.css b/mateclaw-ui/src/assets/main.css index f7e43ed3..81de41ac 100644 --- a/mateclaw-ui/src/assets/main.css +++ b/mateclaw-ui/src/assets/main.css @@ -634,6 +634,19 @@ html.dark body::before { box-shadow: var(--mc-shadow-soft); } +/* While a workflow canvas is in fullscreen mode, suppress the backdrop + filter on every page frame. A non-`none` backdrop-filter promotes the + element to a containing block for position:fixed descendants, which + pinned the fullscreen canvas to the frame's top-left instead of the + viewport. The frames sit behind the fullscreen overlay anyway, so + the blur has no visible cost during this transient state. */ +body.workflow-canvas-fullscreen .mc-page-frame { + backdrop-filter: none; +} +body.workflow-canvas-fullscreen .mc-page-frame::before { + display: none; +} + .mc-page-frame::before { content: ''; position: absolute; diff --git a/mateclaw-ui/src/components/workflow/StepPropertyPanel.vue b/mateclaw-ui/src/components/workflow/StepPropertyPanel.vue index 6f6b77b8..73e58964 100644 --- a/mateclaw-ui/src/components/workflow/StepPropertyPanel.vue +++ b/mateclaw-ui/src/components/workflow/StepPropertyPanel.vue @@ -66,7 +66,7 @@ @input="patch({ promptTemplate: ($event.target as HTMLTextAreaElement).value })" spellcheck="false" rows="3" - :placeholder="t('workflows.canvas.fields.promptPlaceholder')" + :placeholder="PROMPT_PLACEHOLDER" /> @@ -319,6 +319,14 @@ const emit = defineEmits<{ const { t } = useI18n() +// The Pebble example shown in the textarea placeholder is the same in every +// locale and contains literal `{{ }}`. Routing it through vue-i18n's t() +// works but the library does a second compile pass on the returned string +// looking for linked messages / nested placeholders, which trips on the +// inner braces and floods the console with parse errors. Keeping it as a +// plain const sidesteps the parser entirely. +const PROMPT_PLACEHOLDER = 'Hello {{ inputs.payload }}' + const modeType = computed(() => (props.step?.mode?.type ?? 'sequential') as string) const localizedModeLabel = computed(() => { diff --git a/mateclaw-ui/src/components/workflow/WorkflowCanvas.vue b/mateclaw-ui/src/components/workflow/WorkflowCanvas.vue index 72fab20f..905202da 100644 --- a/mateclaw-ui/src/components/workflow/WorkflowCanvas.vue +++ b/mateclaw-ui/src/components/workflow/WorkflowCanvas.vue @@ -123,6 +123,11 @@ const props = withDefaults(defineProps(), { canvasId: 'workflow-canvas' } const emit = defineEmits<{ (e: 'select-step', payload: StepNodeData | null): void (e: 'insert-step', payload: { afterIndex: number; modeType: string }): void + /** Toggled by the fullscreen button. The parent uses this to float the + * property inspector above the fullscreen overlay (otherwise the panel, + * which lives as a flex sibling outside this component, gets hidden + * behind the z-index: 2000 fixed canvas). */ + (e: 'update:fullscreen', value: boolean): void }>() function onAddNode(e: Event) { @@ -238,15 +243,29 @@ watch(fullscreen, (on) => { document.addEventListener('keydown', handleEsc) // Lock page scroll behind the overlay. document.body.style.overflow = 'hidden' + // The settings layout's mc-page-frame uses backdrop-filter, which + // promotes it to a containing block for position:fixed descendants + // (CSS containment spec). That makes our `inset: 0` anchor to the + // frame instead of the viewport, leaving the canvas pinned to the + // top-left of the page rather than truly fullscreen. Suppress the + // filter while fullscreen is active — the frames sit behind the + // overlay anyway, so dropping the blur has no visible cost. + document.body.classList.add('workflow-canvas-fullscreen') } else { document.removeEventListener('keydown', handleEsc) document.body.style.overflow = '' + document.body.classList.remove('workflow-canvas-fullscreen') } + emit('update:fullscreen', on) }) onBeforeUnmount(() => { window.removeEventListener('mateclaw:workflow-step-select', handleDomStepSelect as EventListener) document.removeEventListener('keydown', handleEsc) document.body.style.overflow = '' + // Unmounting while in fullscreen (route change, panel close) must + // restore the page frame's backdrop blur — otherwise we leave the + // rest of the app permanently transparent. + document.body.classList.remove('workflow-canvas-fullscreen') }) diff --git a/mateclaw-ui/src/components/workflow/WorkflowJsonEditor.vue b/mateclaw-ui/src/components/workflow/WorkflowJsonEditor.vue index ff98baa5..88da4472 100644 --- a/mateclaw-ui/src/components/workflow/WorkflowJsonEditor.vue +++ b/mateclaw-ui/src/components/workflow/WorkflowJsonEditor.vue @@ -16,7 +16,12 @@