From 4b330c93131f93f77dae5fa295dafe532325b8d2 Mon Sep 17 00:00:00 2001 From: Will Willems Date: Fri, 20 Feb 2026 16:39:18 +0100 Subject: [PATCH] fix(ai-client): prevent continuation stall when multiple client tools complete in the same round Add re-entrancy guard on drainPostStreamActions to prevent nested drains from stealing queued continuation checks, and add a tool-result type guard in checkForContinuation to prevent spurious continuation requests after the model has already produced its text response. Closes #302 --- .../fix-client-tool-continuation-stall.md | 7 +++++++ .../typescript/ai-client/src/chat-client.ts | 20 ++++++++++++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 .changeset/fix-client-tool-continuation-stall.md 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 {