fix: clear stuck "thinking" state when run already finished in history (#1059)

This commit is contained in:
paisley
2026-05-23 15:20:46 +08:00
committed by GitHub
parent 21c1ba2e33
commit 219db5ff78
6 changed files with 267 additions and 36 deletions

View File

@@ -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<number>(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<number>(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<QuestionDirectoryItem[]>(() => {
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<number, string>();
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 && (
<ActivityIndicator phase="tool_processing" />
)}
{/* Typing indicator when sending but no stream content yet */}
{sending && !pendingFinal && !hasAnyStreamContent && !hasActiveExecutionGraph && (
{inputRunActive && !pendingFinal && !hasAnyStreamContent && !hasActiveExecutionGraph && (
<TypingIndicator />
)}
</>
@@ -1003,7 +1010,7 @@ export function Chat() {
onSend={sendMessage}
onStop={abortRun}
disabled={!isGatewayRunning}
sending={sending || hasActiveExecutionGraph}
sending={inputRunActive}
isEmpty={isEmpty}
/>
</div>
@@ -1147,7 +1154,7 @@ function WelcomeScreen() {
function TypingIndicator() {
return (
<div className="flex gap-3">
<div className="flex gap-3" data-testid="chat-typing-indicator">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full mt-1 bg-black/5 dark:bg-white/5 text-foreground">
<Sparkles className="h-4 w-4" />
</div>
@@ -1167,7 +1174,7 @@ function TypingIndicator() {
function ActivityIndicator({ phase }: { phase: 'tool_processing' }) {
void phase;
return (
<div className="flex gap-3">
<div className="flex gap-3" data-testid="chat-activity-indicator">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full mt-1 bg-black/5 dark:bg-white/5 text-foreground">
<Sparkles className="h-4 w-4" />
</div>

View File

@@ -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;

View File

@@ -2691,6 +2691,26 @@ export const useChatStore = create<ChatState>((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

View File

@@ -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(<Chat />);
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(<Chat />);
await waitFor(() => {
expect(screen.getByTestId('chat-execution-step-thinking-trailing')).toBeInTheDocument();
});
});
});

View File

@@ -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

View File

@@ -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[] = [