Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/fix-drain-post-stream-reentrance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tanstack/ai-client': patch
---

fix(ai-client): prevent drainPostStreamActions re-entrancy stealing queued actions

When multiple client tools complete in the same round, nested `drainPostStreamActions()` calls from `streamResponse()`'s `finally` block could steal queued actions, permanently stalling the conversation. Added a re-entrancy guard and a `shouldAutoSend()` check requiring tool-call parts before triggering continuation.
22 changes: 18 additions & 4 deletions packages/typescript/ai-client/src/chat-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export class ChatClient {
// Tracks whether a queued checkForContinuation was skipped because
// continuationPending was true (chained approval scenario)
private continuationSkipped = false
private draining = false
private sessionGenerating = false
private activeRunIds = new Set<string>()

Expand Down Expand Up @@ -846,9 +847,15 @@ export class ChatClient {
* Drain and execute all queued post-stream actions
*/
private async drainPostStreamActions(): Promise<void> {
while (this.postStreamActions.length > 0) {
const action = this.postStreamActions.shift()!
await action()
if (this.draining) return
this.draining = true
try {
while (this.postStreamActions.length > 0) {
const action = this.postStreamActions.shift()!
await action()
}
} finally {
this.draining = false
}
}

Expand Down Expand Up @@ -884,9 +891,16 @@ export class ChatClient {
}

/**
* Check if all tool calls are complete and we should auto-send
* Check if all tool calls are complete and we should auto-send.
* Requires that there is at least one tool call in the last assistant message;
* a text-only response has nothing to auto-send.
*/
private shouldAutoSend(): boolean {
const messages = this.processor.getMessages()
const lastAssistant = messages.findLast((m) => m.role === 'assistant')
if (!lastAssistant) return false
const hasToolCalls = lastAssistant.parts.some((p) => p.type === 'tool-call')
if (!hasToolCalls) return false
return this.processor.areAllToolsComplete()
}

Expand Down
69 changes: 68 additions & 1 deletion packages/typescript/ai-client/tests/chat-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import {
createApprovalToolCallChunks,
createCustomEventChunks,
} from './test-utils'
import type { ConnectionAdapter } from '../src/connection-adapters'
import type {
ConnectionAdapter,
ConnectConnectionAdapter,
} from '../src/connection-adapters'
Comment on lines +11 to +14
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix import member order to satisfy lint.

The import specifiers are not alphabetically sorted, which matches the reported sort-imports error.

🔧 Suggested fix
 import type {
-  ConnectionAdapter,
   ConnectConnectionAdapter,
+  ConnectionAdapter,
 } from '../src/connection-adapters'
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import type {
ConnectionAdapter,
ConnectConnectionAdapter,
} from '../src/connection-adapters'
import type {
ConnectConnectionAdapter,
ConnectionAdapter,
} from '../src/connection-adapters'
🧰 Tools
🪛 ESLint

[error] 13-13: Member 'ConnectConnectionAdapter' of the import declaration should be sorted alphabetically.

(sort-imports)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/typescript/ai-client/tests/chat-client.test.ts` around lines 11 -
14, The import specifiers in the import from '../src/connection-adapters' are
not alphabetized; reorder the named imports so they are sorted alphabetically
(e.g., place ConnectConnectionAdapter before ConnectionAdapter) to satisfy the
linter's sort-imports rule and update the import line that currently lists
ConnectionAdapter and ConnectConnectionAdapter.

import type { StreamChunk } from '@tanstack/ai'
import type { UIMessage } from '../src/types'

Expand Down Expand Up @@ -1235,6 +1238,70 @@ describe('ChatClient', () => {
})
})

describe('drain re-entrancy guard (fix #302)', () => {
it('should continue after multiple client tools complete in the same round', async () => {
// Round 1: two simultaneous tool calls (triggers the re-entrancy bug)
const round1Chunks = createToolCallChunks([
{ id: 'tc-1', name: 'tool_one', arguments: '{}' },
{ id: 'tc-2', name: 'tool_two', arguments: '{}' },
])
// Round 2: final text response
const round2Chunks = createTextChunks('Done!', 'msg-2')

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

// Both tools execute immediately (synchronously resolve)
const client = new ChatClient({
connection: adapter,
tools: [
{
__toolSide: 'client' as const,
name: 'tool_one',
description: 'Tool one',
execute: async () => ({ result: 'one' }),
},
{
__toolSide: 'client' as const,
name: 'tool_two',
description: 'Tool two',
execute: async () => ({ result: 'two' }),
},
],
})

// Send initial message — triggers round 1 (two tool calls, both auto-executed)
await client.sendMessage('Run both tools')

// Wait for loading to stop and the continuation (round 2) to complete
await vi.waitFor(
() => {
expect(client.getIsLoading()).toBe(false)
// Ensure round 2 actually fired
expect(callIndex).toBeGreaterThanOrEqual(2)
},
{ timeout: 2000 },
)

// The final response "Done!" should appear in messages
const messages = client.getMessages()
const lastAssistant = [...messages]
.reverse()
.find((m) => m.role === 'assistant')
const textPart = lastAssistant?.parts.find((p) => p.type === 'text')
expect(textPart?.content).toBe('Done!')
})
})

describe('error handling', () => {
it('should set error state on connection failure', async () => {
const error = new Error('Network error')
Expand Down
Loading