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
23 changes: 12 additions & 11 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@
"label": "Client Tools",
"to": "tools/client-tools",
"addedAt": "2026-04-15",
"updatedAt": "2026-07-21"
"updatedAt": "2026-08-20"
},
{
"label": "Tool Approval Flow",
Expand Down Expand Up @@ -190,7 +190,7 @@
"label": "Overview",
"to": "interrupts/overview",
"addedAt": "2026-08-04",
"updatedAt": "2026-08-14"
"updatedAt": "2026-08-20"
},
{
"label": "Tool Approval",
Expand All @@ -202,7 +202,7 @@
"label": "Multiple Interrupts",
"to": "interrupts/multiple",
"addedAt": "2026-08-04",
"updatedAt": "2026-08-14"
"updatedAt": "2026-08-20"
},
{
"label": "Generic Interrupts",
Expand Down Expand Up @@ -276,7 +276,8 @@
{
"label": "Client Persistence",
"to": "persistence/client-persistence",
"addedAt": "2026-08-04"
"addedAt": "2026-08-04",
"updatedAt": "2026-08-20"
},
{
"label": "Generation Persistence",
Expand Down Expand Up @@ -833,43 +834,43 @@
"label": "@tanstack/ai-client",
"to": "api/ai-client",
"addedAt": "2026-04-15",
"updatedAt": "2026-08-19"
"updatedAt": "2026-08-20"
},
{
"label": "@tanstack/ai-react",
"to": "api/ai-react",
"addedAt": "2026-04-15",
"updatedAt": "2026-08-19"
"updatedAt": "2026-08-20"
},
{
"label": "@tanstack/ai-solid",
"to": "api/ai-solid",
"addedAt": "2026-04-15",
"updatedAt": "2026-08-19"
"updatedAt": "2026-08-20"
},
{
"label": "@tanstack/ai-preact",
"to": "api/ai-preact",
"addedAt": "2026-04-15",
"updatedAt": "2026-08-19"
"updatedAt": "2026-08-20"
},
{
"label": "@tanstack/ai-vue",
"to": "api/ai-vue",
"addedAt": "2026-04-15",
"updatedAt": "2026-08-19"
"updatedAt": "2026-08-20"
},
{
"label": "@tanstack/ai-svelte",
"to": "api/ai-svelte",
"addedAt": "2026-04-15",
"updatedAt": "2026-08-19"
"updatedAt": "2026-08-20"
},
{
"label": "@tanstack/ai-angular",
"to": "api/ai-angular",
"addedAt": "2026-06-15",
"updatedAt": "2026-08-19"
"updatedAt": "2026-08-20"
}
]
},
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
Loading
Loading