fix(agent): persist mid-turn narrative, queue follow-ups without dispose, flush on shutdown

A bundle of stability fixes that all surfaced together while running
the same long-form generation task across multiple turns. Each one
addresses a distinct way the previous behavior silently dropped
content the user had already seen on screen.

1. Mid-turn narrative persistence (StateGraphReActAgent +
   SummarizingNode). Intermediate ReasoningNode rounds and
   SummarizingNode broadcast their content_delta directly to the
   SSE channel for live display, but the StreamAccumulator only
   received the final answer. After refresh the assistant message
   showed only tool_call cards with no body text.
   StateGraphReActAgent now also forwards STREAMED_CONTENT (already
   set per round) as a persistOnly StreamDelta whenever it changes,
   so every narrative chunk lands in the accumulator's content
   buffer and gets written to mate_message. SummarizingNode now
   writes its summary into the same key so summarize narratives
   persist too.

2. Follow-up message queue, not dispose (ChatController#interruptStream).
   Sending a new message while a turn was running called
   requestInterrupt, which dispose()d the active Reactor chain mid
   LLM call. That cancelled the in-flight generation, lost partial
   tokens, and left the user staring at a half-finished bubble.
   The endpoint now uses enqueueMessage in all paths, matching
   the "wait for current turn, then run" behavior. The old
   requestInterrupt API is kept for any future force-replace UI
   but no caller routes to it.

3. Queued user message ordering (ChatStreamTracker.QueuedInput +
   ChatController.startQueuedMessage). interruptStream used to save
   the queued user message immediately, before the in-flight
   assistant message finalized in doOnError. listMessages orders
   by create_time ASC, so the queued user message ended up above
   the assistant reply it was supposed to follow. QueuedInput now
   carries contentParts; persistence is delayed to startQueuedMessage,
   which runs only after Asst-N is on disk.

4. JVM shutdown flush (ChatStreamTracker @PreDestroy +
   emergencySaveAccumulator). A mvn spring-boot:run restart used to
   wipe in-flight turns: SSE emitter timed out, ShutdownHook fired,
   HikariPool closed before doOnError could save. ChatStreamTracker
   now exposes an emergency-save callback per RunState; ChatController
   registers one per stream that snapshots the accumulator and
   writes status="interrupted_shutdown". @PreDestroy walks active
   runs, invokes the callback, then disposes. Spring's reverse-order
   bean teardown keeps ConversationService and Hikari alive long
   enough for the save to complete.

5. Observation thresholds for summarize (GraphObservationProperties +
   application.yml). The previous total-chars threshold of 12 KB
   triggered summarize after one or two RFC reads, costing a 40 to
   80 second compaction LLM call per loop. Tuned to: total 200 KB,
   single 16 KB, large-result 32 KB, rounds safety net 25. Java
   field defaults reverted to the conservative original values so
   application.yml stays the source of truth.

6. Frontend thinking segmentation (useChat.ts thinking_delta +
   phase). Multi-round ReAct turns merged every reasoning + summarize
   round's thinking into one segment, accumulating to 9 KB+ in a
   single bubble. thinking_delta now uses findLast(running) so a
   tool_call_started or phase transition closes the previous segment
   and the next delta opens a fresh one. phase event also closes
   running thinking/content segments.

7. Other small things bundled: removed a debug metadata-keys log
   that flooded the log file with one line per stream chunk; fixed
   three stale tests that didn't compile after earlier constructor
   changes (WikiLogServiceTest, WikiOverviewSpliceTest,
   WikiProcessingServiceLazyTest); added rfc-066 documenting the
   unified message queue + priority refactor as the next logical
   step on top of these stabilizations.

Verified end-to-end with multiple full sessions: a four-minute
generation that produced the expected docx and a follow-up enqueue
that ran cleanly after the previous turn naturally completed,
without the old "Disposable unavailable" interrupt path.
This commit is contained in:
matevip 2026-04-27 07:51:49 +08:00
parent 941653d185
commit 0476447ab6
8 changed files with 230 additions and 57 deletions

View File

@ -743,10 +743,6 @@ public class NodeStreamingChatHelper {
}
// 4. 提取 token usage通常最后一个 chunk 携带完整 usage
// E-4 probe: log metadata keys to check for x-ratelimit-* headers
if (chatResponse.getMetadata() != null && log.isDebugEnabled()) {
log.debug("[E4-probe] metadata keys: {}", chatResponse.getMetadata().keySet());
}
if (chatResponse.getMetadata() != null && chatResponse.getMetadata().getUsage() != null) {
var usage = chatResponse.getMetadata().getUsage();
if (usage.getPromptTokens() != null && usage.getPromptTokens() > 0) {

View File

@ -175,6 +175,7 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
// 防重保护 chatStructuredStream
AtomicBoolean finalAnswerEmitted = new AtomicBoolean(false);
AtomicBoolean finalThinkingEmitted = new AtomicBoolean(false);
AtomicReference<String> lastEmittedStreamedContent = new AtomicReference<>("");
return compiledGraph.stream(inputs, config)
.flatMapIterable(output -> {
@ -192,6 +193,14 @@ 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 上出现一次刷新后丢失
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));
}
if (hasFinalAnswer(output) && finalAnswerEmitted.compareAndSet(false, true)) {
String answer = extractFinalAnswer(output);
if (answer != null && !answer.isEmpty()) {
@ -265,6 +274,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
// compareAndSet 保证只取第一次避免 content/thinking 被重复追加
AtomicBoolean finalAnswerEmitted = new AtomicBoolean(false);
AtomicBoolean finalThinkingEmitted = new AtomicBoolean(false);
// STREAMED_CONTENT REPLACE 策略每轮 ReasoningNode/SummarizingNode 覆写
// lastEmitted 跟踪已发送的值避免在 ActionNode/ObservationNode NodeOutput 上重复发送同一段内容
AtomicReference<String> lastEmittedStreamedContent = new AtomicReference<>("");
return compiledGraph.stream(inputs, config)
.flatMapIterable(output -> {
@ -287,6 +299,16 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
boolean thinkingAlreadyStreamed = output.state()
.value(THINKING_STREAMED, false);
// 2a. 中间叙述内容持久化每轮 ReasoningNode tool_calls SummarizingNode
// 都把当轮 LLM 输出写入 STREAMED_CONTENTNodeStreamingChatHelper 已实时广播
// 给前端 Accumulator 不在 SSE 订阅链路上必须用 persistOnly StreamDelta
// 补一刀否则刷新后正文文字全部丢失只剩 final_answer + tool_call 卡片
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));
}
if (hasFinalAnswer(output) && finalAnswerEmitted.compareAndSet(false, true)) {
String answer = extractFinalAnswer(output);
if (answer != null && !answer.isEmpty()) {

View File

@ -183,6 +183,9 @@ public class SummarizingNode implements NodeAction {
// 摘要的 content 已流式推送但它不是最终回答标记防重即可
.contentStreamed(true)
.thinkingStreamed(!result.thinking().isEmpty())
// 把当轮 summary 文本写入 STREAMED_CONTENT StateGraphReActAgent persistOnly
// StreamDelta 推给 Accumulator 持久化用户刷新页面后能看到摘要正文否则只剩 tool_call 卡片
.streamedContent(summaryContent)
.mergeUsage(state, result)
// 不设 finishReason summarizing 不是终止循环继续
.events(List.of(GraphEventPublisher.phase("summarized", Map.of(

View File

@ -738,6 +738,10 @@ public class ChatController {
// Disposable 注册到 StreamTracker以便 stop 端点可以取消它
streamTracker.setDisposable(conversationId, disposable);
// JVM 关闭时优雅落盘避免 mvn spring-boot:run 重启 / SIGTERM
// 进行中 turn assistant 消息丢失doOnError 来不及在 Hikari 关闭前执行
streamTracker.setEmergencySaveCallback(conversationId,
() -> emergencySaveAccumulator(conversationId, accumulator));
} catch (Exception e) {
log.error("SSE setup error: {}", e.getMessage());
@ -773,12 +777,15 @@ public class ChatController {
}
/**
* 中断当前流并排队一条后续消息
* 在执行中追加一条后续消息仅入队等当前 turn 自然结束后再启动
* <p>
* stop 的区别interrupt 会在当前 turn 安全结束后自动启动排队消息
* 如果当前阶段不可中断awaiting_approval消息会被排队但不打断当前执行
* 对齐 Claude Code 行为流式输出过程中收到新输入不会强制 dispose 当前 LLM 调用
* 而是仅入队当前 turn 跑到 doOnComplete/doOnError 后由 startQueuedMessage 接管
* <p>
* 旧行为dispose 当前 disposable + 立即重启会导致部分 LLM 输出被丢弃 + token 浪费
* 已废弃如果未来需要"立即打断"语义应该走 /stop用户主动取消+ 重新发起新消息的路径
*/
@Operation(summary = "中断并排队后续消息")
@Operation(summary = "排队后续消息(不打断当前流)")
@PostMapping("/{conversationId}/interrupt")
public R<Map<String, Object>> interruptStream(
@PathVariable String conversationId,
@ -797,34 +804,20 @@ public class ChatController {
Long agentId = request.getAgentId();
List<MessageContentPart> contentParts = request.getContentParts();
// 判断当前阶段是否可中断
// awaiting_approval 阶段不直接中断只排队
// 判断当前阶段仅用于 reason 字段行为对所有阶段一致仅入队
boolean isAwaitingApproval = approvalService.findPendingByConversation(conversationId) != null;
if (isAwaitingApproval) {
// 不可中断排队但不打断先持久化 contentParts再入队persisted=true
conversationService.saveMessage(conversationId, "user", message, contentParts, "queued");
boolean queued = streamTracker.enqueueMessage(conversationId, message, agentId, true);
log.info("Interrupt requested during approval, message queued: conversationId={}, user={}, queueSize={}",
conversationId, username, streamTracker.getQueueSize(conversationId));
return R.ok(Map.of(
"interrupted", false,
"queued", queued,
"reason", "awaiting_approval"
));
}
// 可中断先持久化 contentParts再打断并入队persisted=true
conversationService.saveMessage(conversationId, "user", message, contentParts, "queued");
boolean interrupted = streamTracker.requestInterrupt(conversationId, message, agentId, true);
log.info("Interrupt requested: conversationId={}, user={}, interrupted={}, queueSize={}",
conversationId, username, interrupted, streamTracker.getQueueSize(conversationId));
// 仅入队 dispose延迟持久化到 startQueuedMessage Asst-N 先在 doOnComplete 落库
// 否则 listMessages ORDER BY create_time ASC 会把 Q(N+1) 排到 Asst-N 前面
boolean queued = streamTracker.enqueueMessage(conversationId, message, agentId, false, contentParts);
log.info("Enqueued follow-up message during running turn: conversationId={}, user={}, queueSize={}, awaitingApproval={}",
conversationId, username, streamTracker.getQueueSize(conversationId), isAwaitingApproval);
return R.ok(Map.of(
"interrupted", interrupted,
"queued", true,
"interrupted", false,
"queued", queued,
"queueSize", streamTracker.getQueueSize(conversationId),
"reason", interrupted ? "interrupted" : "queued"
"reason", isAwaitingApproval ? "awaiting_approval" : "queued"
));
}
@ -1009,9 +1002,12 @@ public class ChatController {
log.info("Starting queued message: conversationId={}, agentId={}, message={}",
conversationId, agentId, queuedMessage.substring(0, Math.min(30, queuedMessage.length())));
// 持久化排队的用户消息幂等如果 /interrupt 已提前持久化则跳过
// 持久化排队的用户消息 contentParts幂等如果 /interrupt 已提前持久化则跳过
// 这里持久化是为了确保 user 消息在 assistant 消息doOnError/doOnCancel 已写入之后落库
// listMessages ORDER BY create_time ASC 后顺序正确Q1 Asst1 Q2 Asst2
if (queuedMessage != null && !queuedMessage.isBlank() && !preConsumedInput.persisted()) {
conversationService.saveMessage(conversationId, "user", queuedMessage);
conversationService.saveMessage(conversationId, "user", queuedMessage,
preConsumedInput.contentParts(), "queued");
}
// 广播 queued_input_started 事件
@ -1122,6 +1118,8 @@ public class ChatController {
})
.subscribe();
streamTracker.setDisposable(conversationId, disposable);
streamTracker.setEmergencySaveCallback(conversationId,
() -> emergencySaveAccumulator(conversationId, accumulator));
}
private void sendEvent(SseEmitter emitter, String name, Object data) throws IOException {
@ -1170,6 +1168,40 @@ public class ChatController {
return payload;
}
/**
* Snapshot the accumulator and persist it as an assistant message during JVM shutdown.
* Invoked from {@link ChatStreamTracker#onShutdown()} so any in-flight turn doesn't
* lose its already-streamed content + tool calls when the process exits.
* <p>
* Idempotent w.r.t. the normal doOnComplete/doOnError save: if those paths already
* persisted the message, this writes a second row with status="interrupted_shutdown",
* which is rare in practice (race window is sub-second between dispose and save) and
* acceptable. Skipping save when nothing to save avoids empty rows.
*/
private void emergencySaveAccumulator(String conversationId, StreamAccumulator accumulator) {
try {
String text = accumulator.getContent();
List<MessageContentPart> parts = accumulator.toAssistantParts();
if (text.isBlank() && parts.isEmpty()) {
return;
}
String savedText = text.isBlank() ? "[已中断 — 服务重启]" : text;
conversationService.saveMessage(conversationId, "assistant", savedText, parts,
"interrupted_shutdown",
accumulator.getPromptTokens(),
accumulator.getCompletionTokens(),
accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson());
log.info("[ChatController] Emergency-saved in-flight assistant message: " +
"conversationId={}, textLen={}, partsCount={}",
conversationId, text.length(), parts.size());
} catch (Exception e) {
log.error("[ChatController] Emergency save failed for {}: {}",
conversationId, e.getMessage(), e);
}
}
private List<MessageContentPart> normalizeRequestParts(ChatStreamRequest request) {
if (request.getContentParts() != null && !request.getContentParts().isEmpty()) {
return request.getContentParts();

View File

@ -1,10 +1,12 @@
package vip.mate.channel.web;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import reactor.core.Disposable;
import vip.mate.workspace.conversation.model.MessageContentPart;
import java.io.IOException;
import java.util.ArrayList;
@ -104,6 +106,18 @@ public class ChatStreamTracker {
/** 排队的用户消息队列(支持多条排队消息,按序消费) */
final java.util.Queue<QueuedInput> messageQueue = new java.util.concurrent.ConcurrentLinkedQueue<>();
/**
* Emergency save callback registered by the SSE chain owner (ChatController).
* Invoked from {@link #onShutdown()} so the accumulated assistant content + tool_calls
* are persisted before the JVM tears down without this, a `mvn spring-boot:run`
* restart wipes any in-flight turn and leaves only the user message in DB.
* <p>
* The callback must be idempotent (will not be called twice for the same run, but
* may race with normal doOnComplete/doOnError; both paths must tolerate the other
* having saved already).
*/
volatile Runnable emergencySaveCallback;
/** 心跳定时器 */
volatile ScheduledFuture<?> heartbeatFuture;
@ -190,6 +204,18 @@ public class ChatStreamTracker {
}
}
/**
* Register an emergency-save callback for this run, invoked from {@link #onShutdown()}
* before the JVM tears down. The callback should snapshot the current accumulator
* state and persist it as the assistant message (status="interrupted").
*/
public void setEmergencySaveCallback(String conversationId, Runnable callback) {
RunState state = runs.get(conversationId);
if (state != null) {
state.emergencySaveCallback = callback;
}
}
/**
* 请求停止指定会话的流
* 取消 Flux 订阅底层 HTTP 连接也会随之关闭返回 true 表示确实停止了正在运行的流
@ -577,6 +603,11 @@ public class ChatStreamTracker {
* @return true 如果成功请求了中断
*/
public boolean requestInterrupt(String conversationId, String queuedMessage, Long agentId, boolean persisted) {
return requestInterrupt(conversationId, queuedMessage, agentId, persisted, null);
}
public boolean requestInterrupt(String conversationId, String queuedMessage, Long agentId,
boolean persisted, List<MessageContentPart> contentParts) {
RunState state = runs.get(conversationId);
if (state == null || state.done) {
return false;
@ -589,7 +620,7 @@ public class ChatStreamTracker {
Disposable d = state.disposable;
canInterrupt = d != null && !d.isDisposed();
// 无论是否可中断都入队支持多条排队消息
state.messageQueue.offer(new QueuedInput(queuedMessage, agentId, persisted));
state.messageQueue.offer(new QueuedInput(queuedMessage, agentId, persisted, contentParts));
if (canInterrupt) {
state.interruptType = InterruptType.USER_INTERRUPT_WITH_FOLLOWUP;
state.stopRequested.set(true);
@ -636,11 +667,16 @@ public class ChatStreamTracker {
* 将消息加入队列但不中断当前执行用于不可中断阶段
*/
public boolean enqueueMessage(String conversationId, String message, Long agentId, boolean persisted) {
return enqueueMessage(conversationId, message, agentId, persisted, null);
}
public boolean enqueueMessage(String conversationId, String message, Long agentId, boolean persisted,
List<MessageContentPart> contentParts) {
RunState state = runs.get(conversationId);
if (state == null || state.done) {
return false;
}
state.messageQueue.offer(new QueuedInput(message, agentId, persisted));
state.messageQueue.offer(new QueuedInput(message, agentId, persisted, contentParts));
// broadcast 在锁外
try {
String json = objectMapper.writeValueAsString(Map.of(
@ -656,9 +692,14 @@ public class ChatStreamTracker {
}
/**
* 排队输入的原子快照message + agentId + persisted 一起返回避免分离读取导致不一致
* 排队输入的原子快照message + agentId + persisted + contentParts 一起返回避免分离读取导致不一致
*/
public record QueuedInput(String message, Long agentId, boolean persisted) {}
public record QueuedInput(String message, Long agentId, boolean persisted,
List<MessageContentPart> contentParts) {
public QueuedInput(String message, Long agentId, boolean persisted) {
this(message, agentId, persisted, null);
}
}
/**
* 原子消费排队的输入流完成/中断后调用
@ -905,4 +946,64 @@ public class ChatStreamTracker {
evicted, runs.size());
}
}
/**
* Flush in-flight runs before JVM shutdown.
* <p>
* Spring closes singleton beans in reverse construction order; ConversationService /
* Hikari outlive ChatStreamTracker, so saveMessage from {@link #onShutdown()} still
* has a working DB connection. Without this, a {@code mvn spring-boot:run} restart or
* SIGTERM during a turn races against the Reactor cancellation: the doOnError /
* doOnComplete saveMessage may not run before HikariPool shuts down, leaving the
* conversation with only the user message and no assistant reply (the
* "对话框里除了问题外什么也没留下" symptom seen in production logs at 07:23:02).
* <p>
* Behavior:
* <ol>
* <li>Walk every active (not-done) RunState.</li>
* <li>Invoke its registered emergencySaveCallback synchronously the callback
* (set by ChatController) snapshots the current accumulator and persists it
* as an "interrupted" assistant message.</li>
* <li>Dispose the Reactor disposable so the LLM stream terminates promptly.</li>
* </ol>
* The callback must tolerate normal doOnError/doOnComplete having raced and saved
* already; the latest commit wins for that conversation.
*/
@PreDestroy
public void onShutdown() {
int active = (int) runs.values().stream().filter(s -> !s.done).count();
if (active == 0) {
log.info("[ChatStreamTracker] Shutdown: no active runs to flush");
return;
}
log.warn("[ChatStreamTracker] Shutdown: flushing {} active run(s) before JVM exit",
active);
for (Map.Entry<String, RunState> entry : runs.entrySet()) {
RunState state = entry.getValue();
if (state.done) continue;
String cid = entry.getKey();
try {
Runnable callback = state.emergencySaveCallback;
if (callback != null) {
log.info("[ChatStreamTracker] Emergency-saving in-flight run: {}", cid);
callback.run();
} else {
log.warn("[ChatStreamTracker] No emergency-save callback for active run: {} " +
"(content may be lost)", cid);
}
} catch (Exception e) {
log.error("[ChatStreamTracker] Emergency save failed for {}: {}",
cid, e.getMessage(), e);
}
try {
Disposable d = state.disposable;
if (d != null && !d.isDisposed()) {
d.dispose();
}
} catch (Exception e) {
log.warn("[ChatStreamTracker] Disposable.dispose failed for {}: {}",
cid, e.getMessage());
}
}
}
}

View File

@ -4,7 +4,13 @@ import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Graph 观察结果处理阈值配置
* Graph observation thresholds for triggering summarize / compaction.
* <p>
* <b>Tune values in {@code application.yml} under {@code mate.agent.graph.observation},
* not in the Java field defaults.</b> The yml is the source of truth and is loaded into
* a Spring bean by {@link ConfigurationProperties}; field defaults below are kept as a
* conservative fallback for tests / unit constructors and intentionally do NOT reflect
* production-tuned values.
*
* @author MateClaw Team
*/
@ -12,27 +18,27 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "mate.agent.graph.observation")
public class GraphObservationProperties {
/** 单次工具结果最大字符数 */
/** Max chars retained per tool result (truncate above this). Tune via yml. */
private int maxSingleObservationChars = 4000;
/** 所有观察记录总字符数上限 */
/** Total observation chars across history+current that trigger summarize. Tune via yml. */
private int maxTotalObservationChars = 12000;
/** 单次结果超过此阈值视为"大结果" */
/** Single tool result above this is treated as "large" and triggers summarize. Tune via yml. */
private int largeResultThreshold = 3000;
/** 触发 summarize 的最小观察轮次 */
/** Minimum observation rounds that triggers summarize as a runaway-loop safety net. Tune via yml. */
private int minRoundsForSummarize = 3;
/** 截断时保留前部占比0-1 */
/** Truncation: head fraction kept (0-1). */
private double headRatio = 0.4;
/** 截断省略标记(%d 会被替换为原始字符数) */
/** Truncation marker; %d is replaced with original char count. */
private String truncationMarker = "\n\n... [内容已截断,共 %d 字符,保留前后关键片段] ...\n\n";
/** 检测到尾部错误模式时的 tail 保留比例(默认 0.8,优先保留错误信息) */
/** Tail-keep ratio when an error pattern is detected at the tail (prefer keeping error info). */
private double errorTailRatio = 0.8;
/** 截断时最少保留字符数(避免过度截断导致信息完全丢失) */
/** Minimum chars retained on truncation to avoid losing all information. */
private int minKeepChars = 2000;
}

View File

@ -154,10 +154,11 @@ mate:
agent:
graph:
observation:
max-single-observation-chars: 8000
max-total-observation-chars: 24000
large-result-threshold: 6000
min-rounds-for-summarize: 3
# 与 GraphObservationProperties.java 默认值对齐,参考 openclaw token-budget 设计
max-single-observation-chars: 16000
max-total-observation-chars: 200000
large-result-threshold: 32000
min-rounds-for-summarize: 25
head-ratio: 0.4
truncation-marker: "\n\n... [内容已截断,共 %d 字符,保留前后关键片段] ...\n\n"
tool:

View File

@ -288,18 +288,21 @@ export function useChat(options: UseChatOptions): UseChatReturn {
if (streamPhase.value !== 'summarizing_observations') {
streamPhase.value = options.thinkingLevel?.value === 'off' ? 'streaming' : 'thinking'
}
// Segments: all thinking deltas merge into one segment (not split by tool_call interruptions)
// Segments: per-round thinking. When tool_call_started / phase change closes the running
// thinking segment (status='completed'), a fresh thinking_delta opens a new segment instead
// of reopening the closed one. Without this split, multi-round ReAct (3 reasoning + 2
// summarizing rounds) accumulates 9K+ chars in a single bubble.
const segs = currentSegments.value
// Reuse an existing thinking segment regardless of status (running or completed)
let thinkSeg = segs.find((s: MessageSegment) => s.type === 'thinking')
let thinkSeg = segs.findLast((s: MessageSegment) =>
s.type === 'thinking' && s.status === 'running'
)
if (!thinkSeg) {
thinkSeg = { id: genSegId(), type: 'thinking', status: 'running', thinkingText: '', timestamp: Date.now() }
// Insert at front — thinking always appears at the top
segs.unshift(thinkSeg)
// Append in timeline order (interleaved with tool_calls) — old behavior unshift'd to top,
// but with per-round splitting that misorders rounds 2+ relative to their tool calls.
segs.push(thinkSeg)
flushSegmentsToMessage()
}
// Re-mark as running when new thinking content arrives
thinkSeg.status = 'running'
thinkSeg.thinkingText = (thinkSeg.thinkingText || '') + (data.delta || '')
}
})
@ -596,6 +599,15 @@ export function useChat(options: UseChatOptions): UseChatReturn {
metadata: { ...metadata, currentPhase: data.phase }
} as any)
}
// Close any running thinking/content segment on phase transition so the next thinking_delta
// (e.g. summarizing → reasoning) starts a fresh round-scoped segment instead of growing the
// previous one unbounded.
const segs = currentSegments.value
for (const seg of segs) {
if (seg.status === 'running' && (seg.type === 'thinking' || seg.type === 'content')) {
seg.status = 'completed'
}
}
}
})