Skip to content

joinRun rejoin never drains post-stream actions, stalling a client-tool turn and blocking all later sends #1058

Description

@AlanChauchet

Package

@tanstack/ai-client 0.23.0 (via @tanstack/ai-react 0.19.0, @tanstack/ai 0.43.0)

Summary

When a run is rejoined with joinRun (resumable stream replay after a reload / app relaunch) and that run ends on a client tool call, the tool executes but its result is never sent to the server. The turn stalls forever, and because the interrupt stays pending, every subsequent sendMessage is silently held too — the chat is permanently wedged until the persisted state is cleared.

Live runs are unaffected; only the rejoin path is broken.

Reproduction

  1. Configure useChat with a resumable connection (fetchServerSentEvents against a route using toServerSentEventsResponse(stream, { durability: { adapter: memoryStream(request) } })) and a persistence adapter.
  2. Register a client tool (toolDefinition(...).client(fn)) and pass the bare definition to chat({ tools }) on the server.
  3. Send a message that makes the model call that client tool.
  4. Kill the app / reload the page while the run is still streaming.
  5. Reopen. The client rejoins via GET ...?offset=-1&runId=... and replays the run correctly: text and the tool call are repainted, and the client tool executes.

Expected: the tool result is posted back and the run continues to completion.
Actual: no follow-up request is made. The turn never finishes, and every later sendMessage produces no request at all.

Cause

resumeInFlightRun sets isLoading = true for the duration of the replay:

resumeInFlightRun(runId) {
  ...
  this.setIsLoading(true);
  this.setStatus("streaming");
  (async () => {
    try {
      for await (const chunk of joinRun(runId, controller.signal)) { ... }
    } catch (error) { ... }
    finally {
      ...
      if (this.abortController === controller) {
        this.abortController = null;
        this.setIsLoading(false);
        if (this.status === "streaming") this.setStatus("ready");
      }
    }
  })();
}

During the replay onToolCall fires, the client tool runs, and addToolResultForClientTool resolves the interrupt, which makes InterruptManager call submitresumeInterruptsUnsafe. That method defers whenever a stream is in flight:

resumeInterruptsUnsafe(resume, state) {
  const target = state ?? this.lastResume;
  if (!target) return Promise.resolve(false);
  if (this.isLoading) return new Promise((resolve, reject) => {
    this.queuePostStreamAction(async () => { ... });   // deferred
  });
  ...
}

So the submission is queued. But drainPostStreamActions() is only ever called from streamResponse()'s teardown — resumeInFlightRun has its own finally and never drains. The queued submission is therefore never executed:

  • the continuation request is never sent, so the turn never completes;
  • activeInterruptSubmission / the pending interrupt are never cleared, so later sends are blocked behind them.

The same would apply to any post-stream action queued during a rejoin, including the checkForContinuation() queued by addToolResultForClientTool when it takes the non-interrupt path.

Suggested fix

Drain the queue in resumeInFlightRun's finally, after isLoading is cleared so the deferred call takes the live path on re-entry:

                 if (this.abortController === controller) {
                     this.abortController = null;
                     this.setIsLoading(false);
                     if (this.status === "streaming") this.setStatus("ready");
+                    await this.drainPostStreamActions();
                 }

drainPostStreamActions already guards against re-entrancy with this.draining, so this is safe next to the existing call in streamResponse.

I'm running this as a patch-package patch and it resolves both symptoms.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions