Skip to content
11 changes: 11 additions & 0 deletions .changeset/fix-1086-interrupt-hydrate-source.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@tanstack/ai-client': patch
'@tanstack/ai-react': patch
'@tanstack/ai-solid': patch
'@tanstack/ai-vue': patch
'@tanstack/ai-svelte': patch
'@tanstack/ai-preact': patch
'@tanstack/ai-angular': patch
---

`onInterruptStateChange` now identifies snapshot restoration (`hydrate`) separately from streamed or client-initiated interrupt updates (`live`). The source follows each state publication, so cancelling a restored batch from the callback produces subsequent `live` updates without re-entering hydration. Client-tool interrupts remain hidden from the public list in both cases; `hydrate` lets an app cancel a restored batch without cancelling one that is still running.
1 change: 1 addition & 0 deletions docs/api/ai-angular.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ Extends `ChatClientOptions` from `@tanstack/ai-client` (minus internal state cal
- `onChunk?` - Callback when stream chunk is received
- `onFinish?` - Callback when response finishes
- `onError?` - Callback when error occurs
- `onInterruptStateChange?` - Callback when interrupt state changes; context source is `hydrate` for restored state and `live` for streamed or client-initiated updates
- `onCustomEvent?` - Callback for custom stream events
- `streamProcessor?` - Stream processing configuration

Expand Down
1 change: 1 addition & 0 deletions docs/api/ai-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ goes away. Users of the framework hooks need no change.
- `onChunk?` - Callback when stream chunk is received
- `onFinish?` - Callback when response finishes
- `onError?` - Callback when error occurs
- `onInterruptStateChange?` - Callback when interrupt state changes; context source is `hydrate` for restored state and `live` for streamed or client-initiated updates
- `onMessagesChange?` - Callback when messages change
- `onLoadingChange?` - Callback when loading state changes
- `onErrorChange?` - Callback when error state changes
Expand Down
1 change: 1 addition & 0 deletions docs/api/ai-preact.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ Extends `ChatClientOptions` from `@tanstack/ai-client`:
- `onChunk?` - Callback when stream chunk is received
- `onFinish?` - Callback when response finishes
- `onError?` - Callback when error occurs
- `onInterruptStateChange?` - Callback when interrupt state changes; context source is `hydrate` for restored state and `live` for streamed or client-initiated updates
- `streamProcessor?` - Stream processing configuration

**Note:** Client tools are now automatically executed - no `onToolCall` callback needed!
Expand Down
1 change: 1 addition & 0 deletions docs/api/ai-react.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ Extends `ChatClientOptions` from `@tanstack/ai-client`:
- `onChunk?` - Callback when stream chunk is received
- `onFinish?` - Callback when response finishes
- `onError?` - Callback when error occurs
- `onInterruptStateChange?` - Callback when interrupt state changes; context source is `hydrate` for restored state and `live` for streamed or client-initiated updates
- `streamProcessor?` - Stream processing configuration

**Note:** Client tools are now automatically executed - no `onToolCall` callback needed!
Expand Down
1 change: 1 addition & 0 deletions docs/api/ai-solid.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ Extends `ChatClientOptions` from `@tanstack/ai-client`:
- `onChunk?` - Callback when stream chunk is received
- `onFinish?` - Callback when response finishes
- `onError?` - Callback when error occurs
- `onInterruptStateChange?` - Callback when interrupt state changes; context source is `hydrate` for restored state and `live` for streamed or client-initiated updates
- `streamProcessor?` - Stream processing configuration

**Note:** Client tools are now automatically executed - no `onToolCall` callback needed!
Expand Down
1 change: 1 addition & 0 deletions docs/api/ai-svelte.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ Extends `ChatClientOptions` from `@tanstack/ai-client` (minus internal state cal
- `onChunk?` - Callback when stream chunk is received
- `onFinish?` - Callback when response finishes
- `onError?` - Callback when error occurs
- `onInterruptStateChange?` - Callback when interrupt state changes; context source is `hydrate` for restored state and `live` for streamed or client-initiated updates
- `onCustomEvent?` - Callback for custom stream events
- `streamProcessor?` - Stream processing configuration

Expand Down
1 change: 1 addition & 0 deletions docs/api/ai-vue.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ Extends `ChatClientOptions` from `@tanstack/ai-client` (minus internal state cal
- `onChunk?` - Callback when stream chunk is received
- `onFinish?` - Callback when response finishes
- `onError?` - Callback when error occurs
- `onInterruptStateChange?` - Callback when interrupt state changes; context source is `hydrate` for restored state and `live` for streamed or client-initiated updates
- `onCustomEvent?` - Callback for custom stream events
- `streamProcessor?` - Stream processing configuration

Expand Down
7 changes: 6 additions & 1 deletion docs/interrupts/multiple.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,12 @@ Two shortcuts cover the common cases:
whole queue. It works only when every item is a tool approval that needs no
payload or edits. Generic items, mixed queues, or required payloads are
rejected.
- `cancelInterrupts()` cancels every resumable item with no payload.
- `cancelInterrupts()` cancels every resumable item with no payload, including
client-tool execution steps that are hidden from `interrupts`.

Use the `onInterruptStateChange` source to apply different policies to restored
(`hydrate`) and current-session (`live`) batches. Cancellation still applies to
every resumable item in the batch.

## When an answer is wrong

Expand Down
5 changes: 5 additions & 0 deletions docs/interrupts/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,11 @@ A tool with a `.client()` implementation runs in the browser on its own and
reports its own result. That is not a decision you make, so it never appears in
`interrupts`. See [Client Tools](../tools/client-tools).

If persistence restores a pending client-tool execution, the client leaves it
pending rather than running the browser code again. See
[Client persistence](../persistence/client-persistence#handle-restored-client-tools)
for recovery policies.

The one time a tool pauses is when you mark it `needsApproval: true`. Then it
stops for a yes or no first, whether it runs on the server or in the browser:

Expand Down
44 changes: 44 additions & 0 deletions docs/persistence/client-persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,50 @@ pointer. On the next load `useChat` reads it and:
a durability-backed connection (a route that records the stream and exposes a
replay handler); see [Resumable streams](../resumable-streams/overview).

### Handle restored client tools

A live client tool runs automatically when its call arrives from the stream.
Hydration restores its pending execution but does not run the browser code
again, because that work may not be safe to repeat. The execution remains an
internal interrupt and does not appear in `interrupts`.

Use `onInterruptStateChange` to distinguish restored interrupt state from live
updates. Leaving a restored batch pending is the default. This example cancels
every restored batch instead:

```tsx
import { useEffect, useState } from 'react'
import {
fetchServerSentEvents,
localStoragePersistence,
useChat,
} from '@tanstack/ai-react'

function Chat() {
const [restoredBatch, setRestoredBatch] = useState(false)
const { cancelInterrupts } = useChat({
threadId: 'support-chat',
connection: fetchServerSentEvents('/api/chat'),
persistence: localStoragePersistence(),
onInterruptStateChange(_state, { source }) {
setRestoredBatch(source === 'hydrate')
},
})

useEffect(() => {
if (restoredBatch) cancelInterrupts()
}, [cancelInterrupts, restoredBatch])

return null
}
```

`source` is `hydrate` for state restored from an initial resume snapshot, a
client storage adapter, or server hydration. It is `live` for streamed and
client-initiated changes. `cancelInterrupts()` cancels the complete internal
batch, including visible approvals and hidden client-tool executions; it does
not selectively cancel one kind of interrupt.

## Choose a mode

`persistence` takes a storage adapter or a boolean:
Expand Down
6 changes: 4 additions & 2 deletions docs/tools/client-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,10 @@ Native client-tool execution shares the atomic interrupt **batch** lifecycle
(it can gate multi-item submits) but is **auto-resolved** — you do not call
`resolveInterrupt` for it. See [Interrupts](../interrupts/overview) for the
ephemeral lifecycle, batches, and migration from the historical
`tool-input-available` custom event. Durable recovery is optional and not part
of the default client-tool path.
`tool-input-available` custom event. After hydration, a pending client-tool
execution is restored but not run again. See
[Client persistence](../persistence/client-persistence#handle-restored-client-tools)
for recovery policies.

## Approval is a separate axis

Expand Down
4 changes: 2 additions & 2 deletions packages/ai-angular/src/inject-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,9 @@ export function injectChat<
// signal (via `onRunIdChange`) and pending interrupts arrive through
// `onInterruptStateChange`, so there is nothing left for it to do — and it
// is not a public option here, matching the other framework packages.
onInterruptStateChange: (nextInterruptState) => {
onInterruptStateChange: (nextInterruptState, context) => {
interruptState.set(nextInterruptState)
options.onInterruptStateChange?.(nextInterruptState)
options.onInterruptStateChange?.(nextInterruptState, context)
},
tools: options.tools,
...(options.interrupts !== undefined && {
Expand Down
2 changes: 2 additions & 0 deletions packages/ai-angular/tests/inject-chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ describe('injectChat', () => {
interrupts: result.interrupts(),
interruptErrors: result.interruptErrors(),
}),
{ source: 'live' },
)
})

Expand Down Expand Up @@ -254,6 +255,7 @@ describe('injectChat — resume', () => {
expect.objectContaining({ id: 'interrupt-1' }),
]),
}),
{ source: 'live' },
)
})
})
Expand Down
60 changes: 37 additions & 23 deletions packages/ai-client/src/chat-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@ import type {
UIMessage,
WhenBusy,
} from './types'
import type { InterruptManagerSubmission } from './interrupt-manager'
import type {
InterruptManagerChangeSource,
InterruptManagerSubmission,
} from './interrupt-manager'

