fix: clear stuck "thinking" state when run already finished in history (#1059)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
Reference in New Issue
Block a user