diff --git a/src/stores/chat.ts b/src/stores/chat.ts index 93fe95f..551da28 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -481,20 +481,27 @@ function normalizeStreamingMessage(message: unknown): unknown { * is important: the user bubble renders the cleaned text, so the comparison * used to dedupe optimistic vs server echoes must operate on the same * cleaned form — otherwise the same visible message renders twice. + * + * Order matters: the `[media attached: ...]` lines are commonly emitted + * BETWEEN the Sender block and the `[Mon ... GMT+8]` timestamp prefix. + * If we strip the timestamp before the media-attached lines, the timestamp + * regex (`^\s*\[(?:Mon|...)]`) can never match because the leading `[` is + * `[media attached:` instead — leaving the timestamp in the normalized + * comparison text and breaking optimistic-vs-echo dedupe. */ function stripGatewayUserMetadata(text: string): string { return text + .replace(/\s*\[media attached:[^\]]*\]/g, '') + .replace(/\s*\[message_id:\s*[^\]]+\]/g, '') .replace(/^Sender\s*\([^)]*\)\s*:\s*```[a-z]*\n[\s\S]*?```\s*/i, '') .replace(/^Sender\s*\([^)]*\)\s*:\s*\{[\s\S]*?\}\s*/i, '') .replace(/^Sender\s*\([^)]*\)\s*:\s*[^\n]*(?:\n\s*)*/i, '') .replace(/^Sender\s*:\s*```[a-z]*\n[\s\S]*?```\s*/i, '') .replace(/^Sender\s*:\s*\{[\s\S]*?\}\s*/i, '') .replace(/^Sender\s*:\s*[^\n]*(?:\n\s*)*/i, '') - .replace(/^\s*\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+[^\]]+\]\s*/i, '') - .replace(/\s*\[media attached:[^\]]*\]/g, '') - .replace(/\s*\[message_id:\s*[^\]]+\]/g, '') .replace(/^Conversation info\s*\([^)]*\):\s*```[a-z]*\n[\s\S]*?```\s*/i, '') - .replace(/^Conversation info\s*\([^)]*\):\s*\{[\s\S]*?\}\s*/i, ''); + .replace(/^Conversation info\s*\([^)]*\):\s*\{[\s\S]*?\}\s*/i, '') + .replace(/^\s*\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+[^\]]+\]\s*/i, ''); } function normalizeComparableUserText(content: unknown): string { @@ -1725,6 +1732,16 @@ function collectToolUpdates(message: unknown, eventState: string): ToolStatus[] return updates; } +/** + * True when an assistant message carries user-visible final output (text or + * image). NOTE: `thinking` blocks are intentionally excluded — they are the + * model's internal monologue and frequently precede tool calls in models like + * MiniMax-M2.7 and gpt-5.5. Treating thinking as "final content" causes the + * history-poll closer in applyLoadedMessages and the runtime final handler to + * misclassify intermediate `[thinking, toolCall]` turns as completed replies, + * which prematurely tears down the `sending` / `activeRunId` / `pendingFinal` + * lifecycle flags and makes the Thinking… indicator vanish mid-tool-chain. + */ function hasNonToolAssistantContent(message: RawMessage | undefined): boolean { if (!message) return false; if (typeof message.content === 'string' && message.content.trim()) return true; @@ -1733,7 +1750,6 @@ function hasNonToolAssistantContent(message: RawMessage | undefined): boolean { if (Array.isArray(content)) { for (const block of content as ContentBlock[]) { if (block.type === 'text' && block.text && block.text.trim()) return true; - if (block.type === 'thinking' && block.thinking && block.thinking.trim()) return true; if (block.type === 'image') return true; } } @@ -1744,6 +1760,35 @@ function hasNonToolAssistantContent(message: RawMessage | undefined): boolean { return false; } +/** + * True when an assistant message is still waiting on a tool result, i.e. it + * represents an intermediate tool-use turn rather than a finished reply. + * Detected via: + * - explicit stop_reason = "tool_use" / "toolUse" + * - any tool_use / toolCall block in `content` + * - OpenAI-format `tool_calls` array + * Used by applyLoadedMessages and the runtime `final` handler to keep the + * `sending` / `activeRunId` / `pendingFinal` flags armed across tool rounds. + */ +function hasPendingToolUse(message: RawMessage | undefined): boolean { + if (!message) return false; + const reason = getMessageStopReason(message); + if (reason === 'tool_use' || reason === 'tooluse') return true; + + const content = message.content; + if (Array.isArray(content)) { + for (const block of content as ContentBlock[]) { + if (block.type === 'tool_use' || block.type === 'toolCall') return true; + } + } + + const msg = message as unknown as Record; + const toolCalls = msg.tool_calls ?? msg.toolCalls; + if (Array.isArray(toolCalls) && toolCalls.length > 0) return true; + + return false; +} + // ── Store ──────────────────────────────────────────────────────── export const useChatStore = create((set, get) => ({ @@ -2308,22 +2353,37 @@ export const useChatStore = create((set, get) => ({ return true; } + // Promote pendingFinal only when there's a *final-looking* assistant + // message after the user — i.e. one that has actual user-visible output + // (text/image) AND is not still waiting on a tool result. This used to + // promote on *any* assistant message after the user, which fired on the + // very first `[thinking, toolCall]` intermediate turn and then paired + // with the closer below to clobber the entire run state. if (isSendingNow && !pendingFinal) { - const hasRecentAssistantActivity = [...filteredMessages].reverse().some((msg) => { + const hasFinalLikeAssistant = [...filteredMessages].reverse().some((msg) => { if (msg.role !== 'assistant') return false; - return isAfterUserMsg(msg); + if (!isAfterUserMsg(msg)) return false; + if (hasPendingToolUse(msg)) return false; + return hasNonToolAssistantContent(msg); }); - if (hasRecentAssistantActivity) { + if (hasFinalLikeAssistant) { set({ pendingFinal: true }); } } // If pendingFinal, check whether the AI produced a final text response. + // CRITICAL: reject intermediate tool turns (thinking+tool_use, mixed + // thinking+text+tool_use, etc.) so the run stays "open" across all tool + // rounds. Without `hasPendingToolUse` the closer matches the first + // `[thinking, toolCall]` intermediate turn (because thinking *used to* + // count as non-tool content), clears `sending` / `activeRunId` / + // `pendingFinal`, and makes the Thinking… indicator vanish mid-chain. if (pendingFinal || get().pendingFinal) { const recentAssistant = [...filteredMessages].reverse().find((msg) => { if (msg.role !== 'assistant') return false; - if (!hasNonToolAssistantContent(msg)) return false; - return isAfterUserMsg(msg); + if (!isAfterUserMsg(msg)) return false; + if (hasPendingToolUse(msg)) return false; + return hasNonToolAssistantContent(msg); }); if (recentAssistant) { clearHistoryPoll(); @@ -2914,8 +2974,14 @@ export const useChatStore = create((set, get) => ({ }); break; } - const toolOnly = isToolOnlyMessage(normalizedFinalMessage); - const hasOutput = hasNonToolAssistantContent(normalizedFinalMessage); + // Mixed `[thinking, text, toolCall]` messages with stop_reason="tool_use" + // (some MiniMax / gpt-5.5 variants emit these) are still intermediate + // turns even though they carry user-visible text. Treat them as + // tool-only for lifecycle purposes so the run stays "open" until the + // truly final reply (without a pending tool call) arrives. + const pendingTool = hasPendingToolUse(normalizedFinalMessage); + const toolOnly = isToolOnlyMessage(normalizedFinalMessage) || pendingTool; + const hasOutput = !pendingTool && hasNonToolAssistantContent(normalizedFinalMessage); const msgId = normalizedFinalMessage.id || (toolOnly ? `run-${runId}-tool-${Date.now()}` : `run-${runId}`); set((s) => { const nextTools = updates.length > 0 ? upsertToolStatuses(s.streamingTools, updates) : s.streamingTools; diff --git a/src/stores/chat/helpers.ts b/src/stores/chat/helpers.ts index 8dd6129..bef2af1 100644 --- a/src/stores/chat/helpers.ts +++ b/src/stores/chat/helpers.ts @@ -146,6 +146,7 @@ function normalizeStreamingMessage(message: unknown): unknown { /** * Strip Gateway-injected metadata that does NOT exist on the renderer's * optimistic user message but is echoed back when the Gateway persists it: + * - leading sender metadata `Sender (untrusted metadata): ...` * - leading timestamp `[Wed 2026-04-22 10:30 GMT+8] ` * - `[message_id: uuid]` tags sprinkled throughout the text * - `[media attached: path (mime) | path]` references appended when the @@ -156,14 +157,27 @@ function normalizeStreamingMessage(message: unknown): unknown { * is important: the user bubble renders the cleaned text, so the comparison * used to dedupe optimistic vs server echoes must operate on the same * cleaned form — otherwise the same visible message renders twice. + * + * Order matters: the `[media attached: ...]` lines are commonly emitted + * BETWEEN the Sender block and the `[Mon ... GMT+8]` timestamp prefix. + * If we strip the timestamp before the media-attached lines, the timestamp + * regex (`^\s*\[(?:Mon|...)]`) can never match because the leading `[` is + * `[media attached:` instead — leaving the timestamp in the normalized + * comparison text and breaking optimistic-vs-echo dedupe. */ function stripGatewayUserMetadata(text: string): string { return text - .replace(/^\s*\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+[^\]]+\]\s*/i, '') .replace(/\s*\[media attached:[^\]]*\]/g, '') .replace(/\s*\[message_id:\s*[^\]]+\]/g, '') + .replace(/^Sender\s*\([^)]*\)\s*:\s*```[a-z]*\n[\s\S]*?```\s*/i, '') + .replace(/^Sender\s*\([^)]*\)\s*:\s*\{[\s\S]*?\}\s*/i, '') + .replace(/^Sender\s*\([^)]*\)\s*:\s*[^\n]*(?:\n\s*)*/i, '') + .replace(/^Sender\s*:\s*```[a-z]*\n[\s\S]*?```\s*/i, '') + .replace(/^Sender\s*:\s*\{[\s\S]*?\}\s*/i, '') + .replace(/^Sender\s*:\s*[^\n]*(?:\n\s*)*/i, '') .replace(/^Conversation info\s*\([^)]*\):\s*```[a-z]*\n[\s\S]*?```\s*/i, '') - .replace(/^Conversation info\s*\([^)]*\):\s*\{[\s\S]*?\}\s*/i, ''); + .replace(/^Conversation info\s*\([^)]*\):\s*\{[\s\S]*?\}\s*/i, '') + .replace(/^\s*\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+[^\]]+\]\s*/i, ''); } function normalizeComparableUserText(content: unknown): string { @@ -1216,6 +1230,16 @@ function collectToolUpdates(message: unknown, eventState: string): ToolStatus[] return updates; } +/** + * True when an assistant message carries user-visible final output (text or + * image). NOTE: `thinking` blocks are intentionally excluded — they are the + * model's internal monologue and frequently precede tool calls in models like + * MiniMax-M2.7 and gpt-5.5. Treating thinking as "final content" causes the + * history-poll closer in applyLoadedMessages and the runtime final handler to + * misclassify intermediate `[thinking, toolCall]` turns as completed replies, + * which prematurely tears down the `sending` / `activeRunId` / `pendingFinal` + * lifecycle flags and makes the Thinking… indicator vanish mid-tool-chain. + */ function hasNonToolAssistantContent(message: RawMessage | undefined): boolean { if (!message) return false; if (typeof message.content === 'string' && message.content.trim()) return true; @@ -1224,7 +1248,6 @@ function hasNonToolAssistantContent(message: RawMessage | undefined): boolean { if (Array.isArray(content)) { for (const block of content as ContentBlock[]) { if (block.type === 'text' && block.text && block.text.trim()) return true; - if (block.type === 'thinking' && block.thinking && block.thinking.trim()) return true; if (block.type === 'image') return true; } } @@ -1235,6 +1258,35 @@ function hasNonToolAssistantContent(message: RawMessage | undefined): boolean { return false; } +/** + * True when an assistant message is still waiting on a tool result, i.e. it + * represents an intermediate tool-use turn rather than a finished reply. + * Detected via: + * - explicit stop_reason = "tool_use" / "toolUse" + * - any tool_use / toolCall block in `content` + * - OpenAI-format `tool_calls` array + * Used by applyLoadedMessages and the runtime `final` handler to keep the + * `sending` / `activeRunId` / `pendingFinal` flags armed across tool rounds. + */ +function hasPendingToolUse(message: RawMessage | undefined): boolean { + if (!message) return false; + const reason = getMessageStopReason(message); + if (reason === 'tool_use' || reason === 'tooluse') return true; + + const content = message.content; + if (Array.isArray(content)) { + for (const block of content as ContentBlock[]) { + if (block.type === 'tool_use' || block.type === 'toolCall') return true; + } + } + + const msg = message as unknown as Record; + const toolCalls = msg.tool_calls ?? msg.toolCalls; + if (Array.isArray(toolCalls) && toolCalls.length > 0) return true; + + return false; +} + function setHistoryPollTimer(timer: ReturnType | null): void { _historyPollTimer = timer; } @@ -1299,6 +1351,7 @@ export { collectToolUpdates, upsertToolStatuses, hasNonToolAssistantContent, + hasPendingToolUse, isToolOnlyMessage, normalizeStreamingMessage, matchesOptimisticUserMessage, diff --git a/src/stores/chat/runtime-event-handlers.ts b/src/stores/chat/runtime-event-handlers.ts index 8e040fc..7944a34 100644 --- a/src/stores/chat/runtime-event-handlers.ts +++ b/src/stores/chat/runtime-event-handlers.ts @@ -9,6 +9,7 @@ import { getMessageText, getToolCallFilePath, hasNonToolAssistantContent, + hasPendingToolUse, isInternalMessage, isTerminalAssistantErrorMessage, isToolOnlyMessage, @@ -167,8 +168,14 @@ export function handleRuntimeEventState( }); break; } - const toolOnly = isToolOnlyMessage(normalizedFinalMessage); - const hasOutput = hasNonToolAssistantContent(normalizedFinalMessage); + // Mixed `[thinking, text, toolCall]` messages with stop_reason="tool_use" + // (some MiniMax / gpt-5.5 variants emit these) are still intermediate + // turns even though they carry user-visible text. Treat them as + // tool-only for lifecycle purposes so the run stays "open" until the + // truly final reply (without a pending tool call) arrives. + const pendingTool = hasPendingToolUse(normalizedFinalMessage); + const toolOnly = isToolOnlyMessage(normalizedFinalMessage) || pendingTool; + const hasOutput = !pendingTool && hasNonToolAssistantContent(normalizedFinalMessage); const msgId = normalizedFinalMessage.id || (toolOnly ? `run-${runId}-tool-${Date.now()}` : `run-${runId}`); set((s) => { const nextTools = updates.length > 0 ? upsertToolStatuses(s.streamingTools, updates) : s.streamingTools; diff --git a/src/stores/gateway.ts b/src/stores/gateway.ts index ab12358..e8721a8 100644 --- a/src/stores/gateway.ts +++ b/src/stores/gateway.ts @@ -163,7 +163,15 @@ function handleGatewayNotification(notification: { method?: string; params?: Rec .catch(() => {}); } - if (phase === 'completed' || phase === 'done' || phase === 'finished' || phase === 'end') { + // `phase: 'end'` fires per streaming message (including intermediate tool + // rounds), NOT per-run, so it must not clear lifecycle state — otherwise the + // first `[thinking, toolCall]` round tears down `sending` / `activeRunId` / + // `pendingFinal` and the Thinking… indicator vanishes mid-chain. Only + // `completed` / `done` / `finished` are actual run terminators. We still + // honour `'end'` as a hint to refresh history opportunistically. + const isPerMessageEnd = phase === 'end'; + const isRunCompletion = phase === 'completed' || phase === 'done' || phase === 'finished'; + if (isPerMessageEnd || isRunCompletion) { import('./chat') .then(({ useChatStore }) => { const state = useChatStore.getState(); @@ -182,7 +190,7 @@ function handleGatewayNotification(notification: { method?: string; params?: Rec if (matchesCurrentSession || matchesActiveRun) { maybeLoadHistory(state); } - if ((matchesCurrentSession || matchesActiveRun) && state.sending) { + if (isRunCompletion && (matchesCurrentSession || matchesActiveRun) && state.sending) { useChatStore.setState({ sending: false, activeRunId: null, diff --git a/tests/unit/chat-helpers-lifecycle-predicates.test.ts b/tests/unit/chat-helpers-lifecycle-predicates.test.ts new file mode 100644 index 0000000..c916e58 --- /dev/null +++ b/tests/unit/chat-helpers-lifecycle-predicates.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it } from 'vitest'; +import { + hasNonToolAssistantContent, + hasPendingToolUse, + isToolOnlyMessage, +} from '@/stores/chat/helpers'; +import type { RawMessage } from '@/stores/chat'; + +/** + * Cross-protocol coverage for the lifecycle predicates that drive whether + * ClawX's UI keeps the Thinking… indicator / Execution Graph "active" or + * tears them down. + * + * In production, ClawX consumes already-normalized messages from the OpenClaw + * Gateway (camelCase, Anthropic-style content blocks). But the helpers are + * written defensively so they also work when: + * - Anthropic Messages API output is passed through unchanged (snake_case, + * `type: "tool_use"`, `stop_reason: "tool_use"`) + * - OpenAI Chat Completions API output is normalized to a message-shaped + * object that retains the top-level `tool_calls` array + * + * This file documents the supported shapes via direct unit tests. If a future + * runtime change starts emitting a different shape (e.g. raw OpenAI Responses + * API `output[].type === "function_call"` items), these tests should be the + * place to add new coverage — and the helpers extended accordingly. + */ + +const ANTHROPIC_INTERMEDIATE_TOOL_USE: RawMessage = { + id: 'anthropic-1', + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'Let me search for the weather.' }, + { type: 'tool_use', id: 'toolu_01', name: 'web_search', input: { query: 'Kunming' } }, + ], + // Anthropic native: snake_case at message top level + stop_reason: 'tool_use', +}; + +const ANTHROPIC_FINAL_TEXT: RawMessage = { + id: 'anthropic-2', + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'I have all I need.' }, + { type: 'text', text: 'The weather in Kunming is mild.' }, + ], + stop_reason: 'end_turn', +}; + +const GATEWAY_NORMALIZED_INTERMEDIATE: RawMessage = { + id: 'gateway-1', + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'Need to call a tool.' }, + { type: 'toolCall', id: 'tc1', name: 'web_search', input: { query: 'foo' } }, + ], + // Gateway-normalized: camelCase + verb-cased stop reason + stopReason: 'toolUse', +}; + +const GATEWAY_NORMALIZED_FINAL: RawMessage = { + id: 'gateway-2', + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'Done thinking.' }, + { type: 'text', text: 'Here is the answer.' }, + ], + stopReason: 'stop', +}; + +const GATEWAY_MIXED_PENDING_TOOL: RawMessage = { + id: 'gateway-3', + role: 'assistant', + // Mixed [thinking, text, toolCall] with stopReason=toolUse — observed in + // some MiniMax / gpt-5.5 variants. The text is real user-visible output + // but the run is still pending a tool result. + content: [ + { type: 'thinking', thinking: 'I should search first.' }, + { type: 'text', text: 'Let me search for that.' }, + { type: 'toolCall', id: 'tc2', name: 'web_search', input: { query: 'foo' } }, + ], + stopReason: 'toolUse', +}; + +const OPENAI_CC_INTERMEDIATE_TOOL_CALL: RawMessage = { + id: 'openai-cc-1', + role: 'assistant', + // OpenAI Chat Completions: `content` is a string (often empty when tools + // are called) and the tool-call signal lives in a top-level `tool_calls` + // array. There's no message-level `stop_reason`; the choice-level + // `finish_reason: "tool_calls"` doesn't survive on the message itself. + content: '', + tool_calls: [ + { + id: 'call_abc123', + type: 'function', + function: { name: 'web_search', arguments: '{"query":"foo"}' }, + }, + ], +}; + +const OPENAI_CC_FINAL_TEXT: RawMessage = { + id: 'openai-cc-2', + role: 'assistant', + content: 'Here is the final answer.', +}; + +const OPENAI_CC_TOOLCALLS_CAMELCASE: RawMessage = { + id: 'openai-cc-3', + role: 'assistant', + content: '', + // Some adapters camelCase the field as `toolCalls`. The helper accepts both. + toolCalls: [ + { + id: 'call_xyz', + type: 'function', + function: { name: 'web_search', arguments: '{}' }, + }, + ], +}; + +const PLAIN_USER: RawMessage = { + id: 'user-1', + role: 'user', + content: 'hello', +}; + +describe('hasPendingToolUse', () => { + it('detects Anthropic-native intermediate (stop_reason=tool_use + tool_use block)', () => { + expect(hasPendingToolUse(ANTHROPIC_INTERMEDIATE_TOOL_USE)).toBe(true); + }); + + it('detects Gateway-normalized intermediate (stopReason=toolUse + toolCall block)', () => { + expect(hasPendingToolUse(GATEWAY_NORMALIZED_INTERMEDIATE)).toBe(true); + }); + + it('detects mixed [thinking, text, toolCall] with stopReason=toolUse', () => { + expect(hasPendingToolUse(GATEWAY_MIXED_PENDING_TOOL)).toBe(true); + }); + + it('detects OpenAI Chat Completions intermediate via tool_calls array', () => { + expect(hasPendingToolUse(OPENAI_CC_INTERMEDIATE_TOOL_CALL)).toBe(true); + }); + + it('detects OpenAI Chat Completions intermediate via toolCalls (camelCase) array', () => { + expect(hasPendingToolUse(OPENAI_CC_TOOLCALLS_CAMELCASE)).toBe(true); + }); + + it('returns false for Anthropic-native final reply (end_turn + text)', () => { + expect(hasPendingToolUse(ANTHROPIC_FINAL_TEXT)).toBe(false); + }); + + it('returns false for Gateway-normalized final reply (stopReason=stop + text)', () => { + expect(hasPendingToolUse(GATEWAY_NORMALIZED_FINAL)).toBe(false); + }); + + it('returns false for OpenAI Chat Completions plain-text reply', () => { + expect(hasPendingToolUse(OPENAI_CC_FINAL_TEXT)).toBe(false); + }); + + it('returns false for user messages', () => { + expect(hasPendingToolUse(PLAIN_USER)).toBe(false); + }); + + it('returns false for undefined', () => { + expect(hasPendingToolUse(undefined)).toBe(false); + }); +}); + +describe('hasNonToolAssistantContent', () => { + // CRITICAL invariant: thinking blocks are NEVER counted as user-visible + // output. Treating thinking as content was the root cause of the + // "Thinking… disappears mid-tool-chain" bug. + it('does NOT count thinking-only messages as user-visible content (Anthropic shape)', () => { + const msg: RawMessage = { + id: 'x', + role: 'assistant', + content: [{ type: 'thinking', thinking: 'Some private reasoning…' }], + }; + expect(hasNonToolAssistantContent(msg)).toBe(false); + }); + + it('does NOT count [thinking, tool_use] as user-visible content (Anthropic intermediate)', () => { + expect(hasNonToolAssistantContent(ANTHROPIC_INTERMEDIATE_TOOL_USE)).toBe(false); + }); + + it('does NOT count [thinking, toolCall] as user-visible content (Gateway intermediate)', () => { + expect(hasNonToolAssistantContent(GATEWAY_NORMALIZED_INTERMEDIATE)).toBe(false); + }); + + it('counts text blocks as user-visible (Anthropic final)', () => { + expect(hasNonToolAssistantContent(ANTHROPIC_FINAL_TEXT)).toBe(true); + }); + + it('counts text blocks as user-visible (Gateway final)', () => { + expect(hasNonToolAssistantContent(GATEWAY_NORMALIZED_FINAL)).toBe(true); + }); + + it('counts string content as user-visible (OpenAI ChatCompletions final)', () => { + expect(hasNonToolAssistantContent(OPENAI_CC_FINAL_TEXT)).toBe(true); + }); + + it('does NOT count empty string content (OpenAI ChatCompletions intermediate)', () => { + expect(hasNonToolAssistantContent(OPENAI_CC_INTERMEDIATE_TOOL_CALL)).toBe(false); + }); + + it('counts image blocks as user-visible', () => { + const msg: RawMessage = { + id: 'img', + role: 'assistant', + content: [{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' } }], + }; + expect(hasNonToolAssistantContent(msg)).toBe(true); + }); +}); + +describe('isToolOnlyMessage', () => { + it('flags Anthropic [thinking, tool_use] as tool-only', () => { + expect(isToolOnlyMessage(ANTHROPIC_INTERMEDIATE_TOOL_USE)).toBe(true); + }); + + it('flags Gateway [thinking, toolCall] as tool-only', () => { + expect(isToolOnlyMessage(GATEWAY_NORMALIZED_INTERMEDIATE)).toBe(true); + }); + + it('flags OpenAI ChatCompletions empty-content + tool_calls as tool-only', () => { + expect(isToolOnlyMessage(OPENAI_CC_INTERMEDIATE_TOOL_CALL)).toBe(true); + }); + + it('does NOT flag mixed [thinking, text, toolCall] as tool-only (text present)', () => { + // For mixed messages with real text output, isToolOnlyMessage alone is + // insufficient — this is exactly why the lifecycle code uses + // `isToolOnlyMessage(msg) || hasPendingToolUse(msg)`. + expect(isToolOnlyMessage(GATEWAY_MIXED_PENDING_TOOL)).toBe(false); + expect(hasPendingToolUse(GATEWAY_MIXED_PENDING_TOOL)).toBe(true); + }); + + it('does NOT flag a final text reply as tool-only', () => { + expect(isToolOnlyMessage(GATEWAY_NORMALIZED_FINAL)).toBe(false); + expect(isToolOnlyMessage(ANTHROPIC_FINAL_TEXT)).toBe(false); + expect(isToolOnlyMessage(OPENAI_CC_FINAL_TEXT)).toBe(false); + }); +}); + +/** + * Composite assertion: the trio `isToolOnlyMessage(msg) || hasPendingToolUse(msg)` + * is the actual gate used by `applyLoadedMessages` and the runtime `final` + * handler. This block proves that gate behaves consistently across all three + * provider protocols ClawX may encounter. + */ +describe('lifecycle gate (isToolOnlyMessage || hasPendingToolUse)', () => { + const gate = (msg: RawMessage) => isToolOnlyMessage(msg) || hasPendingToolUse(msg); + + it.each([ + ['Anthropic intermediate', ANTHROPIC_INTERMEDIATE_TOOL_USE, true], + ['Gateway intermediate', GATEWAY_NORMALIZED_INTERMEDIATE, true], + ['Gateway mixed [thinking,text,toolCall]', GATEWAY_MIXED_PENDING_TOOL, true], + ['OpenAI CC intermediate (tool_calls)', OPENAI_CC_INTERMEDIATE_TOOL_CALL, true], + ['OpenAI CC intermediate (toolCalls camelCase)', OPENAI_CC_TOOLCALLS_CAMELCASE, true], + ['Anthropic final text (end_turn)', ANTHROPIC_FINAL_TEXT, false], + ['Gateway final text (stop)', GATEWAY_NORMALIZED_FINAL, false], + ['OpenAI CC final text', OPENAI_CC_FINAL_TEXT, false], + ])('classifies %s as intermediate=%j', (_label, msg, expected) => { + expect(gate(msg)).toBe(expected); + }); +}); diff --git a/tests/unit/chat-optimistic-match.test.ts b/tests/unit/chat-optimistic-match.test.ts index c7dff94..1360f19 100644 --- a/tests/unit/chat-optimistic-match.test.ts +++ b/tests/unit/chat-optimistic-match.test.ts @@ -65,4 +65,53 @@ describe('matchesOptimisticUserMessage', () => { expect(matchesOptimisticUserMessage(candidate, optimistic, 1_700_000_000_000)).toBe(false); }); + + // Regression for the duplicate-bubble bug reproduced in session + // fa07a446-c107-4252-9948-c063357647bc.jsonl: the Gateway echo carries a + // Sender block, one or more `[media attached: ...]` lines BEFORE the + // `[Mon ... GMT+8]` timestamp prefix, the real user text, and a trailing + // `[media attached: ...]` line. Earlier code stripped the timestamp + // regex before the media-attached lines, so the timestamp anchor + // `^\s*\[(?:Mon|...)]` never matched (the leading `[` was + // `[media attached:`). The normalized comparison text kept the + // `[Mon ...]` prefix and never equalled the bare optimistic text, + // causing dedupe to miss and the message to render twice — the second + // bubble showing the leftover `[Mon ...]` prefix. + it('matches the full Gateway echo with Sender block, leading + trailing media lines, and a timestamp prefix', () => { + const optimistic = { + role: 'user', + content: 'Please help me investigate why I am not getting a reply when I send messages on Discord. ClawX is now in connected status.', + timestamp: 1_700_000_000, + _attachedFiles: [ + { + fileName: 'image---9ad2735c.png', + mimeType: 'image/png', + fileSize: 456, + preview: null, + filePath: '/Users/guoyuliang/.openclaw/media/inbound/image---9ad2735c-21ce-443e-af5c-1cd290c1d8d0.png', + }, + ], + } as const; + const candidate = { + role: 'user', + content: [ + 'Sender (untrusted metadata):', + '```json', + '{', + ' "label": "ClawX (gateway-client)",', + ' "id": "gateway-client",', + ' "name": "ClawX",', + ' "username": "ClawX"', + '}', + '```', + '', + '[media attached: /Users/guoyuliang/.openclaw/media/inbound/image---9ad2735c-21ce-443e-af5c-1cd290c1d8d0.png (image/png)]', + '[Mon 2026-05-18 10:39 GMT+8] Please help me investigate why I am not getting a reply when I send messages on Discord. ClawX is now in connected status.', + '[media attached: /Users/guoyuliang/.openclaw/media/outbound/fa3637d9-98b9-4e77-a176-3f66ca763cf4.png (image/png) | /Users/guoyuliang/.openclaw/media/outbound/fa3637d9-98b9-4e77-a176-3f66ca763cf4.png]', + ].join('\n'), + timestamp: 1_700_000_000, + } as const; + + expect(matchesOptimisticUserMessage(candidate, optimistic, 1_700_000_000_000)).toBe(true); + }); }); diff --git a/tests/unit/chat-runtime-event-handlers.test.ts b/tests/unit/chat-runtime-event-handlers.test.ts index a5bd47b..eda338e 100644 --- a/tests/unit/chat-runtime-event-handlers.test.ts +++ b/tests/unit/chat-runtime-event-handlers.test.ts @@ -12,6 +12,7 @@ const getMessageErrorMessage = vi.fn((message: { errorMessage?: string; error_me const getToolCallFilePath = vi.fn(() => undefined); const hasErrorRecoveryTimer = vi.fn(() => false); const hasNonToolAssistantContent = vi.fn(() => true); +const hasPendingToolUse = vi.fn(() => false); const isInternalMessage = vi.fn(() => false); const isTerminalAssistantErrorMessage = vi.fn((message: { role?: string; stopReason?: string; stop_reason?: string } | undefined) => { const stopReason = message?.stopReason ?? message?.stop_reason; @@ -44,6 +45,7 @@ vi.mock('@/stores/chat/helpers', () => ({ getToolCallFilePath: (...args: unknown[]) => getToolCallFilePath(...args), hasErrorRecoveryTimer: (...args: unknown[]) => hasErrorRecoveryTimer(...args), hasNonToolAssistantContent: (...args: unknown[]) => hasNonToolAssistantContent(...args), + hasPendingToolUse: (...args: unknown[]) => hasPendingToolUse(...args), isInternalMessage: (...args: unknown[]) => isInternalMessage(...args), isTerminalAssistantErrorMessage: (...args: unknown[]) => isTerminalAssistantErrorMessage(...args), isToolOnlyMessage: (...args: unknown[]) => isToolOnlyMessage(...args), diff --git a/tests/unit/chat-store-history-retry.test.ts b/tests/unit/chat-store-history-retry.test.ts index b67b288..b2cfdc5 100644 --- a/tests/unit/chat-store-history-retry.test.ts +++ b/tests/unit/chat-store-history-retry.test.ts @@ -612,4 +612,316 @@ describe('useChatStore startup history retry', () => { resolveSend?.({ runId: 'run-deleted' }); await sendPromise; }); + + // Regression for the "thinking disappears mid-tool-chain" bug: + // when the history-poll loads an intermediate `[thinking, toolCall]` assistant + // message (stop_reason=tool_use) the closer half of applyLoadedMessages used + // to match it as a "final reply" — because `hasNonToolAssistantContent` once + // counted thinking blocks as user-visible content — and clear sending / + // activeRunId / pendingFinal. That caused the Execution Graph card to flip to + // inactive, the Thinking… dot to vanish, and ChatInput's stop button to + // revert to a send button while the agent was still running tools. + it('keeps the run open across intermediate [thinking, toolCall] history snapshots', async () => { + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + currentSessionKey: 'agent:main:session-1', + currentAgentId: 'main', + sessions: [{ key: 'agent:main:session-1' }], + messages: [ + { id: 'user-1', role: 'user', content: '帮我查一下昆明未来七天的天气', timestamp: 1000 }, + ], + sessionLabels: {}, + sessionLastActivity: {}, + sending: true, + activeRunId: 'run-keep-open', + streamingText: '', + streamingMessage: null, + streamingTools: [], + pendingFinal: false, + lastUserMessageAt: 1000, + pendingToolImages: [], + error: null, + loading: false, + thinkingLevel: null, + }); + + gatewayRpcMock.mockResolvedValueOnce({ + messages: [ + { id: 'user-1', role: 'user', content: '帮我查一下昆明未来七天的天气', timestamp: 1000 }, + { + id: 'assistant-tool-1', + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'Let me search for the weather.' }, + { type: 'toolCall', id: 'tool-1', name: 'web_search', input: { query: 'Kunming weather' } }, + ], + stopReason: 'toolUse', + timestamp: 1500, + }, + ], + }); + + await useChatStore.getState().loadHistory(true); + + const state = useChatStore.getState(); + expect(state.sending).toBe(true); + expect(state.activeRunId).toBe('run-keep-open'); + expect(state.pendingFinal).toBe(false); + }); + + // Regression for the mixed `[thinking, text, toolCall]` shape some models + // emit. Even though it carries user-visible text, stop_reason=tool_use means + // the assistant is still pending a tool result and the lifecycle must stay + // armed. Without `hasPendingToolUse`, the closer would match this on the + // text block and clear sending. + it('keeps the run open for mixed [thinking, text, toolCall] turns with stopReason=toolUse', async () => { + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + currentSessionKey: 'agent:main:session-2', + currentAgentId: 'main', + sessions: [{ key: 'agent:main:session-2' }], + messages: [ + { id: 'user-2', role: 'user', content: 'mixed turn test', timestamp: 2000 }, + ], + sessionLabels: {}, + sessionLastActivity: {}, + sending: true, + activeRunId: 'run-mixed', + streamingText: '', + streamingMessage: null, + streamingTools: [], + pendingFinal: true, + lastUserMessageAt: 2000, + pendingToolImages: [], + error: null, + loading: false, + thinkingLevel: null, + }); + + gatewayRpcMock.mockResolvedValueOnce({ + messages: [ + { id: 'user-2', role: 'user', content: 'mixed turn test', timestamp: 2000 }, + { + id: 'assistant-mixed-1', + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'I should search before answering.' }, + { type: 'text', text: 'Let me search for that.' }, + { type: 'toolCall', id: 'tool-2', name: 'web_search', input: { query: 'foo' } }, + ], + stopReason: 'toolUse', + timestamp: 2500, + }, + ], + }); + + await useChatStore.getState().loadHistory(true); + + const state = useChatStore.getState(); + expect(state.sending).toBe(true); + expect(state.activeRunId).toBe('run-mixed'); + expect(state.pendingFinal).toBe(true); + }); + + // Positive case: a real final reply (text/image, no pending tool) SHOULD + // close the run when applyLoadedMessages observes it via history poll. + it('closes the run when a final assistant reply (text, stopReason=endTurn) appears', async () => { + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + currentSessionKey: 'agent:main:session-3', + currentAgentId: 'main', + sessions: [{ key: 'agent:main:session-3' }], + messages: [ + { id: 'user-3', role: 'user', content: 'final reply test', timestamp: 3000 }, + ], + sessionLabels: {}, + sessionLastActivity: {}, + sending: true, + activeRunId: 'run-final', + streamingText: '', + streamingMessage: null, + streamingTools: [], + pendingFinal: true, + lastUserMessageAt: 3000, + pendingToolImages: [], + error: null, + loading: false, + thinkingLevel: null, + }); + + gatewayRpcMock.mockResolvedValueOnce({ + messages: [ + { id: 'user-3', role: 'user', content: 'final reply test', timestamp: 3000 }, + { + id: 'assistant-final-1', + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'I have all the info.' }, + { type: 'text', text: 'Here is the answer.' }, + ], + stopReason: 'endTurn', + timestamp: 3500, + }, + ], + }); + + 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 + // detect the intermediate turn via `stop_reason: "tool_use"` plus + // `content[].type === "tool_use"`. + it('keeps the run open for Anthropic-native [thinking, tool_use] (snake_case stop_reason)', async () => { + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + currentSessionKey: 'agent:main:session-anthropic', + currentAgentId: 'main', + sessions: [{ key: 'agent:main:session-anthropic' }], + messages: [ + { id: 'user-a', role: 'user', content: 'anthropic protocol', timestamp: 4000 }, + ], + sessionLabels: {}, + sessionLastActivity: {}, + sending: true, + activeRunId: 'run-anthropic', + streamingText: '', + streamingMessage: null, + streamingTools: [], + pendingFinal: false, + lastUserMessageAt: 4000, + pendingToolImages: [], + error: null, + loading: false, + thinkingLevel: null, + }); + + gatewayRpcMock.mockResolvedValueOnce({ + messages: [ + { id: 'user-a', role: 'user', content: 'anthropic protocol', timestamp: 4000 }, + { + id: 'assistant-anthropic-1', + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'Should I use a tool?' }, + { type: 'tool_use', id: 'toolu_01', name: 'web_search', input: { query: 'foo' } }, + ], + stop_reason: 'tool_use', + timestamp: 4500, + }, + ], + }); + + await useChatStore.getState().loadHistory(true); + + const state = useChatStore.getState(); + expect(state.sending).toBe(true); + expect(state.activeRunId).toBe('run-anthropic'); + }); + + // Cross-protocol coverage: OpenAI Chat Completions native shape. The + // tool-call signal is the top-level `tool_calls` array on the message, with + // no `stop_reason` / `stopReason` field (OpenAI uses `finish_reason` at the + // choice level which doesn't reach the message object). `hasPendingToolUse` + // must still flag this via the `tool_calls` array check. + it('keeps the run open for OpenAI ChatCompletions message with tool_calls array', async () => { + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + currentSessionKey: 'agent:main:session-openai-cc', + currentAgentId: 'main', + sessions: [{ key: 'agent:main:session-openai-cc' }], + messages: [ + { id: 'user-occ', role: 'user', content: 'openai chat completions', timestamp: 5000 }, + ], + sessionLabels: {}, + sessionLastActivity: {}, + sending: true, + activeRunId: 'run-openai-cc', + streamingText: '', + streamingMessage: null, + streamingTools: [], + pendingFinal: false, + lastUserMessageAt: 5000, + pendingToolImages: [], + error: null, + loading: false, + thinkingLevel: null, + }); + + gatewayRpcMock.mockResolvedValueOnce({ + messages: [ + { id: 'user-occ', role: 'user', content: 'openai chat completions', timestamp: 5000 }, + { + id: 'assistant-openai-cc-1', + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call_abc123', + type: 'function', + function: { name: 'web_search', arguments: '{"query":"foo"}' }, + }, + ], + timestamp: 5500, + }, + ], + }); + + await useChatStore.getState().loadHistory(true); + + const state = useChatStore.getState(); + expect(state.sending).toBe(true); + expect(state.activeRunId).toBe('run-openai-cc'); + }); + + // Cross-protocol coverage: OpenAI Chat Completions FINAL reply. No + // tool_calls, plain text content. Must close the run normally. + it('closes the run for OpenAI ChatCompletions plain-text final reply', async () => { + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + currentSessionKey: 'agent:main:session-openai-cc-final', + currentAgentId: 'main', + sessions: [{ key: 'agent:main:session-openai-cc-final' }], + messages: [ + { id: 'user-occf', role: 'user', content: 'openai final', timestamp: 6000 }, + ], + sessionLabels: {}, + sessionLastActivity: {}, + sending: true, + activeRunId: 'run-openai-cc-final', + streamingText: '', + streamingMessage: null, + streamingTools: [], + pendingFinal: true, + lastUserMessageAt: 6000, + pendingToolImages: [], + error: null, + loading: false, + thinkingLevel: null, + }); + + gatewayRpcMock.mockResolvedValueOnce({ + messages: [ + { id: 'user-occf', role: 'user', content: 'openai final', timestamp: 6000 }, + { + id: 'assistant-openai-cc-final-1', + role: 'assistant', + content: 'Here is the final answer.', + timestamp: 6500, + }, + ], + }); + + await useChatStore.getState().loadHistory(true); + + const state = useChatStore.getState(); + expect(state.sending).toBe(false); + expect(state.activeRunId).toBeNull(); + }); });