/** Internal queue entry — public {@link QueuedMessage} plus optional per-send body. */
interface InternalQueuedMessage extends QueuedMessage {
Expand Down Expand Up @@ -111,7 +114,10 @@ type ChatClientUpdateOptionsWithoutContext<
* starts (including a rejoin), `null` when it settles.
*/
onRunIdChange?: (runId: string | null) => void
onInterruptStateChange?: (state: ChatInterruptState<TTools>) => void
onInterruptStateChange?: (
state: ChatInterruptState<TTools, TInterrupts>,
context: { source: 'hydrate' | 'live' },
) => void
onCustomEvent?: (
eventType: string,
data: unknown,
Expand Down Expand Up @@ -422,6 +428,7 @@ export class ChatClient<
onRunIdChange: (runId: string | null) => void
onInterruptStateChange: (
state: ChatInterruptState<TTools, TInterrupts>,
context: { source: 'hydrate' | 'live' },
) => void
onCustomEvent: (
eventType: string,
Expand Down Expand Up @@ -522,7 +529,7 @@ export class ChatClient<
? { interrupts: options.interrupts }
: {}),
submit: (submission) => this.submitInterruptBatch(submission),
onChange: () => this.notifyResumeStateChange(),
onChange: (source) => this.notifyResumeStateChange(source),
})

// In-memory rehydrate of interrupt descriptors (e.g. after a page reload
Expand Down Expand Up @@ -882,24 +889,27 @@ export class ChatClient<
private applyResumeSnapshot(snapshot: ChatResumeSnapshot): void {
const resumeState = readResumeState(snapshot)
if (resumeState === undefined) {
this.interruptManager.reset()
this.interruptManager.reset({ source: 'hydrate' })
return
}
this.lastResume = resumeState
const pendingInterrupts = Array.isArray(snapshot.pendingInterrupts)
? snapshot.pendingInterrupts
: []
if (pendingInterrupts.length === 0) {
this.interruptManager.reset()
this.interruptManager.reset({ source: 'hydrate' })
return
}
const generation = this.interruptGeneration(pendingInterrupts)
this.interruptManager.hydrate({
threadId: resumeState.threadId,
interruptedRunId: resumeState.runId,
generation,
interrupts: pendingInterrupts,
})
this.interruptManager.hydrate(
{
threadId: resumeState.threadId,
interruptedRunId: resumeState.runId,
generation,
interrupts: pendingInterrupts,
},
'hydrate',
)
}

/**
Expand Down Expand Up @@ -1128,12 +1138,15 @@ export class ChatClient<
threadId: threadId ?? this.threadId,
runId: interruptedRunId,
}
this.interruptManager.hydrate({
threadId: this.lastResume.threadId,
interruptedRunId,
generation: this.interruptGeneration(chunk.outcome.interrupts),
interrupts: chunk.outcome.interrupts,
})
this.interruptManager.hydrate(
{
threadId: this.lastResume.threadId,
interruptedRunId,
generation: this.interruptGeneration(chunk.outcome.interrupts),
interrupts: chunk.outcome.interrupts,
},
'live',
)
return
}

Expand Down Expand Up @@ -1180,7 +1193,7 @@ export class ChatClient<
this.interruptManager.reset()
return
}
this.notifyResumeStateChange()
this.notifyResumeStateChange('live')
}

/**
Expand Down Expand Up @@ -1400,19 +1413,20 @@ export class ChatClient<
this.devtoolsBridge.emitSnapshot()
}

private notifyResumeStateChange(): void {
private notifyResumeStateChange(source: InterruptManagerChangeSource): void {
const resumeState = this.getResumeState()
// Capture state before invoking callbacks so a synchronous nested change
// cannot pair this publication's source with a later manager snapshot.
const interruptState = this.interruptManager.getState()
// Persist (or clear) the durable resume snapshot so a full page reload can
// rehydrate pending interrupts and rejoin the run. Folded into the same
// persistence adapter that stores messages (one record per chat).
this.persistResumeSnapshot(resumeState)
this.callbacksRef.current.onResumeStateChange(
resumeState,
this.interruptManager.getInterrupts(),
)
this.callbacksRef.current.onInterruptStateChange(
this.interruptManager.getState(),
interruptState.interrupts,
)
this.callbacksRef.current.onInterruptStateChange(interruptState, { source })
}

/**
Expand Down
Loading