From 219db5ff78ae12e4cff2b540c5daec0ed38cad54 Mon Sep 17 00:00:00 2001 From: paisley <8197966+su8su@users.noreply.github.com> Date: Sat, 23 May 2026 15:20:46 +0800 Subject: [PATCH] fix: clear stuck "thinking" state when run already finished in history (#1059) --- src/pages/Chat/index.tsx | 79 +++++++------ src/pages/Chat/task-visualization.ts | 26 +++++ src/stores/chat.ts | 20 ++++ tests/unit/chat-page-execution-graph.test.tsx | 104 ++++++++++++++++++ tests/unit/chat-store-history-retry.test.ts | 54 +++++++++ tests/unit/run-segment-indices.test.ts | 20 ++++ 6 files changed, 267 insertions(+), 36 deletions(-) diff --git a/src/pages/Chat/index.tsx b/src/pages/Chat/index.tsx index dc31596..e0f063d 100644 --- a/src/pages/Chat/index.tsx +++ b/src/pages/Chat/index.tsx @@ -27,6 +27,7 @@ import { getRunSegmentMessages, hasActiveStreamingReplyInRun, parseSubagentCompletionInfo, + segmentHasFinalReply, type TaskStep, } from './task-visualization'; import { useTranslation } from 'react-i18next'; @@ -321,14 +322,17 @@ export function Chat() { // tool-result wrappers (Anthropic API format). These must NOT split // the run into multiple segments — only genuine user-authored messages // should act as run boundaries. - const nextUserMessageIndexes = new Array(messages.length).fill(-1); - let nextUserMessageIndex = -1; - for (let idx = messages.length - 1; idx >= 0; idx -= 1) { - nextUserMessageIndexes[idx] = nextUserMessageIndex; - if (isRealUserMessage(messages[idx]) && !subagentCompletionInfos[idx]) { - nextUserMessageIndex = idx; + const nextUserMessageIndexes = useMemo(() => { + const indexes = new Array(messages.length).fill(-1); + let nextUserMessageIndex = -1; + for (let idx = messages.length - 1; idx >= 0; idx -= 1) { + indexes[idx] = nextUserMessageIndex; + if (isRealUserMessage(messages[idx]) && !subagentCompletionInfos[idx]) { + nextUserMessageIndex = idx; + } } - } + return indexes; + }, [messages, subagentCompletionInfos]); const questionDirectoryItems = useMemo(() => { const items: QuestionDirectoryItem[] = []; @@ -387,30 +391,7 @@ export function Chat() { const hasToolActivity = postTriggerMessages.some((m) => m.role === 'assistant' && extractToolUse(m).length > 0, ); - // Locate the last tool-use message so we only count text messages that - // come AFTER all tool calls as "final reply". Intermediate narration - // messages (pure text, no tool_use) sit BEFORE tool calls and must not - // be misread as the concluding reply — otherwise `runStillExecutingTools` - // flips to false between tool rounds, collapsing the trailing - // "Thinking..." indicator during the brief gap before the next stream chunk. - let lastToolUseOffset = -1; - for (let i = postTriggerMessages.length - 1; i >= 0; i -= 1) { - const m = postTriggerMessages[i]; - if (m.role === 'assistant' && extractToolUse(m).length > 0) { - lastToolUseOffset = i; - break; - } - } - const hasFinalReply = postTriggerMessages.some((m, i) => { - if (i <= lastToolUseOffset) return false; - if (m.role !== 'assistant') return false; - if (extractText(m).trim().length === 0) return false; - const content = m.content; - if (!Array.isArray(content)) return true; - return !(content as Array<{ type?: string }>).some( - (b) => b.type === 'tool_use' || b.type === 'toolCall', - ); - }); + const hasFinalReply = segmentHasFinalReply(postTriggerMessages); const runStillExecutingTools = hasToolActivity && !hasFinalReply; // runStillExecutingTools bridges the brief gap between tool rounds when // Gateway temporarily clears sending. However, after an explicit abort @@ -418,8 +399,17 @@ export function Chat() { // gate it on activeRunId being present. We also bail out as soon as a // terminal model error has been surfaced so the run doesn't appear active. const isLatestRunSegment = nextUserIndex === -1; + // History may already contain the final answer while lifecycle flags are + // still armed (missing Gateway terminal phase, blocked chat.send RPC, etc.). + // Treat the run as closed for graph/input UI when the transcript is done + // and nothing is actively streaming. Require prior tool activity so an early + // narration-only history snapshot does not collapse the graph mid-chain. + const runCompletedInHistory = hasFinalReply + && !hasAnyStreamContent + && (hasToolActivity || !sending); const isLatestOpenRun = isLatestRunSegment && !runError + && !runCompletedInHistory && (sending || pendingFinal || hasAnyStreamContent || (runStillExecutingTools && !!activeRunId)); const buildSteps = (omitLastStreamingMessageSegment: boolean): TaskStep[] => { @@ -631,6 +621,23 @@ export function Chat() { }]; }, [messages, subagentCompletionInfos, currentSessionKey, streamingMessage, streamingTools, pendingFinal, sending, hasAnyStreamContent, hasStreamText, hasStreamImages, streamText, streamTools.length, hasRunningStreamToolStatus, childTranscripts, currentAgentId, agents, sessionLabels, graphStepCache, runError, isRunTrigger]); const hasActiveExecutionGraph = userRunCards.some((card) => card.active); + let latestRunSegmentCompletion = { hasFinalReply: false, hasToolActivity: false }; + for (let idx = messages.length - 1; idx >= 0; idx -= 1) { + if (!isRealUserMessage(messages[idx]) || subagentCompletionInfos[idx]) continue; + const nextUserIndex = nextUserMessageIndexes[idx]; + const postTrigger = getPostTriggerSegmentMessages(messages, idx, nextUserIndex); + latestRunSegmentCompletion = { + hasFinalReply: segmentHasFinalReply(postTrigger), + hasToolActivity: postTrigger.some((m) => + m.role === 'assistant' && extractToolUse(m).length > 0, + ), + }; + break; + } + const runSettledInHistory = latestRunSegmentCompletion.hasFinalReply + && !hasAnyStreamContent + && (latestRunSegmentCompletion.hasToolActivity || !sending); + const inputRunActive = (sending || hasActiveExecutionGraph) && !runSettledInHistory; const replyTextOverrides = useMemo(() => { const map = new Map(); for (const card of userRunCards) { @@ -932,12 +939,12 @@ export function Chat() { )} {/* Activity indicator: waiting for next AI turn after tool execution */} - {sending && pendingFinal && !shouldRenderStreaming && !hasActiveExecutionGraph && ( + {inputRunActive && pendingFinal && !shouldRenderStreaming && !hasActiveExecutionGraph && ( )} {/* Typing indicator when sending but no stream content yet */} - {sending && !pendingFinal && !hasAnyStreamContent && !hasActiveExecutionGraph && ( + {inputRunActive && !pendingFinal && !hasAnyStreamContent && !hasActiveExecutionGraph && ( )} @@ -1003,7 +1010,7 @@ export function Chat() { onSend={sendMessage} onStop={abortRun} disabled={!isGatewayRunning} - sending={sending || hasActiveExecutionGraph} + sending={inputRunActive} isEmpty={isEmpty} /> @@ -1147,7 +1154,7 @@ function WelcomeScreen() { function TypingIndicator() { return ( -
+
@@ -1167,7 +1174,7 @@ function TypingIndicator() { function ActivityIndicator({ phase }: { phase: 'tool_processing' }) { void phase; return ( -
+
diff --git a/src/pages/Chat/task-visualization.ts b/src/pages/Chat/task-visualization.ts index add9ad5..f03e7c5 100644 --- a/src/pages/Chat/task-visualization.ts +++ b/src/pages/Chat/task-visualization.ts @@ -132,6 +132,32 @@ export function getRunSegmentMessages( return [...orphans, ...core]; } +/** + * True when a run segment already contains a conclusive assistant reply: the + * last assistant message with user-visible text that appears after all tool + * calls (if any). Intermediate narration before tools does not count. + */ +export function segmentHasFinalReply(segmentMessages: RawMessage[]): boolean { + let lastToolUseOffset = -1; + for (let i = segmentMessages.length - 1; i >= 0; i -= 1) { + const message = segmentMessages[i]; + if (message.role === 'assistant' && extractToolUse(message).length > 0) { + lastToolUseOffset = i; + break; + } + } + return segmentMessages.some((message, index) => { + if (index <= lastToolUseOffset) return false; + if (message.role !== 'assistant') return false; + if (extractText(message).trim().length === 0) return false; + const content = message.content; + if (!Array.isArray(content)) return true; + return !(content as Array<{ type?: string }>).some( + (block) => block.type === 'tool_use' || block.type === 'toolCall', + ); + }); +} + interface DeriveTaskStepsInput { messages: RawMessage[]; streamingMessage: unknown | null; diff --git a/src/stores/chat.ts b/src/stores/chat.ts index d843ce6..12dce49 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -2691,6 +2691,26 @@ export const useChatStore = create((set, get) => ({ } } + // Unstick lifecycle when history already has a conclusive reply but the + // Gateway never emitted a terminal phase event (WS drop, console run, etc.). + if (isSendingNow && !get().streamingMessage && get().streamingTools.length === 0) { + const openSegment = postUserSegmentMessages(filteredMessages); + const hasConclusiveReply = openSegment.some((message) => { + if (message.role !== 'assistant') return false; + if (hasPendingToolUse(message)) return false; + return hasNonToolAssistantContent(message); + }); + if (hasConclusiveReply && !segmentHasOpenToolRun(openSegment)) { + clearHistoryPoll(); + set({ + sending: false, + activeRunId: null, + pendingFinal: false, + lastUserMessageAt: null, + }); + } + } + // After session switch the renderer may have reset run lifecycle flags even // though the Gateway is still executing a user-initiated turn. Re-arm only // when this session had an active cached run (e.g. user switched away diff --git a/tests/unit/chat-page-execution-graph.test.tsx b/tests/unit/chat-page-execution-graph.test.tsx index 7b88d56..04cecb9 100644 --- a/tests/unit/chat-page-execution-graph.test.tsx +++ b/tests/unit/chat-page-execution-graph.test.tsx @@ -351,4 +351,108 @@ describe('Chat execution graph lifecycle', () => { expect(screen.queryByTestId('chat-execution-step-thinking-trailing')).not.toBeInTheDocument(); expect(screen.getAllByText('404 Resource not found').length).toBeGreaterThan(0); }); + + it('stops trailing thinking when history already contains the final reply but sending is stuck', async () => { + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + messages: [ + { + role: 'user', + content: '执行一下github1', + }, + { + role: 'assistant', + id: 'tool-turn', + content: [ + { type: 'text', text: 'Fetching GitHub trending data.' }, + { type: 'tool_use', id: 'fetch-1', name: 'web_fetch', input: { url: 'https://example.com' } }, + ], + }, + { + role: 'assistant', + id: 'final-turn', + content: [{ type: 'text', text: '执行完成 ✅ 今天的 github1 已写入飞书文档。' }], + }, + ], + loading: false, + error: null, + runError: null, + sending: true, + activeRunId: 'run-stuck', + streamingText: '', + streamingMessage: null, + streamingTools: [], + pendingFinal: false, + lastUserMessageAt: Date.now() - 60 * 60 * 1000, + pendingToolImages: [], + sessions: [{ key: 'agent:main:main' }], + currentSessionKey: 'agent:main:main', + currentAgentId: 'main', + sessionLabels: {}, + sessionLastActivity: {}, + thinkingLevel: null, + }); + + const { Chat } = await import('@/pages/Chat/index'); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('chat-execution-graph')).toBeInTheDocument(); + }); + + expect(screen.queryByTestId('chat-execution-step-thinking-trailing')).not.toBeInTheDocument(); + expect(screen.getByText('执行完成 ✅ 今天的 github1 已写入飞书文档。')).toBeInTheDocument(); + expect(screen.queryByTestId('chat-typing-indicator')).not.toBeInTheDocument(); + expect(screen.queryByTestId('chat-activity-indicator')).not.toBeInTheDocument(); + }); + + it('keeps the run active when narration landed in history before tools finished', async () => { + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + messages: [ + { + role: 'user', + content: 'Check semiconductor chatter', + }, + { + role: 'assistant', + id: 'narration-turn', + content: [{ type: 'text', text: 'Let me search for that first.' }], + }, + { + role: 'assistant', + id: 'tool-turn', + content: [ + { type: 'tool_use', id: 'browser-search', name: 'browser', input: { action: 'search', query: 'semiconductor' } }, + ], + }, + ], + loading: false, + error: null, + runError: null, + sending: true, + activeRunId: 'run-narration', + streamingText: '', + streamingMessage: null, + streamingTools: [], + pendingFinal: false, + lastUserMessageAt: Date.now(), + pendingToolImages: [], + sessions: [{ key: 'agent:main:main' }], + currentSessionKey: 'agent:main:main', + currentAgentId: 'main', + sessionLabels: {}, + sessionLastActivity: {}, + thinkingLevel: null, + }); + + const { Chat } = await import('@/pages/Chat/index'); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('chat-execution-step-thinking-trailing')).toBeInTheDocument(); + }); + }); }); diff --git a/tests/unit/chat-store-history-retry.test.ts b/tests/unit/chat-store-history-retry.test.ts index 6df544f..5213bc3 100644 --- a/tests/unit/chat-store-history-retry.test.ts +++ b/tests/unit/chat-store-history-retry.test.ts @@ -897,6 +897,60 @@ describe('useChatStore startup history retry', () => { expect(state.pendingFinal).toBe(false); }); + it('unsticks sending when history has a final reply after tools without pendingFinal', async () => { + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + currentSessionKey: 'agent:main:session-stuck', + currentAgentId: 'main', + sessions: [{ key: 'agent:main:session-stuck' }], + messages: [ + { id: 'user-stuck', role: 'user', content: '执行一下github1', timestamp: 1000 }, + ], + sessionLabels: {}, + sessionLastActivity: {}, + sending: true, + activeRunId: 'run-stuck', + streamingText: '', + streamingMessage: null, + streamingTools: [], + pendingFinal: false, + lastUserMessageAt: 1000, + pendingToolImages: [], + error: null, + loading: false, + thinkingLevel: null, + }); + + gatewayRpcMock.mockResolvedValueOnce({ + messages: [ + { id: 'user-stuck', role: 'user', content: '执行一下github1', timestamp: 1000 }, + { + id: 'assistant-tool', + role: 'assistant', + content: [ + { type: 'text', text: 'Fetching data.' }, + { type: 'tool_use', id: 'tool-1', name: 'web_fetch', input: { url: 'https://example.com' } }, + ], + timestamp: 1500, + }, + { + id: 'assistant-final', + role: 'assistant', + content: [{ type: 'text', text: '执行完成 ✅' }], + stopReason: 'endTurn', + timestamp: 2000, + }, + ], + }); + + await useChatStore.getState().loadHistory(true); + + const state = useChatStore.getState(); + expect(state.sending).toBe(false); + expect(state.activeRunId).toBeNull(); + expect(state.pendingFinal).toBe(false); + }); + // Cross-protocol coverage: Anthropic Messages API native shape (snake_case). // OpenClaw's gateway normally normalizes to camelCase, but some paths can // pass Anthropic responses through unchanged. `hasPendingToolUse` must still diff --git a/tests/unit/run-segment-indices.test.ts b/tests/unit/run-segment-indices.test.ts index 07e5b7f..4e7d727 100644 --- a/tests/unit/run-segment-indices.test.ts +++ b/tests/unit/run-segment-indices.test.ts @@ -5,6 +5,7 @@ import { getPostTriggerSegmentMessages, getRunSegmentMessages, hasActiveStreamingReplyInRun, + segmentHasFinalReply, } from '@/pages/Chat/task-visualization'; import type { RawMessage } from '@/stores/chat'; @@ -81,6 +82,25 @@ describe('getPostTriggerSegmentMessages vs getRunSegmentMessages', () => { }); }); +describe('segmentHasFinalReply', () => { + it('returns true when a text reply follows all tool calls', () => { + const segment: RawMessage[] = [ + { role: 'assistant', content: [{ type: 'text', text: 'Working on it.' }] }, + { role: 'assistant', content: [{ type: 'tool_use', id: 't1', name: 'exec', input: {} }] }, + { role: 'assistant', content: [{ type: 'text', text: '执行完成 ✅' }] }, + ]; + expect(segmentHasFinalReply(segment)).toBe(true); + }); + + it('returns false for narration before tools while the chain is still open', () => { + const segment: RawMessage[] = [ + { role: 'assistant', content: [{ type: 'text', text: 'Let me fetch that.' }] }, + { role: 'assistant', content: [{ type: 'tool_use', id: 't1', name: 'exec', input: {} }] }, + ]; + expect(segmentHasFinalReply(segment)).toBe(false); + }); +}); + describe('findReplyMessageIndex / hasActiveStreamingReplyInRun', () => { it('protects a history reply from fold when the run is open but not streaming', () => { const postTrigger: RawMessage[] = [