TanStack AI version
0.44.0
Framework/Library version
@tanstack/ai-client: 0.23.2 | @tanstack/ai-persistence: 0.1.3 | @tanstack/ai-svelte: 0.16.2
Describe the bug and the steps to reproduce it
ChatClient incorrectly sends an ordinary continuation while a sequential client-tool interrupt still owns the next turn. After tool 1's native resume, tool 2 is pending, so the next request must carry that interrupt's resume batch (resume + parentRunId). Instead the client sends a request with neither.
That ordinary request is a protocol violation: the thread is still interrupted. A persistence server rejects it; a stateless server may accept the transcript and hide the bug.
Expected wire sequence:
user request
resume tool 1
resume tool 2
final answer
This shows up in two places.
Nested finalize. While the nested stream is finishing, checkForContinuation() fires before the outer drain can send tool 2's already-queued native resume. The real resume still follows:
user request
resume tool 1
ordinary request without resume
resume tool 2
final answer
After a failed tool-2 resume. After drainPostStreamActions() returns, the same fallback can run again. draining is already false, and a trailing tool-result is enough to start another ordinary request even though native interrupt handling still owns that boundary. A failed tool-2 resume is one way to get there; the pending interrupt alone does not say whether that resume was never sent or was sent and failed:
user request
resume tool 1
ordinary request without resume
resume tool 2 (submission fails)
ordinary request without resume
When tool 1 completes while its parent stream is still loading, resumeInterruptsUnsafe() queues the native resume instead of starting it chat-client.ts#L1206-L1230. That path is reached from addToolResultForClientTool() after InterruptManager.resolveClientToolOutput() matches the interrupt chat-client.ts#L2469-L2478.
The parent stream then finishes and drainPostStreamActions() starts that queued resume chat-client.ts#L2569-L2579. The resumed stream emits tool 2. Tool 2 completes and queues another native resume, but the original drain is still active (draining === true). The nested finally calls drainPostStreamActions() again, sees the guard, and returns without running tool 2's resume.
That same finally then sees a trailing tool-result part and calls checkForContinuation()chat-client.ts#L2259-L2273. checkForContinuation() starts streamResponse() with no pendingResumeItems / parentRunIdchat-client.ts#L2037-L2143. After that ordinary request settles, the outer drain finally sends tool 2's native resume.
addToolResultForClientTool() already skips legacy continuation when the result matches a native interrupt. One hole is the nested stream's post-finish fallback: it does not know a native resume is sitting in postStreamActions. Another appears when the outer post-finish fallback runs after the drain while native resume state remains. It no longer sees draining === true, so it can transfer the same tool boundary to legacy continuation.
Persistence is not the cause. A server with a pending interrupt correctly rejects the ordinary request. A stateless server can accept the full transcript and hide the protocol violation.
Making the nested drain await the outer drain promise would deadlock: the outer drain is waiting for the nested stream, and the nested stream would wait for the outer drain. The missing contract is continuation ownership, not a different lock.
Expected behavior
A fix may need to keep a native-matched tool boundary owned by interrupt handling across nested stream finalization and while native resume state remains pending. Once a result matches a native interrupt, checkForContinuation() would not take over that boundary while its resume is queued or in flight, or after its submission fails. A submission failure could remain available through the interrupt error and retry surfaces instead of triggering an ordinary request.
On the nested-finalize hole, the outer drain already holds tool 2's queued native resume, so nested finalize can return and let that drain send it. That does not apply after a failed native resume: the drain has returned and no longer owns the boundary. Tagging queued actions by kind (native-interrupt-resume vs legacy-continuation) may be one way to keep that ownership visible. The existing pending resume state may provide another signal. Gating only on draining does not cover the outer fallback after the drain returns.
A nested finalize that waits for the outer drain to finish would deadlock. The outer drain is already waiting for that nested stream to return, so neither side can proceed.
Existing issues checked
- Issue #302 / PR #429 added the
draining re-entrancy guard so nested drainPostStreamActions() would not steal sibling checkForContinuation actions. That guard is what leaves the second native resume unprocessed during nested finalize; this issue is the leftover ordinary continuation that then fires.
Your Minimal, Reproducible Example - (Sandbox Highly Recommended)
Nested finalize: https://stackblitz.com/edit/vitejs-vite-w665zuxv?file=package.json,index.html,src%2Fmain.ts
Nested finalize followed by a failed tool-2 resume: https://stackblitz.com/edit/vitejs-vite-epmwewnf?file=package.json,index.html,src%2Fmain.ts
Terms & Code of Conduct
TanStack AI version
0.44.0
Framework/Library version
@tanstack/ai-client: 0.23.2 | @tanstack/ai-persistence: 0.1.3 | @tanstack/ai-svelte: 0.16.2
Describe the bug and the steps to reproduce it
ChatClientincorrectly sends an ordinary continuation while a sequential client-tool interrupt still owns the next turn. After tool 1's native resume, tool 2 is pending, so the next request must carry that interrupt's resume batch (resume+parentRunId). Instead the client sends a request with neither.That ordinary request is a protocol violation: the thread is still interrupted. A persistence server rejects it; a stateless server may accept the transcript and hide the bug.
Expected wire sequence:
This shows up in two places.
Nested finalize. While the nested stream is finishing,
checkForContinuation()fires before the outer drain can send tool 2's already-queued native resume. The real resume still follows:After a failed tool-2 resume. After
drainPostStreamActions()returns, the same fallback can run again.drainingis alreadyfalse, and a trailingtool-resultis enough to start another ordinary request even though native interrupt handling still owns that boundary. A failed tool-2 resume is one way to get there; the pending interrupt alone does not say whether that resume was never sent or was sent and failed:When tool 1 completes while its parent stream is still loading,
resumeInterruptsUnsafe()queues the native resume instead of starting it chat-client.ts#L1206-L1230. That path is reached fromaddToolResultForClientTool()afterInterruptManager.resolveClientToolOutput()matches the interrupt chat-client.ts#L2469-L2478.The parent stream then finishes and
drainPostStreamActions()starts that queued resume chat-client.ts#L2569-L2579. The resumed stream emits tool 2. Tool 2 completes and queues another native resume, but the original drain is still active (draining === true). The nestedfinallycallsdrainPostStreamActions()again, sees the guard, and returns without running tool 2's resume.That same
finallythen sees a trailingtool-resultpart and callscheckForContinuation()chat-client.ts#L2259-L2273.checkForContinuation()startsstreamResponse()with nopendingResumeItems/parentRunIdchat-client.ts#L2037-L2143. After that ordinary request settles, the outer drain finally sends tool 2's native resume.addToolResultForClientTool()already skips legacy continuation when the result matches a native interrupt. One hole is the nested stream's post-finish fallback: it does not know a native resume is sitting inpostStreamActions. Another appears when the outer post-finish fallback runs after the drain while native resume state remains. It no longer seesdraining === true, so it can transfer the same tool boundary to legacy continuation.Persistence is not the cause. A server with a pending interrupt correctly rejects the ordinary request. A stateless server can accept the full transcript and hide the protocol violation.
Making the nested drain await the outer drain promise would deadlock: the outer drain is waiting for the nested stream, and the nested stream would wait for the outer drain. The missing contract is continuation ownership, not a different lock.
Expected behavior
A fix may need to keep a native-matched tool boundary owned by interrupt handling across nested stream finalization and while native resume state remains pending. Once a result matches a native interrupt,
checkForContinuation()would not take over that boundary while its resume is queued or in flight, or after its submission fails. A submission failure could remain available through the interrupt error and retry surfaces instead of triggering an ordinary request.On the nested-finalize hole, the outer drain already holds tool 2's queued native resume, so nested finalize can return and let that drain send it. That does not apply after a failed native resume: the drain has returned and no longer owns the boundary. Tagging queued actions by kind (
native-interrupt-resumevslegacy-continuation) may be one way to keep that ownership visible. The existing pending resume state may provide another signal. Gating only ondrainingdoes not cover the outer fallback after the drain returns.A nested finalize that waits for the outer drain to finish would deadlock. The outer drain is already waiting for that nested stream to return, so neither side can proceed.
Existing issues checked
drainingre-entrancy guard so nesteddrainPostStreamActions()would not steal siblingcheckForContinuationactions. That guard is what leaves the second native resume unprocessed during nested finalize; this issue is the leftover ordinary continuation that then fires.Your Minimal, Reproducible Example - (Sandbox Highly Recommended)
Nested finalize: https://stackblitz.com/edit/vitejs-vite-w665zuxv?file=package.json,index.html,src%2Fmain.ts
Nested finalize followed by a failed tool-2 resume: https://stackblitz.com/edit/vitejs-vite-epmwewnf?file=package.json,index.html,src%2Fmain.ts
Terms & Code of Conduct