Skip to content

Sequential client-tool resumes incorrectly send a non-resume request while the next interrupt is still pending #1106

Description

@kolaworld

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

  • I agree to follow this project's Code of Conduct
  • I understand that if my bug cannot be reliable reproduced in a debuggable environment, it will probably not be fixed and this issue may even be closed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    has-prAn open PR references this issuewaiting-on: maintainerThe ball is in the maintainers’ court

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions