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

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions