From a9f1eb9a81d28b083bfe7a33bc4c9ae44ae90cf7 Mon Sep 17 00:00:00 2001 From: Valeriu Gutu Date: Thu, 11 Jun 2026 21:18:59 +0300 Subject: [PATCH] fix(ai-client): settle status to ready on a message-less terminal run When a continuation run after a client tool call closes with a bare RUN_FINISHED{stop} and no assistant message, the processor's onStreamEnd never fires, so status stayed stuck at submitted. Normalize status to ready on the terminal, non-continuing path. Fixes #421 --- .changeset/chat-client-tool-status-ready.md | 5 + packages/ai-client/src/chat-client.ts | 15 +- .../chat-client-client-tool-status.test.ts | 174 ++++++++++++++++++ 3 files changed, 187 insertions(+), 7 deletions(-) create mode 100644 .changeset/chat-client-tool-status-ready.md create mode 100644 packages/ai-client/tests/chat-client-client-tool-status.test.ts diff --git a/.changeset/chat-client-tool-status-ready.md b/.changeset/chat-client-tool-status-ready.md new file mode 100644 index 000000000..301903cfd --- /dev/null +++ b/.changeset/chat-client-tool-status-ready.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-client': patch +--- + +Fix `useChat` status getting stuck after a client tool call when the continuation run closes with a bare `RUN_FINISHED { finishReason: 'stop' }` and no assistant message. The client only sets status `ready` via the processor's `onStreamEnd`, and `StreamProcessor.finalizeStream()` emits that callback only when it has a `lastAssistantMessage`; a message-less terminal run never fired it, so status stayed at `submitted`. The client now normalizes status to `ready` on the terminal, non-continuing path. Fixes #421. diff --git a/packages/ai-client/src/chat-client.ts b/packages/ai-client/src/chat-client.ts index 468dc7618..d5ed91a8b 100644 --- a/packages/ai-client/src/chat-client.ts +++ b/packages/ai-client/src/chat-client.ts @@ -1073,6 +1073,11 @@ export class ChatClient< } catch (error) { console.error('Failed to continue flow after tool result:', error) } + } else if (this.status !== 'ready') { + // Terminal run, but onStreamEnd never fired: the processor had no + // assistant message to emit it for (e.g. a bare RUN_FINISHED{stop}, + // #421). The normal path already set 'ready', so this is a no-op. + this.setStatus('ready') } } } @@ -1218,13 +1223,9 @@ export class ChatClient< context, ) - // Add result via processor. `result.state` is the authoritative error - // signal; `addToolResult` infers error-ness from the error message being - // truthy. Pass an error message ONLY for output-error results (falling back - // to a default so an empty message like `throw new Error()` still reaches - // the terminal 'error' state), and `undefined` otherwise — so error - // signalling derives solely from `result.state`, never from a stray - // `result.errorText` on a successful result. + // Forward an error message only for output-error results (with a fallback so + // a message-less `throw new Error()` still reaches the terminal 'error' + // state); a stray errorText on a successful result must not signal an error. this.processor.addToolResult( result.toolCallId, result.output, diff --git a/packages/ai-client/tests/chat-client-client-tool-status.test.ts b/packages/ai-client/tests/chat-client-client-tool-status.test.ts new file mode 100644 index 000000000..4e0ebff49 --- /dev/null +++ b/packages/ai-client/tests/chat-client-client-tool-status.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it, vi } from 'vitest' +import { ChatClient } from '../src/chat-client' +import { + createMockConnectionAdapter, + createTextChunks, + createToolCallChunks, +} from './test-utils' +import type { ConnectConnectionAdapter } from '../src/connection-adapters' +import type { StreamChunk } from '@tanstack/ai/client' + +/** + * Regression for https://github.com/TanStack/ai/issues/421 + * + * After a client-side tool call runs, the client posts the tool result and + * auto-continues. A custom backend can answer that continuation run with a + * bare `RUN_FINISHED { finishReason: 'stop' }` and no assistant text/message. + * In that case the StreamProcessor's finalizeStream() has no + * `lastAssistantMessage` to emit `onStreamEnd` for, so `setStatus('ready')` + * never fires and status never settles. The issue reports this as stuck on + * `streaming`; the underlying stuck value is actually `submitted`, since + * `streaming` is only set by `onStreamStart` (which a bare `RUN_FINISHED` + * never triggers). + */ +describe('client tool call status (issue #421)', () => { + function createDeferred() { + let resolve!: (value: T) => void + const promise = new Promise((res) => { + resolve = res + }) + return { promise, resolve } + } + + it('settles status to ready after a client tool when the continuation run emits only RUN_FINISHED', async () => { + // Round 1: the server asks for a CLIENT tool call (finishReason 'tool_calls'). + const round1Chunks = createToolCallChunks([ + { id: 'tc-1', name: 'get_weather', arguments: '{"city":"NYC"}' }, + ]) + // Round 2: the custom backend (as in the issue) closes the run with only a + // bare RUN_FINISHED — no assistant message, no text — after receiving the + // client tool result. + const round2Chunks: Array = [ + { + type: 'RUN_FINISHED', + runId: 'run-2', + threadId: 'thread-2', + model: 'test', + timestamp: Date.now(), + finishReason: 'stop', + } as StreamChunk, + ] + + let callIndex = 0 + const adapter: ConnectConnectionAdapter = { + async *connect(_messages, _data, abortSignal) { + callIndex++ + const chunks = callIndex === 1 ? round1Chunks : round2Chunks + for (const chunk of chunks) { + if (abortSignal?.aborted) return + yield chunk + } + }, + } + + // Controlled promise so the client tool's resolution is deterministic and + // the test is not racy. + const toolGate = createDeferred() + + const statuses: Array = [] + const client = new ChatClient({ + connection: adapter, + onStatusChange: (s) => statuses.push(s), + tools: [ + { + __toolSide: 'client' as const, + name: 'get_weather', + description: 'Get the weather', + execute: async () => { + await toolGate.promise + return { temp: 72 } + }, + }, + ], + }) + + const sendPromise = client.sendMessage('What is the weather in NYC?') + + // Let round 1 stream through and the client tool begin executing. + await vi.waitFor(() => { + expect(callIndex).toBe(1) + }) + + // Release the client tool; this posts the result and triggers the + // continuation (round 2). + toolGate.resolve() + await sendPromise + + await vi.waitFor( + () => { + expect(client.getIsLoading()).toBe(false) + expect(callIndex).toBeGreaterThanOrEqual(2) + }, + { timeout: 2000 }, + ) + + // The run is fully complete (finishReason 'stop'); status must settle to + // 'ready', not stay stuck at 'submitted'. + expect(client.getStatus()).toBe('ready') + expect(statuses[statuses.length - 1]).toBe('ready') + }) + + it("plain text run emits onStatusChange('ready') exactly once", async () => { + // A normal run with an assistant message: onStreamEnd fires and sets + // 'ready'. The terminal normalization added for #421 is guarded on + // `status !== 'ready'`, so it must NOT double-emit 'ready' here. + const adapter = createMockConnectionAdapter({ + chunks: createTextChunks('Hello'), + }) + + const statuses: Array = [] + const client = new ChatClient({ + connection: adapter, + onStatusChange: (s) => statuses.push(s), + }) + + await client.sendMessage('Hi') + + await vi.waitFor(() => { + expect(client.getIsLoading()).toBe(false) + }) + + expect(client.getStatus()).toBe('ready') + expect(statuses.filter((s) => s === 'ready').length).toBe(1) + }) + + it('sendMessage with a bare RUN_FINISHED{stop} first run (no assistant message) settles to ready', async () => { + // Sibling of the #421 repro on the FIRST run: the backend closes the run + // immediately with a bare RUN_FINISHED and no assistant message, so + // onStreamEnd never fires. Status must still settle to 'ready'. + const chunks: Array = [ + { + type: 'RUN_FINISHED', + runId: 'run-1', + threadId: 'thread-1', + model: 'test', + timestamp: Date.now(), + finishReason: 'stop', + } as StreamChunk, + ] + + const adapter: ConnectConnectionAdapter = { + async *connect(_messages, _data, abortSignal) { + for (const chunk of chunks) { + if (abortSignal?.aborted) return + yield chunk + } + }, + } + + const statuses: Array = [] + const client = new ChatClient({ + connection: adapter, + onStatusChange: (s) => statuses.push(s), + }) + + await client.sendMessage('Hi') + + await vi.waitFor(() => { + expect(client.getIsLoading()).toBe(false) + }) + + expect(client.getStatus()).toBe('ready') + expect(statuses[statuses.length - 1]).toBe('ready') + }) +})