TanStack AI version
0.44.0
Framework/Library version
@tanstack/ai-client: 0.23.2 | @tanstack/ai-persistence: 0.1.2 | @tanstack/ai-svelte: 0.16.2
Describe the bug and the steps to reproduce it
Server-authoritative chat persistence can restore a pending client-tool interrupt, but the application cannot determine through a public callback that hydration has installed that hidden interrupt.
During a live stream, ChatClient runs a registered client tool from onToolCall and reports the result through addToolResultForClientTool chat-client.ts#L662-L715. Server hydration does not replay that event: hydrateFromServer() applies stored messages and pending descriptors via applyResumeSnapshot() chat-client.ts#L924-L970, and InterruptManager.hydrate() rebuilds items without executing the tool interrupt-manager.ts#L518-L532.
A descriptor that still correlates with the configured client tool becomes client-tool-execution. It stays in the manager's internal items array but is filtered out of the public BoundInterrupts snapshot interrupt-manager.ts#L761-L777, so onResumeStateChange and onInterruptStateChange see an empty public list while the internal batch remains pending chat-client.ts#L1348-L1360.
Applications can still clear the pending interrupt after hydration: the public cancelInterrupts() API works because InterruptManager.cancel() stages every internal item, including hidden client-tool executions, and submits the native resume batch interrupt-manager.ts#L587-L600.
The missing piece is a public signal that hydration has just installed those hidden descriptors, so the application can choose a recovery policy — cancel the orphaned client-tool batch, leave it pending for another client, or present its own recovery UI — without guessing from an empty public interrupt list:
connection.hydrate() resolves before ChatClient applies the returned descriptors, so acting inside the connection is too early.
onResumeStateChange fires after application, but it exposes only resumeState and the filtered public interrupts.
- The same resume callback also fires for live client-tool batches, so treating every non-null resume state with an empty public list as "hydrated orphan" would cancel live in-flight tools.
onCustomEvent receives streamed CUSTOM chunks from tool execution; hydration is a JSON read and emits no stream event.
Apps can approximate this by correlating a hydrate() wrapper with a later empty onResumeStateChange, but that duplicates internal lifecycle and depends on ordering that isn't a public contract.
Potential solution
ChatClient should expose a post-application hydration callback that runs after messages, resume state, and interrupt-manager state have been installed. The callback should identify server hydration and include both the raw restored descriptors and the filtered public interrupts:
onHydrated({
threadId,
result,
resumeState,
interruptDescriptors,
publicInterrupts,
})
This hook would let an application choose its recovery policy through existing APIs. An application can retain visible approvals, cancel client-tool executions whose original browser is gone, or present its own recovery UI. The default behavior should remain unchanged and should not automatically replay a client tool, because client tools can have non-idempotent browser or application side effects.
Existing issues checked
Your Minimal, Reproducible Example - (Sandbox Highly Recommended)
https://stackblitz.com/edit/vitejs-vite-zmaisc1e?file=package.json,index.html,src%2Fmain.ts
Screenshots or Videos (Optional)
No response
Do you intend to try to help solve this bug with your own PR?
None
Terms & Code of Conduct
TanStack AI version
0.44.0
Framework/Library version
@tanstack/ai-client: 0.23.2 | @tanstack/ai-persistence: 0.1.2 | @tanstack/ai-svelte: 0.16.2
Describe the bug and the steps to reproduce it
Server-authoritative chat persistence can restore a pending client-tool interrupt, but the application cannot determine through a public callback that hydration has installed that hidden interrupt.
During a live stream,
ChatClientruns a registered client tool fromonToolCalland reports the result throughaddToolResultForClientToolchat-client.ts#L662-L715. Server hydration does not replay that event:hydrateFromServer()applies stored messages and pending descriptors viaapplyResumeSnapshot()chat-client.ts#L924-L970, andInterruptManager.hydrate()rebuilds items without executing the tool interrupt-manager.ts#L518-L532.A descriptor that still correlates with the configured client tool becomes
client-tool-execution. It stays in the manager's internalitemsarray but is filtered out of the publicBoundInterruptssnapshot interrupt-manager.ts#L761-L777, soonResumeStateChangeandonInterruptStateChangesee an empty public list while the internal batch remains pending chat-client.ts#L1348-L1360.Applications can still clear the pending interrupt after hydration: the public
cancelInterrupts()API works becauseInterruptManager.cancel()stages every internal item, including hidden client-tool executions, and submits the native resume batch interrupt-manager.ts#L587-L600.The missing piece is a public signal that hydration has just installed those hidden descriptors, so the application can choose a recovery policy — cancel the orphaned client-tool batch, leave it pending for another client, or present its own recovery UI — without guessing from an empty public interrupt list:
connection.hydrate()resolves beforeChatClientapplies the returned descriptors, so acting inside the connection is too early.onResumeStateChangefires after application, but it exposes onlyresumeStateand the filtered public interrupts.onCustomEventreceives streamedCUSTOMchunks from tool execution; hydration is a JSON read and emits no stream event.Apps can approximate this by correlating a
hydrate()wrapper with a later emptyonResumeStateChange, but that duplicates internal lifecycle and depends on ordering that isn't a public contract.Potential solution
ChatClientshould expose a post-application hydration callback that runs after messages, resume state, and interrupt-manager state have been installed. The callback should identify server hydration and include both the raw restored descriptors and the filtered public interrupts:This hook would let an application choose its recovery policy through existing APIs. An application can retain visible approvals, cancel client-tool executions whose original browser is gone, or present its own recovery UI. The default behavior should remain unchanged and should not automatically replay a client tool, because client tools can have non-idempotent browser or application side effects.
Existing issues checked
joinRunrejoin never drains post-stream actions concerns replaying a still-running durability stream. That path re-emits a live tool-call event and executes the client tool. It does not cover restoration fromreconstructChatafter the run has reached the persisted interrupt boundary.Your Minimal, Reproducible Example - (Sandbox Highly Recommended)
https://stackblitz.com/edit/vitejs-vite-zmaisc1e?file=package.json,index.html,src%2Fmain.ts
Screenshots or Videos (Optional)
No response
Do you intend to try to help solve this bug with your own PR?
None
Terms & Code of Conduct