Skip to content

ChatClient with Persistence cannot identify server-hydrated hidden client-tool interrupts #1086

Description

@kolaworld

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

  • 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

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