feat(channel): extract the per-turn stream accumulator and share it between web SSE and IM sync paths

This commit is contained in:
matevip 2026-07-22 18:09:59 +08:00
parent d999608b2c
commit a183519d69
3 changed files with 617 additions and 115 deletions

View File

@ -15,6 +15,7 @@ import vip.mate.channel.event.ChannelMessageReceivedEvent;
import vip.mate.channel.model.ChannelEntity;
import vip.mate.channel.notification.ApprovalNotificationService;
import vip.mate.channel.service.ChannelService;
import vip.mate.channel.web.AgentStreamAccumulator;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.exception.MateClawException;
import vip.mate.memory.event.ConversationCompletionPublisher;
@ -157,30 +158,6 @@ public class ChannelMessageRouter {
return currentMergedLength > LONG_TEXT_THRESHOLD ? LONG_DEBOUNCE_MS : DEBOUNCE_MS;
}
/**
* Plan-Execute SSE events that the Web Console mirror needs to see when
* a conversation runs through an IM channel.
* <p>
* The agent emits these via {@code GraphEventPublisher} and they ride on
* the {@code chatStructuredStream} Flux as {@code StreamDelta.event(...)}.
* Web direct chats already broadcast them via the ChatController
* accumulator. IM channels (DingTalk + the seven sync-path adapters)
* historically dropped them DingTalk's {@code processStreamAsText}
* only consumes {@code delta.content()}, and the sync {@code chat()}
* collector explicitly filters {@code delta.isEvent()} out. The whitelist
* is applied in the IM stream path so PlanStepsPanel renders correctly
* when an operator monitors an IM conversation in the Web Console.
* <p>
* Whitelist (not pass-through) so Web-side accumulator-internal events
* like {@code _usage_final} or future agent-internal markers don't leak
* to subscribers.
*/
private static final Set<String> MIRRORED_PLAN_EVENTS = Set.of(
"plan_created",
"plan_step_started",
"plan_step_completed"
);
/** 是否已关闭 */
private volatile boolean shutdown = false;
@ -802,12 +779,10 @@ public class ChannelMessageRouter {
// Sync path for non-streaming IM adapters (weixin / slack /
// discord / qq / telegram). We can't use agentService.chat()
// because its collector filters out `delta.isEvent()` deltas that
// would silently drop plan_created / plan_step_* events that the Web
// Console mirror needs to render PlanStepsPanel. Instead we consume
// chatStructuredStream directly: content gets accumulated for the IM
// reply, and whitelisted plan events are mirrored to ChatStreamTracker
// for any Web SSE viewer of the same conversationId.
StringBuilder replyAccumulator = new StringBuilder();
// would silently drop the tool/plan events the Web Console
// mirror and the persisted execution metadata both need.
// Instead we consume chatStructuredStream directly through
// the shared accumulator (reply text, metadata, live mirror).
final String channelType = adapter.getChannelType();
// Channel-level toggle for relaying per-stage narration as
// standalone messages mid-run. Shares the key the streaming
@ -817,35 +792,25 @@ public class ChannelMessageRouter {
// still see it via the live broadcast).
final boolean relayNarration = channelConfigBoolean(
channelEntity, "stream_progress", true);
// Token usage + model attribution: capture _usage_final event emitted at stream end
final int[] usage = {0, 0, 0, 0, 0}; // [prompt, completion, cacheRead, cacheWrite, reasoning]
final String[] modelInfo = {null, null}; // [runtimeModel, runtimeProvider]
// Shared accumulator: builds the segments/toolCalls metadata
// the Web console renders for history, mirrors events +
// deltas to live Web observers, and captures token usage +
// model attribution (_usage_final is consumed internally).
// Reply text also comes from it same semantics as the
// legacy collector: persistOnly deltas included (DirectAnswerNode-
// routed answers arrive as persistOnly when CONTENT_STREAMED=true
// and IM channels still need the text for the outgoing reply),
// segmentOnly narration excluded (issue #120).
AgentStreamAccumulator accumulator = newAccumulator();
agentService.chatStructuredStream(agentId, promptText, conversationId,
message.getSenderId(), chatOrigin)
.doOnNext(delta -> {
if (delta.isEvent()) {
if ("_usage_final".equals(delta.eventType())) {
Map<String, Object> data = delta.eventData();
usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
usage[2] = ((Number) data.getOrDefault("cacheReadTokens", 0)).intValue();
usage[3] = ((Number) data.getOrDefault("cacheWriteTokens", 0)).intValue();
usage[4] = ((Number) data.getOrDefault("reasoningTokens", 0)).intValue();
Object model = data.get("runtimeModelName");
Object provider = data.get("runtimeProviderId");
if (model != null) modelInfo[0] = model.toString();
if (provider != null) modelInfo[1] = provider.toString();
}
mirrorPlanEventToTracker(conversationId, delta, channelType);
} else if (delta.segmentOnly()) {
accumulator.accept(delta, conversationId);
if (!delta.isEvent() && delta.segmentOnly()) {
// Per-stage narration ("Let me look that up…"), emitted as
// one complete delta per agent loop iteration. Relay it
// immediately as its own outgoing message so the user sees
// progress mid-run, and keep it out of the reply accumulator:
// concatenated narrations read as a wall of text in the
// final reply, and persisting them feeds the next turn's
// LLM history an unanswered chain of stated intents that
// replay mistakes for unfinished work (issue #120).
// progress mid-run.
String narration = delta.content() != null ? delta.content().trim() : "";
if (relayNarration && !narration.isEmpty() && replyTarget != null) {
try {
@ -857,16 +822,10 @@ public class ChannelMessageRouter {
channelType, sendErr.getMessage());
}
}
} else if (delta.content() != null) {
// Match the legacy agentService.chat() behavior: include
// persistOnly deltas too. DirectAnswerNode-routed answers
// arrive as persistOnly when CONTENT_STREAMED=true and IM
// channels still need the text for the outgoing reply.
replyAccumulator.append(delta.content());
}
})
.blockLast(Duration.ofMinutes(10));
String reply = replyAccumulator.toString();
String reply = accumulator.getContent();
// The IM sync path bypasses FinalAnswerNode, so hallucinated
// /api/v1/files/generated/{id} URLs (LLM wrote a fake link
@ -905,9 +864,19 @@ public class ChannelMessageRouter {
// error turns must not pollute memory extraction.
boolean isError = errorClassifier.isErrorReply(reply);
String status = isError ? "error" : "completed";
// Persist the full execution record (parts + metadata)
// so the Web console renders IM-routed turns exactly
// like Web direct chats. The content column keeps the
// scrubbed reply text that actually went out.
MessageEntity saved = conversationService.saveMessage(
conversationId, "assistant", reply, null, status,
usage[0], usage[1], usage[2], usage[3], usage[4], modelInfo[0], modelInfo[1], null);
conversationId, "assistant", reply,
accumulator.toAssistantParts(), status,
accumulator.getPromptTokens(), accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(), accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
blankToNull(accumulator.getRuntimeModelName()),
blankToNull(accumulator.getRuntimeProviderId()),
accumulator.toMetadataJson());
savedAssistantId = saved != null ? saved.getId() : null;
if (!isError) {
publishConversationCompletedEvent(agentId, conversationId, message.getContent(), reply, chatOrigin);
@ -1066,6 +1035,30 @@ public class ChannelMessageRouter {
}
}
/**
* Build a per-turn accumulator wired to the stream tracker, so live Web
* observers of an IM conversation receive the same event fan-out as Web
* direct chats, and the persisted metadata matches byte-for-byte.
*/
private AgentStreamAccumulator newAccumulator() {
return new AgentStreamAccumulator(objectMapper, new AgentStreamAccumulator.Sink() {
@Override
public void broadcast(String conversationId, String eventName, Object payload) {
streamTracker.broadcastObject(conversationId, eventName, payload);
}
@Override
public void updatePhase(String conversationId, String phase) {
streamTracker.updatePhase(conversationId, phase);
}
});
}
/** Map the accumulator's empty-string defaults back to SQL NULL. */
private static String blankToNull(String s) {
return s == null || s.isBlank() ? null : s;
}
/**
* 流式处理路径渠道无关
* <p>
@ -1074,30 +1067,6 @@ public class ChannelMessageRouter {
* - StreamingChannelAdapter 负责渲染AI Card / 卡片更新 / 文本累积等
* - Router 负责后续的审批检查消息持久化事件发布
*/
/**
* Forward whitelisted Plan-Execute SSE events to ChatStreamTracker so a
* Web Console viewer of an IM-routed conversation sees PlanStepsPanel.
* <p>
* Bounded to {@link #MIRRORED_PLAN_EVENTS} see the constant's javadoc
* for why this is a whitelist rather than a pass-through. Failures here
* are best-effort and never propagate, since dropping a UI update is
* preferable to derailing the channel reply.
*/
private void mirrorPlanEventToTracker(String conversationId,
AgentService.StreamDelta delta,
String channelTypeForLog) {
String eventType = delta.eventType();
if (eventType == null || !MIRRORED_PLAN_EVENTS.contains(eventType)) {
return;
}
try {
streamTracker.broadcastObject(conversationId, eventType, delta.eventData());
} catch (Exception ex) {
log.debug("[{}] Failed to mirror plan event {}: {}",
channelTypeForLog, eventType, ex.getMessage());
}
}
private Long processWithStreaming(ChannelMessage message, StreamingChannelAdapter streamingAdapter,
String conversationId, Long agentId, String promptText,
ChannelEntity channelEntity, ChatOrigin chatOrigin) {
@ -1105,33 +1074,22 @@ public class ChannelMessageRouter {
log.info("[{}] Streaming processing started: conversationId={}", channelType, conversationId);
try {
// Step 1: 产生事件流RFC-063r §2.5: forward ChatOrigin so tools see channelId
// Step 1: 产生事件流forward ChatOrigin so tools see channelId
Flux<AgentService.StreamDelta> stream = agentService.chatStructuredStream(
agentId, promptText, conversationId, message.getSenderId(), chatOrigin);
// Mirror plan-execute SSE events to ChatStreamTracker before the
// adapter consumes the Flux. DingTalkChannelAdapter.processStreamAsText
// only reads `delta.content()` and would otherwise eat plan_created /
// plan_step_* events, leaving the Web Console mirror with no
// PlanStepsPanel for IM-routed conversations.
// Token usage + model attribution: capture _usage_final event emitted at stream end
final int[] usage = {0, 0, 0, 0, 0}; // [prompt, completion, cacheRead, cacheWrite, reasoning]
final String[] modelInfo = {null, null}; // [runtimeModel, runtimeProvider]
Flux<AgentService.StreamDelta> mirroredStream = stream.doOnNext(delta -> {
if (delta.isEvent() && "_usage_final".equals(delta.eventType())) {
Map<String, Object> data = delta.eventData();
usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
usage[2] = ((Number) data.getOrDefault("cacheReadTokens", 0)).intValue();
usage[3] = ((Number) data.getOrDefault("cacheWriteTokens", 0)).intValue();
usage[4] = ((Number) data.getOrDefault("reasoningTokens", 0)).intValue();
Object model = data.get("runtimeModelName");
Object provider = data.get("runtimeProviderId");
if (model != null) modelInfo[0] = model.toString();
if (provider != null) modelInfo[1] = provider.toString();
}
mirrorPlanEventToTracker(conversationId, delta, channelType);
});
// Feed every delta through the shared accumulator before the
// adapter consumes the Flux. The accumulator builds the same
// segments/toolCalls metadata the Web SSE path persists (so the
// console renders the execution timeline for IM-routed turns),
// mirrors tool/plan/content events to any live Web observer of
// this conversation, and captures token usage + model
// attribution. Internal bookkeeping events (_usage_final,
// _routing_decision) are consumed inside the accumulator and
// never reach subscribers.
AgentStreamAccumulator accumulator = newAccumulator();
Flux<AgentService.StreamDelta> mirroredStream = stream.doOnNext(delta ->
accumulator.accept(delta, conversationId));
// Step 2: 委托渠道渲染渠道内部消费 Flux 并处理 UI 更新
String finalContent = streamingAdapter.processStream(mirroredStream, message, conversationId);
@ -1150,9 +1108,20 @@ public class ChannelMessageRouter {
} else if (finalContent != null && !finalContent.isBlank()) {
boolean isError = errorClassifier.isErrorReply(finalContent);
String status = isError ? "error" : "completed";
// Persist the full execution record parts (text/thinking/
// tool_call) and metadata (segments/toolCalls/plan/) so
// the Web console renders IM-routed turns exactly like Web
// direct chats. The content column keeps the adapter's final
// text (the adapter may have post-processed it).
MessageEntity saved = conversationService.saveMessage(
conversationId, "assistant", finalContent, null, status,
usage[0], usage[1], usage[2], usage[3], usage[4], modelInfo[0], modelInfo[1], null);
conversationId, "assistant", finalContent,
accumulator.toAssistantParts(), status,
accumulator.getPromptTokens(), accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(), accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
blankToNull(accumulator.getRuntimeModelName()),
blankToNull(accumulator.getRuntimeProviderId()),
accumulator.toMetadataJson());
if (!isError) {
publishConversationCompletedEvent(agentId, conversationId, promptText, finalContent, chatOrigin);
}

View File

@ -0,0 +1,530 @@
package vip.mate.channel.web;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import vip.mate.agent.AgentService;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.workspace.conversation.model.MessageContentPart;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 流式累积器 收集 StreamDelta 事件持久化到 DB
* <p>
* 维护两份数据
* <ul>
* <li>{@code toolCalls} 兼容旧逻辑执行面板等 UI 使用</li>
* <li>{@code segments} 按事件到达顺序记录的有序时间线前端分段渲染用</li>
* </ul>
* 两份数据从同一事件流构建保证一致segments 保留了 thinking tools content
* 的真实交错顺序toolCalls segments tool_call 类型的平铺视图
* <p>
* Shared by the Web SSE path ({@code ChatController}) and the IM channel
* router live fan-out side effects go through the injected {@link Sink}
* so each caller keeps its own broadcast semantics. Internal bookkeeping
* events ({@code _usage_final}, {@code _routing_decision}) are consumed
* here and never reach the sink.
*/
@Slf4j
public final class AgentStreamAccumulator {
/**
* Live fan-out hooks. The accumulator itself only builds the persisted
* metadata/parts; anything a subscriber should see in real time is
* delegated here.
*/
public interface Sink {
/** Broadcast a named event to live subscribers of the conversation. */
void broadcast(String conversationId, String eventName, Object payload);
/** Update the conversation's current phase indicator. */
void updatePhase(String conversationId, String phase);
}
/** Markdown link pointing at a generated-file download URL. Used to
* surface generated artifacts in the run-overview rail. */
private static final Pattern GENERATED_FILE_LINK_PATTERN =
Pattern.compile("\\[([^\\]]+)\\]\\(((?:https?://[^/\\s)\\]]+)?/api/v1/files/generated/[A-Za-z0-9-]+)\\)");
private final ObjectMapper objectMapper;
private final Sink sink;
private final StringBuilder content = new StringBuilder();
private final StringBuilder thinking = new StringBuilder();
private final List<Map<String, Object>> toolCalls = new ArrayList<>();
/** 有序事件时间线 — 前端分段渲染的权威数据源 */
private final List<Map<String, Object>> segments = new ArrayList<>();
private final List<Map<String, Object>> browserActions = new ArrayList<>();
private final List<String> warnings = new ArrayList<>();
private final List<Map<String, Object>> planStepResults = new ArrayList<>();
/** Tool names whose returnDirect output was folded into the assistant message */
private final List<String> directToolNames = new ArrayList<>();
/** Generated file artifacts extracted from tool results — surfaced in the run-overview rail. */
private final List<Map<String, Object>> generatedFiles = new ArrayList<>();
private int segCounter = 0;
private int promptTokens = 0;
private int completionTokens = 0;
private int cacheReadTokens = 0;
private int cacheWriteTokens = 0;
private int reasoningTokens = 0;
private String runtimeModelName = "";
private String runtimeProviderId = "";
private boolean awaitingApproval = false;
private String currentPhase = "";
/**
* Graph-emitted FinishReason for the turn (e.g. {@code "incomplete"},
* {@code "stopped"}, {@code "evidence_insufficient"}). Sourced from
* the {@code finish_reason} {@link GraphEventPublisher}
* event that {@code FinalAnswerNode} attaches to its PENDING_EVENTS
* output same pipeline the SSE accumulator already drains, so the
* value is delivered alongside the assistant content (not via a
* sibling SSE-only broadcast that would bypass this accumulator).
* Persisted into message metadata so downstream filters
* (memory promotion gate) see a machine-readable status instead of
* having to guess from text. Empty string until the event arrives.
*/
private String finishReason = "";
/**
* Recovery affordance payload from {@link GraphEventPublisher#feedback}.
* Persisted into {@code metadata.feedbackEvent} so a page reload still
* surfaces the retry/regenerate/report card on the failed assistant
* bubble. Null when the turn ended cleanly.
*/
private Map<String, Object> feedbackEvent = null;
private Long planId = null;
private List<String> planSteps = List.of();
private Integer currentPlanStep = null;
private Map<String, Object> pendingApproval = null;
/**
* Multimodal sidecar routing decision for this turn (null when no
* routing happened). Captured from the {@code _routing_decision}
* event emitted before the graph stream and folded into
* {@code metadata.routing} on persistence so the chat UI can show
* which sidecar (if any) was invoked.
*/
private Map<String, Object> routingDecision = null;
public AgentStreamAccumulator(ObjectMapper objectMapper, Sink sink) {
this.objectMapper = objectMapper;
this.sink = sink;
}
public synchronized void accept(AgentService.StreamDelta delta, String conversationId) {
if (delta == null) return;
if (delta.isEvent()) {
if ("_usage_final".equals(delta.eventType())) {
Map<String, Object> data = delta.eventData();
promptTokens = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
completionTokens = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
cacheReadTokens = ((Number) data.getOrDefault("cacheReadTokens", 0)).intValue();
cacheWriteTokens = ((Number) data.getOrDefault("cacheWriteTokens", 0)).intValue();
reasoningTokens = ((Number) data.getOrDefault("reasoningTokens", 0)).intValue();
runtimeModelName = String.valueOf(data.getOrDefault("runtimeModelName", ""));
runtimeProviderId = String.valueOf(data.getOrDefault("runtimeProviderId", ""));
return;
}
if ("phase".equals(delta.eventType())) {
String phase = String.valueOf(delta.eventData().getOrDefault("phase", ""));
if (!phase.isBlank()) {
currentPhase = phase;
sink.updatePhase(conversationId, phase);
// phase 切换时关闭 running content/thinking segment保留边界
finalizeRunningSegments("content", "thinking");
}
}
if ("finish_reason".equals(delta.eventType())) {
Object reason = delta.eventData().get("reason");
if (reason != null) {
// Last-write-wins: graph normally fires this exactly once
// at FinalAnswerNode completion. Replay paths that re-enter
// the graph after approval will emit a fresh value, which
// is the correct behavior the latest reason is what gets
// persisted with the assistant message.
finishReason = String.valueOf(reason);
}
}
if (GraphEventPublisher.EVENT_FEEDBACK.equals(delta.eventType())) {
// Snapshot the affordance payload so it persists into
// message metadata. The same event is also rebroadcast
// live (via the sink fall-through below) so an
// already-mounted UI sees it instantly without
// waiting for the message-save round trip.
feedbackEvent = delta.eventData();
}
if (GraphEventPublisher.EVENT_ROUTING_DECISION.equals(delta.eventType())) {
// Captured at turn start; persisted under metadata.routing so the
// chat UI can render which sidecar (if any) was invoked. Internal
// event return early to skip rebroadcast on IM channels.
routingDecision = delta.eventData();
return;
}
accumulateToolEvent(delta.eventType(), delta.eventData(), conversationId);
try {
sink.broadcast(conversationId, delta.eventType(), delta.eventData());
} catch (Exception e) {
log.warn("Failed to broadcast event {}: {}", delta.eventType(), e.getMessage());
}
return;
}
// content_delta
if (delta.content() != null && !delta.content().isBlank()) {
// 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());
}
sink.updatePhase(conversationId, "drafting_answer");
if (!delta.persistenceOnly()) {
sink.broadcast(conversationId, "content_delta", Map.of("delta", delta.content()));
}
// segments: 追加到当前 running content segment或创建新的
var seg = findLastRunning("content");
if (seg != null) {
seg.put("text", seg.getOrDefault("text", "") + delta.content());
} else {
finalizeRunningSegments("thinking");
var s = newSegment("content");
s.put("text", delta.content());
segments.add(s);
}
}
// thinking_delta
if (delta.thinking() != null && !delta.thinking().isBlank()) {
if (!delta.segmentOnly()) {
thinking.append(delta.thinking());
}
if (!delta.persistenceOnly()) {
sink.broadcast(conversationId, "thinking_delta", Map.of("delta", delta.thinking()));
}
var seg = findLastRunning("thinking");
if (seg != null) {
seg.put("thinkingText", seg.getOrDefault("thinkingText", "") + delta.thinking());
} else {
var s = newSegment("thinking");
s.put("thinkingText", delta.thinking());
segments.add(s);
}
}
}
public boolean isAwaitingApproval() { return awaitingApproval; }
private void accumulateToolEvent(String eventType, Map<String, Object> data, String conversationId) {
if ("tool_approval_requested".equals(eventType)) {
awaitingApproval = true;
currentPhase = "awaiting_approval";
pendingApproval = new LinkedHashMap<>();
pendingApproval.put("pendingId", data.getOrDefault("pendingId", ""));
pendingApproval.put("toolName", data.getOrDefault("toolName", ""));
pendingApproval.put("arguments", data.getOrDefault("arguments", ""));
pendingApproval.put("reason", data.getOrDefault("reason", ""));
pendingApproval.put("status", "pending_approval");
if (data.containsKey("findings")) pendingApproval.put("findings", data.get("findings"));
if (data.containsKey("maxSeverity")) pendingApproval.put("maxSeverity", data.get("maxSeverity"));
if (data.containsKey("summary")) pendingApproval.put("summary", data.get("summary"));
sink.updatePhase(conversationId, "awaiting_approval");
} else if ("tool_approval_resolved".equals(eventType)) {
if (pendingApproval != null) {
pendingApproval.put("status",
"approved".equals(String.valueOf(data.getOrDefault("decision", ""))) ? "approved" : "denied");
}
} else if ("plan_created".equals(eventType)) {
Object rawPlanId = data.get("planId");
if (rawPlanId instanceof Number n) {
planId = n.longValue();
} else if (rawPlanId != null) {
try { planId = Long.valueOf(String.valueOf(rawPlanId)); } catch (Exception ignored) {}
}
Object steps = data.get("steps");
if (steps instanceof List<?> list) {
planSteps = list.stream().map(String::valueOf).toList();
planStepResults.clear();
for (int i = 0; i < planSteps.size(); i++) {
planStepResults.add(null);
}
}
currentPlanStep = 0;
} else if ("plan_step_started".equals(eventType)) {
Object idx = data.get("index");
if (idx instanceof Number n) {
currentPlanStep = n.intValue();
}
} else if ("plan_step_completed".equals(eventType)) {
Object idx = data.get("index");
if (idx instanceof Number n) {
int index = n.intValue();
currentPlanStep = index;
ensurePlanStepCapacity(index + 1);
Map<String, Object> stepResult = new LinkedHashMap<>();
stepResult.put("result", data.getOrDefault("result", ""));
stepResult.put("status", "completed");
planStepResults.set(index, stepResult);
}
} else if ("browser_action".equals(eventType)) {
browserActions.add(new LinkedHashMap<>(data));
} else if ("warning".equals(eventType)) {
String warning = String.valueOf(data.getOrDefault("message",
data.getOrDefault("delta", "")));
if (!warning.isBlank()) {
warnings.add(warning);
}
} 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");
toolCalls.add(tc);
// 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);
} else if ("tool_direct_result".equals(eventType)) {
// returnDirect tool track the tool name so history replay can
// render a "data returned directly by tool" badge. The actual
// textual content reaches the user/persistence layer through the
// regular content_delta path (FinalAnswerNode's FINAL_ANSWER
// StateGraphReActAgent StreamDelta), so we intentionally do
// NOT add a content-bearing segment here to avoid the user
// seeing the same text twice.
String toolName = String.valueOf(data.getOrDefault("toolName", ""));
if (!toolName.isBlank() && !directToolNames.contains(toolName)) {
directToolNames.add(toolName);
}
} else if ("tool_call_completed".equals(eventType)) {
String toolName = String.valueOf(data.getOrDefault("toolName", ""));
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);
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");
break;
}
}
// segments: 标记对应 tool_call 完成
for (int i = segments.size() - 1; i >= 0; i--) {
var seg = segments.get(i);
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));
break;
}
}
// Extract generated-file links from the tool result so the
// run-overview rail can surface artifacts without re-scanning
// segments on the frontend.
extractGeneratedFiles(String.valueOf(data.getOrDefault("result", "")), toolName);
}
}
/** Scan a tool result for markdown links pointing at generated-file
* download URLs and collect them into {@link #generatedFiles}.
* De-duplicates by URL so a link echoed in later tool results doesn't
* produce duplicate entries in the run-overview rail. */
private void extractGeneratedFiles(String result, String toolName) {
if (result == null || result.isBlank()) return;
Matcher m = GENERATED_FILE_LINK_PATTERN.matcher(result);
while (m.find()) {
String url = m.group(2);
boolean dup = generatedFiles.stream()
.anyMatch(f -> url.equals(String.valueOf(f.get("url"))));
if (dup) continue;
Map<String, Object> file = new LinkedHashMap<>();
file.put("filename", m.group(1));
file.put("url", url);
file.put("toolName", toolName);
generatedFiles.add(file);
}
}
private void ensurePlanStepCapacity(int size) {
while (planStepResults.size() < size) {
planStepResults.add(null);
}
}
// ==================== Segment helpers ====================
private Map<String, Object> newSegment(String type) {
Map<String, Object> seg = new LinkedHashMap<>();
seg.put("id", type.substring(0, 2) + "-" + segCounter++);
seg.put("type", type);
seg.put("status", "running");
return seg;
}
private Map<String, Object> findLastRunning(String type) {
for (int i = segments.size() - 1; i >= 0; i--) {
var seg = segments.get(i);
if (type.equals(seg.get("type")) && "running".equals(seg.get("status"))) return seg;
}
return null;
}
private void finalizeRunningSegments(String... types) {
var typeSet = Set.of(types);
for (var seg : segments) {
if ("running".equals(seg.get("status")) && typeSet.contains(seg.get("type"))) {
seg.put("status", "completed");
}
}
}
// ==================== 原有访问器 ====================
public String getContent() { return content.toString().trim(); }
public String getThinking() { return thinking.toString().trim(); }
public int getPromptTokens() { return promptTokens; }
public int getCompletionTokens() { return completionTokens; }
public int getCacheReadTokens() { return cacheReadTokens; }
public int getCacheWriteTokens() { return cacheWriteTokens; }
public int getReasoningTokens() { return reasoningTokens; }
public String getRuntimeModelName() { return runtimeModelName; }
public String getRuntimeProviderId() { return runtimeProviderId; }
public String getCurrentPhase() { return currentPhase; }
public String getFinishReason() { return finishReason; }
public boolean segmentsEmpty() { return segments.isEmpty(); }
public synchronized List<MessageContentPart> toAssistantParts() {
List<MessageContentPart> parts = new ArrayList<>();
if (!getContent().isBlank()) {
MessageContentPart textPart = new MessageContentPart();
textPart.setType("text");
textPart.setText(getContent());
parts.add(textPart);
}
if (!getThinking().isBlank()) {
MessageContentPart thinkingPart = new MessageContentPart();
thinkingPart.setType("thinking");
thinkingPart.setText(getThinking());
parts.add(thinkingPart);
}
for (Map<String, Object> tc : toolCalls) {
try {
parts.add(MessageContentPart.toolCall(objectMapper.writeValueAsString(tc)));
} catch (Exception e) {
log.warn("Failed to serialize tool call: {}", e.getMessage());
}
}
return parts;
}
private void finalizeToolCalls() {
for (Map<String, Object> tc : toolCalls) {
if ("running".equals(tc.get("status"))) tc.put("status", "completed");
}
}
/**
* 生成 metadata JSON包含 toolCalls + segments
* toolCalls 保留兼容旧 UIsegments 是按事件顺序的完整时间线
*/
public synchronized String toMetadataJson() {
finalizeToolCalls();
finalizeRunningSegments("thinking", "content", "tool_call");
SegmentSupersedeDetector.markSuperseded(segments);
try {
Map<String, Object> metadata = new LinkedHashMap<>();
if (!toolCalls.isEmpty()) {
metadata.put("toolCalls", toolCalls);
}
if (!segments.isEmpty()) {
metadata.put("segments", segments);
}
if (!currentPhase.isBlank()) {
metadata.put("currentPhase", currentPhase);
}
if (planId != null || !planSteps.isEmpty() || currentPlanStep != null) {
Map<String, Object> plan = new LinkedHashMap<>();
if (planId != null) plan.put("planId", planId);
if (!planSteps.isEmpty()) plan.put("steps", planSteps);
if (currentPlanStep != null) plan.put("currentStep", currentPlanStep);
if (planStepResults.stream().anyMatch(Objects::nonNull)) {
plan.put("stepResults", planStepResults);
}
metadata.put("plan", plan);
}
if (pendingApproval != null && !pendingApproval.isEmpty()) {
metadata.put("pendingApproval", pendingApproval);
}
if (!browserActions.isEmpty()) {
metadata.put("browserActions", browserActions);
}
if (!warnings.isEmpty()) {
metadata.put("warnings", warnings);
}
if (!directToolNames.isEmpty()) {
// Only the tool names go into metadata the full content
// already lives in mate_message.content (assembled by
// FinalAnswerNode). UI uses this to badge historical
// messages as "data returned directly by tool".
metadata.put("directToolNames", directToolNames);
}
if (!generatedFiles.isEmpty()) {
metadata.put("generatedFiles", generatedFiles);
}
if (!finishReason.isEmpty()) {
// Surface graph FinishReason so MemorySummarizationGate and
// any other downstream consumer can branch on a structured
// status (e.g. skip INCOMPLETE / STOPPED / ERROR_FALLBACK
// turns from long-term memory promotion) instead of doing
// brittle text matching on the assistant content.
metadata.put("finishReason", finishReason);
}
if (feedbackEvent != null && !feedbackEvent.isEmpty()) {
// Persist the recovery-affordance payload so the
// retry/regenerate/report card survives page reload.
// Stored as-is (errorType, errorMessage, actions,
// timestamp) frontend MessageBubble reads
// metadata.feedbackEvent and renders one button per
// entry in `actions`.
metadata.put("feedbackEvent", feedbackEvent);
}
if (routingDecision != null && !routingDecision.isEmpty()) {
metadata.put("routing", routingDecision);
}
return objectMapper.writeValueAsString(metadata);
} catch (Exception e) {
log.warn("Failed to serialize metadata: {}", e.getMessage());
return "{}";
}
}
}

View File

@ -162,11 +162,14 @@ class ChannelMessageRouterNarrationTest {
process.invoke(router, message, adapter, channel, "telegram:alice");
}
/** The persisted assistant row must hold the final-answer span only. */
/** The persisted assistant row must hold the final-answer span only.
* Parts and metadata now carry the execution record (segments etc.),
* so they are non-null content semantics are what this asserts. */
void verifyPersistedAssistantContent(String expected) {
verify(conversationService).saveMessage(
eq("telegram:alice"), eq("assistant"), eq(expected), isNull(), eq("completed"),
eq(0), eq(0), eq(0), eq(0), eq(0), isNull(), isNull(), isNull());
eq("telegram:alice"), eq("assistant"), eq(expected), anyList(), eq("completed"),
eq(0), eq(0), eq(0), eq(0), eq(0), isNull(), isNull(),
argThat((String metadata) -> metadata != null && metadata.contains("\"segments\"")));
}
/** processMessage swallows failures into a generic error reply — assert none fired. */