From 12d7c8ade08d84dda9818762047ee2f5e1d5e218 Mon Sep 17 00:00:00 2001 From: Haze <709547807@qq.com> Date: Mon, 20 Apr 2026 17:09:20 +0800 Subject: [PATCH] fix(chat): refine tool phase detection and clean stale reply from graph cache - hasCompletedToolPhase now checks that the last assistant message in the segment has no tool_use blocks, preventing false positives during intermediate tool rounds that would suppress the trailing thinking indicator - Filter reply text from cached graph steps when a completed run falls back to the step cache, preventing the final response from appearing inside the graph when expanding after completion - Remove debug logging Co-Authored-By: Claude Opus 4.6 (1M context) --- src/pages/Chat/index.tsx | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/src/pages/Chat/index.tsx b/src/pages/Chat/index.tsx index ffd1ef1..1ec3706 100644 --- a/src/pages/Chat/index.tsx +++ b/src/pages/Chat/index.tsx @@ -278,13 +278,19 @@ export function Chat() { // Three signals indicate "tools finished, now streaming the reply": // 1. `pendingFinal` — set by tool-result final events // 2. `allToolsCompleted` — all entries in streamingTools are completed - // 3. `hasCompletedToolPhase` — historical messages (loaded by the poll) - // contain tool_use blocks, meaning the Gateway executed tools - // server-side without sending streaming tool events to the client + // 3. `hasCompletedToolPhase` — the Gateway executed tools server-side + // (no streaming tool events) and the tool phase is over. Detected + // by finding tool_use in historical messages AND the last assistant + // message in the segment having no tool_use blocks (meaning the + // model has moved past tool calls into the reply phase). const allToolsCompleted = streamingTools.length > 0 && !hasRunningStreamToolStatus; - const hasCompletedToolPhase = segmentMessages.some((msg) => + const lastAssistantInSegment = [...segmentMessages].reverse().find((m) => m.role === 'assistant'); + const segmentHasTools = segmentMessages.some((msg) => msg.role === 'assistant' && extractToolUse(msg).length > 0, ); + const lastAssistantHasNoTools = lastAssistantInSegment != null + && extractToolUse(lastAssistantInSegment).length === 0; + const hasCompletedToolPhase = segmentHasTools && lastAssistantHasNoTools; const rawStreamingReplyCandidate = isLatestOpenRun && (pendingFinal || allToolsCompleted || hasCompletedToolPhase) && (hasStreamText || hasStreamImages) @@ -323,6 +329,16 @@ export function Chat() { } const cached = graphStepCache[runKey]; if (!cached) return []; + // When using cached steps for a completed run, filter out message + // steps whose text matches the final reply. The cache was captured + // during streaming when all text was narration; now that the run is + // complete the reply should not appear inside the graph. + const cachedReplyIdx = cached.replyIndex; + const replyMsg = cachedReplyIdx != null ? messages[cachedReplyIdx] : null; + const replyText = replyMsg ? extractText(replyMsg).trim() : ''; + const cleanedSteps = replyText + ? cached.steps.filter((s) => !(s.kind === 'message' && s.detail?.trim() === replyText)) + : cached.steps; return [{ triggerIndex: idx, replyIndex: cached.replyIndex, @@ -330,8 +346,8 @@ export function Chat() { agentLabel: cached.agentLabel, sessionLabel: cached.sessionLabel, segmentEnd: nextUserIndex === -1 ? messages.length - 1 : nextUserIndex - 1, - steps: cached.steps, - messageStepTexts: getPrimaryMessageStepTexts(cached.steps), + steps: cleanedSteps, + messageStepTexts: getPrimaryMessageStepTexts(cleanedSteps), streamingReplyText: null, }]; }