fix(chat): chat bubble loss thinking status error (#1030)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
265
tests/unit/chat-helpers-lifecycle-predicates.test.ts
Normal file
265
tests/unit/chat-helpers-lifecycle-predicates.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user