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-client-tool-continuation-stall.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tanstack/ai-client': patch
---

fix: prevent client tool continuation stall when multiple tools complete in the same round

When an LLM response triggers multiple client-side tool calls simultaneously, the chat would permanently stall after all tools completed. This was caused by nested `drainPostStreamActions` calls stealing queued continuation checks from the outer drain. Added a re-entrancy guard on `drainPostStreamActions` and a `tool-result` type check in `checkForContinuation` to prevent both the structural and semantic causes of the stall.
20 changes: 17 additions & 3 deletions packages/typescript/ai-client/src/chat-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export class ChatClient {
private pendingToolExecutions: Map<string, Promise<void>> = new Map()
// Flag to deduplicate continuation checks during action draining
private continuationPending = false
// Re-entrancy guard for drainPostStreamActions
private draining = false

private callbacksRef: {
current: {
Expand Down Expand Up @@ -619,9 +621,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 All @@ -634,6 +642,12 @@ export class ChatClient {
return
}

const messages = this.processor.getMessages()
const lastPart = messages.at(-1)?.parts.at(-1)
if (lastPart?.type !== 'tool-result') {
return
}
Comment on lines +645 to +649
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 1. Find areAllToolsComplete implementation
echo "=== areAllToolsComplete ==="
rg -n "areAllToolsComplete" --type ts -A 15

echo ""
echo "=== addToolApprovalResponse on StreamProcessor ==="
rg -n "addToolApprovalResponse" --type ts -A 20

echo ""
echo "=== checkForContinuation implementation ==="
rg -n "checkForContinuation" --type ts -A 25

echo ""
echo "=== addToolResult implementation (context) ==="
rg -n "addToolResult" --type ts -A 15 | head -60

echo ""
echo "=== tool-call / tool-result type definitions ==="
rg -n "type.*tool-result|type.*tool-call" --type ts -B 2 -A 2

Repository: TanStack/ai

Length of output: 50368


🏁 Script executed:

#!/bin/bash
# Get complete areAllToolsComplete implementation
echo "=== areAllToolsComplete (full implementation) ==="
rg -n "areAllToolsComplete" packages/typescript/ai/src/activities/chat/stream/processor.ts -A 30

echo ""
echo "=== checkForContinuation (full implementation) ==="
rg -n "checkForContinuation" packages/typescript/ai-client/src/chat-client.ts -B 5 -A 30

echo ""
echo "=== addToolResult (for comparison) ==="
rg -n "addToolResult" packages/typescript/ai-client/src/chat-client.ts -B 2 -A 20 | head -50

echo ""
echo "=== addToolApprovalResponse in processor.ts ==="
rg -n "addToolApprovalResponse" packages/typescript/ai/src/activities/chat/stream/processor.ts -A 20

Repository: TanStack/ai

Length of output: 8083


Regression in approval flow: guard at line 647 causes premature exit.

The guard if (lastPart?.type !== 'tool-result') returns early before shouldAutoSend() / areAllToolsComplete() can be called. In the addToolApprovalResponse() path, the last part remains type 'tool-call' with state 'approval-responded', not 'tool-result'. Even though areAllToolsComplete() correctly returns true for approval-responded tools, the guard prevents that check from being reached, stalling the approval flow.

The guard should either be removed from checkForContinuation() itself and moved into the addToolResult queueing lambda (line 482) where it's contextually correct, or it should be extended to also allow the 'approval-responded' state when the tool has no approval attached.

πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/typescript/ai-client/src/chat-client.ts` around lines 645 - 649, The
early return in checkForContinuation() that inspects lastPart (const lastPart =
messages.at(-1)?.parts.at(-1); if (lastPart?.type !== 'tool-result') return)
blocks shouldAutoSend() / areAllToolsComplete() for approval responses; update
the logic so approval-responded tool-calls are allowed through: either remove
that guard from checkForContinuation() and perform the tool-result/type gating
inside the addToolResult queueing lambda instead, or extend the guard to also
allow lastPart.type === 'tool-call' when lastPart.state === 'approval-responded'
and the tool has no approval attached; ensure functions referenced
(checkForContinuation, shouldAutoSend, areAllToolsComplete,
addToolApprovalResponse, addToolResult, lastPart) are used to locate affected
code.


if (this.shouldAutoSend()) {
this.continuationPending = true
try {
Expand Down
Loading