mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 11:58:34 +08:00
fix(chat): keep every iteration reasoning and render the timeline in emission order
This commit is contained in:
parent
30560eb7cf
commit
22c49f3a07
@ -208,6 +208,7 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
|||||||
AtomicBoolean finalAnswerEmitted = new AtomicBoolean(false);
|
AtomicBoolean finalAnswerEmitted = new AtomicBoolean(false);
|
||||||
AtomicBoolean finalThinkingEmitted = new AtomicBoolean(false);
|
AtomicBoolean finalThinkingEmitted = new AtomicBoolean(false);
|
||||||
AtomicReference<String> lastEmittedStreamedContent = new AtomicReference<>("");
|
AtomicReference<String> lastEmittedStreamedContent = new AtomicReference<>("");
|
||||||
|
AtomicReference<String> lastEmittedIterationThinking = new AtomicReference<>("");
|
||||||
// Silent-termination guard (mirrors chatStructuredStream)
|
// Silent-termination guard (mirrors chatStructuredStream)
|
||||||
AtomicInteger lastIteration = new AtomicInteger(0);
|
AtomicInteger lastIteration = new AtomicInteger(0);
|
||||||
AtomicInteger lastSoftCap = new AtomicInteger(0);
|
AtomicInteger lastSoftCap = new AtomicInteger(0);
|
||||||
@ -229,6 +230,47 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
|||||||
boolean contentAlreadyStreamed = output.state().value(CONTENT_STREAMED, false);
|
boolean contentAlreadyStreamed = output.state().value(CONTENT_STREAMED, false);
|
||||||
boolean thinkingAlreadyStreamed = output.state().value(THINKING_STREAMED, false);
|
boolean thinkingAlreadyStreamed = output.state().value(THINKING_STREAMED, false);
|
||||||
|
|
||||||
|
// Thinking is emitted BEFORE any content delta of the same
|
||||||
|
// batch. The reasoning that produced an answer precedes the
|
||||||
|
// answer, and the accumulator builds its segment timeline in
|
||||||
|
// delta arrival order — emitting thinking last appended a
|
||||||
|
// thinking segment after the content segment, which readers
|
||||||
|
// then had to reorder. FINAL_THINKING and FINAL_ANSWER are
|
||||||
|
// written by the same node output, so ordering them here is
|
||||||
|
// enough to make the persisted timeline match reality.
|
||||||
|
//
|
||||||
|
// Every iteration's reasoning is persisted, not just the
|
||||||
|
// terminal one. A tool-calling iteration parks its reasoning
|
||||||
|
// in STREAMED_THINKING (REPLACE, one value per node), which
|
||||||
|
// the live channel already broadcast — persistOnly carries it
|
||||||
|
// into the accumulator without a second broadcast. Without
|
||||||
|
// this, a turn that ran N tool rounds kept only the last
|
||||||
|
// round's thinking, so the persisted turn read as a bare
|
||||||
|
// conclusion and the reasoning that justified each tool call
|
||||||
|
// survived nowhere.
|
||||||
|
//
|
||||||
|
// The cursor tracks STREAMED_THINKING and nothing else. A
|
||||||
|
// shared cursor would let the stale value re-qualify: the key
|
||||||
|
// keeps its last write for the rest of the run, so once an
|
||||||
|
// unrelated emission moved a shared cursor past it, the same
|
||||||
|
// span was emitted a second time — after the final answer,
|
||||||
|
// since the later nodes run after the answer was streamed.
|
||||||
|
String iterationThinking = output.state().<String>value(STREAMED_THINKING).orElse("");
|
||||||
|
if (!iterationThinking.isEmpty()
|
||||||
|
&& !iterationThinking.equals(lastEmittedIterationThinking.get())) {
|
||||||
|
lastEmittedIterationThinking.set(iterationThinking);
|
||||||
|
deltas.add(AgentService.StreamDelta.persistOnly(null, iterationThinking));
|
||||||
|
}
|
||||||
|
|
||||||
|
String thinking = extractFinalThinking(output);
|
||||||
|
if (thinking != null && !thinking.isEmpty()
|
||||||
|
&& !thinking.equals(lastEmittedIterationThinking.get())
|
||||||
|
&& finalThinkingEmitted.compareAndSet(false, true)) {
|
||||||
|
deltas.add(thinkingAlreadyStreamed
|
||||||
|
? AgentService.StreamDelta.persistOnly(null, thinking)
|
||||||
|
: new AgentService.StreamDelta(null, thinking));
|
||||||
|
}
|
||||||
|
|
||||||
// Route per-iteration STREAMED_CONTENT (reasoning preamble +
|
// Route per-iteration STREAMED_CONTENT (reasoning preamble +
|
||||||
// SummarizingNode output) into segments only — final-answer
|
// SummarizingNode output) into segments only — final-answer
|
||||||
// text arrives via the FINAL_ANSWER branch below. Pre-#120
|
// text arrives via the FINAL_ANSWER branch below. Pre-#120
|
||||||
@ -264,13 +306,6 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
|||||||
addWithKindEvent(deltas, AgentService.StreamDelta.finalAnswer(answer, contentAlreadyStreamed));
|
addWithKindEvent(deltas, AgentService.StreamDelta.finalAnswer(answer, contentAlreadyStreamed));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
String thinking = extractFinalThinking(output);
|
|
||||||
if (thinking != null && !thinking.isEmpty()
|
|
||||||
&& finalThinkingEmitted.compareAndSet(false, true)) {
|
|
||||||
deltas.add(thinkingAlreadyStreamed
|
|
||||||
? AgentService.StreamDelta.persistOnly(null, thinking)
|
|
||||||
: new AgentService.StreamDelta(null, thinking));
|
|
||||||
}
|
|
||||||
|
|
||||||
finalPromptTokens.set(output.state().value(PROMPT_TOKENS, 0));
|
finalPromptTokens.set(output.state().value(PROMPT_TOKENS, 0));
|
||||||
finalCompletionTokens.set(output.state().value(COMPLETION_TOKENS, 0));
|
finalCompletionTokens.set(output.state().value(COMPLETION_TOKENS, 0));
|
||||||
@ -368,6 +403,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
|||||||
// 用 compareAndSet 保证只取第一次,避免 content/thinking 被重复追加
|
// 用 compareAndSet 保证只取第一次,避免 content/thinking 被重复追加
|
||||||
AtomicBoolean finalAnswerEmitted = new AtomicBoolean(false);
|
AtomicBoolean finalAnswerEmitted = new AtomicBoolean(false);
|
||||||
AtomicBoolean finalThinkingEmitted = new AtomicBoolean(false);
|
AtomicBoolean finalThinkingEmitted = new AtomicBoolean(false);
|
||||||
|
// 同 STREAMED_CONTENT:STREAMED_THINKING 也是 REPLACE,用独立游标
|
||||||
|
// 跟踪已持久化的每轮 thinking,避免后续节点的 NodeOutput 重复发送。
|
||||||
|
AtomicReference<String> lastEmittedIterationThinking = new AtomicReference<>("");
|
||||||
// STREAMED_CONTENT 是 REPLACE 策略(每轮 ReasoningNode/SummarizingNode 覆写),
|
// STREAMED_CONTENT 是 REPLACE 策略(每轮 ReasoningNode/SummarizingNode 覆写),
|
||||||
// 用 lastEmitted 跟踪已发送的值,避免在 ActionNode/ObservationNode 的 NodeOutput 上重复发送同一段内容。
|
// 用 lastEmitted 跟踪已发送的值,避免在 ActionNode/ObservationNode 的 NodeOutput 上重复发送同一段内容。
|
||||||
AtomicReference<String> lastEmittedStreamedContent = new AtomicReference<>("");
|
AtomicReference<String> lastEmittedStreamedContent = new AtomicReference<>("");
|
||||||
@ -404,7 +442,30 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
|||||||
boolean thinkingAlreadyStreamed = output.state()
|
boolean thinkingAlreadyStreamed = output.state()
|
||||||
.value(THINKING_STREAMED, false);
|
.value(THINKING_STREAMED, false);
|
||||||
|
|
||||||
// 2a. Route per-iteration narrative into the segments timeline
|
// 2a. Thinking first — see the ordering note in
|
||||||
|
// chatStructuredStream. The accumulator builds its
|
||||||
|
// segment timeline in delta arrival order, so the
|
||||||
|
// reasoning must be emitted ahead of the answer it
|
||||||
|
// produced. Every iteration's reasoning is persisted —
|
||||||
|
// see the note in chatStructuredStream for why the
|
||||||
|
// terminal one alone is not enough.
|
||||||
|
String iterationThinking = output.state().<String>value(STREAMED_THINKING).orElse("");
|
||||||
|
if (!iterationThinking.isEmpty()
|
||||||
|
&& !iterationThinking.equals(lastEmittedIterationThinking.get())) {
|
||||||
|
lastEmittedIterationThinking.set(iterationThinking);
|
||||||
|
deltas.add(AgentService.StreamDelta.persistOnly(null, iterationThinking));
|
||||||
|
}
|
||||||
|
|
||||||
|
String thinking = extractFinalThinking(output);
|
||||||
|
if (thinking != null && !thinking.isEmpty()
|
||||||
|
&& !thinking.equals(lastEmittedIterationThinking.get())
|
||||||
|
&& finalThinkingEmitted.compareAndSet(false, true)) {
|
||||||
|
deltas.add(thinkingAlreadyStreamed
|
||||||
|
? AgentService.StreamDelta.persistOnly(null, thinking)
|
||||||
|
: new AgentService.StreamDelta(null, thinking));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2b. Route per-iteration narrative into the segments timeline
|
||||||
// so the segmented UI view still shows "我来…" preludes
|
// so the segmented UI view still shows "我来…" preludes
|
||||||
// between tool cards, but keep the top-level content
|
// between tool cards, but keep the top-level content
|
||||||
// field (= persisted mate_message.content) reserved for
|
// field (= persisted mate_message.content) reserved for
|
||||||
@ -437,14 +498,6 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String thinking = extractFinalThinking(output);
|
|
||||||
if (thinking != null && !thinking.isEmpty()
|
|
||||||
&& finalThinkingEmitted.compareAndSet(false, true)) {
|
|
||||||
deltas.add(thinkingAlreadyStreamed
|
|
||||||
? AgentService.StreamDelta.persistOnly(null, thinking)
|
|
||||||
: new AgentService.StreamDelta(null, thinking));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 更新最新累计 token usage
|
// 3. 更新最新累计 token usage
|
||||||
finalPromptTokens.set(output.state().value(PROMPT_TOKENS, 0));
|
finalPromptTokens.set(output.state().value(PROMPT_TOKENS, 0));
|
||||||
finalCompletionTokens.set(output.state().value(COMPLETION_TOKENS, 0));
|
finalCompletionTokens.set(output.state().value(COMPLETION_TOKENS, 0));
|
||||||
|
|||||||
@ -139,7 +139,12 @@ public class FinalAnswerNode implements NodeAction {
|
|||||||
} else if (!existingAnswer.isEmpty()) {
|
} else if (!existingAnswer.isEmpty()) {
|
||||||
// 来自 reasoning 直接回答(或 stopped partial)
|
// 来自 reasoning 直接回答(或 stopped partial)
|
||||||
finalAnswer = existingAnswer;
|
finalAnswer = existingAnswer;
|
||||||
finalThinking = !currentThinking.isEmpty() ? currentThinking : existingThinking;
|
// FINAL_THINKING wins here: every writer of FINAL_ANSWER on this path
|
||||||
|
// sets it in the same node output, so it is the reasoning that produced
|
||||||
|
// this very answer. CURRENT_THINKING is REPLACE and was last written by
|
||||||
|
// a tool-calling iteration, so preferring it swapped in an earlier
|
||||||
|
// round's reasoning on any turn that used tools.
|
||||||
|
finalThinking = !existingThinking.isEmpty() ? existingThinking : currentThinking;
|
||||||
// 尊重上游已设的 finishReason(如 STOPPED),只有未设时才默认 NORMAL
|
// 尊重上游已设的 finishReason(如 STOPPED),只有未设时才默认 NORMAL
|
||||||
finishReason = !existingReason.isEmpty() ? parseFinishReason(existingReason) : FinishReason.NORMAL;
|
finishReason = !existingReason.isEmpty() ? parseFinishReason(existingReason) : FinishReason.NORMAL;
|
||||||
log.info("[FinalAnswerNode] Using existing finalAnswer ({} chars), reason={}",
|
log.info("[FinalAnswerNode] Using existing finalAnswer ({} chars), reason={}",
|
||||||
|
|||||||
@ -179,14 +179,9 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
|||||||
// 2a. 各步骤执行结果(StepExecutionNode 已通过 NodeStreamingChatHelper 直推 SSE,
|
// 2a. 各步骤执行结果(StepExecutionNode 已通过 NodeStreamingChatHelper 直推 SSE,
|
||||||
// 这里仅作为 persistOnly 送入 Accumulator,确保写入 mate_message)
|
// 这里仅作为 persistOnly 送入 Accumulator,确保写入 mate_message)
|
||||||
// 利用内容本身去重,避免 PlanSummaryNode 输出时重复 emit 上一步残留在 state 的值
|
// 利用内容本身去重,避免 PlanSummaryNode 输出时重复 emit 上一步残留在 state 的值
|
||||||
output.state().<String>value(PlanStateKeys.CURRENT_STEP_RESULT)
|
// Within each pair the thinking is emitted first: the
|
||||||
.filter(s -> !s.isEmpty())
|
// accumulator orders its segment timeline by delta arrival,
|
||||||
.filter(s -> !s.equals(lastPersistedStepResult.get()))
|
// and the reasoning behind a step precedes the step's result.
|
||||||
.ifPresent(stepContent -> {
|
|
||||||
deltas.add(AgentService.StreamDelta.persistOnly(stepContent, null));
|
|
||||||
lastPersistedStepResult.set(stepContent);
|
|
||||||
});
|
|
||||||
|
|
||||||
output.state().<String>value(PlanStateKeys.CURRENT_STEP_THINKING)
|
output.state().<String>value(PlanStateKeys.CURRENT_STEP_THINKING)
|
||||||
.filter(s -> !s.isEmpty())
|
.filter(s -> !s.isEmpty())
|
||||||
.filter(s -> !s.equals(lastPersistedStepThinking.get()))
|
.filter(s -> !s.equals(lastPersistedStepThinking.get()))
|
||||||
@ -195,19 +190,27 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
|||||||
lastPersistedStepThinking.set(stepThinking);
|
lastPersistedStepThinking.set(stepThinking);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2b. 最终汇总
|
output.state().<String>value(PlanStateKeys.CURRENT_STEP_RESULT)
|
||||||
output.state().<String>value(PlanStateKeys.FINAL_SUMMARY)
|
|
||||||
.filter(s -> !s.isEmpty())
|
.filter(s -> !s.isEmpty())
|
||||||
.ifPresent(summary -> deltas.add(contentAlreadyStreamed
|
.filter(s -> !s.equals(lastPersistedStepResult.get()))
|
||||||
? AgentService.StreamDelta.persistOnly(summary, null)
|
.ifPresent(stepContent -> {
|
||||||
: new AgentService.StreamDelta(summary, null)));
|
deltas.add(AgentService.StreamDelta.persistOnly(stepContent, null));
|
||||||
|
lastPersistedStepResult.set(stepContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2b. 最终汇总(同样 thinking 先于 content)
|
||||||
output.state().<String>value(PlanStateKeys.FINAL_SUMMARY_THINKING)
|
output.state().<String>value(PlanStateKeys.FINAL_SUMMARY_THINKING)
|
||||||
.filter(s -> !s.isEmpty())
|
.filter(s -> !s.isEmpty())
|
||||||
.ifPresent(thinking -> deltas.add(thinkingAlreadyStreamed
|
.ifPresent(thinking -> deltas.add(thinkingAlreadyStreamed
|
||||||
? AgentService.StreamDelta.persistOnly(null, thinking)
|
? AgentService.StreamDelta.persistOnly(null, thinking)
|
||||||
: new AgentService.StreamDelta(null, thinking)));
|
: new AgentService.StreamDelta(null, thinking)));
|
||||||
|
|
||||||
|
output.state().<String>value(PlanStateKeys.FINAL_SUMMARY)
|
||||||
|
.filter(s -> !s.isEmpty())
|
||||||
|
.ifPresent(summary -> deltas.add(contentAlreadyStreamed
|
||||||
|
? AgentService.StreamDelta.persistOnly(summary, null)
|
||||||
|
: new AgentService.StreamDelta(summary, null)));
|
||||||
|
|
||||||
// 3. 更新最新累计 token usage
|
// 3. 更新最新累计 token usage
|
||||||
finalPromptTokens.set(output.state().value(MateClawStateKeys.PROMPT_TOKENS, 0));
|
finalPromptTokens.set(output.state().value(MateClawStateKeys.PROMPT_TOKENS, 0));
|
||||||
finalCompletionTokens.set(output.state().value(MateClawStateKeys.COMPLETION_TOKENS, 0));
|
finalCompletionTokens.set(output.state().value(MateClawStateKeys.COMPLETION_TOKENS, 0));
|
||||||
|
|||||||
@ -212,13 +212,22 @@ public final class AgentStreamAccumulator {
|
|||||||
|
|
||||||
// thinking_delta
|
// thinking_delta
|
||||||
if (delta.thinking() != null && !delta.thinking().isBlank()) {
|
if (delta.thinking() != null && !delta.thinking().isBlank()) {
|
||||||
|
var seg = findLastRunning("thinking");
|
||||||
|
// No running thinking segment means this delta opens a new reasoning
|
||||||
|
// span (a fresh iteration, after a tool call closed the previous one).
|
||||||
|
// The flat `thinking` field concatenates every span of the turn, so
|
||||||
|
// without a break the spans glue into one run-on paragraph — spans
|
||||||
|
// are separate thoughts and read as such only when kept apart.
|
||||||
|
boolean opensNewSpan = seg == null;
|
||||||
if (!delta.segmentOnly()) {
|
if (!delta.segmentOnly()) {
|
||||||
|
if (opensNewSpan && thinking.length() > 0) {
|
||||||
|
thinking.append("\n\n");
|
||||||
|
}
|
||||||
thinking.append(delta.thinking());
|
thinking.append(delta.thinking());
|
||||||
}
|
}
|
||||||
if (!delta.persistenceOnly()) {
|
if (!delta.persistenceOnly()) {
|
||||||
sink.broadcast(conversationId, "thinking_delta", Map.of("delta", delta.thinking()));
|
sink.broadcast(conversationId, "thinking_delta", Map.of("delta", delta.thinking()));
|
||||||
}
|
}
|
||||||
var seg = findLastRunning("thinking");
|
|
||||||
if (seg != null) {
|
if (seg != null) {
|
||||||
seg.put("thinkingText", seg.getOrDefault("thinkingText", "") + delta.thinking());
|
seg.put("thinkingText", seg.getOrDefault("thinkingText", "") + delta.thinking());
|
||||||
} else {
|
} else {
|
||||||
@ -394,8 +403,15 @@ public final class AgentStreamAccumulator {
|
|||||||
|
|
||||||
private Map<String, Object> newSegment(String type) {
|
private Map<String, Object> newSegment(String type) {
|
||||||
Map<String, Object> seg = new LinkedHashMap<>();
|
Map<String, Object> seg = new LinkedHashMap<>();
|
||||||
seg.put("id", type.substring(0, 2) + "-" + segCounter++);
|
int seq = segCounter++;
|
||||||
|
seg.put("id", type.substring(0, 2) + "-" + seq);
|
||||||
seg.put("type", type);
|
seg.put("type", type);
|
||||||
|
// Monotonic emission index. Renderers order the timeline by this
|
||||||
|
// rather than inferring a position from the segment's type: array
|
||||||
|
// order can be perturbed on the way to the UI (dedup, fallback
|
||||||
|
// injection, live/persisted merges), and type-based relocation
|
||||||
|
// moves a span away from the point it was actually produced at.
|
||||||
|
seg.put("seq", seq);
|
||||||
seg.put("status", "running");
|
seg.put("status", "running");
|
||||||
// Wall-clock bounds let history replays show the real per-segment
|
// Wall-clock bounds let history replays show the real per-segment
|
||||||
// duration (e.g. "thought for 12s") instead of estimating from length.
|
// duration (e.g. "thought for 12s") instead of estimating from length.
|
||||||
|
|||||||
@ -0,0 +1,156 @@
|
|||||||
|
package vip.mate.channel.web;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import vip.mate.agent.AgentService.StreamDelta;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pins the delta emission order that the graph agents use for a turn's
|
||||||
|
* thinking, and the segment timeline it produces.
|
||||||
|
*
|
||||||
|
* <p>Reasoning precedes the answer it produced, so the thinking delta is
|
||||||
|
* emitted ahead of the final-answer content delta of the same batch. The
|
||||||
|
* accumulator builds {@code metadata.segments} strictly in delta arrival
|
||||||
|
* order, so emitting thinking last used to append a thinking segment
|
||||||
|
* <em>after</em> the content segment — a timeline that contradicts what
|
||||||
|
* actually happened and that readers had to reorder before rendering.
|
||||||
|
*
|
||||||
|
* <p>These cases lock the producer-side contract: a consumer may render
|
||||||
|
* {@code segments} in array order without any type-based reordering.
|
||||||
|
*/
|
||||||
|
class AgentStreamAccumulatorThinkingOrderTest {
|
||||||
|
|
||||||
|
private static final AgentStreamAccumulator.Sink NOOP_SINK = new AgentStreamAccumulator.Sink() {
|
||||||
|
@Override public void broadcast(String conversationId, String eventName, Object payload) { }
|
||||||
|
@Override public void updatePhase(String conversationId, String phase) { }
|
||||||
|
};
|
||||||
|
|
||||||
|
private static JsonNode segmentsOf(AgentStreamAccumulator acc, ObjectMapper mapper) throws Exception {
|
||||||
|
return mapper.readTree(acc.toMetadataJson()).path("segments");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> typesOf(JsonNode segments) {
|
||||||
|
return segments.findValuesAsText("type");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("direct answer — thinking segment precedes the answer's content segment")
|
||||||
|
void thinkingPrecedesFinalAnswer() throws Exception {
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
AgentStreamAccumulator acc = new AgentStreamAccumulator(mapper, NOOP_SINK);
|
||||||
|
String cid = "conv-order-1";
|
||||||
|
|
||||||
|
// Emission order used by the graph agents for a no-tool turn.
|
||||||
|
acc.accept(StreamDelta.persistOnly(null, "用户问的是时区换算,直接算即可。"), cid);
|
||||||
|
acc.accept(StreamDelta.finalAnswer("北京时间 21:00 对应 UTC 13:00。", true), cid);
|
||||||
|
|
||||||
|
JsonNode segments = segmentsOf(acc, mapper);
|
||||||
|
assertEquals(List.of("thinking", "content"), typesOf(segments),
|
||||||
|
"thinking must land before the content it produced — no consumer-side reordering");
|
||||||
|
assertEquals("用户问的是时区换算,直接算即可。", segments.get(0).path("thinkingText").asText());
|
||||||
|
assertEquals("北京时间 21:00 对应 UTC 13:00。", segments.get(1).path("text").asText());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("tool turn — the final call's thinking sits between the tool card and the answer")
|
||||||
|
void thinkingKeepsItsPlaceInAToolTurn() throws Exception {
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
AgentStreamAccumulator acc = new AgentStreamAccumulator(mapper, NOOP_SINK);
|
||||||
|
String cid = "conv-order-2";
|
||||||
|
|
||||||
|
acc.accept(StreamDelta.segmentOnly("先查一下会议室占用。", null), cid);
|
||||||
|
acc.accept(StreamDelta.event("tool_call_started",
|
||||||
|
Map.of("toolCallId", "t1", "toolName", "roomQuery", "arguments", "{}")), cid);
|
||||||
|
acc.accept(StreamDelta.event("tool_call_completed",
|
||||||
|
Map.of("toolCallId", "t1", "toolName", "roomQuery", "result", "[]", "success", true)), cid);
|
||||||
|
acc.accept(StreamDelta.persistOnly(null, "返回为空,说明当前没有占用记录。"), cid);
|
||||||
|
acc.accept(StreamDelta.finalAnswer("目前没有会议室被占用。", true), cid);
|
||||||
|
|
||||||
|
JsonNode segments = segmentsOf(acc, mapper);
|
||||||
|
assertEquals(List.of("content", "tool_call", "thinking", "content"), typesOf(segments),
|
||||||
|
"the final call's reasoning belongs after the observation it read, not at the top of the turn");
|
||||||
|
assertEquals("目前没有会议室被占用。", acc.getContent(),
|
||||||
|
"segmentOnly narration stays out of the persisted top-level content");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("multi-iteration turn keeps one thinking span per iteration, in place")
|
||||||
|
void everyIterationsThinkingSurvives() throws Exception {
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
AgentStreamAccumulator acc = new AgentStreamAccumulator(mapper, NOOP_SINK);
|
||||||
|
String cid = "conv-order-4";
|
||||||
|
|
||||||
|
// Two tool rounds, each preceded by its own reasoning, then the answer.
|
||||||
|
acc.accept(StreamDelta.persistOnly(null, "先确认今天的日期。"), cid);
|
||||||
|
acc.accept(StreamDelta.event("tool_call_started",
|
||||||
|
Map.of("toolCallId", "t1", "toolName", "clock", "arguments", "{}")), cid);
|
||||||
|
acc.accept(StreamDelta.event("tool_call_completed",
|
||||||
|
Map.of("toolCallId", "t1", "toolName", "clock", "result", "2026-08-06", "success", true)), cid);
|
||||||
|
acc.accept(StreamDelta.persistOnly(null, "拿到日期了,再算天数差。"), cid);
|
||||||
|
acc.accept(StreamDelta.event("tool_call_started",
|
||||||
|
Map.of("toolCallId", "t2", "toolName", "calc", "arguments", "{}")), cid);
|
||||||
|
acc.accept(StreamDelta.event("tool_call_completed",
|
||||||
|
Map.of("toolCallId", "t2", "toolName", "calc", "result", "147", "success", true)), cid);
|
||||||
|
acc.accept(StreamDelta.persistOnly(null, "147 天,可以作答。"), cid);
|
||||||
|
acc.accept(StreamDelta.finalAnswer("还有 147 天。", true), cid);
|
||||||
|
|
||||||
|
JsonNode segments = segmentsOf(acc, mapper);
|
||||||
|
assertEquals(
|
||||||
|
List.of("thinking", "tool_call", "thinking", "tool_call", "thinking", "content"),
|
||||||
|
typesOf(segments),
|
||||||
|
"each iteration's reasoning stays at the point it was produced");
|
||||||
|
|
||||||
|
assertEquals("先确认今天的日期。", segments.get(0).path("thinkingText").asText());
|
||||||
|
assertEquals("拿到日期了,再算天数差。", segments.get(2).path("thinkingText").asText());
|
||||||
|
assertEquals("147 天,可以作答。", segments.get(4).path("thinkingText").asText());
|
||||||
|
|
||||||
|
assertEquals("先确认今天的日期。\n\n拿到日期了,再算天数差。\n\n147 天,可以作答。", acc.getThinking(),
|
||||||
|
"the flat thinking field carries every span, separated so they stay readable");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("deltas within one span append without inserting a separator")
|
||||||
|
void withinSpanDeltasAreNotSeparated() throws Exception {
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
AgentStreamAccumulator acc = new AgentStreamAccumulator(mapper, NOOP_SINK);
|
||||||
|
String cid = "conv-order-5";
|
||||||
|
|
||||||
|
// A streaming channel feeds one span as many small deltas.
|
||||||
|
acc.accept(StreamDelta.persistOnly(null, "先看"), cid);
|
||||||
|
acc.accept(StreamDelta.persistOnly(null, "一下"), cid);
|
||||||
|
acc.accept(StreamDelta.persistOnly(null, "输入。"), cid);
|
||||||
|
|
||||||
|
JsonNode segments = segmentsOf(acc, mapper);
|
||||||
|
assertEquals(List.of("thinking"), typesOf(segments), "one running span, not three");
|
||||||
|
assertEquals("先看一下输入。", acc.getThinking());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("every segment carries a monotonic seq matching its emission position")
|
||||||
|
void segmentsCarryMonotonicSeq() throws Exception {
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
AgentStreamAccumulator acc = new AgentStreamAccumulator(mapper, NOOP_SINK);
|
||||||
|
String cid = "conv-order-3";
|
||||||
|
|
||||||
|
acc.accept(StreamDelta.segmentOnly("先查一下会议室占用。", null), cid);
|
||||||
|
acc.accept(StreamDelta.event("tool_call_started",
|
||||||
|
Map.of("toolCallId", "t1", "toolName", "roomQuery", "arguments", "{}")), cid);
|
||||||
|
acc.accept(StreamDelta.event("tool_call_completed",
|
||||||
|
Map.of("toolCallId", "t1", "toolName", "roomQuery", "result", "[]", "success", true)), cid);
|
||||||
|
acc.accept(StreamDelta.persistOnly(null, "返回为空,说明当前没有占用记录。"), cid);
|
||||||
|
acc.accept(StreamDelta.finalAnswer("目前没有会议室被占用。", true), cid);
|
||||||
|
|
||||||
|
JsonNode segments = segmentsOf(acc, mapper);
|
||||||
|
for (int i = 0; i < segments.size(); i++) {
|
||||||
|
assertEquals(i, segments.get(i).path("seq").asInt(-1),
|
||||||
|
"seq is the emission index — renderers sort by it instead of relocating by type");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -39,6 +39,12 @@
|
|||||||
--mc-bg-elevated: #ffffff;
|
--mc-bg-elevated: #ffffff;
|
||||||
--mc-bg-sunken: #ebe3db;
|
--mc-bg-sunken: #ebe3db;
|
||||||
--mc-bg-muted: #f1e8df;
|
--mc-bg-muted: #f1e8df;
|
||||||
|
/* Hover wash for icon buttons and list rows. Consumers write
|
||||||
|
`background: var(--mc-bg-hover)` with no literal fallback, and an
|
||||||
|
undefined custom property makes the whole declaration compute to the
|
||||||
|
property's initial value — transparent — so a missing definition here
|
||||||
|
silently erases the element's background instead of leaving it alone. */
|
||||||
|
--mc-bg-hover: #ebe3db;
|
||||||
--mc-surface-strong: #fdfaf6;
|
--mc-surface-strong: #fdfaf6;
|
||||||
--mc-surface-overlay: rgba(255, 255, 255, 0.72);
|
--mc-surface-overlay: rgba(255, 255, 255, 0.72);
|
||||||
--mc-panel-top: rgba(255, 255, 255, 0.94);
|
--mc-panel-top: rgba(255, 255, 255, 0.94);
|
||||||
@ -166,6 +172,7 @@ html.dark {
|
|||||||
--mc-bg-elevated: #221a16;
|
--mc-bg-elevated: #221a16;
|
||||||
--mc-bg-sunken: #2a211c;
|
--mc-bg-sunken: #2a211c;
|
||||||
--mc-bg-muted: #201813;
|
--mc-bg-muted: #201813;
|
||||||
|
--mc-bg-hover: #2a211c;
|
||||||
--mc-surface-strong: #2a201a;
|
--mc-surface-strong: #2a201a;
|
||||||
--mc-surface-overlay: rgba(34, 26, 22, 0.78);
|
--mc-surface-overlay: rgba(34, 26, 22, 0.78);
|
||||||
--mc-panel-top: rgba(36, 28, 24, 0.96);
|
--mc-panel-top: rgba(36, 28, 24, 0.96);
|
||||||
|
|||||||
@ -786,6 +786,24 @@ defineExpose({
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Focus ring. Left unstyled, these fall back to the UA default, whose
|
||||||
|
`outline: auto` draws a thick halo in a colour the browser picks for
|
||||||
|
contrast — a gold ring on the red stop button, which reads as an error
|
||||||
|
state rather than focus. Restyled, not removed: keyboard users need the
|
||||||
|
affordance, so this stays a visible ring in the app's own palette. */
|
||||||
|
.action-btn:focus-visible {
|
||||||
|
outline: 2px solid var(--mc-primary, #D97757);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn.is-loading:focus-visible {
|
||||||
|
outline-color: var(--mc-danger, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn.is-interrupt:focus-visible {
|
||||||
|
outline-color: var(--mc-warning, #f59e0b);
|
||||||
|
}
|
||||||
|
|
||||||
.thinking-btn {
|
.thinking-btn {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@ -818,7 +836,11 @@ defineExpose({
|
|||||||
background: var(--mc-primary-light, rgba(217, 119, 87, 0.08));
|
background: var(--mc-primary-light, rgba(217, 119, 87, 0.08));
|
||||||
}
|
}
|
||||||
|
|
||||||
.send-btn {
|
/* Qualified with .action-btn so the send button outranks the generic
|
||||||
|
`.action-btn:hover` that leaks in from a non-scoped stylesheet elsewhere
|
||||||
|
in the app. Bare `.send-btn` ties with it on specificity and loses on
|
||||||
|
source order, which erased the button's fill on hover. */
|
||||||
|
.action-btn.send-btn {
|
||||||
background: var(--mc-primary, #D97757);
|
background: var(--mc-primary, #D97757);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -70,7 +70,12 @@
|
|||||||
@click="toggleSupersededSegment(seg.id)"
|
@click="toggleSupersededSegment(seg.id)"
|
||||||
>
|
>
|
||||||
<el-icon><InfoFilled /></el-icon>
|
<el-icon><InfoFilled /></el-icon>
|
||||||
<span>{{ $t('chat.supersededPreviewCollapsed') }}</span>
|
<!-- Label tracks the actual state — it read "已折叠" even while
|
||||||
|
the body below it was expanded, so the banner contradicted
|
||||||
|
what the reader could plainly see. -->
|
||||||
|
<span>{{ isSupersededExpanded(seg.id)
|
||||||
|
? $t('chat.supersededPreviewExpanded')
|
||||||
|
: $t('chat.supersededPreviewCollapsed') }}</span>
|
||||||
<span class="superseded-toggle__action">
|
<span class="superseded-toggle__action">
|
||||||
{{ isSupersededExpanded(seg.id) ? $t('chat.collapse') : $t('chat.expand') }}
|
{{ isSupersededExpanded(seg.id) ? $t('chat.collapse') : $t('chat.expand') }}
|
||||||
</span>
|
</span>
|
||||||
@ -1111,9 +1116,15 @@ const segments = computed<MessageSegment[]>(() => {
|
|||||||
// iteration's thinking bucket instead of the default-zero bucket
|
// iteration's thinking bucket instead of the default-zero bucket
|
||||||
// colliding with later iteration content. Without this, the fallback
|
// colliding with later iteration content. Without this, the fallback
|
||||||
// thinking renders below the answer for any conversation that has
|
// thinking renders below the answer for any conversation that has
|
||||||
// multi-iteration RFC-22 segments tagged elsewhere.
|
// multi-iteration segments tagged elsewhere.
|
||||||
|
//
|
||||||
|
// seq=-1 keeps it ahead of every producer-numbered segment so the
|
||||||
|
// sort below still applies to the array it was injected into. This
|
||||||
|
// reconstruction has no real emission position — the thinking came
|
||||||
|
// from contentParts, not from the timeline — and leading the turn is
|
||||||
|
// the only defensible placement for it.
|
||||||
const firstIter = segs.find(s => typeof s.iterationIndex === 'number')?.iterationIndex ?? 0
|
const firstIter = segs.find(s => typeof s.iterationIndex === 'number')?.iterationIndex ?? 0
|
||||||
segs.unshift({ id: 'th-fb', type: 'thinking', status: 'completed', thinkingText: thinkingPart.text, iterationIndex: firstIter })
|
segs.unshift({ id: 'th-fb', type: 'thinking', status: 'completed', thinkingText: thinkingPart.text, iterationIndex: firstIter, seq: -1 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1137,15 +1148,15 @@ const segments = computed<MessageSegment[]>(() => {
|
|||||||
segs.length = 0
|
segs.length = 0
|
||||||
segs.push(...deduped)
|
segs.push(...deduped)
|
||||||
|
|
||||||
// 修复历史消息顺序:如果 thinking 被落在 content 后面,提到首个 content 前
|
// 按发射序号排序。segments 携带生产端的单调 `seq`,排序对上面的去重
|
||||||
// 只处理单个 thinking 段的常见场景,避免破坏复杂交错时间线
|
// 步骤是稳定的,且不会按类型搬运任何段落 —— 一段在工具观察之后产生的
|
||||||
const thinkingIndices = segs
|
// thinking 就留在观察之后,那才是模型真正产出它的位置。此前这里把"唯一
|
||||||
.map((seg, index) => seg.type === 'thinking' ? index : -1)
|
// 的 thinking 段"强行提到首个 content 之前,会把读完工具结果才得出的推理
|
||||||
.filter(index => index >= 0)
|
// 显示在工具卡片上方,语义与实际发生顺序相反。
|
||||||
const firstNonThinkingIdx = segs.findIndex((seg: MessageSegment) => seg.type !== 'thinking')
|
// 没有 `seq` 的走原数组顺序:live 段本就按事件顺序追加,历史消息也只有
|
||||||
if (thinkingIndices.length === 1 && firstNonThinkingIdx >= 0 && thinkingIndices[0] > firstNonThinkingIdx) {
|
// 数组顺序这一个信息源,无从重排。
|
||||||
const [thinkingSeg] = segs.splice(thinkingIndices[0], 1)
|
if (segs.every(seg => typeof seg.seq === 'number')) {
|
||||||
segs.splice(0, 0, thinkingSeg)
|
segs.sort((a, b) => (a.seq as number) - (b.seq as number))
|
||||||
}
|
}
|
||||||
|
|
||||||
return segs
|
return segs
|
||||||
@ -1251,8 +1262,8 @@ function fmtTokens(n: number): string {
|
|||||||
* Group segments by iterationIndex so each ReAct iteration renders as its own
|
* Group segments by iterationIndex so each ReAct iteration renders as its own
|
||||||
* thinking/tool-calls/content cluster. Falls back to a single ungrouped bucket
|
* thinking/tool-calls/content cluster. Falls back to a single ungrouped bucket
|
||||||
* for legacy messages (no iterationIndex tagged) so historical conversations
|
* for legacy messages (no iterationIndex tagged) so historical conversations
|
||||||
* keep rendering as before — including the existing "single-thinking reorder"
|
* keep rendering as before. Ordering within a bucket is whatever the
|
||||||
* normalization done in the `segments` computed above.
|
* `segments` computed above settled on — emission order, by `seq`.
|
||||||
*/
|
*/
|
||||||
const groupedIterations = computed(() => {
|
const groupedIterations = computed(() => {
|
||||||
const segs = segments.value || []
|
const segs = segments.value || []
|
||||||
|
|||||||
@ -532,6 +532,7 @@ export default {
|
|||||||
iterationEmpty: 'Iteration {index} interrupted (no output)',
|
iterationEmpty: 'Iteration {index} interrupted (no output)',
|
||||||
contentRepetitionWarning: 'Repetitive content detected near the end (model artifact)',
|
contentRepetitionWarning: 'Repetitive content detected near the end (model artifact)',
|
||||||
supersededPreviewCollapsed: 'Collapsed: content drafted before tool execution (may not match actual results)',
|
supersededPreviewCollapsed: 'Collapsed: content drafted before tool execution (may not match actual results)',
|
||||||
|
supersededPreviewExpanded: 'Below is content drafted before tool execution (may not match actual results)',
|
||||||
pendingReply: 'Preparing a reply…',
|
pendingReply: 'Preparing a reply…',
|
||||||
expand: 'Expand',
|
expand: 'Expand',
|
||||||
// INCOMPLETE truncation card (finishReason=incomplete)
|
// INCOMPLETE truncation card (finishReason=incomplete)
|
||||||
|
|||||||
@ -532,6 +532,7 @@ export default {
|
|||||||
iterationEmpty: '第 {index} 轮被中断(无输出)',
|
iterationEmpty: '第 {index} 轮被中断(无输出)',
|
||||||
contentRepetitionWarning: '检测到内容尾部重复(疑似模型输出 artifact)',
|
contentRepetitionWarning: '检测到内容尾部重复(疑似模型输出 artifact)',
|
||||||
supersededPreviewCollapsed: '已折叠:模型在工具执行前预写的内容(可能与实际结果不符)',
|
supersededPreviewCollapsed: '已折叠:模型在工具执行前预写的内容(可能与实际结果不符)',
|
||||||
|
supersededPreviewExpanded: '以下是模型在工具执行前预写的内容(可能与实际结果不符)',
|
||||||
pendingReply: '正在准备回复…',
|
pendingReply: '正在准备回复…',
|
||||||
expand: '展开',
|
expand: '展开',
|
||||||
// INCOMPLETE 截断卡片(finishReason=incomplete)
|
// INCOMPLETE 截断卡片(finishReason=incomplete)
|
||||||
|
|||||||
@ -208,6 +208,12 @@ export interface MessageSegment {
|
|||||||
id: string
|
id: string
|
||||||
type: 'thinking' | 'tool_call' | 'content' | 'phase' | 'approval' | 'plan'
|
type: 'thinking' | 'tool_call' | 'content' | 'phase' | 'approval' | 'plan'
|
||||||
status: 'running' | 'completed' | 'error'
|
status: 'running' | 'completed' | 'error'
|
||||||
|
/**
|
||||||
|
* Producer-assigned emission index, monotonic within a turn. Present on
|
||||||
|
* persisted segments; absent on live ones, which are already appended in
|
||||||
|
* event order. Renderers sort by it instead of relocating segments by type.
|
||||||
|
*/
|
||||||
|
seq?: number
|
||||||
/** type=thinking */
|
/** type=thinking */
|
||||||
thinkingText?: string
|
thinkingText?: string
|
||||||
/** type=tool_call */
|
/** type=tool_call */
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user