diff --git a/.changeset/fix-client-tool-continuation-stall.md b/.changeset/fix-client-tool-continuation-stall.md new file mode 100644 index 00000000..b330e26e --- /dev/null +++ b/.changeset/fix-client-tool-continuation-stall.md @@ -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. diff --git a/packages/typescript/ai-client/src/chat-client.ts b/packages/typescript/ai-client/src/chat-client.ts index 27078d5e..a89eedb5 100644 --- a/packages/typescript/ai-client/src/chat-client.ts +++ b/packages/typescript/ai-client/src/chat-client.ts @@ -40,6 +40,8 @@ export class ChatClient { private pendingToolExecutions: Map> = new Map() // Flag to deduplicate continuation checks during action draining private continuationPending = false + // Re-entrancy guard for drainPostStreamActions + private draining = false private callbacksRef: { current: { @@ -619,9 +621,15 @@ export class ChatClient { * Drain and execute all queued post-stream actions */ private async drainPostStreamActions(): Promise { - 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 } } @@ -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 + } + if (this.shouldAutoSend()) { this.continuationPending = true try {