fix(chat): chat bubble loss thinking status error (#1030)

This commit is contained in:
Haze
2026-05-18 14:36:53 +08:00
committed by GitHub
parent dea38c0c44
commit fdc0b0d746
8 changed files with 781 additions and 19 deletions

View File

@@ -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<string, unknown>;
const toolCalls = msg.tool_calls ?? msg.toolCalls;
if (Array.isArray(toolCalls) && toolCalls.length > 0) return true;
return false;
}
// ── Store ────────────────────────────────────────────────────────
export const useChatStore = create<ChatState>((set, get) => ({
@@ -2308,22 +2353,37 @@ export const useChatStore = create<ChatState>((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<ChatState>((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;

View File

@@ -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<string, unknown>;
const toolCalls = msg.tool_calls ?? msg.toolCalls;
if (Array.isArray(toolCalls) && toolCalls.length > 0) return true;
return false;
}
function setHistoryPollTimer(timer: ReturnType<typeof setTimeout> | null): void {
_historyPollTimer = timer;
}
@@ -1299,6 +1351,7 @@ export {
collectToolUpdates,
upsertToolStatuses,
hasNonToolAssistantContent,
hasPendingToolUse,
isToolOnlyMessage,
normalizeStreamingMessage,
matchesOptimisticUserMessage,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();
});
});