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
- Configure
useChat with a resumable connection (fetchServerSentEvents against a route using toServerSentEventsResponse(stream, { durability: { adapter: memoryStream(request) } })) and a persistence adapter.
- Register a client tool (
toolDefinition(...).client(fn)) and pass the bare definition to chat({ tools }) on the server.
- Send a message that makes the model call that client tool.
- Kill the app / reload the page while the run is still streaming.
- 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 submit → resumeInterruptsUnsafe. 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.
Package
@tanstack/ai-client0.23.0 (via@tanstack/ai-react0.19.0,@tanstack/ai0.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 subsequentsendMessageis 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
useChatwith a resumable connection (fetchServerSentEventsagainst a route usingtoServerSentEventsResponse(stream, { durability: { adapter: memoryStream(request) } })) and apersistenceadapter.toolDefinition(...).client(fn)) and pass the bare definition tochat({ tools })on the server.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
sendMessageproduces no request at all.Cause
resumeInFlightRunsetsisLoading = truefor the duration of the replay:During the replay
onToolCallfires, the client tool runs, andaddToolResultForClientToolresolves the interrupt, which makesInterruptManagercallsubmit→resumeInterruptsUnsafe. That method defers whenever a stream is in flight:So the submission is queued. But
drainPostStreamActions()is only ever called fromstreamResponse()'s teardown —resumeInFlightRunhas its ownfinallyand never drains. The queued submission is therefore never executed: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 byaddToolResultForClientToolwhen it takes the non-interrupt path.Suggested fix
Drain the queue in
resumeInFlightRun'sfinally, afterisLoadingis 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(); }drainPostStreamActionsalready guards against re-entrancy withthis.draining, so this is safe next to the existing call instreamResponse.I'm running this as a
patch-packagepatch and it resolves both symptoms.