Skip to content

Commit e274294

Browse files
committed
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
1 parent 984ac3c commit e274294

3 files changed

Lines changed: 186 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/ai-client': patch
3+
---
4+
5+
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.

packages/ai-client/src/chat-client.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1073,6 +1073,13 @@ export class ChatClient<
10731073
} catch (error) {
10741074
console.error('Failed to continue flow after tool result:', error)
10751075
}
1076+
} else if (this.status !== 'ready') {
1077+
// The run is fully terminal but onStreamEnd never fired because the
1078+
// processor had no assistant message to emit it for — e.g. a custom
1079+
// backend that closes a continuation with a bare RUN_FINISHED{stop}
1080+
// and no text (issue #421). Normalize status here; on the normal
1081+
// path onStreamEnd already set 'ready', so this is a no-op.
1082+
this.setStatus('ready')
10761083
}
10771084
}
10781085
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
import { ChatClient } from '../src/chat-client'
3+
import {
4+
createMockConnectionAdapter,
5+
createTextChunks,
6+
createToolCallChunks,
7+
} from './test-utils'
8+
import type { ConnectConnectionAdapter } from '../src/connection-adapters'
9+
import type { StreamChunk } from '@tanstack/ai/client'
10+
11+
/**
12+
* Regression for https://github.com/TanStack/ai/issues/421
13+
*
14+
* After a client-side tool call runs, the client posts the tool result and
15+
* auto-continues. A custom backend can answer that continuation run with a
16+
* bare `RUN_FINISHED { finishReason: 'stop' }` and no assistant text/message.
17+
* In that case the StreamProcessor's finalizeStream() has no
18+
* `lastAssistantMessage` to emit `onStreamEnd` for, so `setStatus('ready')`
19+
* never fires and status never settles. The issue reports this as stuck on
20+
* `streaming`; the underlying stuck value is actually `submitted`, since
21+
* `streaming` is only set by `onStreamStart` (which a bare `RUN_FINISHED`
22+
* never triggers).
23+
*/
24+
describe('client tool call status (issue #421)', () => {
25+
function createDeferred<T>() {
26+
let resolve!: (value: T) => void
27+
const promise = new Promise<T>((res) => {
28+
resolve = res
29+
})
30+
return { promise, resolve }
31+
}
32+
33+
it('settles status to ready after a client tool when the continuation run emits only RUN_FINISHED', async () => {
34+
// Round 1: the server asks for a CLIENT tool call (finishReason 'tool_calls').
35+
const round1Chunks = createToolCallChunks([
36+
{ id: 'tc-1', name: 'get_weather', arguments: '{"city":"NYC"}' },
37+
])
38+
// Round 2: the custom backend (as in the issue) closes the run with only a
39+
// bare RUN_FINISHED — no assistant message, no text — after receiving the
40+
// client tool result.
41+
const round2Chunks: Array<StreamChunk> = [
42+
{
43+
type: 'RUN_FINISHED',
44+
runId: 'run-2',
45+
threadId: 'thread-2',
46+
model: 'test',
47+
timestamp: Date.now(),
48+
finishReason: 'stop',
49+
} as StreamChunk,
50+
]
51+
52+
let callIndex = 0
53+
const adapter: ConnectConnectionAdapter = {
54+
async *connect(_messages, _data, abortSignal) {
55+
callIndex++
56+
const chunks = callIndex === 1 ? round1Chunks : round2Chunks
57+
for (const chunk of chunks) {
58+
if (abortSignal?.aborted) return
59+
yield chunk
60+
}
61+
},
62+
}
63+
64+
// Controlled promise so the client tool's resolution is deterministic and
65+
// the test is not racy.
66+
const toolGate = createDeferred<void>()
67+
68+
const statuses: Array<string> = []
69+
const client = new ChatClient({
70+
connection: adapter,
71+
onStatusChange: (s) => statuses.push(s),
72+
tools: [
73+
{
74+
__toolSide: 'client' as const,
75+
name: 'get_weather',
76+
description: 'Get the weather',
77+
execute: async () => {
78+
await toolGate.promise
79+
return { temp: 72 }
80+
},
81+
},
82+
],
83+
})
84+
85+
const sendPromise = client.sendMessage('What is the weather in NYC?')
86+
87+
// Let round 1 stream through and the client tool begin executing.
88+
await vi.waitFor(() => {
89+
expect(callIndex).toBe(1)
90+
})
91+
92+
// Release the client tool; this posts the result and triggers the
93+
// continuation (round 2).
94+
toolGate.resolve()
95+
await sendPromise
96+
97+
await vi.waitFor(
98+
() => {
99+
expect(client.getIsLoading()).toBe(false)
100+
expect(callIndex).toBeGreaterThanOrEqual(2)
101+
},
102+
{ timeout: 2000 },
103+
)
104+
105+
// The run is fully complete (finishReason 'stop'); status must settle to
106+
// 'ready', not stay stuck at 'submitted'.
107+
expect(client.getStatus()).toBe('ready')
108+
expect(statuses[statuses.length - 1]).toBe('ready')
109+
})
110+
111+
it("plain text run emits onStatusChange('ready') exactly once", async () => {
112+
// A normal run with an assistant message: onStreamEnd fires and sets
113+
// 'ready'. The terminal normalization added for #421 is guarded on
114+
// `status !== 'ready'`, so it must NOT double-emit 'ready' here.
115+
const adapter = createMockConnectionAdapter({
116+
chunks: createTextChunks('Hello'),
117+
})
118+
119+
const statuses: Array<string> = []
120+
const client = new ChatClient({
121+
connection: adapter,
122+
onStatusChange: (s) => statuses.push(s),
123+
})
124+
125+
await client.sendMessage('Hi')
126+
127+
await vi.waitFor(() => {
128+
expect(client.getIsLoading()).toBe(false)
129+
})
130+
131+
expect(client.getStatus()).toBe('ready')
132+
expect(statuses.filter((s) => s === 'ready').length).toBe(1)
133+
})
134+
135+
it('sendMessage with a bare RUN_FINISHED{stop} first run (no assistant message) settles to ready', async () => {
136+
// Sibling of the #421 repro on the FIRST run: the backend closes the run
137+
// immediately with a bare RUN_FINISHED and no assistant message, so
138+
// onStreamEnd never fires. Status must still settle to 'ready'.
139+
const chunks: Array<StreamChunk> = [
140+
{
141+
type: 'RUN_FINISHED',
142+
runId: 'run-1',
143+
threadId: 'thread-1',
144+
model: 'test',
145+
timestamp: Date.now(),
146+
finishReason: 'stop',
147+
} as StreamChunk,
148+
]
149+
150+
const adapter: ConnectConnectionAdapter = {
151+
async *connect(_messages, _data, abortSignal) {
152+
for (const chunk of chunks) {
153+
if (abortSignal?.aborted) return
154+
yield chunk
155+
}
156+
},
157+
}
158+
159+
const statuses: Array<string> = []
160+
const client = new ChatClient({
161+
connection: adapter,
162+
onStatusChange: (s) => statuses.push(s),
163+
})
164+
165+
await client.sendMessage('Hi')
166+
167+
await vi.waitFor(() => {
168+
expect(client.getIsLoading()).toBe(false)
169+
})
170+
171+
expect(client.getStatus()).toBe('ready')
172+
expect(statuses[statuses.length - 1]).toBe('ready')
173+
})
174+
})

0 commit comments

Comments
 (0)