mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(agent): structured tool-call replay + final-answer-only content (#120)
This commit is contained in:
parent
004df80611
commit
bf3f4af913
@ -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
|
||||
|
||||
@ -484,24 +484,55 @@ public class AgentService {
|
||||
|
||||
// ==================== StreamDelta ====================
|
||||
|
||||
public record StreamDelta(String content, String thinking, String eventType, Map<String, Object> eventData, boolean persistenceOnly) {
|
||||
public record StreamDelta(String content, String thinking, String eventType, Map<String, Object> 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<String, Object> 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.
|
||||
*
|
||||
* <p>The accumulator should:
|
||||
* <ul>
|
||||
* <li>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;</li>
|
||||
* <li>NOT broadcast — already broadcast live by NodeStreamingChatHelper;</li>
|
||||
* <li>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).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Implies {@code persistenceOnly} (no broadcast) at the accumulator
|
||||
* layer, but is a stricter promise: <em>nothing</em> 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<String, Object> data) {
|
||||
return new StreamDelta(null, null, type, data, false);
|
||||
return new StreamDelta(null, null, type, data, false, false);
|
||||
}
|
||||
|
||||
public boolean isEvent() {
|
||||
|
||||
@ -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<Message> 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.
|
||||
*
|
||||
* <p>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:
|
||||
* <ol>
|
||||
* <li>{@link AssistantMessage} carrying the persisted narration <em>and</em>
|
||||
* the {@link AssistantMessage.ToolCall} list reconstructed from
|
||||
* {@code metadata.toolCalls}.</li>
|
||||
* <li>A single {@link ToolResponseMessage} bundling one
|
||||
* {@link ToolResponseMessage.ToolResponse} per completed tool call,
|
||||
* with the persisted {@code result} string as content.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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).
|
||||
*
|
||||
* <p>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<Message> 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<PersistedToolCall> 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.
|
||||
*
|
||||
* <p>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<Message> buildToolExchange(MessageEntity entity, String content,
|
||||
List<PersistedToolCall> persisted) {
|
||||
List<AssistantMessage.ToolCall> toolCalls = new ArrayList<>(persisted.size());
|
||||
List<ToolResponseMessage.ToolResponse> 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}.
|
||||
*
|
||||
* <p>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<PersistedToolCall> 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<String, Object> meta = HISTORY_METADATA_MAPPER.readValue(json,
|
||||
new TypeReference<Map<String, Object>>() {});
|
||||
Object tc = meta.get("toolCalls");
|
||||
if (!(tc instanceof List<?> list)) return List.of();
|
||||
List<PersistedToolCall> result = new ArrayList<>(list.size());
|
||||
for (Object entry : list) {
|
||||
if (!(entry instanceof Map<?, ?> raw2)) continue;
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> call = (Map<String, Object>) 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) {}
|
||||
|
||||
/**
|
||||
* 判断消息是否为持久化的压缩摘要。
|
||||
*/
|
||||
|
||||
@ -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().<String>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().<String>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.
|
||||
*
|
||||
* <p>The contract:
|
||||
* <ul>
|
||||
* <li>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}.</li>
|
||||
* <li>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.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>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;
|
||||
|
||||
@ -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<String, Object> 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<String, Object> 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));
|
||||
|
||||
@ -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.
|
||||
*
|
||||
* <p>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<BaseAgent.PersistedToolCall> 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<BaseAgent.PersistedToolCall> 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<BaseAgent.PersistedToolCall> 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<BaseAgent.PersistedToolCall> 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<BaseAgent.PersistedToolCall> calls = List.of(
|
||||
new BaseAgent.PersistedToolCall("call_x", "search", "{\"q\":\"a\"}", "no hits"));
|
||||
|
||||
List<Message> 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<BaseAgent.PersistedToolCall> calls = List.of(
|
||||
new BaseAgent.PersistedToolCall("id-1", "fetch", "{}", "200 OK"));
|
||||
|
||||
List<Message> 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-<id>-<idx>' synthesis")
|
||||
void buildToolExchange_legacyIdSynthesis() {
|
||||
MessageEntity entity = new MessageEntity();
|
||||
entity.setId(99L);
|
||||
entity.setRole("assistant");
|
||||
|
||||
List<BaseAgent.PersistedToolCall> calls = List.of(
|
||||
new BaseAgent.PersistedToolCall("", "search", "{}", "hit"),
|
||||
new BaseAgent.PersistedToolCall(null, "fetch", "{}", "ok"));
|
||||
|
||||
List<Message> 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<BaseAgent.PersistedToolCall> calls = List.of(
|
||||
new BaseAgent.PersistedToolCall("a", "t1", "{}", "r1"),
|
||||
new BaseAgent.PersistedToolCall("b", "t2", "{}", "r2"),
|
||||
new BaseAgent.PersistedToolCall("c", "t3", "{}", "r3"));
|
||||
|
||||
List<Message> 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<Message> 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<Message> 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<Message> 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<Message> 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<Message> 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<Message> 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<Message> 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<Message> 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<Message> 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<Message> callExpand(MessageEntity entity) {
|
||||
return expandToSpringMessages(entity);
|
||||
}
|
||||
|
||||
@Override public String chat(String userMessage, String conversationId) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override public reactor.core.publisher.Flux<String> chatStream(String userMessage, String conversationId) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override public String execute(String goal, String conversationId) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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());
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
3
mateclaw-ui/pnpm-lock.yaml
generated
3
mateclaw-ui/pnpm-lock.yaml
generated
@ -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))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user