diff --git a/.changeset/ag-ui-interrupts.md b/.changeset/ag-ui-interrupts.md new file mode 100644 index 000000000..af939a21e --- /dev/null +++ b/.changeset/ag-ui-interrupts.md @@ -0,0 +1,24 @@ +--- +'@tanstack/ai': minor +'@tanstack/ai-client': minor +'@tanstack/ai-react': minor +'@tanstack/ai-preact': minor +'@tanstack/ai-solid': minor +'@tanstack/ai-vue': minor +'@tanstack/ai-svelte': minor +'@tanstack/ai-angular': minor +'@tanstack/ai-persistence': patch +'@tanstack/ai-persistence-drizzle': patch +'@tanstack/ai-persistence-prisma': patch +'@tanstack/ai-persistence-cloudflare': patch +--- + +Adopt the AG-UI interrupt lifecycle for tool approvals, generic responses, and +client-tool execution, with typed bound resolvers, atomic batches, structured +errors, persistence-backed recovery, and exact continuation replay. + +This changes native approval and client-tool streams from legacy custom events +to snapshot-plus-`RUN_FINISHED` interrupt outcomes. Deprecated +`pendingInterrupts`, `addToolApprovalResponse`, raw `resumeInterrupts`, and +legacy event readers remain as limited compatibility surfaces for migration; +`addToolResult` remains supported. diff --git a/docs/architecture/approval-flow-processing.md b/docs/architecture/approval-flow-processing.md index 7838c9b14..153df803b 100644 --- a/docs/architecture/approval-flow-processing.md +++ b/docs/architecture/approval-flow-processing.md @@ -16,7 +16,7 @@ Tool approval is an interrupt-and-resume protocol. A run that needs user input ends with one canonical event: ```ts -{ +const interruptTerminal = { type: 'RUN_FINISHED', runId: 'run-1', threadId: 'thread-1', @@ -26,17 +26,29 @@ ends with one canonical event: interrupts: [ { id: 'approval-1', - reason: 'approval_required', - metadata: { kind: 'approval' }, + reason: 'tool_call', + toolCallId: 'call-1', + responseSchema: { + oneOf: [ + { type: 'object', properties: { approved: { const: true } } }, + { type: 'object', properties: { approved: { const: false } } }, + ], + }, }, ], }, } ``` -The canonical event stream is the only approval event stream. Server state -persistence stores the interrupt record; SSE delivery durability separately -assigns opaque resume offsets to delivered events. +The canonical event stream is the only native approval event stream. It works +ephemerally without persistence. When server state persistence is configured, +it stores the complete descriptor/binding batch before the terminal is exposed; +SSE delivery durability separately assigns opaque resume offsets to delivered +events. Native paths do not emit `approval-requested` or +`tool-input-available` custom events. + +See [Interrupts](../chat/interrupts) for the public server/client guide and +[Migrate to AG-UI interrupts](../migration/interrupts) for deprecated readers. ## Responsibilities @@ -44,27 +56,65 @@ assigns opaque resume offsets to delivered events. | --- | --- | | Tool definition | Declares `needsApproval: true` for a sensitive operation. | | Chat engine | Stops before tool execution and emits the interrupt outcome. | -| Chat persistence middleware | Creates pending interrupt records, marks the run interrupted, and snapshots messages. | -| Chat client | Exposes `pendingInterrupts`, preserves `resumeState`, and sends typed resume entries. | -| Application UI | Explains the operation and resolves or cancels each interrupt. | +| Chat persistence middleware | Optionally opens the descriptor/binding batch atomically, marks the run interrupted, and snapshots authoritative messages. | +| Chat client | Binds descriptors to typed methods, stages drafts, and submits one exact resume batch. | +| Application UI | Explains the operation and uses `resolveInterrupt`, `cancel`, or root batch controls. | | Delivery adapter | Optionally replays SSE events by opaque adapter-owned offsets. | +## Descriptor to continuation pipeline + +The base invariant is **descriptor → validate all → continuation → history**. +Durable mode inserts **open batch → compare-and-swap → receipt** around that +flow: + +1. The engine builds public descriptors and bindings. Output includes + `MESSAGES_SNAPSHOT`, optional `STATE_SNAPSHOT`, and the interrupt + `RUN_FINISHED` terminal. When persistence is configured, it first opens the + entire batch atomically and assigns its generation. +2. The client binds only descriptors whose reason, tool identity, call ID, + schema hashes, interrupted run, and generation match its tool registry. + Anything untrusted degrades to `generic` rather than gaining a typed tool + resolver. +3. Item methods validate and stage local drafts. The submit boundary contains + every pending interrupt ID exactly once. +4. The client submits a fresh run with the full current message history, the + interrupted `parentRunId`, and the complete resume batch. +5. The server validates **all** payloads, edited inputs, outputs, hashes, and + correlation before executing anything. Ephemeral mode reconstructs the + expected batch from client-provided history and current tool definitions. + Durable mode instead uses stored authoritative state and also validates + expiry and generation. +6. Durable mode uses one transaction to compare the current interrupted run and + generation, store the canonical resolution fingerprint, and record the + continuation receipt. Ephemeral mode proceeds directly after validation. +7. Resumed tool calls emit results only; they do not replay synthetic tool-call + start/argument events. Successful history belongs to the continuation run. + +With persistence, an exact retry returns the recorded continuation and a stale +or different submission returns authoritative recovery state. Ephemeral mode +does not provide replay, exactly-once, restart, or cross-instance guarantees; +its message history is validated but remains client-provided input. + ## Server setup -Define the tool and add state persistence before handling requests: +Define the tool normally. The following route opts into state persistence for +durable recovery and concurrency guarantees; omit the persistence imports, +store, and middleware for the zero-configuration ephemeral flow: ```ts // tools.ts import { toolDefinition } from '@tanstack/ai' import { z } from 'zod' -export const deleteProject = toolDefinition({ +export const deleteProjectDefinition = toolDefinition({ name: 'delete_project', description: 'Delete a project permanently', inputSchema: z.object({ projectId: z.string() }), outputSchema: z.object({ deleted: z.boolean() }), needsApproval: true, -}).server(async ({ projectId }) => { +}) + +export const deleteProject = deleteProjectDefinition.server(async ({ projectId }) => { await deleteProjectFromDatabase(projectId) return { deleted: true } }) @@ -96,6 +146,7 @@ export async function POST(request: Request) { messages: params.messages, threadId: params.threadId, runId: params.runId, + parentRunId: params.parentRunId, ...(params.resume ? { resume: params.resume } : {}), tools: [deleteProject], middleware: [withChatPersistence(persistence)], @@ -105,10 +156,11 @@ export async function POST(request: Request) { } ``` -There is no feature flag. Middleware behavior follows the stores present on the -`AIPersistence` object. `interrupts` requires `runs`; invalid store -combinations fail at compile time when statically known and at runtime for -untyped JavaScript. +Persistence is not required to emit or resolve interrupts. When middleware is +present, behavior follows the stores on the `AIPersistence` object. +`interrupts` requires `runs` within that optional durable configuration; +invalid store combinations fail at compile time when statically known and at +runtime for untyped JavaScript. ## Client state machine @@ -117,60 +169,52 @@ A single approval follows this sequence: 1. The model emits a tool call. 2. The client tool-call part reaches `approval-requested`. 3. The run ends with `RUN_FINISHED.outcome.type === 'interrupt'`. -4. `useChat` exposes the descriptor in `pendingInterrupts` and retains - `{ threadId, runId }` in `resumeState`. -5. The UI calls `resumeInterrupts(...)` with a resolution for every pending - interrupt. -6. The next request carries those values in `RunAgentInput.resume`. -7. Persistence validates and resolves the stored records before the engine - continues the tool call. +4. `useChat` exposes a bound item in `interrupts`. +5. The UI calls `resolveInterrupt(...)` or `cancel()`; a singleton + submits immediately, while a multi-item batch waits for every valid draft. +6. The next request carries a fresh `runId`, the interrupted `parentRunId`, and + the exact AG-UI `resume` array. +7. The server validates the full set before the engine continues the tool call. + With persistence, it also commits the set atomically against authoritative + state. Normal input is rejected at step 4. This prevents a second branch from being created while the existing run still waits for a decision. -## Real React approval UI +## React approval UI -Use the values returned by `useChat`. Rendering the actual -`pendingInterrupts` array keeps ids and types connected to the hook that owns -the run. +Use the bound values returned by `useChat`. Rendering `interrupts` keeps IDs, +tool types, drafts, and errors connected to the hook that owns the run. -```tsx +```tsx group=approval-ui +import type { ItemInterruptError } from '@tanstack/ai' import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { deleteProjectDefinition } from './tools' export function ApprovalQueue() { const chat = useChat({ id: 'project-chat', threadId: 'project-thread', connection: fetchServerSentEvents('/api/chat'), + tools: [deleteProjectDefinition] as const, }) return (
- {chat.pendingInterrupts.map((interrupt) => ( + {chat.interrupts.map((interrupt) => (

Approval required: {interrupt.reason}

- - + {interrupt.kind === 'tool-approval' ? ( + + ) : null} + + {interrupt.errors.map((error: ItemInterruptError) => ( +

+ {error.message} +

+ ))}
))}
@@ -178,38 +222,30 @@ export function ApprovalQueue() { } ``` -For a batch, send one entry per pending interrupt in one call: - -```tsx -import type { RunAgentResumeItem } from '@tanstack/ai/client' - -function resolveInterrupt( - interruptId: string, - approved: boolean, -): RunAgentResumeItem { - return approved - ? { - interruptId, - status: 'resolved', - payload: { approved: true }, - } - : { interruptId, status: 'cancelled' } -} +For a batch, stage every resolution in one synchronous root callback: +```tsx group=approval-ui function ResolveAll({ approved }: { approved: boolean }) { const chat = useChat({ threadId: 'project-thread', connection: fetchServerSentEvents('/api/chat'), + tools: [deleteProjectDefinition] as const, }) return ( + + + + {transfer.errors.map((error: ItemInterruptError) => ( +

+ {error.message} +

+ ))} + + ) : null} + + {interruptErrors.map((error) => ( +

{error.message}

+ ))} + + + + + + ) +} +``` + +For approval, `true` without `editedArgs` executes the original input. When +provided, `editedArgs` is a validated **full replacement**, not a partial merge. +Only approval can edit arguments. Rejection options never accept `editedArgs`. + +Branch data always belongs under `payload`: + +```ts ignore +interrupt.resolveInterrupt(true, { + editedArgs: { amount: 12, recipient: 'Ada' }, + payload: { note: 'Reviewed' }, +}) + +interrupt.resolveInterrupt(false, { + payload: { reason: 'Policy limit' }, +}) +``` + +The selected `approve` or `reject` schema validates only its nested `payload`. +The server validates the complete envelope again before executing anything. + +### Rejection is not cancellation + +`resolveInterrupt(false, ...)` is a resolved denial. The continuation receives +the denial and its optional rejection payload, so the model can respond to the +decision. `cancelInterrupt()` produces AG-UI `status: 'cancelled'`, carries no +payload, and does not select the reject branch. + +Use denial when the user has answered "no." Use cancellation when the workflow +is being abandoned without an answer. + +## Singleton and batch submission + +Item methods return `void` because they stage local state. A valid resolution +for a singleton batch submits automatically. In a multi-item native batch, +each `resolveInterrupt` stages its item and the last valid item automatically +submits the entire batch. The server accepts all resolutions atomically or none +of them. + +For one synchronous transaction, use root `resolveInterrupts` and resolve every +item inside its callback: + +```ts ignore +await resolveInterrupts((interrupt) => { + if ( + interrupt.kind === 'tool-approval' && + interrupt.toolName === 'transfer' + ) { + interrupt.resolveInterrupt(true, { + editedArgs: interrupt.originalArgs, + payload: { note: 'Approved in batch review' }, + }) + return + } + + interrupt.cancel() +}) +``` + +The callback must synchronously produce a valid resolution or cancellation for +every item. It cannot be async. If it throws or leaves an item unresolved, no +partial submission occurs. + +The boolean shorthand `resolveInterrupts(true)` or +`resolveInterrupts(false)` is only valid when every item is a tool approval and +the selected branches require no payload or edits. It is rejected for generic +interrupts, client-tool outputs, mixed batches, or required branch payloads. +`cancelInterrupts()` is the payloadless all-items operation. + +`clearResolution()` removes an item's staged draft and validation errors. +`retryInterrupts()` retries the current complete batch after a retryable root +transport or server error; it does not invent missing item responses. + +## Validate a generic AG-UI response schema + +A generic AG-UI descriptor may carry a Draft 2020-12 `responseSchema`. Because +the schema arrives over the wire, its application payload remains `unknown` +statically. Convert the received schema to a runtime validator before calling +the bound resolver. + +This example uses the real Zod 4 JSON Schema converter and never asserts a +wire-derived static type: + +```ts +import { z } from 'zod' +import type { GenericAGUIInterrupt } from '@tanstack/ai-client' + +export function resolveGenericEditorValue( + interrupt: GenericAGUIInterrupt, + editorValue: string, +): readonly string[] { + if (!interrupt.responseSchema) { + return ['This interrupt has no response schema.'] + } + + let candidateResponse: unknown + try { + candidateResponse = JSON.parse(editorValue) + } catch { + return ['Enter valid JSON.'] + } + + const responseValidator = z.fromJSONSchema(interrupt.responseSchema) + const parsed = responseValidator.safeParse(candidateResponse) + if (!parsed.success) { + return parsed.error.issues.map( + (issue) => `${issue.path.join('.')}: ${issue.message}`, + ) + } + + interrupt.resolveInterrupt(parsed.data) + return [] +} +``` + +The conversion produces a runtime schema, not a trustworthy static application +type. TanStack AI still performs canonical Draft 2020-12 validation in the +client, and server validation remains authoritative. An invalid or unsupported +wire schema surfaces an item or root `invalid-response-schema` error rather +than bypassing validation. + +Applications that emit generic descriptors must send JSON-compatible Draft +2020-12 schemas and validate the complete resume batch server-side. They may +persist the batch when they need durable recovery or concurrency guarantees. +Tool approvals usually prefer shared `approvalSchema` because it adds +compile-time branch inference as well as runtime validation. + +## Client-tool execution is a separate interrupt + +Approval and execution are independent axes. A client tool with +`needsApproval: true` first produces a `tool-approval` decision. If approved, +the server can later produce a `client-tool-execution` item for the browser +result. + +```ts ignore +const execution = interrupts.find( + (item) => + item.kind === 'client-tool-execution' && item.toolName === 'showReceipt', +) + +if ( + execution?.kind === 'client-tool-execution' && + execution.toolName === 'showReceipt' +) { + execution.resolveInterrupt({ displayed: true }) +} +``` + +The output is validated against the tool's output schema. The existing +`addToolResult` API remains supported: for a matching native item it delegates +to the same staged client-tool resolution, while legacy streams retain their +historical result path. + +## Errors and retry + +Each item exposes `errors` for payload, edited-argument, output, expiry, and +binding failures. Root `interruptErrors` reports batch, transport, persistence, +stale-generation, conflict, and recovery failures. `canResolve` is false when +an item is expired, submitting, or quarantined for recovery. + +A failed candidate does not erase the last valid staged response. Correct the +form and call `resolveInterrupt` again, call `clearResolution()` to start over, +or call `retryInterrupts()` after a retryable submission failure. Non-retryable +stale and conflict errors require authoritative recovery. + +## Ephemeral mode and optional durable persistence + +Without `withChatPersistence`, interrupts are request-scoped and ephemeral. +This preserves the zero-configuration approval flow, but the submitted message +history is client-provided input. TanStack AI validates it against the current +tool definitions and response schemas; it cannot prove that the client omitted +or rewrote earlier history. + +Ephemeral mode does not provide authoritative reload recovery, exactly-once +execution, replay protection, restart recovery, or cross-tab and cross-instance +coordination. Use it when those guarantees are not required. Add server +persistence when approvals protect operations that need durable audit and +concurrency guarantees. + +Server state and browser drafts have different authority: + +- `withChatPersistence` atomically opens the descriptor/binding batch and + commits the exact resolution set with compare-and-swap semantics. +- Browser `ChatResumeSnapshotV2` stores raw JSON-safe drafts only. It never + stores bound methods, validators, tool implementations, or hydrated errors. +- On reload, authoritative recovery runs before descriptors are rebound and + before a draft can be restored. +- Submission fingerprints make an exact retry idempotent. The same accepted + batch replays or joins the winning continuation instead of executing tools + again. +- A second tab with a stale generation loses the compare-and-swap. It replaces + local state from recovery or joins the already-committed continuation; it + never starts a second tool execution. + +For example, add `withChatPersistence(persistence)` to the server route's +`middleware` array after configuring a supported store. `migrate: true` is for +local SQLite development; generate, review, and deploy your backend's native +migration for production. See +[Persistence migrations](../persistence/migrations). + +Recovery routes are opt-in and explicit. TanStack AI never infers a recovery +or continuation URL from the chat URL. Configure the connection with the +application-owned endpoints: + +```ts +import { + createInterruptContinuationLoader, + createInterruptStateFetcher, + fetchServerSentEvents, +} from '@tanstack/ai-client' + +const connection = fetchServerSentEvents('/api/chat', { + interruptStateFetcher: createInterruptStateFetcher( + '/api/interrupts/recovery', + ), + continuationLoader: createInterruptContinuationLoader( + '/api/interrupts/continuation', + ), +}) +``` + +The recovery endpoint must authenticate the caller, authorize the requested +thread, and return the application store's authoritative recovery DTO. The +continuation endpoint is a read-only GET of the winning run. See +[Chat persistence](../persistence/chat-persistence), +[Custom stores](../persistence/custom-stores), and +[Delivery durability](../persistence/delivery-durability). + +`resumeInterruptsUnsafe` remains a distinct low-level escape hatch for already +validated raw AG-UI resume entries and explicit recovery tooling. It is not the +replacement for normal approval UI; prefer bound item methods and root batch +controls. + +## Next steps + +- [Tool approval flow](../tools/tool-approval) focuses on approval forms. +- [Client tools](../tools/client-tools) explains browser execution. +- [Migrate to AG-UI interrupts](../migration/interrupts) maps deprecated APIs. +- [Approval flow architecture](../architecture/approval-flow-processing) + details validate-all, CAS, continuation, and replay processing. diff --git a/docs/chat/persistence.md b/docs/chat/persistence.md index dfa146104..d86828799 100644 --- a/docs/chat/persistence.md +++ b/docs/chat/persistence.md @@ -18,14 +18,15 @@ keywords: `useChat` can persist two browser-side values independently: - `persistence.client` stores the rendered `UIMessage[]` under the chat `id`. -- `persistence.server` stores the latest `ChatResumeSnapshot` under the - `threadId`. The snapshot contains the run identity and any pending - interrupts needed after a reload. +- `persistence.server` stores the latest `ChatResumeSnapshotV2` under the + `threadId`. The snapshot contains run identity, authoritative recovery + correlation, and raw JSON-safe interrupt drafts. This is client hydration, not server state durability. Persist authoritative messages, runs, and interrupts with `withChatPersistence(...)`; make an in-flight response replayable with SSE delivery durability. See -[Chat Persistence](../persistence/chat-persistence) for the complete flow. +[Chat Persistence](../persistence/chat-persistence) for the complete flow and +[Interrupts](./interrupts) for resolution and recovery semantics. ## Use IndexedDB for chat messages @@ -121,10 +122,12 @@ const messages = localStoragePersistence>({ serialize: serializeJson, deserialize(value) { const stored: Array = JSON.parse(value) - return stored.map((message) => ({ - ...message, - ...(message.createdAt - ? { createdAt: new Date(message.createdAt) } + return stored.map(({ id, role, parts, createdAt }) => ({ + id, + role, + parts, + ...(createdAt + ? { createdAt: new Date(createdAt) } : {}), })) }, @@ -153,57 +156,85 @@ export function PersistentChat() { `localStorage` survives browser restarts. `sessionStorage` is scoped to the current tab. Both adapters default to the key prefix `tanstack-ai:`. -## Resume pending interrupts after reload +## Recover interrupts before rebinding drafts -The hook exposes the persisted interrupt state directly. Render -`pendingInterrupts` and call the returned `resumeInterrupts` function; do not -manufacture a hook return type outside a component. +The browser stores raw V2 drafts, not hydrated bound items. It never serializes +`resolveInterrupt`, validators, configured tools, discriminated `kind`, or +hydrated error objects. On reload, authoritative server recovery must confirm +the thread, interrupted run, generation, exact IDs, schema hashes, expiry, and +commit state before the client rebinds descriptors or restores a draft. + +Render `interrupts` and use their bound methods. `pendingInterrupts` and raw +`resumeInterrupts` remain deprecated compatibility surfaces. ```tsx +import { toolDefinition } from '@tanstack/ai' import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +const approvalTool = toolDefinition({ + name: 'sensitive_action', + description: 'Perform a sensitive action', + needsApproval: true, +}) + export function Interrupts() { - const { pendingInterrupts, resumeInterrupts } = useChat({ + const { interrupts, interruptErrors, retryInterrupts } = useChat({ threadId: 'thread-1', connection: fetchServerSentEvents('/api/chat'), + tools: [approvalTool] as const, }) return ( ) } ``` -The server still validates that every resume entry matches a pending -interrupt. Normal new input is rejected while interrupts remain unresolved. +The server still validates every entry and commits the exact batch atomically. +Normal new input is rejected while interrupts remain unresolved. + +Recovery is opt-in. Configure explicit application-owned recovery and winning +continuation URLs; connection adapters never infer them from the chat URL: + +```ts +import { + createInterruptContinuationLoader, + createInterruptStateFetcher, + fetchServerSentEvents, +} from '@tanstack/ai-client' + +export const connection = fetchServerSentEvents('/api/chat', { + interruptStateFetcher: createInterruptStateFetcher( + '/api/interrupts/recovery', + ), + continuationLoader: createInterruptContinuationLoader( + '/api/interrupts/continuation', + ), +}) +``` + +Exact retries use the stored fingerprint and join the winning continuation. +Stale tabs recover the authoritative generation instead of executing a second +continuation. See [Migrate to AG-UI interrupts](../migration/interrupts) for V1 +compatibility and rollout steps. ## SSR and custom adapters diff --git a/docs/config.json b/docs/config.json index b6427d169..9e23d0555 100644 --- a/docs/config.json +++ b/docs/config.json @@ -102,13 +102,13 @@ "label": "Client Tools", "to": "tools/client-tools", "addedAt": "2026-04-15", - "updatedAt": "2026-07-08" + "updatedAt": "2026-07-14" }, { "label": "Tool Approval Flow", "to": "tools/tool-approval", "addedAt": "2026-04-15", - "updatedAt": "2026-07-09" + "updatedAt": "2026-07-14" }, { "label": "Lazy Tool Discovery", @@ -174,11 +174,17 @@ "to": "chat/thinking-content", "addedAt": "2026-04-15" }, + { + "label": "Interrupts", + "to": "chat/interrupts", + "addedAt": "2026-07-14", + "updatedAt": "2026-07-15" + }, { "label": "Persistence", "to": "chat/persistence", "addedAt": "2026-06-02", - "updatedAt": "2026-07-10" + "updatedAt": "2026-07-14" } ] }, @@ -417,7 +423,7 @@ "label": "Delivery Durability", "to": "persistence/delivery-durability", "addedAt": "2026-07-09", - "updatedAt": "2026-07-10" + "updatedAt": "2026-07-14" }, { "label": "Persistence Controls", @@ -483,7 +489,7 @@ "label": "Custom Stores", "to": "persistence/custom-stores", "addedAt": "2026-07-03", - "updatedAt": "2026-07-10" + "updatedAt": "2026-07-14" }, { "label": "Persistence Internals", @@ -536,6 +542,12 @@ "label": "Typed Pre-Configured Options", "to": "advanced/typed-options", "addedAt": "2026-05-25" + }, + { + "label": "Approval Flow Processing", + "to": "architecture/approval-flow-processing", + "addedAt": "2026-04-15", + "updatedAt": "2026-07-15" } ] }, @@ -559,6 +571,12 @@ "addedAt": "2026-05-16", "updatedAt": "2026-07-08" }, + { + "label": "AG-UI Interrupts", + "to": "migration/interrupts", + "addedAt": "2026-07-14", + "updatedAt": "2026-07-15" + }, { "label": "Sampling \u2192 modelOptions", "to": "migration/sampling-options-to-model-options", @@ -901,7 +919,8 @@ { "label": "toolDefinition", "to": "reference/functions/toolDefinition", - "addedAt": "2026-04-15" + "addedAt": "2026-04-15", + "updatedAt": "2026-07-14" }, { "label": "uiMessageToModelMessages", diff --git a/docs/migration/interrupts.md b/docs/migration/interrupts.md new file mode 100644 index 000000000..278099be7 --- /dev/null +++ b/docs/migration/interrupts.md @@ -0,0 +1,330 @@ +--- +title: Migrate to AG-UI Interrupts +id: migrate-interrupts +order: 8 +description: "Migrate TanStack AI approval and resume code from legacy custom events and raw resume APIs to typed, atomic AG-UI interrupts." +keywords: + - tanstack ai migration + - ag-ui interrupts + - addToolApprovalResponse + - pendingInterrupts + - resumeInterrupts +--- + +# Migrate to AG-UI Interrupts + +TanStack AI now represents approvals, generic pauses, and client-tool execution +with standard AG-UI interrupt descriptors and results. Native runs end with +`RUN_FINISHED.outcome.type === 'interrupt'`; the continuation is a new run whose +`parentRunId` is the interrupted run. + +There is no codemod. Migrate the server lifecycle, client rendering, and +persistence schema together. The old readers remain temporarily available for +legacy streams, but they cannot provide the full native contract. + +For the complete API and runnable server/client setup, read +[Interrupts](../chat/interrupts). + +## API mapping + +| Deprecated or legacy surface | Current surface | +| --- | --- | +| `pendingInterrupts` | `interrupts` (`pendingInterrupts` is a deprecated alias of the same bound array) | +| `ChatClient.getPendingInterrupts()` | `ChatClient.getInterrupts()` | +| `addToolApprovalResponse({ id, approved })` | Find the bound `tool-approval` item and call `interrupt.resolveInterrupt(approved)` | +| Raw `resumeInterrupts(entries, state)` | Bound item methods or root `resolveInterrupts(...)`; reserve `resumeInterruptsUnsafe` for validated low-level recovery tooling | +| Native `approval-requested` custom event | `RUN_FINISHED` interrupt descriptor with reason `tool_call` | +| Native `tool-input-available` custom event | `RUN_FINISHED` interrupt descriptor with reason `tanstack:client_tool_execution` | +| Boolean denial treated like cancellation | `resolveInterrupt(false)` for denial; `cancel()` for payloadless cancellation | + +`addToolResult` is **not** removed. It remains supported for client-tool results +and delegates to a matching native client-tool interrupt when one exists. +`needsApproval` also remains the tool-definition switch for approvals. + +## Migrate a single approval + +Before: + +```ts ignore +await addToolApprovalResponse({ + id: approval.id, + approved: true, +}) +``` + +After: + +```ts ignore +const interrupt = interrupts.find( + (item) => item.kind === 'tool-approval' && item.toolName === 'transfer', +) + +if ( + interrupt?.kind === 'tool-approval' && + interrupt.toolName === 'transfer' +) { + interrupt.resolveInterrupt(true) +} +``` + +The bound method validates locally and the server validates again. A valid +singleton submits automatically. + +## Add branch payloads and optional edits + +Legacy boolean approvals could not carry typed branch-specific application +data. Add `approvalSchema` to the shared tool definition: + +```ts +import { toolDefinition } from '@tanstack/ai' +import { z } from 'zod' + +export const transferTool = toolDefinition({ + name: 'transfer', + description: 'Transfer funds', + needsApproval: true, + inputSchema: z.object({ + amount: z.number().positive(), + recipient: z.string().min(1), + }), + approvalSchema: { + approve: z.object({ note: z.string().min(1) }), + reject: z.object({ reason: z.string().min(1) }), + }, +}) +``` + +Resolve the selected branch with data nested under `payload`: + +```ts ignore +interrupt.resolveInterrupt(true, { + editedArgs: { amount: 12, recipient: 'Ada' }, + payload: { note: 'Reviewed' }, +}) + +interrupt.resolveInterrupt(false, { + payload: { reason: 'Policy limit' }, +}) +``` + +`editedArgs` is optional and approval-only. It is a full replacement validated +against the original input schema. Omitting it keeps the original tool input. +Rejection never accepts edits. Custom branch fields at the top level are +invalid; keep them grouped under `payload`. + +If `approvalSchema` is one schema instead of `{ approve, reject }`, that schema +applies to the selected decision. If no approval schema exists, the normal +boolean shorthand remains valid. + +## Separate denial from cancellation + +These are intentionally different results: + +```ts ignore +// Canonical denial: a resolved answer, optionally with a reject payload. +interrupt.resolveInterrupt(false, { + payload: { reason: 'Not authorized' }, +}) + +// Cancellation: no decision and no payload. +interrupt.cancel() +``` + +A denial continues the model with an explicit rejected decision. Cancellation +emits AG-UI `status: 'cancelled'` and never validates or selects the reject +branch. Deprecated `addToolApprovalResponse({ approved: false })` maps to a +denial for compatibility; it does not map to cancellation. + +## Migrate a batch + +Previously, applications often looped over approval IDs and sent multiple +requests. Native interrupt batches are all-or-nothing. Stage each item; the +last valid item auto-submits the complete batch: + +```ts ignore +for (const interrupt of interrupts) { + if (interrupt.kind === 'tool-approval') { + interrupt.resolveInterrupt(true) + } else { + interrupt.cancel() + } +} +``` + +For a single synchronous transaction, use the root callback: + +```ts ignore +await resolveInterrupts((interrupt) => { + if ( + interrupt.kind === 'tool-approval' && + interrupt.toolName === 'transfer' + ) { + interrupt.resolveInterrupt(true, { + editedArgs: interrupt.originalArgs, + payload: { note: 'Approved during batch review' }, + }) + return + } + + interrupt.cancel() +}) +``` + +The callback must synchronously resolve or cancel every item. It cannot return a +promise. An invalid item, thrown error, or incomplete set prevents the entire +submission. + +`resolveInterrupts(true)` and `resolveInterrupts(false)` are convenience +operations only for all-tool-approval batches whose selected branches require +no payload or edits. They are rejected for generic items, client-tool outputs, +mixed batches, and required branch payloads. Use `cancelInterrupts()` for a +payloadless all-items cancellation. + +Use `interrupt.clearResolution()` to remove one staged draft and its errors. +Use `retryInterrupts()` only after every item still has a valid staged response +and the root error is retryable. + +## Migrate generic responses + +Generic AG-UI descriptors carry a wire `responseSchema`, but their application +payload remains `unknown`. Do not derive a static TypeScript type from received +JSON. Parse the editor value as `unknown`, convert the Draft 2020-12 schema to a +runtime validator, then resolve the validated value: + +```ts +import { z } from 'zod' +import type { GenericAGUIInterrupt } from '@tanstack/ai-client' + +function submitGenericValue( + interrupt: GenericAGUIInterrupt, + editorValue: string, +): readonly string[] { + if (!interrupt.responseSchema) return ['Missing response schema.'] + + let value: unknown + try { + value = JSON.parse(editorValue) + } catch { + return ['Enter valid JSON.'] + } + + const validator = z.fromJSONSchema(interrupt.responseSchema) + const result = validator.safeParse(value) + if (!result.success) { + return result.error.issues.map((issue) => issue.message) + } + + interrupt.resolveInterrupt(result.data) + return [] +} +``` + +The client performs canonical validation too, and server validation remains +authoritative. + +## Migrate server events; add persistence only when needed + +A native server must emit snapshots before the interrupt terminal: + +1. `MESSAGES_SNAPSHOT` +2. optional `STATE_SNAPSHOT` +3. `RUN_FINISHED` with a nonempty interrupt outcome + +Persistence is optional. Without it, TanStack AI emits the interrupt terminal +and reconstructs the expected batch from the full message history on the +continuation request. It validates the entire resume array against the current +tool definitions before executing anything. + +Continuations use a fresh `runId`, the same `threadId`, and the interrupted run +as `parentRunId`. The resume request contains every pending ID exactly once. +In ephemeral mode, server validation covers every payload, edited input, and +tool schema, but the submitted history remains client-provided input. There is +no authoritative reload recovery, exactly-once execution, replay protection, +restart recovery, or cross-instance compare-and-swap. + +When persistence is configured, it additionally validates stored schema hashes, +expiry, and generation before one compare-and-swap commit. Exact retries attach +to the winning continuation rather than executing tools again. + +If your application uses persistence, upgrade its schema before deploying the +new runtime: + +- add the interrupt batch, binding, generation, submission fingerprint, + continuation, and accepted-tombstone storage required by your adapter; +- add the parent/current run correlation used by compare-and-swap; +- generate and deploy your application's native Prisma migration after copying + the updated model fragment; +- deploy Drizzle or Cloudflare migration assets through the application's normal + migration process. + +See [Persistence migrations](../persistence/migrations) and +[Custom stores](../persistence/custom-stores). + +## Opt in to native recovery explicitly + +Browser persistence now writes a V2 envelope containing raw JSON-safe drafts. +Bound methods, validators, tool implementations, kinds, and hydrated errors are +not stored. After reload, the client must fetch authoritative recovery state +before rebinding descriptors or restoring a draft. + +Configure application-owned URLs explicitly: + +```ts +import { + createInterruptContinuationLoader, + createInterruptStateFetcher, + fetchServerSentEvents, +} from '@tanstack/ai-client' + +const connection = fetchServerSentEvents('/api/chat', { + interruptStateFetcher: createInterruptStateFetcher( + '/api/interrupts/recovery', + ), + continuationLoader: createInterruptContinuationLoader( + '/api/interrupts/continuation', + ), +}) +``` + +No recovery route is inferred from `/api/chat`. Your recovery endpoint must +authenticate the caller and authorize the thread. The continuation endpoint +must read the winning run without scheduling a new model invocation. + +`resumeInterruptsUnsafe` is available only for low-level recovery integrations +that already possess validated raw AG-UI resume entries and correlation state. +It is not the normal migration target for approval UI. + +## Legacy compatibility limits + +Deprecated readers recognize well-formed historical `approval-requested` and +`tool-input-available` events. They do not create a second native writer. A +legacy batch is converted into one cloned-history follow-up only after every +item is covered. + +Legacy descriptors do not support: + +- edited arguments or custom approval payloads; +- generic AG-UI responses; +- native payloadless cancellation semantics; +- expiry and schema-hash reconciliation; +- generation conflicts or authoritative native recovery. + +Those operations fail with `legacy-unsupported`. Native and legacy items cannot +be mixed in one batch. If legacy transport submission fails, staged decisions +remain available and the root reports `legacy-submit-failed`. + +## Migration checklist + +1. Deploy persistence schema changes for every configured backend. +2. Add `withChatPersistence(...)` to interrupt-producing chat routes. +3. Replace native custom-event writers with the AG-UI interrupt terminal. +4. Replace `pendingInterrupts` rendering with bound `interrupts`. +5. Replace boolean approval helpers with `resolveInterrupt` and explicit + denial/cancellation behavior. +6. Replace approval loops with atomic batch staging or root + `resolveInterrupts(...)`. +7. Add explicit recovery and continuation endpoints before enabling V2 draft + restoration. +8. Keep `addToolResult` for client-tool results where it is still useful. +9. Test reloads, exact retries, two-tab conflicts, expired items, and failed + transport recovery before removing legacy support. diff --git a/docs/persistence/chat-persistence.md b/docs/persistence/chat-persistence.md index bffcf6061..865a286bf 100644 --- a/docs/persistence/chat-persistence.md +++ b/docs/persistence/chat-persistence.md @@ -155,7 +155,7 @@ handled. This prevents accidental conversation forks. To reconnect to an in-flight SSE response, add a `StreamDurability` adapter to the response: -```ts +```ts ignore import { durableStream } from '@tanstack/ai-durable-stream' declare function getDurableStreamsToken(): Promise diff --git a/docs/persistence/cloudflare.md b/docs/persistence/cloudflare.md index 02564e7d8..68fa9471a 100644 --- a/docs/persistence/cloudflare.md +++ b/docs/persistence/cloudflare.md @@ -66,13 +66,14 @@ export { CloudflareLockDurableObject } from '@tanstack/ai-persistence-cloudflare ## Create persistence -```ts +```ts group=cloudflare-persistence import { cloudflarePersistence } from '@tanstack/ai-persistence-cloudflare' +import type { CloudflarePersistenceOptions } from '@tanstack/ai-persistence-cloudflare' interface Env { - AI_STATE: D1Database - AI_MEDIA: R2Bucket - AI_LOCKS: DurableObjectNamespace + AI_STATE: NonNullable + AI_MEDIA: NonNullable + AI_LOCKS: NonNullable } export function createPersistence(env: Env) { @@ -96,7 +97,7 @@ serializes owners and uses leases/alarms for recovery. ## Use it with chat -```ts +```ts group=cloudflare-persistence import { chat, chatParamsFromRequest, @@ -157,12 +158,13 @@ for (const migration of d1Migrations) { Use Cloudflare as the base and replace only application-owned stores: -```ts +```ts group=cloudflare-persistence import { composePersistence } from '@tanstack/ai-persistence' import type { InterruptStore, RunStore } from '@tanstack/ai-persistence' declare const customInterrupts: InterruptStore declare const customRuns: RunStore +declare const env: Env const persistence = composePersistence(createPersistence(env), { overrides: { diff --git a/docs/persistence/controls.md b/docs/persistence/controls.md index 201acb625..68f5a79e2 100644 --- a/docs/persistence/controls.md +++ b/docs/persistence/controls.md @@ -32,21 +32,22 @@ the complete capability selection mechanism. `composePersistence` takes the base backend first and a configuration object second: -```ts +```ts group=persistence-controls import { composePersistence, defineAIPersistence, } from '@tanstack/ai-persistence' import { cloudflarePersistence } from '@tanstack/ai-persistence-cloudflare' +import type { CloudflarePersistenceOptions } from '@tanstack/ai-persistence-cloudflare' import type { InterruptStore, RunStore, } from '@tanstack/ai-persistence' declare const env: { - AI_STATE: D1Database - AI_MEDIA: R2Bucket - AI_LOCKS: DurableObjectNamespace + AI_STATE: NonNullable + AI_MEDIA: NonNullable + AI_LOCKS: NonNullable } declare const interrupts: InterruptStore declare const runs: RunStore @@ -76,7 +77,7 @@ Each override is independent: | a store object | Replace that store only. | | `false` | Remove that store. | -```ts +```ts group=persistence-controls const withoutGeneratedMedia = composePersistence(base, { overrides: { artifacts: false, @@ -101,7 +102,7 @@ Some capabilities require related stores: Known-invalid static compositions fail to type-check at the middleware call. Runtime validation covers dynamically typed inputs. -```ts +```ts group=persistence-controls import { withGenerationPersistence } from '@tanstack/ai-persistence' // Valid: both stores remain present. diff --git a/docs/persistence/custom-stores.md b/docs/persistence/custom-stores.md index 22889ce0d..f49ef4f93 100644 --- a/docs/persistence/custom-stores.md +++ b/docs/persistence/custom-stores.md @@ -14,15 +14,17 @@ database. ```ts import { defineAIPersistence } from '@tanstack/ai-persistence' import type { + InterruptStore, MessageStore, RunStore, } from '@tanstack/ai-persistence' declare const messages: MessageStore declare const runs: RunStore +declare const interrupts: InterruptStore export const persistence = defineAIPersistence({ - stores: { messages, runs }, + stores: { messages, runs, interrupts }, }) ``` @@ -74,22 +76,73 @@ Implement `createOrResume` idempotently. Retries may repeat the same run id. ### Interrupts +Interrupt persistence is an atomic capability, not a collection of sequential +`create`, `resolve`, and `cancel` calls. Opening a batch must store all +descriptors and protected bindings together. Committing a response must compare +the expected generation and exact ID set, then resolve every item and create +the continuation receipt in the same transaction. + ```ts -import type { InterruptRecord } from '@tanstack/ai-persistence' +import type { + CommitInterruptResolutionsInput, + InterruptCommitResult, + InterruptRecoveryQuery, + InterruptRecoveryStateV1, + OpenInterruptBatchInput, +} from '@tanstack/ai' interface InterruptStore { - create(record: Omit): Promise - resolve(interruptId: string, response?: unknown): Promise - cancel(interruptId: string): Promise - get(interruptId: string): Promise - list(threadId: string): Promise> - listPending(threadId: string): Promise> - listByRun(runId: string): Promise> - listPendingByRun(runId: string): Promise> + openInterruptBatch(input: OpenInterruptBatchInput): Promise<{ + generation: number + descriptors: OpenInterruptBatchInput['descriptors'] + }> + commitInterruptResolutions( + input: CommitInterruptResolutionsInput, + ): Promise + getInterruptRecoveryState( + input: InterruptRecoveryQuery, + ): Promise } ``` +The commit result is the durable receipt: + +- `committed` returns the newly accepted `continuationRunId`; +- `replayed` returns the same winning continuation for an exact idempotent + retry; +- `conflict` returns authoritative recovery state for a stale generation or a + different submission. + +Compute and compare the canonical submission fingerprint inside the same +transaction as the current-run/parent-run compare-and-swap. Never rerun tools +for `replayed`. Do not accept a subset or superset of the opened IDs. + An `interrupts` store requires a `runs` store when used with chat persistence. +`defineAIPersistence` rejects an adapter that advertises interrupts without the +atomic methods. See [Interrupts](../chat/interrupts) for client semantics and +[Delivery durability](./delivery-durability) for exact replay behavior. + +### Explicit recovery handler + +Expose recovery only on an application-owned route. The handler does not infer +a URL and must authorize the caller and thread before reading state: + +```ts +// app/api/interrupts/recovery/route.ts +import { createInterruptRecoveryHandler } from '@tanstack/ai-persistence' +import { persistence } from '../../../../persistence' +import { authorizeInterruptRecovery } from '../../../../authorization' + +export const POST = createInterruptRecoveryHandler({ + gateway: persistence.stores.interrupts, + authorize: authorizeInterruptRecovery, +}) +``` + +Treat the concrete authorization object as application policy: authenticate the +request, authorize the requested thread, and redact protected bindings or +responses that the caller must not receive. Configure the matching client URL +explicitly with `createInterruptStateFetcher`. ### Metadata @@ -159,7 +212,7 @@ transactions, conditional writes, or stable idempotency keys. ## Override selected packaged stores -```ts +```ts ignore /// import { composePersistence } from '@tanstack/ai-persistence' diff --git a/docs/persistence/delivery-durability.md b/docs/persistence/delivery-durability.md index 556b1c166..405dec5c2 100644 --- a/docs/persistence/delivery-durability.md +++ b/docs/persistence/delivery-durability.md @@ -9,6 +9,12 @@ Delivery durability records an ordered SSE stream so a dropped connection can replay chunks without invoking the provider again. It is separate from state persistence for messages, runs, interrupts, metadata, and artifacts. +Interrupt submission has a second exact-replay rule. The persistence store +canonicalizes the complete resolution set into an idempotency fingerprint and +commits it with the continuation run ID. Repeating that exact set returns the +same winning continuation; it never executes approved tools twice. A different +set or stale generation is a conflict and returns authoritative recovery state. + Two adapters ship: - `memoryStream(request)` stores a process-local log for development and tests. @@ -115,13 +121,44 @@ declare const runId: string const connection = fetchServerSentEvents('/api/chat') for await (const chunk of connection.joinRun(runId)) { - console.log(chunk.type) + if ('type' in chunk) { + console.log(chunk.type) + } } ``` `joinRun` performs a GET with `offset=-1` and `runId`. The endpoint must accept that GET, as shown above. +For interrupt recovery, configure this winning-run loader explicitly with +`createInterruptContinuationLoader`. TanStack AI never guesses a continuation +or recovery route from the chat URL. See [Interrupts](../chat/interrupts) for +the client setup. + +## Accepted tombstones and exact replay + +After a native batch is accepted, the browser first persists a V2 +`phase: 'accepted'` tombstone containing the winning continuation ID and no +drafts, then attempts to remove the resume record. If removal fails, reload does +not show stale pending UI. Authoritative committed recovery confirms the winner +and retries cleanup. + +The layers cooperate without sharing identifiers: + +1. Interrupt persistence compares the generation, exact ID set, and canonical + resolution fingerprint. +2. A first valid submission atomically stores the resolutions and winning + continuation receipt. +3. An exact retry returns `replayed` with that continuation ID. +4. The client joins the winning run through SSE durability or an explicit + continuation loader. +5. SSE delivery resumes from the adapter-owned opaque offset and de-duplicates + the replayed prefix. + +The fingerprint is not an SSE offset. The accepted tombstone is not the +authoritative server commit. Keep both state persistence and delivery +durability when the workflow must survive request retries and connection loss. + ## Offset ownership `StreamDurability` owns its offset format. Core only passes returned @@ -172,3 +209,7 @@ Delivery logs replay chunks. They are not the queryable source of truth for thread messages, pending interrupts, generation artifacts, or retention policies. Add [Chat Persistence](./chat-persistence) or [Generation Persistence](./generation-persistence) for that state. + +For atomic interrupt store requirements and conflict receipts, see +[Custom stores](./custom-stores). For the legacy-to-native transition, see +[Migrate to AG-UI interrupts](../migration/interrupts). diff --git a/docs/persistence/drizzle.md b/docs/persistence/drizzle.md index 82128f64a..dd2ddbbf7 100644 --- a/docs/persistence/drizzle.md +++ b/docs/persistence/drizzle.md @@ -18,7 +18,7 @@ backend. ## Node SQLite -```ts +```ts group=drizzle-node import { sqlitePersistence } from '@tanstack/ai-persistence-drizzle/sqlite' export const persistence = sqlitePersistence({ @@ -34,25 +34,30 @@ deployment-time migrations in production. ## Bring your own SQLite Drizzle database ```ts -import { drizzle } from 'drizzle-orm/d1' +import type { CloudflarePersistenceOptions } from '@tanstack/ai-persistence-cloudflare' import { drizzlePersistence, schema, } from '@tanstack/ai-persistence-drizzle' +import { drizzle } from 'drizzle-orm/d1' -declare const env: { AI_STATE: D1Database } +declare const env: { + AI_STATE: NonNullable +} const db = drizzle(env.AI_STATE, { schema }) -export const persistence = drizzlePersistence(db) +export const persistence = drizzlePersistence(db, { interrupts: false }) ``` The root entry does not import Node built-ins and works with Cloudflare D1 and other SQLite-compatible Drizzle drivers. The application owns connection -lifecycle and migration timing. +lifecycle and migration timing. The low-level factory requires either an +atomic `interrupts` transaction executor or `interrupts: false`; use the +Cloudflare persistence adapter when you need interrupt recovery on D1. ## Use the middleware -```ts +```ts group=drizzle-node import { chat, chatParamsFromRequest, @@ -104,10 +109,10 @@ normal SQLite, D1, or Drizzle deployment workflow. ## Schema ownership -The exported `schema` contains `messages`, `runs`, `interrupts`, `metadata`, -`artifacts`, and `blobs`. Artifact rows contain metadata; blob bodies are stored -in the separate blob table. Application tables may live beside these tables in -the same database. +The exported `schema` contains `messages`, `runs`, `interrupts`, +`interruptBatches`, `metadata`, `artifacts`, and `blobs`. Artifact rows contain +metadata; blob bodies are stored in the separate blob table. Application tables +may live beside these tables in the same database. ## Own the schema @@ -142,8 +147,14 @@ Then pass the schema back so the runtime reads and writes through your copy: import { drizzlePersistence } from '@tanstack/ai-persistence-drizzle' import { schema } from './tanstack-ai-schema' import { db } from './db' +import type { DrizzleTransactionExecutor } from '@tanstack/ai-persistence-drizzle' + +declare const transactionExecutor: DrizzleTransactionExecutor -export const persistence = drizzlePersistence(db, { schema }) +export const persistence = drizzlePersistence(db, { + schema, + interrupts: transactionExecutor, +}) ``` Because the runtime operates on the table objects you pass, the file is truly diff --git a/docs/persistence/generation-persistence.md b/docs/persistence/generation-persistence.md index 3f3651ed6..cade7cfb8 100644 --- a/docs/persistence/generation-persistence.md +++ b/docs/persistence/generation-persistence.md @@ -13,7 +13,7 @@ Generated bytes never belong in browser resume state. ## Create the server endpoint -```ts +```ts group=generation-persistence import { generateImage, generationParamsFromRequest, @@ -122,7 +122,7 @@ restart or reconnect to provider work. Artifact metadata and blob bodies are separate. The default generated blob key is `artifacts//`. -```ts +```ts group=generation-persistence export async function GET( _request: Request, context: { params: Promise<{ artifactId: string }> }, @@ -154,7 +154,7 @@ Built-in extraction handles input media, generated image/audio/video output, transcription input audio, and structured transcription results. Supply `extractArtifacts` to replace the built-in extraction for a run: -```ts +```ts group=generation-persistence const generationPersistence = withGenerationPersistence(persistence, { extractArtifacts({ result }) { return [ diff --git a/docs/persistence/migrations.md b/docs/persistence/migrations.md index 9da62c376..8fe58af83 100644 --- a/docs/persistence/migrations.md +++ b/docs/persistence/migrations.md @@ -8,6 +8,18 @@ id: migrations Migration ownership depends on the backend. Apply schema changes before deploying code that reads or writes the corresponding stores. +AG-UI interrupts add atomic batch state. Every backend must persist the +descriptor and protected binding set, generation, exact ID set, canonical +submission fingerprint, committed resolutions, continuation run ID, and +accepted/commit timestamps. Runs also need current/parent correlation for the +compare-and-swap that creates a continuation. Add uniqueness for one generation +of an interrupted run and indexes for pending recovery lookups. + +Do not approximate this with sequential interrupt rows. A partial write can +expose an interrupt that cannot be resumed safely. See +[Custom stores](./custom-stores) for the atomic capability and +[Interrupts](../chat/interrupts) for the lifecycle. + ## Drizzle SQLite The Drizzle package bundles ordered canonical SQLite migrations. Copy them into @@ -27,6 +39,11 @@ The CLI preserves existing identical files and refuses to overwrite divergent files without `--force`. Apply the copied SQL using your normal SQLite, Drizzle, or D1 deployment process. +Refresh the manifest to pick up the interrupt batch table/columns, generation +and pending-recovery indexes, submission fingerprint uniqueness, continuation +receipt, and parent-run correlation. Review the generated SQL before applying +it to every SQLite environment. + For local Node development, the `/sqlite` factory can apply the same manifest: ```ts @@ -59,6 +76,11 @@ It also exports `d1Migrations` for programmatic tooling. R2 and Durable Object locks do not use the D1 table migration set; configure their bindings and Durable Object migration tags in Wrangler. +The D1 migration set carries the same atomic interrupt fields and indexes as +Drizzle SQLite. Apply it before deploying Workers that can emit native +interrupts. A Durable Object lock can serialize workers, but it does not replace +the D1 compare-and-swap or accepted receipt. + ## Prisma Prisma ships a provider-neutral models fragment, then delegates SQL generation @@ -75,12 +97,33 @@ The copied fragment contains no datasource or generator. Keep those in your application schema and commit the native migration Prisma creates for your provider. +The updated fragment adds the models and fields needed for atomic interrupt +batches, bindings, generations, fingerprints, accepted receipts, continuation +runs, and parent/current-run correlation. The package does **not** ship SQL for +your provider. Generate and deploy your own Prisma migration before using the +new package version: + +```bash +pnpm exec tanstack-ai-prisma-models --out prisma/schema +pnpm prisma migrate dev --name add-ag-ui-interrupts +pnpm prisma generate +pnpm prisma migrate deploy +``` + +Inspect and commit the migration that Prisma generates. Do not deploy the +runtime first or rely on `prisma db push` as a production migration. + ## Custom stores Custom `AIPersistence` adapters own their schema and migrations entirely. Maintain compatibility with the public store records and method semantics; TanStack AI does not inspect your table layout. +For this upgrade, add one transaction boundary spanning exact-set validation, +generation compare-and-swap, resolution persistence, continuation receipt, and +run-parent linkage. Preserve committed fingerprints long enough for exact retry +replay and recovery after a browser accepted-tombstone cleanup failure. + ## Upgrade discipline 1. Read the package release notes for schema changes. diff --git a/docs/persistence/overview.md b/docs/persistence/overview.md index 12d44f98f..57a9acc91 100644 --- a/docs/persistence/overview.md +++ b/docs/persistence/overview.md @@ -79,12 +79,13 @@ the second argument: import { composePersistence } from '@tanstack/ai-persistence' import { cloudflarePersistence } from '@tanstack/ai-persistence-cloudflare' import { defineAIPersistence } from '@tanstack/ai-persistence' +import type { CloudflarePersistenceOptions } from '@tanstack/ai-persistence-cloudflare' import type { AIPersistenceStores } from '@tanstack/ai-persistence' declare const env: { - AI_STATE: D1Database - AI_MEDIA: R2Bucket - AI_LOCKS: DurableObjectNamespace + AI_STATE: NonNullable + AI_MEDIA: NonNullable + AI_LOCKS: NonNullable } declare const myInterruptStore: NonNullable< AIPersistenceStores['interrupts'] diff --git a/docs/persistence/prisma.md b/docs/persistence/prisma.md index a26133193..2d6d90e14 100644 --- a/docs/persistence/prisma.md +++ b/docs/persistence/prisma.md @@ -99,10 +99,12 @@ transaction across multiple backends. ## Model layout -The fragment maps six persisted store contracts to `Message`, `Run`, `Interrupt`, -`Metadata`, `Artifact`, and `Blob` models. Artifact rows contain metadata; -binary bodies live on `Blob.body`. IDs and timestamps use portable Prisma -types so the application can generate migrations for its chosen provider. +The fragment maps six persisted store contracts to `Message`, `Run`, +`Interrupt`, `InterruptBatch`, `Metadata`, `Artifact`, and `Blob` models. The +interrupt store uses both interrupt models for atomic resolution. Artifact rows +contain metadata; binary bodies live on `Blob.body`. IDs and timestamps use +portable Prisma types so the application can generate migrations for its +chosen provider. ## Rename the models diff --git a/docs/persistence/sandbox-persistence.md b/docs/persistence/sandbox-persistence.md index a74d34cae..997ffca17 100644 --- a/docs/persistence/sandbox-persistence.md +++ b/docs/persistence/sandbox-persistence.md @@ -129,14 +129,13 @@ product contract. Cloudflare can back all workspace checkpoint stores: ```ts -/// - import { cloudflarePersistence } from '@tanstack/ai-persistence-cloudflare' +import type { CloudflarePersistenceOptions } from '@tanstack/ai-persistence-cloudflare' declare const env: { - AI_STATE: D1Database - AI_MEDIA: R2Bucket - AI_LOCKS: DurableObjectNamespace + AI_STATE: NonNullable + AI_MEDIA: NonNullable + AI_LOCKS: NonNullable } const persistence = cloudflarePersistence({ diff --git a/docs/persistence/sql-backends.md b/docs/persistence/sql-backends.md index fbf85471f..875632c22 100644 --- a/docs/persistence/sql-backends.md +++ b/docs/persistence/sql-backends.md @@ -31,13 +31,14 @@ export const persistence = sqlitePersistence({ ## Existing SQLite or D1 Drizzle database ```ts +import type { CloudflarePersistenceOptions } from '@tanstack/ai-persistence-cloudflare' import { drizzlePersistence, schema, } from '@tanstack/ai-persistence-drizzle' import { drizzle } from 'drizzle-orm/d1' -declare const stateDatabase: D1Database +declare const stateDatabase: NonNullable const db = drizzle(stateDatabase, { schema }) export const persistence = drizzlePersistence(db) @@ -47,7 +48,7 @@ The package root is edge-safe; `/sqlite` is Node-only. ## Prisma -```ts +```ts group=sql-backends import { PrismaClient } from '@prisma/client' import { prismaPersistence } from '@tanstack/ai-persistence-prisma' @@ -64,7 +65,7 @@ Both adapters provide messages, runs, interrupts, metadata, artifacts, blobs, and an in-process lock store. Replace `locks` with a distributed store when multiple processes can mutate the same run: -```ts +```ts group=sql-backends import { composePersistence } from '@tanstack/ai-persistence' import type { LockStore } from '@tanstack/ai' diff --git a/docs/reference/functions/toolDefinition.md b/docs/reference/functions/toolDefinition.md index 66ce2837e..f769550f2 100644 --- a/docs/reference/functions/toolDefinition.md +++ b/docs/reference/functions/toolDefinition.md @@ -6,7 +6,19 @@ title: toolDefinition # Function: toolDefinition() ```ts -function toolDefinition(config): ToolDefinition; +function toolDefinition< + TInput, + TOutput, + TName, + TNeedsApproval, + TApprovalSchema, +>(config): ToolDefinition< + TInput, + TOutput, + TName, + TNeedsApproval, + TApprovalSchema +> ``` Defined in: [packages/ai/src/activities/chat/tools/tool-definition.ts:191](https://github.com/TanStack/ai/blob/main/packages/ai/src/activities/chat/tools/tool-definition.ts#L191) @@ -21,6 +33,34 @@ The definition contains all tool metadata (name, description, schemas) and can b Supports any Standard JSON Schema compliant library (Zod v4+, ArkType, Valibot, etc.) or plain JSON Schema objects. +## Conditional approval schema + +`approvalSchema` is available only when `needsApproval: true`. It accepts either +one Standard Schema/JSON Schema for both decisions or a nonempty branch map: + +```ts +type ApprovalSchemaConfig = + | SchemaInput + | { approve: SchemaInput; reject?: SchemaInput } + | { approve?: SchemaInput; reject: SchemaInput } +``` + +The schema generic is preserved by `.server()` and `.client()`. Client +`tool-approval` interrupts infer the selected branch payload, require it when +the schema requires it, and place it under `payload`. Approval may also carry an +optional, fully validated `editedArgs` replacement when the tool has an input +schema. Rejection never accepts edited arguments. + +Plain JSON Schema remains runtime-only and therefore produces `unknown` payload +data. Standard Schema inputs such as Zod infer both runtime validation and the +bound resolver overloads. + +At runtime, defining `approvalSchema` without `needsApproval: true` throws. +TanStack AI converts the input, output, and selected approval branches to +canonical JSON Schema, embeds their hashes in the protected interrupt binding, +and validates again on resume. See [Interrupts](../../chat/interrupts) for the +full lifecycle. + ## Type Parameters ### TInput @@ -35,6 +75,16 @@ or plain JSON Schema objects. `TName` *extends* `string` = `string` +### TNeedsApproval + +`TNeedsApproval` *extends* `boolean` = `false`. The literal `true` enables the +approval capability in mapped client interrupt types. + +### TApprovalSchema + +`TApprovalSchema` *extends* `ApprovalSchemaConfig | undefined` = `undefined`. +This generic is conditionally permitted only when `TNeedsApproval` is `true`. + ## Parameters ### config @@ -65,6 +115,10 @@ const addToCartTool = toolDefinition({ cartId: z.string(), totalItems: z.number(), }), + approvalSchema: { + approve: z.object({ note: z.string() }), + reject: z.object({ reason: z.string() }), + }, }); // Use directly in chat (server-side, no execute function) @@ -89,3 +143,17 @@ const addToCartClient = addToCartTool.client(async (args) => { return { success: true, cartId: 'local', totalItems: 1 }; }); ``` + +With `tools: [addToCartTool] as const`, the corresponding bound approval has +branch-specific overloads: + +```ts +interrupt.resolveInterrupt(true, { + editedArgs: { guitarId: 'guitar-2', quantity: 2 }, + payload: { note: 'Reviewed' }, +}) + +interrupt.resolveInterrupt(false, { + payload: { reason: 'Budget limit' }, +}) +``` diff --git a/docs/superpowers/plans/2026-07-13-ag-ui-interrupts.md b/docs/superpowers/plans/2026-07-13-ag-ui-interrupts.md new file mode 100644 index 000000000..0ba63a140 --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-ag-ui-interrupts.md @@ -0,0 +1,5205 @@ +# AG-UI Interrupts Full-Spec Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement native AG-UI interrupts with typed bound resolution APIs, exhaustive validation, durable atomic batching, recovery, compatibility shims, and equivalent behavior in every supported framework. + +**Architecture:** Core owns the wire DTOs, Draft 2020-12 validation, tool approval type machinery, and a persistence capability interface so `@tanstack/ai` never imports a persistence package. Persistence implementations atomically open and compare-and-swap complete interrupt batches; the headless client owns hydration, staging, validation, retry, and draft durability, while framework packages expose thin reactive projections. Continuations use the same thread, a fresh `runId`, and the interrupted run as `parentRunId` and the CAS correlation key. + +**Tech Stack:** TypeScript 5.9, pnpm 11.9.0, Nx, Vitest 4, Ajv 8 Draft 2020-12, ajv-formats, Standard Schema, Drizzle/SQLite, Prisma, Cloudflare D1, React, Preact, Solid, Vue, Svelte, Angular, Playwright, TanStack Start + +--- + +## Scope, file structure, and dependency order + +The user already approved this feature-wide scope even though it changes more than five files. Keep each task narrowly scoped where feasible, do not add a codemod, and do not expand into general checkpoint, message, or tool-result persistence beyond what the interrupt transaction requires. + +Implementation must proceed in dependency order: + +1. Core schema validation and tool-definition types establish one runtime/static contract. +2. Core wire DTOs and the core-owned interrupt persistence capability remove package-cycle pressure. +3. The persistence contract, memory reference implementation, and shared conformance suite define atomic semantics. +4. Drizzle, Prisma, and Cloudflare D1 implement the same atomic contract before server resumption depends on it. +5. Server middleware and the chat engine enforce authoritative validation, event ordering, and continuation semantics. +6. The headless client implements bound interrupts and draft durability against the settled wire contract. +7. Compatibility readers and framework wrappers delegate to that single headless state machine. +8. Browser E2E, documentation, release metadata, review, and quality gates close the work. + +The planned file ownership is: + +| Unit | Files | Responsibility | +| --- | --- | --- | +| Core validation | `packages/ai/src/activities/chat/tools/json-schema-validator.ts`, `packages/ai/src/activities/chat/tools/approval-schema.ts` | Compile Draft 2020-12 schemas, normalize issues, distinguish approval schema forms, generate response envelopes and schema identities. | +| Core public contract | `packages/ai/src/interrupts.ts`, `packages/ai/src/types.ts`, `packages/ai/src/activities/chat/tools/tool-definition.ts`, `packages/ai/src/activities/chat/middleware/types.ts` | Public interrupt errors/recovery DTOs, capability seam, conditional generics, continuation state. | +| Persistence core | `packages/ai-persistence/src/types.ts`, `packages/ai-persistence/src/interrupts.ts`, `packages/ai-persistence/src/memory.ts`, `packages/ai-persistence/src/testkit/conformance.ts` | Exact-set CAS contract, canonical fingerprint, memory critical section, reusable adapter tests. | +| Durable stores | `packages/ai-persistence-drizzle/**`, `packages/ai-persistence-prisma/**`, `packages/ai-persistence-cloudflare/**` | Transactional batches, migrations, packaged assets, API and migration tests. R2 remains unchanged. | +| Server lifecycle | `packages/ai-persistence/src/middleware.ts`, `packages/ai/src/activities/chat/index.ts` | Rebind current schemas, validate every entry, commit once, emit one structured error, snapshot before terminal, result-only continuation. | +| Headless client | `packages/ai-client/src/interrupt-manager.ts`, `packages/ai-client/src/chat-client.ts`, `packages/ai-client/src/chat-persistence-controller.ts`, `packages/ai-client/src/connection-adapters.ts`, `packages/ai-client/src/types.ts` | Bound union, staging transaction, recovery, raw V2 drafts, explicit recovery adapters, compatibility delegation. | +| Framework adapters | `packages/ai-{react,preact,solid,vue,svelte,angular}/src/**` | Reactive projection and generic preservation only. | +| Browser/docs/release | `testing/e2e/**`, `docs/**`, `.changeset/ag-ui-interrupts.md` | Approved scenarios, feature/migration guides, timestamps, breaking changeset. | + +## Spec coverage matrix + +| Approved requirement | Owning task(s) | +| --- | --- | +| Protocol wire types and structured item/root errors | 3, 8, 9 | +| Canonical `tool_call` and `tanstack:client_tool_execution` reasons | 9 | +| `MESSAGES_SNAPSHOT` / `STATE_SNAPSHOT` before interrupt terminal | 9, 16 | +| New continuation `runId`, same thread, interrupted run as `parentRunId` | 8, 9, 12, 16 | +| Result-only resumed tool events and mixed audit history | 9, 16 | +| Conditional `approvalSchema`, Standard Schema inference, `.client()` / `.server()` preservation | 2, 10, 15 | +| Tool approval, client-tool, and generic bound unions | 10 | +| Singleton auto-submit; multi-item wait; replacement; clear; callback rollback/late-call protection | 11 | +| Root boolean/callback/cancel/retry, statuses, errors, staged response, unsafe raw resume | 11, 12 | +| Draft 2020-12 Ajv validation and deterministic paths | 1, 2, 8, 10 | +| Versioned client draft persistence and recovery reconciliation | 13 | +| Exhaustive authoritative server validation, one error event, no write on invalid | 8 | +| Atomic CAS memory/Drizzle/Prisma/D1, exact replay, conflict, first writer, recovery | 4, 5, 6, 7 | +| Core-owned persistence capability without package cycles | 3, 8 | +| Validator rebind by registered tool name/schema hash; drift is stale | 8 | +| Explicit recovery handler/fetcher, never a guessed URL | 8, 13 | +| Generic continuation exposed to application middleware | 3, 8, 9 | +| Deprecations, legacy reader, no native dual writer, retained `addToolResult` | 12 | +| Sandbox legacy-event collision guard | 12 | +| React/Preact/Solid/Vue/Svelte/Angular parity | 14, 15 | +| Documentation timestamps, migration guide, breaking changeset, no codemod | 17 | +| All approved unit/type/framework/E2E scenarios | 1-17 | + +### Task 0: Prepare the isolated Windows execution environment + +**Prerequisites:** None. Run every later command from `F:\projects\tanstack\ai-interrupts-full-spec-design` in native Windows PowerShell. + +**Files:** +- Read: `AGENTS.md` +- Read: `CLAUDE.md` +- Read: `docs/superpowers/specs/2026-07-13-ag-ui-interrupts-design.md` +- Read: `docs/superpowers/plans/2026-07-13-ag-ui-interrupts.md` + +- [ ] **Step 1: Verify branch and working tree ownership** + +Run: + +```powershell +git status --short --branch +git branch --show-current +``` + +Expected: branch `codex/interrupts-full-spec-design`; only intentional plan/spec history is present before implementation. Preserve unrelated user changes if any appear. + +- [ ] **Step 2: Install with the repository-enforced package manager** + +Run: + +```powershell +corepack pnpm --version +pnpm install +``` + +Expected: pnpm reports `11.9.0` and install completes successfully. PowerShell environment assignments are process-local, so every later command block that runs Vitest, Nx, or Playwright sets `$env:CI='true'` itself. Run `pnpm install` again after any merge from `main` before resuming work. + +- [ ] **Step 3: Establish execution-time quality peers** + +Use the `test-hygiene` skill for a peer review before modifying test helpers or fixtures. Use the `debugging-discipline` skill whenever a red test fails for a reason other than the named missing symbol/behavior, and retain the investigation ledger for Task 18. These are execution requirements, not optional final polish. + +- [ ] **Step 4: Enforce subprocess hygiene** + +Use one-shot commands only; never start watch mode, a development server, or a WSL shell. Await each pnpm/Nx/Vitest/Playwright process tree before starting the next memory-heavy command. Do not kill all Node processes and do not run `wsl --shutdown` while any command is active. + +### Task 1: Add one Draft 2020-12 validator for browser and server + +**Prerequisites:** Task 0. + +**Files:** +- Create: `packages/ai/src/activities/chat/tools/json-schema-validator.ts` +- Modify: `packages/ai/src/index.ts:43-52` +- Modify: `packages/ai/src/client.ts:229-234,300-301` +- Modify: `packages/ai/package.json:82-94` +- Modify: `pnpm-lock.yaml` +- Test: `packages/ai/tests/interrupts.test.ts` + +- [ ] **Step 1: Write focused failing validator tests** + +Append these tests; they prove Ajv issue order, local references, repeated object reuse, cycle rejection, canonical JSON input, format handling, mutation safety, and RFC 6901 paths: + +```ts +import { + JsonSchemaCompilationError, + compileJsonSchema202012, +} from '../src/activities/chat/tools/json-schema-validator' + +it('normalizes every Draft 2020-12 issue without mutating input', () => { + const validate = compileJsonSchema202012({ + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + 'a/b': { type: 'string', minLength: 3 }, + email: { type: 'string', format: 'email' }, + }, + required: ['a/b', 'email'], + additionalProperties: false, + }) + const input = { 'a/b': 'x', email: 'bad', extra: true } + + expect(validate(input)).toEqual([ + expect.objectContaining({ keyword: 'additionalProperties', path: ['extra'] }), + expect.objectContaining({ path: ['a/b'] }), + expect.objectContaining({ path: ['email'] }), + ]) + expect(input).toEqual({ 'a/b': 'x', email: 'bad', extra: true }) +}) + +it('accepts #, local $defs, and repeated acyclic object identities', () => { + const sharedProperty = { type: 'string', minLength: 2 } as const + const sharedObject = { + type: 'object', + properties: { label: sharedProperty }, + required: ['label'], + additionalProperties: false, + } as const + const root = { + type: 'object', + $defs: { entry: sharedObject }, + properties: { + selfCopy: { $ref: '#' }, + first: { $ref: '#/$defs/entry' }, + second: sharedObject, + third: sharedObject, + }, + additionalProperties: false, + } as const + const validate = compileJsonSchema202012(root) + + expect(validate({ + first: { label: 'ok' }, + second: { label: 'yes' }, + third: { label: 'no' }, + })).toEqual([]) +}) + +it('rejects unresolved local references, true cycles, Date, and Map', () => { + expect(() => compileJsonSchema202012({ $ref: '#/$defs/missing' })).toThrow( + JsonSchemaCompilationError, + ) + + const cyclic: Record = { type: 'object' } + cyclic['properties'] = { self: cyclic } + expect(() => compileJsonSchema202012(cyclic)).toThrow(/cycles/) + expect(() => compileJsonSchema202012(new Date())).toThrow(/plain JSON/) + expect(() => compileJsonSchema202012(new Map())).toThrow(/plain JSON/) +}) + +it('accepts canonical JSON primitives and rejects non-JSON values', () => { + const validate = compileJsonSchema202012({ + type: 'array', + items: { type: ['string', 'number', 'boolean', 'null'] }, + }) + expect(validate(['text', 1, true, null])).toEqual([]) + expect(() => compileJsonSchema202012({ const: undefined })).toThrow( + /JSON-compatible/, + ) +}) + +it('rejects other dialects, remote references, and unknown formats', () => { + expect(() => + compileJsonSchema202012({ + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'string', + }), + ).toThrow(JsonSchemaCompilationError) + expect(() => + compileJsonSchema202012({ $ref: 'https://example.com/schema.json' }), + ).toThrow(/document-local/) + expect(() => + compileJsonSchema202012({ type: 'string', format: 'private-id' }), + ).toThrow(JsonSchemaCompilationError) +}) +``` + +- [ ] **Step 2: Run the red test** + +Run: + +```powershell +$env:CI='true' +pnpm --filter @tanstack/ai test:lib -- --run tests/interrupts.test.ts +``` + +Expected: FAIL because `json-schema-validator.ts` and `compileJsonSchema202012` do not exist. + +- [ ] **Step 3: Add dependencies and the minimal shared compiler** + +Add runtime dependencies `"ajv": "^8.20.0"` and `"ajv-formats": "^3.0.1"`, run `pnpm install`, and implement this public shape: + +```ts +import Ajv2020 from 'ajv/dist/2020.js' +import addFormats from 'ajv-formats' +import type { ErrorObject } from 'ajv' +import type { JSONSchema } from '../../../types' + +export interface JsonSchemaValidationIssue { + keyword: string + message: string + path: readonly (string | number)[] +} + +export class JsonSchemaCompilationError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'JsonSchemaCompilationError' + } +} + +function decodePointer(pointer: string): Array { + if (pointer === '') return [] + return pointer + .slice(1) + .split('/') + .map((part) => part.replaceAll('~1', '/').replaceAll('~0', '~')) +} + +function issuePath(error: ErrorObject): Array { + const path = decodePointer(error.instancePath) + if (error.keyword === 'required') { + path.push(String(error.params['missingProperty'])) + } + if (error.keyword === 'additionalProperties') { + path.push(String(error.params['additionalProperty'])) + } + return path +} + +const draft202012 = 'https://json-schema.org/draft/2020-12/schema' + +function assertSupportedSchemaTree(schema: unknown): asserts schema is JSONSchema { + const active = new WeakSet() + const visit = (value: unknown): void => { + if (value === null || ['string', 'number', 'boolean'].includes(typeof value)) { + return + } + if (typeof value !== 'object') { + throw new JsonSchemaCompilationError('Schema values must be JSON-compatible.') + } + if ( + !Array.isArray(value) && + Object.getPrototypeOf(value) !== Object.prototype && + Object.getPrototypeOf(value) !== null + ) { + throw new JsonSchemaCompilationError('Schema nodes must be plain JSON objects.') + } + if (active.has(value)) { + throw new JsonSchemaCompilationError('Schema values must not contain cycles.') + } + active.add(value) + if (Array.isArray(value)) { + value.forEach(visit) + active.delete(value) + return + } + const record = value as Record + if ( + typeof record['$schema'] === 'string' && + record['$schema'] !== draft202012 + ) { + throw new JsonSchemaCompilationError('Only Draft 2020-12 is supported.') + } + if ( + typeof record['$ref'] === 'string' && + !record['$ref'].startsWith('#') + ) { + throw new JsonSchemaCompilationError('Only document-local $ref values are supported.') + } + Object.values(record).forEach(visit) + active.delete(value) + } + visit(schema) +} + +export function compileJsonSchema202012( + schema: unknown, +): (value: unknown) => readonly JsonSchemaValidationIssue[] { + assertSupportedSchemaTree(schema) + const ajv = new Ajv2020({ + allErrors: true, + strict: true, + validateFormats: true, + coerceTypes: false, + useDefaults: false, + removeAdditional: false, + }) + addFormats(ajv, { mode: 'full' }) + let validate + try { + validate = ajv.compile(schema) + } catch (cause) { + throw new JsonSchemaCompilationError('Invalid Draft 2020-12 schema.', { + cause, + }) + } + return (value) => { + if (validate(value)) return [] + return (validate.errors ?? []).map((error) => ({ + keyword: error.keyword, + message: error.message ?? 'Schema validation failed.', + path: issuePath(error), + })) + } +} +``` + +The active recursion set is removed on unwind, so repeated acyclic object identities are legal while true cycles fail. Ajv owns local-reference resolution and issue evaluation order; do not sort `validate.errors`. Export the compiler and issue/error types from both core barrels; do not alter the provider-facing draft-07 conversion in `schema-converter.ts`. + +- [ ] **Step 4: Run the green test and type check** + +Run: + +```powershell +$env:CI='true' +pnpm --filter @tanstack/ai test:lib -- --run tests/interrupts.test.ts +pnpm --filter @tanstack/ai test:types +``` + +Expected: both commands PASS; the test reports all three normalized paths in deterministic Ajv order. + +- [ ] **Step 5: Refactor without changing behavior** + +Extract pointer decoding and recursive `$ref` traversal into private pure functions, keep one Ajv option object, rerun both commands, and confirm no validator path mutates input or performs network/file resolution. + +### Task 2: Make `approvalSchema` conditional, branch-aware, and generic-preserving + +**Prerequisites:** Task 1. + +**Files:** +- Create: `packages/ai/src/activities/chat/tools/approval-schema.ts` +- Create: `packages/ai/src/interrupt-serialization.ts` +- Modify: `packages/ai/src/activities/chat/tools/tool-definition.ts:12-227` +- Modify: `packages/ai/src/types.ts:133-153,601-735` +- Modify: `packages/ai/src/index.ts:43-52` +- Modify: `packages/ai/src/client.ts:229-234,300-301` +- Modify: `packages/ai/package.json:82-94` +- Modify: `pnpm-lock.yaml` +- Create: `packages/ai/tests/interrupts-types.test-d.ts` +- Modify: `packages/ai/tests/interrupts.test.ts` + +- [ ] **Step 1: Write failing compile-time and runtime cases** + +Create type tests covering shared/branch schemas, omitted branches, input/output inference, and both transformations: + +```ts +import { expectTypeOf } from 'vitest' +import { z } from 'zod' +import { + toolDefinition, + type ApprovalCapabilityOf, + type ApprovalSchemaOf, + type InferToolInput, + type InferToolOutput, + type InputSchemaOf, + type NoSchema, +} from '../src' + +const transfer = toolDefinition({ + name: 'transfer', + description: 'Transfer funds', + needsApproval: true, + inputSchema: z.object({ cents: z.number() }), + outputSchema: z.object({ receipt: z.string() }), + approvalSchema: { + approve: z.object({ note: z.string() }), + reject: z.object({ reason: z.string() }), + }, +}) + +expectTypeOf>().toEqualTypeOf<{ + cents: number +}>() +expectTypeOf>>().toEqualTypeOf<{ + receipt: string +}>() +expectTypeOf>>().toEqualTypeOf() +expectTypeOf>>().toEqualTypeOf() + +toolDefinition({ + name: 'invalid', + description: 'Cannot declare an approval payload', + // @ts-expect-error approvalSchema requires needsApproval: true + approvalSchema: z.object({ note: z.string() }), +}) + +const noInputDefinition = toolDefinition({ + name: 'noInput', + description: 'Approval without editable input', + needsApproval: true, +}) +const noInputClient = noInputDefinition.client() +const noInputServer = noInputDefinition.server(async () => ({ ok: true })) +expectTypeOf(noInputClient.inputSchema).toEqualTypeOf() +expectTypeOf(noInputServer.inputSchema).toEqualTypeOf() +expectTypeOf>().toEqualTypeOf() +expectTypeOf>().toEqualTypeOf() +expectTypeOf>().toEqualTypeOf() +``` + +Add runtime assertions: + +```ts +it('builds branch-aware approval envelopes and rejects malformed maps', () => { + const normalized = normalizeApprovalSchema({ + approve: { type: 'object', required: ['note'] }, + }) + expect(normalized.responseSchema).toMatchObject({ oneOf: expect.any(Array) }) + expect(normalized.branches.reject).toBeNull() + expect(() => normalizeApprovalSchema({})).toThrow(/approve or reject/) + expect(() => + normalizeApprovalSchema({ approve: { unsupported: Symbol('bad') } }), + ).toThrow(/SchemaInput/) +}) +``` + +- [ ] **Step 2: Run the red targets** + +Run: + +```powershell +$env:CI='true' +pnpm --filter @tanstack/ai test:types +pnpm --filter @tanstack/ai test:lib -- --run tests/interrupts.test.ts +``` + +Expected: type check FAILS because `approvalSchema`, `ApprovalCapabilityOf`, and `ApprovalSchemaOf` are missing; runtime test FAILS because `normalizeApprovalSchema` is missing. + +- [ ] **Step 3: Add the type marker and discriminated config** + +Replace the config interface with a discriminated intersection and add an approval generic to definition/client/server forms: + +```ts +export declare const toolApprovalCapability: unique symbol + +export interface ToolApprovalCapabilityMarker< + TNeedsApproval extends boolean, + TApprovalSchema, +> { + readonly [toolApprovalCapability]: { + needsApproval: TNeedsApproval + approvalSchema: TApprovalSchema + } +} + +export type ApprovalSchemaConfig = + | SchemaInput + | { approve: SchemaInput; reject?: SchemaInput } + | { approve?: SchemaInput; reject: SchemaInput } + +type ApprovalConfig< + TNeedsApproval extends boolean, + TApprovalSchema extends ApprovalSchemaConfig | undefined, +> = TNeedsApproval extends true + ? { needsApproval: true; approvalSchema?: TApprovalSchema } + : { needsApproval?: false; approvalSchema?: never } + +export type ToolDefinitionConfig< + TInput extends SchemaInput | undefined = undefined, + TOutput extends SchemaInput | undefined = undefined, + TName extends string = string, + TNeedsApproval extends boolean = false, + TApprovalSchema extends ApprovalSchemaConfig | undefined = undefined, +> = { + name: TName + description: string + inputSchema?: TInput + outputSchema?: TOutput + lazy?: boolean + metadata?: Record +} & ApprovalConfig + +export type ApprovalCapabilityOf = + TTool extends ToolApprovalCapabilityMarker + ? TNeeds + : false + +export type ApprovalSchemaOf = + TTool extends ToolApprovalCapabilityMarker + ? TSchema + : undefined + +export declare const noSchema: unique symbol +export type NoSchema = typeof noSchema + +export type InputSchemaOf = + TTool extends { inputSchema: infer TInput } + ? TInput extends undefined ? NoSchema : TInput + : NoSchema + +export type OutputSchemaOf = + TTool extends { outputSchema: infer TOutput } + ? TOutput extends undefined ? NoSchema : TOutput + : NoSchema +``` + +Apply `TInput extends SchemaInput | undefined = undefined` and `TOutput extends SchemaInput | undefined = undefined` verbatim to `ToolDefinitionConfig`, `ToolDefinition`, `ToolDefinitionInstance`, `ClientTool`, `ServerTool`, `toolDefinition()`, `.client()`, and `.server()`. Every returned intersection carries the exact `TInput`, `TOutput`, `TNeedsApproval`, and `TApprovalSchema` parameters. The runtime `inputSchema` and `outputSchema` fields remain `undefined` when omitted, while `InputSchemaOf`/`OutputSchemaOf` map only that literal absence to `NoSchema`. `InferToolInput` and `InferToolOutput` return `unknown` for a present raw JSON Schema but never widen an omitted schema into a present schema. The compile failures above prove `editedArgs` cannot appear for an omitted input schema before any client-manager work begins. + +- [ ] **Step 4: Normalize schemas once and generate the wire envelope** + +Implement `normalizeApprovalSchema` with this returned contract: + +```ts +export interface NormalizedApprovalSchema { + branches: { + approve: NormalizedSchemaInput | null + reject: NormalizedSchemaInput | null + } + responseSchema: JSONSchema + responseSchemaHash: string + approvalSchemaHash: string +} + +export function normalizeApprovalSchema( + approvalSchema: ApprovalSchemaConfig | undefined, + inputSchema?: SchemaInput, +): NormalizedApprovalSchema +``` + +Add `@noble/hashes` as a runtime dependency and implement `interrupt-serialization.ts` in core. This is the only canonicalization, digest, clone, and deep-freeze implementation used later by persistence and the client: + +```ts +import { sha256 } from '@noble/hashes/sha2.js' +import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js' + +function canonical(value: unknown, active: WeakSet): string { + if (value === null) return 'null' + if (typeof value === 'string' || typeof value === 'boolean') { + return JSON.stringify(value) + } + if (typeof value === 'number' && Number.isFinite(value)) { + return JSON.stringify(value) + } + if (typeof value !== 'object') { + throw new TypeError('Interrupt values must be JSON-compatible.') + } + if (active.has(value)) throw new TypeError('Interrupt values must not cycle.') + if ( + !Array.isArray(value) && + Object.getPrototypeOf(value) !== Object.prototype && + Object.getPrototypeOf(value) !== null + ) { + throw new TypeError('Interrupt values must use plain JSON objects.') + } + + active.add(value) + let encoded: string + if (Array.isArray(value)) { + encoded = `[${value.map((item) => canonical(item, active)).join(',')}]` + } else { + const record = value as Record + encoded = `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonical(record[key], active)}`) + .join(',')}}` + } + active.delete(value) + return encoded +} + +export function canonicalInterruptJson(value: unknown): string { + return canonical(value, new WeakSet()) +} + +export function digestInterruptJson(canonicalJson: string): string { + return `sha256:${bytesToHex(sha256(utf8ToBytes(canonicalJson)))}` +} + +function freezeTree(value: unknown): void { + if (value === null || typeof value !== 'object' || Object.isFrozen(value)) return + Object.values(value).forEach(freezeTree) + Object.freeze(value) +} + +export function cloneAndDeepFreezeJson(value: T): T { + const clone: T = JSON.parse(canonicalInterruptJson(value)) + freezeTree(clone) + return clone +} +``` + +Implement the approval normalizer with no undefined helper. `schemaToWire` checks Standard Schema first, retains its validator, uses its Standard JSON Schema export when present, and otherwise uses `{}` only for the nested wire payload while keeping the validator. Raw JSON Schema is recognized before a branch map by the keyword set: + +```ts +const jsonSchemaKeywords = new Set([ + '$schema', '$id', '$ref', '$defs', 'type', 'properties', 'required', + 'additionalProperties', 'items', 'oneOf', 'anyOf', 'allOf', 'enum', 'const', + 'format', 'minimum', 'maximum', 'minLength', 'maxLength', 'pattern', +]) + +function isPlainRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function isRawJsonSchema(value: unknown): value is JSONSchema { + return isPlainRecord(value) && + Object.keys(value).some((key) => jsonSchemaKeywords.has(key)) +} + +function isApprovalBranchMap( + value: unknown, +): value is { approve?: SchemaInput; reject?: SchemaInput } { + if (!isPlainRecord(value)) return false + const keys = Object.keys(value) + return ( + keys.length > 0 && + keys.every((key) => key === 'approve' || key === 'reject') && + keys.every((key) => isSchemaInput(value[key])) + ) +} + +function schemaToWire(schema: SchemaInput): NormalizedSchemaInput { + if (isStandardSchema(schema)) { + const jsonSchema = isStandardJSONSchema(schema) + ? schema['~standard'].jsonSchema.input({ target: 'draft-2020-12' }) + : undefined + return { source: schema, validator: schema, jsonSchema } + } + if (isRawJsonSchema(schema)) { + compileJsonSchema202012(schema) + return { source: schema, jsonSchema: schema } + } + throw new TypeError('Expected a supported SchemaInput.') +} + +function decisionEnvelope(input: { + approved: boolean + payload: NormalizedSchemaInput | null + inputSchema: NormalizedSchemaInput | null +}): JSONSchema { + const properties: Record = { + approved: { const: input.approved }, + } + const required = ['approved'] + if (input.approved && input.inputSchema) { + properties['editedArgs'] = input.inputSchema.jsonSchema ?? {} + } + if (input.payload) { + properties['payload'] = input.payload.jsonSchema ?? {} + required.push('payload') + } + return { + type: 'object', + properties, + required, + additionalProperties: false, + } +} + +export function normalizeApprovalSchema( + approvalSchema: ApprovalSchemaConfig | undefined, + inputSchema?: SchemaInput, +): NormalizedApprovalSchema { + const normalizedInput = inputSchema ? schemaToWire(inputSchema) : null + let approve: NormalizedSchemaInput | null = null + let reject: NormalizedSchemaInput | null = null + if (approvalSchema !== undefined) { + if (isStandardSchema(approvalSchema) || isRawJsonSchema(approvalSchema)) { + approve = schemaToWire(approvalSchema) + reject = approve + } else if (isApprovalBranchMap(approvalSchema)) { + approve = approvalSchema.approve + ? schemaToWire(approvalSchema.approve) + : null + reject = approvalSchema.reject + ? schemaToWire(approvalSchema.reject) + : null + } else { + throw new TypeError('approvalSchema must be a SchemaInput or nonempty approve/reject map.') + } + } + const responseSchema: JSONSchema = { + oneOf: [ + decisionEnvelope({ approved: true, payload: approve, inputSchema: normalizedInput }), + decisionEnvelope({ approved: false, payload: reject, inputSchema: null }), + ], + } + const responseCanonical = canonicalInterruptJson(responseSchema) + const approvalCanonical = canonicalInterruptJson({ + approve: approve?.jsonSchema ?? null, + reject: reject?.jsonSchema ?? null, + }) + return { + branches: { approve, reject }, + responseSchema, + responseSchemaHash: digestInterruptJson(responseCanonical), + approvalSchemaHash: digestInterruptJson(approvalCanonical), + } +} + +export function hashSchemaInput(schema: SchemaInput | undefined): string { + if (schema === undefined) return digestInterruptJson('undefined') + const normalized = schemaToWire(schema) + const identity = normalized.jsonSchema ?? { standardValidator: 'unserialized' } + return digestInterruptJson(canonicalInterruptJson(identity)) +} +``` + +Add the imported `isSchemaInput` guard to `schema-converter.ts` using the same Standard-first/raw-keyword checks, export it, and use it above. Add `NormalizedSchemaInput` with `source`, optional `validator`, and optional `jsonSchema`. Add runtime tests for every shared/branch/omitted form, malformed empty/unknown-key maps, Standard Schema with and without JSON export, raw schema compilation, response/approval hash stability, and rejection of function/Symbol/cyclic values. Never derive identity from function source text. + +- [ ] **Step 5: Run green type/runtime checks** + +Run: + +```powershell +$env:CI='true' +pnpm --filter @tanstack/ai test:types +pnpm --filter @tanstack/ai test:lib -- --run tests/interrupts.test.ts +``` + +Expected: both PASS; `.client()` and `.server()` preserve `true`, input/output, and approval-schema generics; malformed branch objects throw synchronously. + +- [ ] **Step 6: Focused refactor and recheck** + +Make the type-level `ApproveSchema`/`RejectSchema` selectors read the same normalized forms used at runtime, name conditional helpers so diagnostics remain legible, and rerun both commands. Confirm no provider schema-converter code changed. + +### Task 3: Define core wire DTOs, recovery DTO, and persistence capability + +**Prerequisites:** Task 2. + +**Files:** +- Create: `packages/ai/src/interrupts.ts` +- Modify: `packages/ai/src/types.ts:1081-1124,1371-1400,1480-1550` +- Modify: `packages/ai/src/activities/chat/middleware/types.ts:88-115,208-235` +- Modify: `packages/ai/src/index.ts:111-172` +- Modify: `packages/ai/src/client.ts:229-234,300-301` +- Modify: `packages/ai/tests/interrupts.test.ts` +- Modify: `packages/ai/tests/interrupts-types.test-d.ts` + +- [ ] **Step 1: Write failing DTO and capability tests** + +Add compile/runtime assertions for item/root errors, recovery correlation, generic continuation, and the core-owned capability: + +```ts +import { + InterruptPersistenceCapability, + canonicalizeInterruptResolutions, + type InterruptRecoveryStateV1, + type InterruptSubmissionError, +} from '../src/interrupts' + +it('correlates interrupt errors and recovery to the interrupted run', () => { + const recovery = { + schemaVersion: 1, + state: 'committed', + threadId: 'thread-1', + interruptedRunId: 'run-old', + generation: 2, + pendingInterrupts: [], + committed: { + fingerprint: 'v1:abc', + resolutions: [], + continuationRunId: 'run-new', + committedAt: '2026-07-13T10:00:00.000Z', + }, + } satisfies InterruptRecoveryStateV1 + const error: InterruptSubmissionError = { + scope: 'batch', + code: 'conflict', + message: 'Another response batch won.', + source: 'server', + retryable: false, + interruptIds: ['interrupt-1'], + threadId: 'thread-1', + interruptedRunId: 'run-old', + generation: 2, + } + + expect(recovery.committed.continuationRunId).toBe('run-new') + expect(error.interruptedRunId).toBe('run-old') + expect(InterruptPersistenceCapability.capabilityName).toBe( + 'interrupt-persistence', + ) +}) + +it('canonicalizes and deeply freezes response batches in core', () => { + const left = canonicalizeInterruptResolutions([ + { interruptId: 'b', status: 'cancelled' }, + { interruptId: 'a', status: 'resolved', payload: { z: 1, a: 2 } }, + ]) + const right = canonicalizeInterruptResolutions([ + { interruptId: 'a', status: 'resolved', payload: { a: 2, z: 1 } }, + { interruptId: 'b', status: 'cancelled' }, + ]) + expect(left.canonicalResolutions).toBe(right.canonicalResolutions) + expect(left.fingerprint).toMatch(/^sha256:[0-9a-f]{64}$/) + expect(left.fingerprint).toBe(right.fingerprint) + expect(Object.isFrozen(left.resolutions)).toBe(true) + expect(Object.isFrozen(left.resolutions[0])).toBe(true) +}) +``` + +- [ ] **Step 2: Run the red targets** + +Run: + +```powershell +$env:CI='true' +pnpm --filter @tanstack/ai test:lib -- --run tests/interrupts.test.ts +pnpm --filter @tanstack/ai test:types +``` + +Expected: FAIL because `interrupts.ts`, `InterruptRecoveryStateV1`, and `InterruptPersistenceCapability` do not exist. + +- [ ] **Step 3: Implement the core-owned seam and public DTOs** + +Define the complete error code unions from the approved spec, `InterruptCorrelation`, item/root errors, and recovery DTO. The atomic gateway contract must include opening descriptors atomically as well as committing responses: + +```ts +export interface OpenInterruptBatchInput { + threadId: string + interruptedRunId: string + descriptors: readonly Interrupt[] + bindings: readonly UnopenedInterruptBinding[] +} + +export interface CommitInterruptResolutionsInput { + threadId: string + interruptedRunId: string + continuationRunId: string + expectedGeneration: number + expectedInterruptIds: readonly string[] + resolutions: readonly RunAgentResumeItem[] + fingerprint: string + canonicalResolutions: string +} + +export interface InterruptCorrelation { + threadId: string + interruptedRunId: string + generation: number + submissionId?: string + continuationRunId?: string +} + +export type ItemInterruptErrorCode = + | 'invalid-payload' + | 'invalid-edited-args' + | 'invalid-tool-output' + | 'invalid-response-schema' + | 'unknown-interrupt' + | 'expired' + | 'stale' + | 'conflict' + | 'legacy-unsupported' + +export type BatchInterruptErrorCode = + | 'incomplete-batch' + | 'item-validation-failed' + | 'unsupported-bulk-operation' + | 'async-resolver' + | 'inactive-transaction' + | 'mixed-provenance' + | 'transport' + | 'server' + | 'protocol' + | 'invalid-response-schema' + | 'expired' + | 'stale' + | 'conflict' + | 'persistence-required' + | 'atomic-commit-unsupported' + | 'recovery-unavailable' + | 'legacy-submit-failed' + +export interface ItemInterruptError extends InterruptCorrelation { + scope: 'item' + interruptId: string + code: ItemInterruptErrorCode + message: string + path?: readonly (string | number)[] + source: 'client' | 'server' + retryable: boolean +} + +export interface BatchInterruptError extends InterruptCorrelation { + scope: 'batch' + code: BatchInterruptErrorCode + message: string + source: 'client' | 'server' | 'transport' + retryable: boolean + interruptIds: readonly string[] +} + +export type InterruptSubmissionError = ItemInterruptError | BatchInterruptError + +export type InterruptBinding = + | { + kind: 'tool-approval' + interruptId: string + interruptedRunId: string + generation: number + toolName: string + toolCallId: string + originalArgs: unknown + inputSchemaHash: string + approvalSchemaHash: string + responseSchemaHash: string + expiresAt?: string + } + | { + kind: 'client-tool-execution' + interruptId: string + interruptedRunId: string + generation: number + toolName: string + toolCallId: string + outputSchemaHash: string + responseSchemaHash: string + expiresAt?: string + } + | { + kind: 'generic' + interruptId: string + interruptedRunId: string + generation: number + responseSchemaHash: string + expiresAt?: string + } + +export type UnopenedInterruptBinding = InterruptBinding extends infer TBinding + ? TBinding extends InterruptBinding + ? Omit + : never + : never + +export type InterruptCommitResult = + | { status: 'committed'; continuationRunId: string } + | { status: 'replayed'; continuationRunId: string } + | { status: 'conflict'; authoritativeState: InterruptRecoveryStateV1 } + +export interface InterruptRecoveryQuery { + threadId: string + interruptedRunId: string + knownGeneration: number +} + +export interface InterruptRecoveryStateV1 extends InterruptCorrelation { + schemaVersion: 1 + state: 'pending' | 'committed' | 'expired' | 'missing' | 'legacy-committed' + pendingInterrupts: readonly Interrupt[] + committed?: { + fingerprint: string + resolutions?: readonly RunAgentResumeItem[] + continuationRunId?: string + committedAt: string + } +} + +export interface InterruptPersistenceGateway { + openInterruptBatch( + input: OpenInterruptBatchInput, + ): Promise<{ generation: number; descriptors: readonly Interrupt[] }> + commitInterruptResolutions( + input: CommitInterruptResolutionsInput, + ): Promise + getInterruptRecoveryState( + input: InterruptRecoveryQuery, + ): Promise +} + +export const InterruptPersistenceCapability = + createCapability()('interrupt-persistence') + +export const [getInterruptPersistence, provideInterruptPersistence] = + InterruptPersistenceCapability +``` + +Add this core-owned response canonicalizer; persistence stores compare both the SHA-256 digest and canonical bytes on replay: + +```ts +export function canonicalizeInterruptResolutions( + resolutions: readonly RunAgentResumeItem[], +): { + resolutions: readonly RunAgentResumeItem[] + canonicalResolutions: string + fingerprint: string +} { + const sorted = [...resolutions].sort((left, right) => + left.interruptId.localeCompare(right.interruptId), + ) + const frozen = cloneAndDeepFreezeJson(sorted) + const canonicalResolutions = canonicalInterruptJson(frozen) + return Object.freeze({ + resolutions: frozen, + canonicalResolutions, + fingerprint: digestInterruptJson(canonicalResolutions), + }) +} +``` + +Extend `RunErrorEvent` with optional top-level fields: + +```ts +export interface RunErrorEvent extends AGUIRunErrorEvent { + model?: string + 'tanstack:interruptErrors'?: readonly InterruptSubmissionError[] + 'tanstack:interruptRecovery'?: InterruptRecoveryStateV1 + error?: { message: string; code?: string | undefined } | undefined +} +``` + +Extend middleware context/config state without importing persistence: + +```ts +export interface ChatMiddlewareContext { + runId: string + parentRunId?: string + threadId: string +} + +export type ChatResumeGenericResolution = + | { interruptId: string; status: 'resolved'; payload: unknown } + | { interruptId: string; status: 'cancelled'; payload?: never } + +export interface ChatResumeToolState { + approvals?: ReadonlyMap + clientToolResults?: ReadonlyMap + genericInterrupts?: ReadonlyMap + deniedToolResults?: ReadonlyMap + cancelledToolCallIds?: ReadonlySet +} +``` + +Add `parentRunId` when `TextEngine` constructs `middlewareCtx`. Deprecate `ApprovalRequestedEvent` and `ToolInputAvailableEvent` with removal-at-1.0 JSDoc, but keep them in `KnownCustomEvent` for compatibility. Export every new public DTO/capability from root and client barrels. + +- [ ] **Step 4: Run green tests** + +Run: + +```powershell +$env:CI='true' +pnpm --filter @tanstack/ai test:lib -- --run tests/interrupts.test.ts +pnpm --filter @tanstack/ai test:types +``` + +Expected: both PASS; no dependency from `packages/ai` to any persistence package appears. + +- [ ] **Step 5: Refactor and verify the seam** + +Group wire-only types separately from capability methods in `interrupts.ts`, keep `parentRunId` optional for non-resume requests, and rerun the focused commands. Run `pnpm --filter @tanstack/ai test:lib -- --run tests/custom-events-integration.test.ts` and expect PASS to prove deprecated types remain readable. + +### Task 4: Replace sequential persistence with the atomic reference contract + +**Prerequisites:** Task 3. + +**Files:** +- Modify: `packages/ai-persistence/src/types.ts:36-55,139,255-287` +- Modify: `packages/ai-persistence/src/interrupts.ts` +- Modify: `packages/ai-persistence/src/capabilities.ts` +- Modify: `packages/ai-persistence/src/memory.ts:65-126,355` +- Modify: `packages/ai-persistence/src/testkit/conformance.ts:114-184` +- Modify: `packages/ai-persistence/src/index.ts` +- Modify: `packages/ai-persistence/tests/interrupts.test.ts` +- Modify: `packages/ai-persistence/tests/memory.conformance.test.ts` +- Modify: `packages/ai-persistence/tests/with-persistence.test.ts` +- Modify: `packages/ai-persistence/tests/persistence-validation.test.ts` +- Modify: `packages/ai-persistence/tests/persistence-types.test-d.ts` + +- [ ] **Step 1: Rewrite the shared conformance tests to fail on sequential stores** + +Replace the interrupt conformance block with complete-batch cases. Use stable IDs per test so adapters can run the same suite: + +```ts +it('commits all responses, replays exactly, and rejects a different writer', async () => { + const store = persistence.stores.interrupts + expect(store, 'interrupt conformance requires stores.interrupts').toBeDefined() + if (!store) throw new Error('interrupt store missing') + const opened = await store.openInterruptBatch({ + threadId: 'thread-cas', + interruptedRunId: 'run-interrupted', + descriptors: [ + { id: 'int-a', reason: 'confirmation' }, + { id: 'int-b', reason: 'input_required' }, + ], + bindings: [ + { interruptId: 'int-a', kind: 'generic', responseSchemaHash: 'v1:a' }, + { interruptId: 'int-b', kind: 'generic', responseSchemaHash: 'v1:b' }, + ], + }) + const resolutions = [ + { interruptId: 'int-b', status: 'cancelled' }, + { interruptId: 'int-a', status: 'resolved', payload: { ok: true } }, + ] as const + const canonical = canonicalizeInterruptResolutions(resolutions) + const input = { + threadId: 'thread-cas', + interruptedRunId: 'run-interrupted', + continuationRunId: 'run-continuation-a', + expectedGeneration: opened.generation, + expectedInterruptIds: ['int-b', 'int-a'], + resolutions: canonical.resolutions, + fingerprint: canonical.fingerprint, + canonicalResolutions: canonical.canonicalResolutions, + } + + await expect(store.commitInterruptResolutions(input)).resolves.toEqual({ + status: 'committed', + continuationRunId: 'run-continuation-a', + }) + await expect( + store.commitInterruptResolutions({ + ...input, + continuationRunId: 'ignored-on-replay', + }), + ).resolves.toEqual({ + status: 'replayed', + continuationRunId: 'run-continuation-a', + }) + await expect( + store.commitInterruptResolutions({ + ...input, + continuationRunId: 'run-continuation-b', + resolutions: [ + { interruptId: 'int-a', status: 'cancelled' }, + { interruptId: 'int-b', status: 'cancelled' }, + ], + fingerprint: canonicalizeInterruptResolutions([ + { interruptId: 'int-a', status: 'cancelled' }, + { interruptId: 'int-b', status: 'cancelled' }, + ]).fingerprint, + canonicalResolutions: canonicalizeInterruptResolutions([ + { interruptId: 'int-a', status: 'cancelled' }, + { interruptId: 'int-b', status: 'cancelled' }, + ]).canonicalResolutions, + }), + ).resolves.toMatchObject({ status: 'conflict' }) +}) +``` + +Define the testkit contract and fixtures in `src/testkit/conformance.ts`; the store is required and no case may return early: + +```ts +export interface InterruptConformanceHarness { + getStore(): InterruptStore | undefined + advanceBy(milliseconds: number): void + inspect(interruptedRunId: string): Promise<{ + statuses: readonly string[] + batchCount: number + }> + failTransitionOnce(interruptId: string): void + reopen?(): Promise +} + +export async function openTwoInterrupts(store: InterruptStore) { + return store.openInterruptBatch({ + threadId: 'thread-cas', + interruptedRunId: 'run-interrupted', + descriptors: [ + { id: 'int-a', reason: 'confirmation' }, + { + id: 'int-b', + reason: 'input_required', + expiresAt: '2026-07-13T10:01:00.000Z', + }, + ], + bindings: [ + { interruptId: 'int-a', kind: 'generic', responseSchemaHash: 'sha256:a' }, + { + interruptId: 'int-b', + kind: 'generic', + responseSchemaHash: 'sha256:b', + expiresAt: '2026-07-13T10:01:00.000Z', + }, + ], + }) +} + +export function validTwoItemCommit( + generation: number, + continuationRunId = 'run-continuation-a', +): CommitInterruptResolutionsInput { + const candidate = canonicalizeInterruptResolutions([ + { interruptId: 'int-b', status: 'cancelled' }, + { interruptId: 'int-a', status: 'resolved', payload: { ok: true } }, + ]) + return { + threadId: 'thread-cas', + interruptedRunId: 'run-interrupted', + continuationRunId, + expectedGeneration: generation, + expectedInterruptIds: ['int-b', 'int-a'], + resolutions: candidate.resolutions, + fingerprint: candidate.fingerprint, + canonicalResolutions: candidate.canonicalResolutions, + } +} + +export function runInterruptStoreConformance( + createHarness: () => Promise, +): void { + it('requires the atomic interrupt capability', async () => { + const harness = await createHarness() + expect( + harness.getStore(), + 'interrupt conformance requires stores.interrupts', + ).toBeDefined() + if (!harness.getStore()) throw new Error('interrupt store missing') + }) + + it('keeps canonical order stable and rejects malformed exact sets', async () => { + const harness = await createHarness() + const store = harness.getStore() + if (!store) throw new Error('interrupt store missing') + const opened = await openTwoInterrupts(store) + const ordered = validTwoItemCommit(opened.generation) + const reordered = canonicalizeInterruptResolutions([ + { interruptId: 'int-a', status: 'resolved', payload: { ok: true } }, + { interruptId: 'int-b', status: 'cancelled' }, + ]) + expect(reordered.fingerprint).toBe(ordered.fingerprint) + expect(reordered.canonicalResolutions).toBe(ordered.canonicalResolutions) + for (const expectedInterruptIds of [ + ['int-a'], + ['int-a', 'int-b', 'int-c'], + ['int-a', 'int-a'], + ]) { + await expect(store.commitInterruptResolutions({ + ...ordered, + expectedInterruptIds, + })).resolves.toMatchObject({ status: 'conflict' }) + } + await expect(store.commitInterruptResolutions({ + ...ordered, + expectedGeneration: opened.generation + 1, + })).resolves.toMatchObject({ status: 'conflict' }) + }) + + it('rolls back every row when a transition fails', async () => { + const harness = await createHarness() + const store = harness.getStore() + if (!store) throw new Error('interrupt store missing') + const opened = await openTwoInterrupts(store) + harness.failTransitionOnce('int-b') + await expect( + store.commitInterruptResolutions(validTwoItemCommit(opened.generation)), + ).rejects.toThrow() + await expect(harness.inspect('run-interrupted')).resolves.toEqual({ + statuses: ['pending', 'pending'], + batchCount: 0, + }) + }) + + it('projects pending, committed replay, restart, and one concurrent winner', async () => { + const harness = await createHarness() + const store = harness.getStore() + if (!store) throw new Error('interrupt store missing') + const opened = await openTwoInterrupts(store) + await expect(store.getInterruptRecoveryState({ + threadId: 'thread-cas', + interruptedRunId: 'run-interrupted', + knownGeneration: opened.generation, + })).resolves.toMatchObject({ + state: 'pending', + generation: opened.generation, + }) + + const first = validTwoItemCommit(opened.generation, 'run-continuation-a') + const secondCandidate = canonicalizeInterruptResolutions([ + { interruptId: 'int-a', status: 'cancelled' }, + { interruptId: 'int-b', status: 'cancelled' }, + ]) + const second = { + ...first, + continuationRunId: 'run-continuation-b', + resolutions: secondCandidate.resolutions, + fingerprint: secondCandidate.fingerprint, + canonicalResolutions: secondCandidate.canonicalResolutions, + } + const results = await Promise.all([ + store.commitInterruptResolutions(first), + store.commitInterruptResolutions(second), + ]) + expect(results.map((result) => result.status).sort()).toEqual([ + 'committed', + 'conflict', + ]) + const winningRunId = results.find( + (result) => result.status === 'committed', + )?.continuationRunId + expect(winningRunId).toBeDefined() + const replayInput = winningRunId === 'run-continuation-a' ? first : second + await expect(store.commitInterruptResolutions({ + ...replayInput, + continuationRunId: 'ignored-replay-run', + })).resolves.toEqual({ + status: 'replayed', + continuationRunId: winningRunId, + }) + + const recoveredStore = harness.reopen ? await harness.reopen() : store + await expect(recoveredStore.getInterruptRecoveryState({ + threadId: 'thread-cas', + interruptedRunId: 'run-interrupted', + knownGeneration: opened.generation, + })).resolves.toMatchObject({ + state: 'committed', + committed: { continuationRunId: winningRunId }, + }) + }) + + it('marks an uncommitted expired batch expired', async () => { + const harness = await createHarness() + const store = harness.getStore() + if (!store) throw new Error('interrupt store missing') + const opened = await openTwoInterrupts(store) + harness.advanceBy(60_001) + await expect( + store.commitInterruptResolutions(validTwoItemCommit(opened.generation)), + ).resolves.toMatchObject({ + status: 'conflict', + authoritativeState: { state: 'expired' }, + }) + }) +} +``` + +Memory omits `reopen` to make process-local loss explicit. Drizzle, Prisma, and Cloudflare supply it and must recover the committed winner. Every harness supplies `failTransitionOnce`; it may wrap the store at the gateway boundary so production adapters do not expose a test-only option. No conformance case returns early. + +- [ ] **Step 2: Run the red persistence targets** + +Run: + +```powershell +$env:CI='true' +pnpm --filter @tanstack/ai-persistence test:lib -- --run tests/interrupts.test.ts tests/memory.conformance.test.ts tests/with-persistence.test.ts tests/persistence-validation.test.ts +pnpm --filter @tanstack/ai-persistence test:types +``` + +Expected: FAIL because `openInterruptBatch`, `commitInterruptResolutions`, recovery, generation, and `fingerprintInterruptResolutions` are missing. + +- [ ] **Step 3: Define the store records and capability alias** + +Make `InterruptStore` extend the core gateway and retain read methods only; remove sequential `resolve`/`cancel` as a middleware path: + +```ts +export interface InterruptRecord { + interruptId: string + runId: string + threadId: string + generation: number + status: 'pending' | 'resolved' | 'cancelled' + requestedAt: number + resolvedAt?: number + payload: unknown + binding: InterruptBinding + response?: unknown +} + +export interface InterruptBatchRecord { + threadId: string + interruptedRunId: string + generation: number + expectedInterruptIds: readonly string[] + fingerprint: string + canonicalResolutions: string + resolutions: readonly RunAgentResumeItem[] + continuationRunId: string + committedAt: number +} + +export interface InterruptStore extends InterruptPersistenceGateway { + get(interruptId: string): Promise + list(threadId: string): Promise + listPending(threadId: string): Promise + listByRun(runId: string): Promise + listPendingByRun(runId: string): Promise +} +``` + +In `capabilities.ts`, stop constructing a second interrupt token. Re-export the core `InterruptPersistenceCapability`, `getInterruptPersistence`, and `provideInterruptPersistence`. Keep `InterruptsCapability`, `getInterrupts`, and `provideInterrupts` as deprecated aliases referencing those exact same values until 1.0. + +- [ ] **Step 4: Implement exact-set helpers and consume the core identity** + +Delete persistence-owned canonicalization and FNV. Import `canonicalizeInterruptResolutions` and `cloneAndDeepFreezeJson` from `@tanstack/ai`; persistence owns only exact-set checks: + +```ts +export class InterruptStoreCorruptionError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'InterruptStoreCorruptionError' + } +} + +export function hasExactInterruptIds( + expected: readonly string[], + actual: readonly string[], +): boolean { + const left = [...new Set(expected)].sort() + const right = [...new Set(actual)].sort() + return left.length === expected.length && + right.length === actual.length && + left.length === right.length && + left.every((id, index) => id === right[index]) +} + +export function projectInterruptRecovery(input: { + query: InterruptRecoveryQuery + rows: readonly InterruptRecord[] + batch: InterruptBatchRecord | null + now: number + includeResolutions: boolean +}): InterruptRecoveryStateV1 { + const correlation = { + schemaVersion: 1 as const, + threadId: input.query.threadId, + interruptedRunId: input.query.interruptedRunId, + generation: + input.batch?.generation ?? + input.rows[0]?.generation ?? + input.query.knownGeneration, + pendingInterrupts: [] as readonly Interrupt[], + } + if (input.batch) { + return cloneAndDeepFreezeJson({ + ...correlation, + state: 'committed', + committed: { + fingerprint: input.batch.fingerprint, + ...(input.includeResolutions && { + resolutions: input.batch.resolutions, + }), + continuationRunId: input.batch.continuationRunId, + committedAt: new Date(input.batch.committedAt).toISOString(), + }, + }) + } + if (input.rows.length === 0) { + return cloneAndDeepFreezeJson({ ...correlation, state: 'missing' }) + } + const pending = input.rows.filter((row) => row.status === 'pending') + if (pending.length > 0) { + const expired = pending.some( + (row) => + row.binding.expiresAt !== undefined && + Date.parse(row.binding.expiresAt) <= input.now, + ) + return cloneAndDeepFreezeJson({ + ...correlation, + state: expired ? 'expired' : 'pending', + pendingInterrupts: expired + ? [] + : pending.map((row) => row.payload as Interrupt), + }) + } + return cloneAndDeepFreezeJson({ + ...correlation, + state: 'legacy-committed', + }) +} +``` + +Before any store operation, recompute `canonicalizeInterruptResolutions(input.resolutions)` and require both its digest and canonical bytes to equal `input.fingerprint` and `input.canonicalResolutions`. Replay requires stored digest equality **and** stored canonical-byte equality, so a digest alone never proves identity. Memory and every durable adapter call `projectInterruptRecovery` from inside their run lock/transaction with rows and winner read from that same snapshot; its five branches are exercised by the shared conformance suite and the migrated-legacy tests. + +- [ ] **Step 5: Implement the memory critical section and immutable winner** + +Use maps keyed by `interruptedRunId` plus a per-key promise chain. `openInterruptBatch` writes every descriptor or none, stamps generation `1` and schema identity into returned descriptors, and returns an existing identical pending batch idempotently. `commitInterruptResolutions` executes inside the same critical section: + +```ts +return this.withInterruptLock(input.interruptedRunId, async () => { + const candidate = canonicalizeInterruptResolutions(input.resolutions) + if ( + candidate.fingerprint !== input.fingerprint || + candidate.canonicalResolutions !== input.canonicalResolutions + ) { + throw new TypeError('Interrupt batch identity does not match its resolutions.') + } + const winner = this.batches.get(input.interruptedRunId) + if (winner) { + return winner.fingerprint === input.fingerprint && + winner.canonicalResolutions === input.canonicalResolutions + ? { status: 'replayed', continuationRunId: winner.continuationRunId } + : { + status: 'conflict', + authoritativeState: this.recoveryFromWinner(winner), + } + } + const pending = this.pendingForRun(input.interruptedRunId) + if ( + !hasExactInterruptIds( + input.expectedInterruptIds, + pending.map((record) => record.interruptId), + ) || + pending.some((record) => record.generation !== input.expectedGeneration) || + pending.some((record) => + record.binding.expiresAt !== undefined && + Date.parse(record.binding.expiresAt) <= this.clock(), + ) + ) { + return { + status: 'conflict', + authoritativeState: this.recoveryForRun(input), + } + } + const committedAt = this.clock() + const nextInterrupts = new Map(this.interrupts) + for (const resolution of candidate.resolutions) { + const current = nextInterrupts.get(resolution.interruptId) + if ( + !current || + current.runId !== input.interruptedRunId || + current.status !== 'pending' || + current.generation !== input.expectedGeneration + ) { + throw new Error(`Pending interrupt changed: ${resolution.interruptId}`) + } + nextInterrupts.set( + resolution.interruptId, + cloneAndDeepFreezeJson({ + ...current, + status: resolution.status === 'cancelled' ? 'cancelled' : 'resolved', + response: resolution, + resolvedAt: committedAt, + }), + ) + } + const immutableWinner = cloneAndDeepFreezeJson({ + ...input, + resolutions: candidate.resolutions, + canonicalResolutions: candidate.canonicalResolutions, + fingerprint: candidate.fingerprint, + committedAt, + }) + const nextBatches = new Map(this.batches) + nextBatches.set(input.interruptedRunId, immutableWinner) + + // Publish only after every row and winner has been validated and built. + this.interrupts = nextInterrupts + this.batches = nextBatches + return { + status: 'committed', + continuationRunId: input.continuationRunId, + } +}) +``` + +`openInterruptBatch` runs under the same per-run lock: verify descriptor/binding exact IDs and correlations, build a cloned next interrupt map and frozen descriptor snapshot, and publish the map only after every record is built. `getInterruptRecoveryState` reads one locked snapshot and returns `pending`, `committed`, `expired`, `missing`, or `legacy-committed` without exposing mutable store objects. + +- [ ] **Step 6: Reject non-atomic custom stores and export the new helpers** + +Update `defineAIPersistence` validation so an interrupt store lacking all three gateway methods throws `atomic-commit-unsupported`; do not adapt `resolve`/`cancel` sequentially. Re-export new types/helpers and update `createInterruptController` to expose `openInterruptBatch`, `commitInterruptResolutions`, `getInterruptRecoveryState`, and pending reads only. + +- [ ] **Step 7: Run green persistence checks and refactor** + +Run: + +```powershell +$env:CI='true' +pnpm --filter @tanstack/ai-persistence test:lib -- --run tests/interrupts.test.ts tests/memory.conformance.test.ts tests/with-persistence.test.ts tests/persistence-validation.test.ts +pnpm --filter @tanstack/ai-persistence test:types +``` + +Expected: PASS; the concurrency test has one winner, replay returns its continuation ID, and fault injection leaves every row pending. Refactor duplicated recovery construction into pure helpers and rerun both commands. + +### Task 5: Implement transactional CAS and migrations for Drizzle SQLite + +**Prerequisites:** Task 4. + +**Files:** +- Modify: `packages/ai-persistence-drizzle/src/schema.ts:65-78,115-125` +- Modify: `packages/ai-persistence-drizzle/src/stores.ts:1-38,196-286` +- Modify: `packages/ai-persistence-drizzle/src/index.ts` +- Modify: `packages/ai-persistence-drizzle/src/sqlite.ts` +- Modify: `packages/ai-persistence-drizzle/src/migrations.ts` +- Create: `packages/ai-persistence-drizzle/drizzle/0001_tanstack_ai_interrupt_batches.sql` +- Create: `packages/ai-persistence-drizzle/drizzle/meta/0001_snapshot.json` +- Modify: `packages/ai-persistence-drizzle/drizzle/meta/_journal.json` +- Create: `packages/ai-persistence-drizzle/src/assets/0001_tanstack_ai_interrupt_batches.sql` +- Modify: `packages/ai-persistence-drizzle/tests/drizzle.conformance.test.ts` +- Modify: `packages/ai-persistence-drizzle/tests/migrations.test.ts` +- Modify: `packages/ai-persistence-drizzle/tests/migration-cli.test.ts` +- Modify: `packages/ai-persistence-drizzle/tests/api-types.test-d.ts` + +- [ ] **Step 1: Add failing adapter and nonempty-migration tests** + +Extend the shared conformance invocation and assert historical rows migrate deterministically: + +```ts +it('backfills pending generation and identifies historical commits', () => { + const db = new DatabaseSync(':memory:') + applySqliteMigrations(db, [sqliteMigrations[0]]) + const binding = { + kind: 'generic', + interruptId: 'pending', + interruptedRunId: 'run-p', + generation: 1, + responseSchemaHash: 'sha256:pending', + } + const descriptor = { + id: 'pending', + reason: 'confirmation', + responseSchema: { type: 'object', required: ['approved'] }, + } + const insert = db.prepare(`INSERT INTO interrupts ( + interrupt_id, run_id, thread_id, status, requested_at, + resolved_at, payload_json, response_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`) + insert.run('pending', 'run-p', 'thread', 'pending', 1, null, JSON.stringify(descriptor), null) + insert.run('done', 'run-d', 'thread', 'resolved', 1, 2, '{}', '{}') + applySqliteMigrations(db, [sqliteMigrations[1]]) + + expect(db.prepare('SELECT generation FROM interrupts WHERE interrupt_id = ?').get('pending')).toEqual({ generation: 1 }) + expect(db.prepare('SELECT generation FROM interrupts WHERE interrupt_id = ?').get('done')).toEqual({ generation: 0 }) + expect(db.prepare('SELECT COUNT(*) AS count FROM interrupt_batches').get()).toEqual({ count: 0 }) + const migrated = db.prepare( + 'SELECT status, generation, binding_json FROM interrupts WHERE interrupt_id = ?', + ).get('pending') + expect(migrated).toMatchObject({ status: 'pending', generation: 1 }) + expect(JSON.parse(String(migrated.binding_json))).toEqual({ + ...binding, + responseSchemaHash: expect.stringMatching(/^legacy-json:/), + }) + expect( + db.prepare( + 'SELECT payload_json FROM interrupts WHERE interrupt_id = ? AND status = ?', + ).get('pending', 'pending'), + ).toEqual({ payload_json: JSON.stringify(descriptor) }) +}) + +it('upgrades all legacy pending descriptors atomically', () => { + const db = new DatabaseSync(':memory:') + applySqliteMigrations(db, [sqliteMigrations[0]]) + db.prepare(`INSERT INTO interrupts ( + interrupt_id, run_id, thread_id, status, requested_at, payload_json + ) VALUES (?, ?, ?, ?, ?, ?)`) + .run( + 'legacy-tool', + 'run-legacy', + 'thread', + 'pending', + 1, + JSON.stringify({ + id: 'legacy-tool', + reason: 'approval_required', + toolCallId: 'call-legacy', + responseSchema: { + type: 'object', + properties: { approved: { type: 'boolean' } }, + required: ['approved'], + }, + metadata: { kind: 'approval', toolName: 'transfer', input: { cents: 5 } }, + }), + ) + expect(() => applySqliteMigrations(db, [sqliteMigrations[1]])).not.toThrow() + const rows = db.prepare( + 'SELECT status, generation, binding_json FROM interrupts WHERE run_id = ?', + ).all('run-legacy') + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ status: 'pending', generation: 1 }) + expect(JSON.parse(String(rows[0]?.binding_json))).toMatchObject({ + kind: 'generic', + interruptId: 'legacy-tool', + interruptedRunId: 'run-legacy', + generation: 1, + }) +}) +``` + +- [ ] **Step 2: Run the red Drizzle targets** + +Run: + +```powershell +pnpm --filter @tanstack/ai-persistence-drizzle test:lib -- --run tests/drizzle.conformance.test.ts tests/migrations.test.ts tests/migration-cli.test.ts +pnpm --filter @tanstack/ai-persistence-drizzle test:types +``` + +Expected: FAIL because the schema lacks generation/binding/batch columns and the store lacks atomic gateway methods. + +- [ ] **Step 3: Add schema and an explicit transaction executor** + +Add `generation`, `bindingJson`, and `responseSchemaHash` to `interrupts`; add an `interruptBatches` table keyed by interrupted run with a unique continuation ID. Do not claim atomicity from the schema-agnostic `DrizzleDb` alone: + +```ts +export interface DrizzleTransactionExecutor { + transaction(work: (tx: DrizzleDb) => Promise): Promise +} + +export function createInterruptStore( + db: DrizzleDb, + executor: DrizzleTransactionExecutor, +): InterruptStore + +export function drizzlePersistence( + db: DrizzleDb, + options: { interruptTransactions: DrizzleTransactionExecutor }, +) +``` + +The Node factory must serialize asynchronous transaction callbacks per connection: + +```ts +export function createSerializedSqliteExecutor( + database: DatabaseSync, + db: DrizzleDb, +): DrizzleTransactionExecutor { + let tail: Promise = Promise.resolve() + return { + transaction(work: (tx: DrizzleDb) => Promise): Promise { + const run = tail.then(async () => { + database.exec('BEGIN IMMEDIATE') + try { + const value = await work(db) + database.exec('COMMIT') + return value + } catch (error) { + database.exec('ROLLBACK') + throw error + } + }) + tail = run.then(() => undefined, () => undefined) + return run + }, + } +} +``` + +`sqlitePersistence` constructs exactly one executor per `DatabaseSync`. `drizzlePersistence` requires `interruptTransactions` whenever interrupts are enabled and throws `atomic-commit-unsupported` during construction if it is absent. + +- [ ] **Step 4: Implement the transaction and exact replay** + +Implement `openInterruptBatch`, `commitInterruptResolutions`, and `getInterruptRecoveryState` through this executor. The commit body is: + +```ts +return executor.transaction(async (tx) => { + const existing = await tx.select().from(interruptBatches) + .where(eq(interruptBatches.interruptedRunId, input.interruptedRunId)).get() + if (existing) { + if ( + existing.fingerprint === input.fingerprint && + existing.resolutionsJson === input.canonicalResolutions + ) { + return { status: 'replayed', continuationRunId: existing.continuationRunId } + } + return { status: 'conflict', authoritativeState: recoveryFromBatch(existing) } + } + const pending = await tx.select().from(interrupts).where(and( + eq(interrupts.runId, input.interruptedRunId), + eq(interrupts.status, 'pending'), + )).all() + const now = clock() + if ( + !hasExactInterruptIds(input.expectedInterruptIds, pending.map((row) => row.interruptId)) || + pending.some((row) => row.generation !== input.expectedGeneration) || + pending.some((row) => isExpiredBindingJson(row.bindingJson, now)) + ) { + return { status: 'conflict', authoritativeState: recoveryFromRows(input, pending, now) } + } + await tx.insert(interruptBatches).values({ + interruptedRunId: input.interruptedRunId, + threadId: input.threadId, + generation: input.expectedGeneration, + expectedInterruptIdsJson: JSON.stringify([...input.expectedInterruptIds].sort()), + fingerprint: input.fingerprint, + resolutionsJson: input.canonicalResolutions, + continuationRunId: input.continuationRunId, + committedAt: now, + }).run() + for (const resolution of input.resolutions) { + const result = await tx.update(interrupts).set({ + status: resolution.status === 'cancelled' ? 'cancelled' : 'resolved', + responseJson: resolution, + resolvedAt: now, + }).where(and( + eq(interrupts.interruptId, resolution.interruptId), + eq(interrupts.runId, input.interruptedRunId), + eq(interrupts.status, 'pending'), + eq(interrupts.generation, input.expectedGeneration), + )).run() + if (result.changes !== 1) throw new Error('interrupt CAS row changed') + } + return { status: 'committed', continuationRunId: input.continuationRunId } +}) +``` + +`openInterruptBatch` and recovery use the same executor and the shared projection: + +```ts +async function openInterruptBatch( + input: OpenInterruptBatchInput, +): Promise<{ generation: number; descriptors: readonly Interrupt[] }> { + if (!hasExactInterruptIds( + input.descriptors.map((descriptor) => descriptor.id), + input.bindings.map((binding) => binding.interruptId), + )) { + throw new TypeError('Interrupt descriptors and bindings must have exact IDs.') + } + return executor.transaction(async (tx) => { + const existing = await tx.select().from(interrupts).where( + eq(interrupts.runId, input.interruptedRunId), + ).all() + if (existing.length > 0) { + const existingIds = existing.map((row) => row.interruptId) + if (!hasExactInterruptIds(existingIds, input.descriptors.map((item) => item.id))) { + throw new Error('Interrupt batch already opened with a different set.') + } + return { + generation: existing[0]?.generation ?? 1, + descriptors: cloneAndDeepFreezeJson( + existing.map((row) => JSON.parse(row.payloadJson) as Interrupt), + ), + } + } + + const generation = 1 + const bindings = new Map(input.bindings.map((binding) => [ + binding.interruptId, + cloneAndDeepFreezeJson({ + ...binding, + interruptedRunId: input.interruptedRunId, + generation, + } satisfies InterruptBinding), + ])) + const descriptors = input.descriptors.map((descriptor) => { + const binding = bindings.get(descriptor.id) + if (!binding) throw new Error(`Missing binding for ${descriptor.id}`) + return cloneAndDeepFreezeJson({ + ...descriptor, + metadata: { ...descriptor.metadata, 'tanstack:binding': binding }, + }) + }) + await tx.insert(interrupts).values(descriptors.map((descriptor) => { + const binding = bindings.get(descriptor.id) + if (!binding) throw new Error(`Missing binding for ${descriptor.id}`) + return { + interruptId: descriptor.id, + runId: input.interruptedRunId, + threadId: input.threadId, + generation, + status: 'pending', + requestedAt: clock(), + payloadJson: JSON.stringify(descriptor), + bindingJson: JSON.stringify(binding), + responseSchemaHash: binding.responseSchemaHash, + } + })).run() + return { generation, descriptors: cloneAndDeepFreezeJson(descriptors) } + }) +} + +async function getInterruptRecoveryState( + query: InterruptRecoveryQuery, +): Promise { + return executor.transaction(async (tx) => { + const batchRow = await tx.select().from(interruptBatches).where( + eq(interruptBatches.interruptedRunId, query.interruptedRunId), + ).get() + const rows = await tx.select().from(interrupts).where( + eq(interrupts.runId, query.interruptedRunId), + ).orderBy(interrupts.interruptId).all() + return projectInterruptRecovery({ + query, + batch: batchRow ? decodeInterruptBatchRow(batchRow) : null, + rows: rows.map(decodeInterruptRow), + now: clock(), + includeResolutions: true, + }) + }) +} +``` + +Define the decoders beside the store; these names are used by the preceding methods: + +```ts +function parseJsonRecord(value: string, label: string): Record { + try { + const parsed: unknown = JSON.parse(value) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new TypeError(`${label} must contain a JSON object.`) + } + return parsed as Record + } catch (cause) { + throw new InterruptStoreCorruptionError(`Invalid ${label}.`, { cause }) + } +} + +function decodeInterruptRow( + row: typeof interrupts.$inferSelect, +): InterruptRecord { + const payload = parseJsonRecord(row.payloadJson, 'interrupt payload') + const binding = parseJsonRecord(row.bindingJson, 'interrupt binding') + if ( + binding['interruptId'] !== row.interruptId || + binding['interruptedRunId'] !== row.runId || + binding['generation'] !== row.generation + ) { + throw new InterruptStoreCorruptionError('Interrupt binding correlation mismatch.') + } + return cloneAndDeepFreezeJson({ + interruptId: row.interruptId, + runId: row.runId, + threadId: row.threadId, + generation: row.generation, + status: row.status, + requestedAt: row.requestedAt, + ...(row.resolvedAt !== null && { resolvedAt: row.resolvedAt }), + payload, + binding, + ...(row.responseJson !== null && { + response: JSON.parse(row.responseJson) as unknown, + }), + }) as InterruptRecord +} + +function decodeInterruptBatchRow( + row: typeof interruptBatches.$inferSelect, +): InterruptBatchRecord { + const expectedInterruptIds: unknown = JSON.parse(row.expectedInterruptIdsJson) + const resolutions: unknown = JSON.parse(row.resolutionsJson) + if (!Array.isArray(expectedInterruptIds) || !Array.isArray(resolutions)) { + throw new InterruptStoreCorruptionError('Invalid interrupt batch arrays.') + } + const resolutionIds = resolutions.map((entry) => { + if (!entry || typeof entry !== 'object' || !('interruptId' in entry)) { + throw new InterruptStoreCorruptionError('Invalid interrupt batch resolution.') + } + return String(entry.interruptId) + }) + if (!hasExactInterruptIds(expectedInterruptIds.map(String), resolutionIds)) { + throw new InterruptStoreCorruptionError('Interrupt batch ID set mismatch.') + } + return cloneAndDeepFreezeJson({ + threadId: row.threadId, + interruptedRunId: row.interruptedRunId, + generation: row.generation, + expectedInterruptIds: expectedInterruptIds.map(String), + fingerprint: row.fingerprint, + canonicalResolutions: row.resolutionsJson, + resolutions, + continuationRunId: row.continuationRunId, + committedAt: row.committedAt, + }) as InterruptBatchRecord +} +``` + +Direct tests assert `pending`, `committed`, `expired`, `missing`, and resolved-without-batch `legacy-committed`; expiry is evaluated inside the executor transaction. + +- [ ] **Step 5: Generate and package the numbered migration** + +Run: + +```powershell +$env:CI='true' +pnpm --filter @tanstack/ai-persistence-drizzle db:generate -- --name tanstack_ai_interrupt_batches +``` + +Expected: Drizzle creates the `0001` SQL/meta entries. Ensure the SQL represented by both the generated file and packaged asset contains: + +```sql +ALTER TABLE `interrupts` ADD `generation` integer NOT NULL DEFAULT 1; +ALTER TABLE `interrupts` ADD `binding_json` text; +ALTER TABLE `interrupts` ADD `response_schema_hash` text NOT NULL DEFAULT ''; +UPDATE `interrupts` +SET `binding_json` = json_object( + 'kind', 'generic', + 'interruptId', `interrupt_id`, + 'interruptedRunId', `run_id`, + 'generation', 1, + 'responseSchemaHash', + 'legacy-json:' || COALESCE(json(json_extract(`payload_json`, '$.responseSchema')), 'null'), + 'expiresAt', json_extract(`payload_json`, '$.expiresAt') + ), + `response_schema_hash` = + 'legacy-json:' || COALESCE(json(json_extract(`payload_json`, '$.responseSchema')), 'null') +WHERE `status` = 'pending'; +UPDATE `interrupts` SET `generation` = 0 WHERE `status` <> 'pending'; +CREATE TABLE `interrupt_batches` ( + `interrupted_run_id` text PRIMARY KEY NOT NULL, + `thread_id` text NOT NULL, + `generation` integer NOT NULL, + `expected_interrupt_ids_json` text NOT NULL, + `fingerprint` text NOT NULL, + `resolutions_json` text NOT NULL, + `continuation_run_id` text NOT NULL UNIQUE, + `committed_at` integer NOT NULL +); +CREATE INDEX `interrupts_run_status_generation_idx` ON `interrupts` (`run_id`,`status`,`generation`); +CREATE INDEX `interrupts_thread_status_idx` ON `interrupts` (`thread_id`,`status`); +``` + +In `migrations.test.ts`, read the generated SQL, packaged asset, `_journal.json`, and snapshot. Assert: journal entry tag is `0001_tanstack_ai_interrupt_batches`; snapshot contains `interrupt_batches` and both indexes; packaged SQL bytes equal generated SQL bytes; and `sqliteMigrations[1]` has the same ID, filename, and SQL. In `migration-cli.test.ts`, assert the CLI writes `0000` then `0001` with those exact bytes. Do not rename only the SQL; regenerate journal and snapshot together. + +- [ ] **Step 6: Run green adapter checks and refactor** + +Run: + +```powershell +pnpm --filter @tanstack/ai-persistence-drizzle test:lib -- --run tests/drizzle.conformance.test.ts tests/migrations.test.ts tests/migration-cli.test.ts +pnpm --filter @tanstack/ai-persistence-drizzle test:types +``` + +Expected: PASS; migration reruns are idempotent, CLI copies both files, and the conformance race has one winner. Extract row-to-recovery mapping, rerun both commands, and confirm no transaction accepts a partial expected set. + +### Task 6: Implement Prisma CAS and keep all schema copies identical + +**Prerequisites:** Task 4. May run after Task 5 once shared types settle. + +**Files:** +- Modify: `packages/ai-persistence-prisma/src/stores.ts:200-300` +- Modify: `packages/ai-persistence-prisma/prisma/schema.prisma` +- Modify: `packages/ai-persistence-prisma/prisma/tanstack-ai.prisma` +- Modify: `packages/ai-persistence-prisma/src/assets/tanstack-ai.prisma` +- Modify: `packages/ai-persistence-prisma/tests/prisma.conformance.test.ts` +- Modify: `packages/ai-persistence-prisma/tests/models.test.ts` +- Modify: `packages/ai-persistence-prisma/tests/models-cli.test.ts` +- Modify: `packages/ai-persistence-prisma/tests/api-types.test-d.ts` + +- [ ] **Step 1: Add failing schema-copy and transaction tests** + +Add assertions that all model fragments contain the batch model and are byte-identical where they are intended to be copied: + +```ts +expect(prismaModels).toContain('model InterruptBatch') +expect(prismaModels).toContain('generation Int @default(1)') +expect(prismaModels).toContain('continuationRunId String @unique') +expect(copiedModels).toBe(prismaModels) +``` + +Run shared conformance with two Prisma clients submitting different complete batches concurrently. + +- [ ] **Step 2: Run the red Prisma targets** + +Run: + +```powershell +$env:CI='true' +pnpm --filter @tanstack/ai-persistence-prisma db:generate +pnpm --filter @tanstack/ai-persistence-prisma test:lib -- --run tests/prisma.conformance.test.ts tests/models.test.ts tests/models-cli.test.ts +pnpm --filter @tanstack/ai-persistence-prisma test:types +``` + +Expected: tests FAIL because `InterruptBatch`, generation, and gateway methods are absent. + +- [ ] **Step 3: Update the three Prisma schema sources** + +Add the same fields to `Interrupt` in all copies and this provider-neutral model: + +```prisma +model InterruptBatch { + interruptedRunId String @id @map("interrupted_run_id") + threadId String @map("thread_id") + generation Int + expectedInterruptIdsJson String @map("expected_interrupt_ids_json") + fingerprint String + resolutionsJson String @map("resolutions_json") + continuationRunId String @unique @map("continuation_run_id") + committedAt BigInt @map("committed_at") + + @@map("interrupt_batches") +} +``` + +Add `generation Int @default(1)`, `bindingJson String?`, `responseSchemaHash String @default("")`, plus run/status/generation and thread/status indexes to `Interrupt`. + +- [ ] **Step 4: Implement `$transaction` CAS** + +Use an interactive transaction with the exact-set decision inside it: + +```ts +return prisma.$transaction(async (tx) => { + const winner = await tx.interruptBatch.findUnique({ + where: { interruptedRunId: input.interruptedRunId }, + }) + if (winner) { + if ( + winner.fingerprint === input.fingerprint && + winner.resolutionsJson === input.canonicalResolutions + ) { + return { status: 'replayed', continuationRunId: winner.continuationRunId } + } + return { status: 'conflict', authoritativeState: recoveryFromPrismaBatch(winner) } + } + + const pending = await tx.interrupt.findMany({ + where: { + runId: input.interruptedRunId, + status: 'pending', + generation: input.expectedGeneration, + }, + orderBy: { interruptId: 'asc' }, + }) + const now = clock() + if ( + !hasExactInterruptIds(input.expectedInterruptIds, pending.map((row) => row.interruptId)) || + pending.some((row) => bindingExpired(row.bindingJson, now)) + ) { + return { status: 'conflict', authoritativeState: recoveryFromPrismaRows(input, pending, now) } + } + await tx.interruptBatch.create({ + data: { + interruptedRunId: input.interruptedRunId, + threadId: input.threadId, + generation: input.expectedGeneration, + expectedInterruptIdsJson: JSON.stringify([...input.expectedInterruptIds].sort()), + fingerprint: input.fingerprint, + resolutionsJson: input.canonicalResolutions, + continuationRunId: input.continuationRunId, + committedAt: BigInt(now), + }, + }) + for (const resolution of input.resolutions) { + const updated = await tx.interrupt.updateMany({ + where: { + interruptId: resolution.interruptId, + runId: input.interruptedRunId, + status: 'pending', + generation: input.expectedGeneration, + }, + data: { + status: resolution.status === 'cancelled' ? 'cancelled' : 'resolved', + responseJson: JSON.stringify(resolution), + resolvedAt: BigInt(now), + }, + }) + if (updated.count !== 1) throw new Error('interrupt CAS row changed') + } + return { + status: 'committed', + continuationRunId: input.continuationRunId, + } +}) +``` + +Add these two gateway methods beside the commit method: + +```ts +async function openInterruptBatch( + input: OpenInterruptBatchInput, +): Promise<{ generation: number; descriptors: readonly Interrupt[] }> { + if (!hasExactInterruptIds( + input.descriptors.map((descriptor) => descriptor.id), + input.bindings.map((binding) => binding.interruptId), + )) { + throw new TypeError('Interrupt descriptors and bindings must have exact IDs.') + } + return prisma.$transaction(async (tx) => { + const existing = await tx.interrupt.findMany({ + where: { runId: input.interruptedRunId }, + orderBy: { interruptId: 'asc' }, + }) + if (existing.length > 0) { + if (!hasExactInterruptIds( + existing.map((row) => row.interruptId), + input.descriptors.map((descriptor) => descriptor.id), + )) { + throw new Error('Interrupt batch already opened with a different set.') + } + return { + generation: existing[0]?.generation ?? 1, + descriptors: cloneAndDeepFreezeJson( + existing.map((row) => JSON.parse(row.payloadJson) as Interrupt), + ), + } + } + + const generation = 1 + const unopened = new Map( + input.bindings.map((binding) => [binding.interruptId, binding]), + ) + const descriptors = input.descriptors.map((descriptor) => { + const binding = unopened.get(descriptor.id) + if (!binding) throw new Error(`Missing binding for ${descriptor.id}`) + const authoritativeBinding = { + ...binding, + interruptedRunId: input.interruptedRunId, + generation, + } satisfies InterruptBinding + return { + descriptor: cloneAndDeepFreezeJson({ + ...descriptor, + metadata: { + ...descriptor.metadata, + 'tanstack:binding': authoritativeBinding, + }, + }), + binding: authoritativeBinding, + } + }) + await tx.interrupt.createMany({ + data: descriptors.map(({ descriptor, binding }) => ({ + interruptId: descriptor.id, + runId: input.interruptedRunId, + threadId: input.threadId, + generation, + status: 'pending', + requestedAt: BigInt(clock()), + payloadJson: JSON.stringify(descriptor), + bindingJson: JSON.stringify(binding), + responseSchemaHash: binding.responseSchemaHash, + })), + }) + return { + generation, + descriptors: cloneAndDeepFreezeJson( + descriptors.map(({ descriptor }) => descriptor), + ), + } + }) +} + +async function getInterruptRecoveryState( + query: InterruptRecoveryQuery, +): Promise { + return prisma.$transaction(async (tx) => { + const batch = await tx.interruptBatch.findUnique({ + where: { interruptedRunId: query.interruptedRunId }, + }) + const rawRows = await tx.interrupt.findMany({ + where: { runId: query.interruptedRunId }, + orderBy: { interruptId: 'asc' }, + }) + const rows = [] + for (const row of rawRows) { + if (row.status === 'pending' && row.bindingJson === null) { + const descriptor: Interrupt = JSON.parse(row.payloadJson) + const responseSchemaHash = `legacy-json:${canonicalInterruptJson( + descriptor.responseSchema ?? null, + )}` + const binding = cloneAndDeepFreezeJson({ + kind: 'generic', + interruptId: row.interruptId, + interruptedRunId: row.runId, + generation: row.generation, + responseSchemaHash, + ...(descriptor.expiresAt !== undefined && { + expiresAt: descriptor.expiresAt, + }), + } satisfies InterruptBinding) + await tx.interrupt.update({ + where: { interruptId: row.interruptId }, + data: { + bindingJson: JSON.stringify(binding), + responseSchemaHash, + }, + }) + rows.push(decodePrismaInterruptRow({ ...row, bindingJson: JSON.stringify(binding) })) + } else { + rows.push(decodePrismaInterruptRow(row)) + } + } + return projectInterruptRecovery({ + query, + batch: batch ? decodePrismaBatchRow(batch) : null, + rows, + now: clock(), + includeResolutions: true, + }) + }) +} +``` + +Define the Prisma decoders used above: + +```ts +type PrismaInterruptRow = { + interruptId: string + runId: string + threadId: string + generation: number + status: string + requestedAt: bigint + resolvedAt: bigint | null + payloadJson: string + bindingJson: string | null + responseJson: string | null +} + +function decodePrismaInterruptRow(row: PrismaInterruptRow): InterruptRecord { + if (row.bindingJson === null) { + throw new InterruptStoreCorruptionError('Pending interrupt has no binding.') + } + const payload: unknown = JSON.parse(row.payloadJson) + const binding: unknown = JSON.parse(row.bindingJson) + if ( + !binding || + typeof binding !== 'object' || + !('interruptId' in binding) || + binding.interruptId !== row.interruptId || + !('interruptedRunId' in binding) || + binding.interruptedRunId !== row.runId || + !('generation' in binding) || + binding.generation !== row.generation + ) { + throw new InterruptStoreCorruptionError('Interrupt binding correlation mismatch.') + } + if ( + row.status !== 'pending' && + row.status !== 'resolved' && + row.status !== 'cancelled' + ) { + throw new InterruptStoreCorruptionError('Invalid interrupt status.') + } + return cloneAndDeepFreezeJson({ + interruptId: row.interruptId, + runId: row.runId, + threadId: row.threadId, + generation: row.generation, + status: row.status, + requestedAt: Number(row.requestedAt), + ...(row.resolvedAt !== null && { resolvedAt: Number(row.resolvedAt) }), + payload, + binding, + ...(row.responseJson !== null && { + response: JSON.parse(row.responseJson) as unknown, + }), + }) as InterruptRecord +} + +type PrismaInterruptBatchRow = { + interruptedRunId: string + threadId: string + generation: number + expectedInterruptIdsJson: string + fingerprint: string + resolutionsJson: string + continuationRunId: string + committedAt: bigint +} + +function decodePrismaBatchRow( + row: PrismaInterruptBatchRow, +): InterruptBatchRecord { + const expected: unknown = JSON.parse(row.expectedInterruptIdsJson) + const resolutions: unknown = JSON.parse(row.resolutionsJson) + if (!Array.isArray(expected) || !Array.isArray(resolutions)) { + throw new InterruptStoreCorruptionError('Invalid interrupt batch arrays.') + } + const resolutionIds = resolutions.map((entry) => { + if (!entry || typeof entry !== 'object' || !('interruptId' in entry)) { + throw new InterruptStoreCorruptionError('Invalid interrupt resolution.') + } + return String(entry.interruptId) + }) + if (!hasExactInterruptIds(expected.map(String), resolutionIds)) { + throw new InterruptStoreCorruptionError('Interrupt batch ID set mismatch.') + } + return cloneAndDeepFreezeJson({ + interruptedRunId: row.interruptedRunId, + threadId: row.threadId, + generation: row.generation, + expectedInterruptIds: expected.map(String), + fingerprint: row.fingerprint, + canonicalResolutions: row.resolutionsJson, + resolutions, + continuationRunId: row.continuationRunId, + committedAt: Number(row.committedAt), + }) as InterruptBatchRecord +} +``` + +The in-transaction legacy branch is the Prisma data migration path: it reconstructs a safe generic authoritative binding from the stored descriptor and persists it before returning `pending`; it never guesses a current tool binding. + +Catch a `P2002` unique race outside the commit transaction, reload the authoritative batch, and compare both fingerprint and canonical `resolutionsJson`; replay always returns the stored continuation ID. Invoke the shared conformance kit with a controllable clock, a faulting gateway wrapper, and a `reopen` that constructs a new Prisma client. Add a direct test that inserts a pending row with `bindingJson: null`, calls recovery, asserts the row was atomically upgraded to `kind: 'generic'`, and can then commit; add expiry-inside-transaction and resolved-without-batch `legacy-committed` tests. All three Prisma schema copies use the field-level `String @unique @map("continuation_run_id")` form shown above; no test or copy uses `@@unique([continuationRunId])`. + +- [ ] **Step 5: Generate the client and run green checks** + +Run: + +```powershell +$env:CI='true' +pnpm --filter @tanstack/ai-persistence-prisma db:generate +pnpm --filter @tanstack/ai-persistence-prisma test:lib -- --run tests/prisma.conformance.test.ts tests/models.test.ts tests/models-cli.test.ts +pnpm --filter @tanstack/ai-persistence-prisma test:types +``` + +Expected: PASS; copied model output contains the new model, and concurrent writers produce one commit and one conflict. + +- [ ] **Step 6: Refactor and recheck** + +Centralize JSON serialization/deserialization for IDs, resolutions, and bindings, rerun the same commands, and verify docs in Task 17 instruct applications to generate and deploy their own Prisma migration before using the new package. + +### Task 7: Implement an atomic Cloudflare D1 conditional batch + +**Prerequisites:** Tasks 4 and 5 for shared schema/migration shape. + +**Files:** +- Modify: `packages/ai-persistence-cloudflare/src/d1.ts` +- Modify: `packages/ai-persistence-cloudflare/src/migrations.ts` +- Create: `packages/ai-persistence-cloudflare/migrations/0001_tanstack_ai_interrupt_batches.sql` +- Create: `packages/ai-persistence-cloudflare/src/assets/0001_tanstack_ai_interrupt_batches.sql` +- Modify: `packages/ai-persistence-cloudflare/tests/runtime.conformance.test.ts` +- Modify: `packages/ai-persistence-cloudflare/tests/migrations.test.ts` +- Modify: `packages/ai-persistence-cloudflare/tests/migration-cli.test.ts` +- Modify: `packages/ai-persistence-cloudflare/tests/api-types.test-d.ts` + +- [ ] **Step 1: Add failing D1 race, rollback, and migration tests** + +Invoke `runInterruptStoreConformance` with `createD1Stores(database).interrupts`, an injected clock, and a reopen callback that reuses the Miniflare database through a new store instance. Add this D1-only rollback assertion: + +```ts +const store = createD1Stores(database, { + interruptTestHook: { failStatementForInterruptId: 'int-b' }, +}).interrupts +if (!store) throw new Error('interrupt store missing') +await openTwoInterrupts(store) +await expect(store.commitInterruptResolutions(validTwoItemCommit())).rejects.toThrow( + /forced D1 statement failure/, +) +expect(await database.prepare('SELECT COUNT(*) AS count FROM interrupt_batches').first()).toEqual({ count: 0 }) +expect(await database.prepare("SELECT COUNT(*) AS count FROM interrupts WHERE status = 'pending'").first()).toEqual({ count: 2 }) +``` + +The fixture functions `openTwoInterrupts` and `validTwoItemCommit` are exported by the shared conformance testkit created in Task 4; they contain the literal two descriptors/bindings and core-canonicalized commit input. Migration tests assert `0001` is bundled and copied after `0000`. + +- [ ] **Step 2: Run the red Cloudflare targets** + +Run: + +```powershell +$env:CI='true' +pnpm --filter @tanstack/ai-persistence-cloudflare test:lib -- --run tests/runtime.conformance.test.ts tests/migrations.test.ts tests/migration-cli.test.ts +pnpm --filter @tanstack/ai-persistence-cloudflare test:types +``` + +Expected: FAIL because `createD1Stores` delegates interrupts to a Drizzle store without an atomic executor and migration `0001` is missing. + +- [ ] **Step 3: Add and register the D1 migration** + +Both D1 migration locations contain these statements, including the same authoritative generic binding reconstruction for PR-head pending descriptors: + +```sql +ALTER TABLE `interrupts` ADD `generation` integer NOT NULL DEFAULT 1; +ALTER TABLE `interrupts` ADD `binding_json` text; +ALTER TABLE `interrupts` ADD `response_schema_hash` text NOT NULL DEFAULT ''; +UPDATE `interrupts` +SET `binding_json` = json_object( + 'kind', 'generic', + 'interruptId', `interrupt_id`, + 'interruptedRunId', `run_id`, + 'generation', 1, + 'responseSchemaHash', + 'legacy-json:' || COALESCE(json(json_extract(`payload_json`, '$.responseSchema')), 'null'), + 'expiresAt', json_extract(`payload_json`, '$.expiresAt') + ), + `response_schema_hash` = + 'legacy-json:' || COALESCE(json(json_extract(`payload_json`, '$.responseSchema')), 'null') +WHERE `status` = 'pending'; +UPDATE `interrupts` SET `generation` = 0 WHERE `status` <> 'pending'; +CREATE TABLE `interrupt_batches` ( + `interrupted_run_id` text PRIMARY KEY NOT NULL, + `thread_id` text NOT NULL, + `generation` integer NOT NULL, + `expected_interrupt_ids_json` text NOT NULL, + `fingerprint` text NOT NULL, + `resolutions_json` text NOT NULL, + `continuation_run_id` text NOT NULL UNIQUE, + `committed_at` integer NOT NULL +); +CREATE INDEX `interrupts_run_status_generation_idx` + ON `interrupts` (`run_id`,`status`,`generation`); +CREATE INDEX `interrupts_thread_status_idx` + ON `interrupts` (`thread_id`,`status`); +``` + +The D1 migration test first inserts PR-head `approval_required` and `client_tool_input` descriptors with no reserved binding, applies `0001`, and asserts both remain `pending` with generation `1`, a `kind: 'generic'` binding correlated to the stored row/run, and a `legacy-json:` response-schema identity. Apply the migration in one D1 migration transaction and assert the table, columns, indexes, and both upgraded rows appear together. Import the raw asset and append: + +```ts +{ + id: '0001_tanstack_ai_interrupt_batches', + filename: '0001_tanstack_ai_interrupt_batches.sql', + sql: interruptBatchesMigrationSql, +} +``` + +Keep `src/r2.ts` and R2 migrations unchanged. + +- [ ] **Step 4: Override the D1 interrupt store with one conditional batch** + +Implement `createD1InterruptStore(d1, clock)` and return it from `createD1Stores`. The open and recovery methods are: + +```ts +async function openInterruptBatch( + input: OpenInterruptBatchInput, +): Promise<{ generation: number; descriptors: readonly Interrupt[] }> { + const descriptorIds = input.descriptors.map((descriptor) => descriptor.id) + const bindingIds = input.bindings.map((binding) => binding.interruptId) + if (!hasExactInterruptIds(descriptorIds, bindingIds)) { + throw new TypeError('Interrupt descriptors and bindings must have exact IDs.') + } + const existing = await d1.prepare( + 'SELECT * FROM interrupts WHERE run_id = ? ORDER BY interrupt_id', + ).bind(input.interruptedRunId).all() + if (existing.results.length > 0) { + if (!hasExactInterruptIds( + existing.results.map((row) => row.interrupt_id), + descriptorIds, + )) { + throw new Error('Interrupt batch already opened with a different set.') + } + return { + generation: existing.results[0]?.generation ?? 1, + descriptors: cloneAndDeepFreezeJson( + existing.results.map((row) => JSON.parse(row.payload_json) as Interrupt), + ), + } + } + + const generation = 1 + const bindings = new Map(input.bindings.map((binding) => [ + binding.interruptId, + cloneAndDeepFreezeJson({ + ...binding, + interruptedRunId: input.interruptedRunId, + generation, + } satisfies InterruptBinding), + ])) + const descriptors = input.descriptors.map((descriptor) => { + const binding = bindings.get(descriptor.id) + if (!binding) throw new Error(`Missing binding for ${descriptor.id}`) + return cloneAndDeepFreezeJson({ + ...descriptor, + metadata: { ...descriptor.metadata, 'tanstack:binding': binding }, + }) + }) + const results = await d1.batch(descriptors.map((descriptor) => { + const binding = bindings.get(descriptor.id) + if (!binding) throw new Error(`Missing binding for ${descriptor.id}`) + return d1.prepare(`INSERT INTO interrupts ( + interrupt_id, run_id, thread_id, generation, status, requested_at, + payload_json, binding_json, response_schema_hash + ) VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, ?)`).bind( + descriptor.id, + input.interruptedRunId, + input.threadId, + generation, + clock(), + JSON.stringify(descriptor), + JSON.stringify(binding), + binding.responseSchemaHash, + ) + })) + if (results.some((result) => result.meta.changes !== 1)) { + throw new Error('D1 did not open every interrupt row.') + } + return { generation, descriptors: cloneAndDeepFreezeJson(descriptors) } +} + +async function getInterruptRecoveryState( + query: InterruptRecoveryQuery, +): Promise { + const [batchResult, rowsResult] = await d1.batch([ + d1.prepare( + 'SELECT * FROM interrupt_batches WHERE interrupted_run_id = ?', + ).bind(query.interruptedRunId), + d1.prepare( + 'SELECT * FROM interrupts WHERE run_id = ? ORDER BY interrupt_id', + ).bind(query.interruptedRunId), + ]) + const batchRow = batchResult.results[0] as D1InterruptBatchRow | undefined + const rows = rowsResult.results as D1InterruptRow[] + return projectInterruptRecovery({ + query, + batch: batchRow ? decodeD1BatchRow(batchRow) : null, + rows: rows.map(decodeD1InterruptRow), + now: clock(), + includeResolutions: true, + }) +} +``` + +Add the row contracts and decoders used by those methods: + +```ts +interface D1InterruptRow { + interrupt_id: string + run_id: string + thread_id: string + generation: number + status: 'pending' | 'resolved' | 'cancelled' + requested_at: number + resolved_at: number | null + payload_json: string + binding_json: string + response_json: string | null +} + +interface D1InterruptBatchRow { + interrupted_run_id: string + thread_id: string + generation: number + expected_interrupt_ids_json: string + fingerprint: string + resolutions_json: string + continuation_run_id: string + committed_at: number +} + +function decodeD1InterruptRow(row: D1InterruptRow): InterruptRecord { + const payload: unknown = JSON.parse(row.payload_json) + const binding: unknown = JSON.parse(row.binding_json) + if ( + !binding || + typeof binding !== 'object' || + !('interruptId' in binding) || + binding.interruptId !== row.interrupt_id || + !('interruptedRunId' in binding) || + binding.interruptedRunId !== row.run_id || + !('generation' in binding) || + binding.generation !== row.generation + ) { + throw new InterruptStoreCorruptionError('Interrupt binding correlation mismatch.') + } + return cloneAndDeepFreezeJson({ + interruptId: row.interrupt_id, + runId: row.run_id, + threadId: row.thread_id, + generation: row.generation, + status: row.status, + requestedAt: row.requested_at, + ...(row.resolved_at !== null && { resolvedAt: row.resolved_at }), + payload, + binding, + ...(row.response_json !== null && { + response: JSON.parse(row.response_json) as unknown, + }), + }) as InterruptRecord +} + +function decodeD1BatchRow(row: D1InterruptBatchRow): InterruptBatchRecord { + const expected: unknown = JSON.parse(row.expected_interrupt_ids_json) + const resolutions: unknown = JSON.parse(row.resolutions_json) + if (!Array.isArray(expected) || !Array.isArray(resolutions)) { + throw new InterruptStoreCorruptionError('Invalid interrupt batch arrays.') + } + const resolutionIds = resolutions.map((entry) => { + if (!entry || typeof entry !== 'object' || !('interruptId' in entry)) { + throw new InterruptStoreCorruptionError('Invalid interrupt resolution.') + } + return String(entry.interruptId) + }) + if (!hasExactInterruptIds(expected.map(String), resolutionIds)) { + throw new InterruptStoreCorruptionError('Interrupt batch ID set mismatch.') + } + return cloneAndDeepFreezeJson({ + interruptedRunId: row.interrupted_run_id, + threadId: row.thread_id, + generation: row.generation, + expectedInterruptIds: expected.map(String), + fingerprint: row.fingerprint, + canonicalResolutions: row.resolutions_json, + resolutions, + continuationRunId: row.continuation_run_id, + committedAt: row.committed_at, + }) as InterruptBatchRecord +} +``` + +Recovery reads winner and rows in one D1 batch snapshot. Commit builds the dynamic `IN` list from the already unique expected IDs and submits this insert followed by one gated update per resolution: + +```sql +INSERT INTO interrupt_batches ( + interrupted_run_id, thread_id, generation, + expected_interrupt_ids_json, fingerprint, resolutions_json, + continuation_run_id, committed_at +) +SELECT ?, ?, ?, ?, ?, ?, ?, ? +WHERE ( + SELECT COUNT(*) FROM interrupts + WHERE run_id = ? AND status = 'pending' AND generation = ? +) = ? +AND NOT EXISTS ( + SELECT 1 FROM interrupts + WHERE run_id = ? AND status = 'pending' AND generation = ? + AND interrupt_id NOT IN (?, ?) +) +AND NOT EXISTS ( + SELECT 1 FROM interrupts + WHERE run_id = ? AND status = 'pending' AND generation = ? + AND json_extract(binding_json, '$.expiresAt') IS NOT NULL + AND unixepoch(json_extract(binding_json, '$.expiresAt')) * 1000 <= ? +); +``` + +Each update includes `WHERE interrupt_id = ? AND run_id = ? AND status = 'pending' AND generation = ? AND EXISTS (SELECT 1 FROM interrupt_batches WHERE interrupted_run_id = ? AND fingerprint = ? AND resolutions_json = ? AND continuation_run_id = ?)`. Inspect every batch result: the insert and every expected update must report one change. Zero insert changes or a uniqueness race triggers an authoritative batch read; replay requires both fingerprint and canonical `resolutions_json`, otherwise conflict. Any other zero update or statement error throws, and D1 rolls the batch back. The shared conformance suite plus D1 migration tests cover open, commit, all five recovery projections, expiry in the SQL predicate, exact replay, conflict, forced rollback, restart, and migrated pending/resolved rows. + +- [ ] **Step 5: Run green D1 checks and refactor** + +Run: + +```powershell +$env:CI='true' +pnpm --filter @tanstack/ai-persistence-cloudflare test:lib -- --run tests/runtime.conformance.test.ts tests/migrations.test.ts tests/migration-cli.test.ts +pnpm --filter @tanstack/ai-persistence-cloudflare test:types +``` + +Expected: PASS; forced failure rolls back, exact replay returns the first continuation ID, and different writers observe one conflict. Extract D1 row decoding/recovery helpers and rerun both commands. + +### Task 8: Build the authoritative resume gateway and explicit recovery handler + +**Prerequisites:** Tasks 3-7. All built-in stores must satisfy conformance before server execution depends on them. + +**Files:** +- Modify: `packages/ai-persistence/src/middleware.ts:93-214,675-711,764-911` +- Modify: `packages/ai-persistence/src/capabilities.ts` +- Create: `packages/ai-persistence/src/recovery.ts` +- Modify: `packages/ai-persistence/src/index.ts` +- Modify: `packages/ai-persistence/tests/interrupts.test.ts` +- Modify: `packages/ai-persistence/tests/with-persistence.test.ts` +- Modify: `packages/ai-persistence/tests/persistence-validation.test.ts` +- Modify: `packages/ai/tests/interrupts.test.ts` + +- [ ] **Step 1: Write failing exhaustive-validation and recovery tests** + +Add one batch containing three independent invalid entries and prove all are reported while the store remains pending. Reuse the existing local `mockAdapter` and `collect` test utilities already defined at the top of `tests/interrupts.test.ts`. Define the tool and fixture in that file: + +```ts +const transferInput = z.object({ cents: z.number().int().positive() }) +const transferApprove = z.object({ note: z.string().min(1) }) +const transferReject = z.object({ reason: z.string().min(1) }) +const transferTool = toolDefinition({ + name: 'transfer', + description: 'Transfer funds', + needsApproval: true, + inputSchema: transferInput, + approvalSchema: { + approve: transferApprove, + reject: transferReject, + }, +}).server(async ({ cents }) => ({ receipt: `receipt-${cents}` })) + +async function openApprovalBatch( + persistence: AIPersistence, + interruptedRunId: string, +): Promise { + const store = persistence.stores.interrupts + if (!store) throw new Error('interrupt store missing') + const approval = normalizeApprovalSchema( + transferTool.approvalSchema, + transferTool.inputSchema, + ) + await store.openInterruptBatch({ + threadId: 'thread-1', + interruptedRunId, + descriptors: [ + { + id: 'approve', + reason: 'tool_call', + toolCallId: 'call-approve', + responseSchema: approval.responseSchema, + }, + { + id: 'deny', + reason: 'tool_call', + toolCallId: 'call-deny', + responseSchema: approval.responseSchema, + }, + { + id: 'generic', + reason: 'input_required', + responseSchema: { + type: 'object', + properties: { email: { type: 'string', format: 'email' } }, + required: ['email'], + additionalProperties: false, + }, + }, + ], + bindings: [ + { + kind: 'tool-approval', + interruptId: 'approve', + toolName: 'transfer', + toolCallId: 'call-approve', + originalArgs: { cents: 1 }, + inputSchemaHash: hashSchemaInput(transferTool.inputSchema), + approvalSchemaHash: approval.approvalSchemaHash, + responseSchemaHash: approval.responseSchemaHash, + }, + { + kind: 'tool-approval', + interruptId: 'deny', + toolName: 'transfer', + toolCallId: 'call-deny', + originalArgs: { cents: 2 }, + inputSchemaHash: hashSchemaInput(transferTool.inputSchema), + approvalSchemaHash: approval.approvalSchemaHash, + responseSchemaHash: approval.responseSchemaHash, + }, + { + kind: 'generic', + interruptId: 'generic', + responseSchemaHash: digestInterruptJson(canonicalInterruptJson({ + type: 'object', + properties: { email: { type: 'string', format: 'email' } }, + required: ['email'], + additionalProperties: false, + })), + }, + ], + }) +} +``` + +Obtain the no-execution adapter as `const { adapter: shouldNotRunAdapter, calls: adapterCalls } = mockAdapter([])` and assert `adapterCalls` rather than assuming `chatStream` is a spy: + +```ts +it('reports every invalid response and commits nothing', async () => { + const persistence = memoryPersistence() + const { + adapter: shouldNotRunAdapter, + calls: adapterCalls, + } = mockAdapter([]) + await openApprovalBatch(persistence, 'run-old') + const stream = chat({ + adapter: shouldNotRunAdapter, + messages: [], + threadId: 'thread-1', + runId: 'run-new', + parentRunId: 'run-old', + resume: [ + { + interruptId: 'approve', + status: 'resolved', + payload: { approved: true, editedArgs: { cents: 'wrong' } }, + }, + { + interruptId: 'deny', + status: 'resolved', + payload: { approved: false, editedArgs: { cents: 1 } }, + }, + { + interruptId: 'generic', + status: 'resolved', + payload: { email: 42 }, + }, + ], + tools: [transferTool], + middleware: [withChatPersistence(persistence)], + }) + const chunks = await collect(stream) + const errors = chunks.filter((chunk) => chunk.type === 'RUN_ERROR') + + expect(errors).toHaveLength(1) + expect(errors[0]?.['tanstack:interruptErrors']).toEqual([ + expect.objectContaining({ interruptId: 'approve', code: 'invalid-edited-args' }), + expect.objectContaining({ interruptId: 'deny', code: 'invalid-edited-args' }), + expect.objectContaining({ interruptId: 'generic', code: 'invalid-payload' }), + ]) + expect(await persistence.stores.interrupts?.listPendingByRun('run-old')).toHaveLength(3) + expect(adapterCalls).toHaveLength(0) +}) +``` + +Add the exact-set aggregate test: + +```ts +it('returns item and batch exact-set errors together', async () => { + const persistence = memoryPersistence() + const store = persistence.stores.interrupts + if (!store) throw new Error('interrupt store missing') + const responseSchema = { + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'], + additionalProperties: false, + } as const + await store.openInterruptBatch({ + threadId: 'thread-1', + interruptedRunId: 'run-old', + descriptors: ['a', 'b', 'c'].map((id) => ({ + id, + reason: 'input_required', + responseSchema, + })), + bindings: ['a', 'b', 'c'].map((interruptId) => ({ + kind: 'generic' as const, + interruptId, + responseSchemaHash: digestInterruptJson( + canonicalInterruptJson(responseSchema), + ), + })), + }) + const { adapter, calls } = mockAdapter([]) + const chunks = await collect(chat({ + adapter, + messages: [], + threadId: 'thread-1', + runId: 'run-new', + parentRunId: 'run-old', + resume: [ + { interruptId: 'a', status: 'resolved', payload: { value: 1 } }, + { interruptId: 'a', status: 'cancelled' }, + { interruptId: 'x', status: 'resolved', payload: { value: 'extra' } }, + ], + middleware: [withChatPersistence(persistence)], + })) + const runErrors = chunks.filter((chunk) => chunk.type === 'RUN_ERROR') + expect(runErrors).toHaveLength(1) + expect(runErrors[0]?.['tanstack:interruptErrors']).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + scope: 'item', + interruptId: 'a', + code: 'invalid-payload', + }), + expect.objectContaining({ + scope: 'item', + interruptId: 'x', + code: 'unknown-interrupt', + }), + expect.objectContaining({ + scope: 'batch', + code: 'protocol', + interruptIds: ['a'], + }), + expect.objectContaining({ + scope: 'batch', + code: 'protocol', + interruptIds: ['x'], + }), + expect.objectContaining({ + scope: 'batch', + code: 'incomplete-batch', + interruptIds: ['b', 'c'], + }), + ]), + ) + expect(await store.listPendingByRun('run-old')).toHaveLength(3) + expect(calls).toHaveLength(0) +}) +``` + +Add a table whose setup functions are concrete closures in the same test file: + +```ts +async function createInvalidGatewayFixture(interruptId: string) { + const persistence = memoryPersistence() + const store = persistence.stores.interrupts + if (!store) throw new Error('interrupt store missing') + const { adapter, calls: adapterCalls } = mockAdapter([]) + const approval = normalizeApprovalSchema( + transferTool.approvalSchema, + transferTool.inputSchema, + ) + const isTool = interruptId === 'tool' || interruptId === 'drift' + const descriptor: Interrupt = isTool + ? { + id: interruptId, + reason: 'tool_call', + toolCallId: `call-${interruptId}`, + responseSchema: approval.responseSchema, + ...(interruptId === 'expired' && { + expiresAt: '2026-07-13T09:59:00.000Z', + }), + } + : { + id: interruptId, + reason: 'confirmation', + responseSchema: { type: 'object' }, + ...(interruptId === 'expired' && { + expiresAt: '2026-07-13T09:59:00.000Z', + }), + } + const binding: UnopenedInterruptBinding = isTool + ? { + kind: 'tool-approval', + interruptId, + toolName: interruptId === 'tool' ? 'removed-tool' : 'transfer', + toolCallId: `call-${interruptId}`, + originalArgs: { cents: 1 }, + inputSchemaHash: + interruptId === 'drift' + ? 'sha256:deployed-different-schema' + : hashSchemaInput(transferTool.inputSchema), + approvalSchemaHash: approval.approvalSchemaHash, + responseSchemaHash: approval.responseSchemaHash, + } + : { + kind: 'generic', + interruptId, + responseSchemaHash: digestInterruptJson( + canonicalInterruptJson(descriptor.responseSchema ?? null), + ), + ...(descriptor.expiresAt !== undefined && { + expiresAt: descriptor.expiresAt, + }), + } + await store.openInterruptBatch({ + threadId: 'thread-1', + interruptedRunId: 'run-old', + descriptors: [descriptor], + bindings: [binding], + }) + return { + persistence, + store, + adapter, + adapterCalls, + threadId: 'thread-1', + interruptedRunId: 'run-old', + tools: interruptId === 'drift' ? [transferTool] : [], + } +} + +it.each([ + ['cancelled payload', { interruptId: 'a', status: 'cancelled', payload: {} }, 'protocol'], + ['expired', { interruptId: 'expired', status: 'cancelled' }, 'expired'], + ['unknown tool after deploy', { interruptId: 'tool', status: 'cancelled' }, 'stale'], + ['schema hash drift', { interruptId: 'drift', status: 'cancelled' }, 'stale'], +] as const)('%s performs no write or execution', async (_name, resumeItem, code) => { + const fixture = await createInvalidGatewayFixture(resumeItem.interruptId) + const chunks = await collect(chat({ + adapter: fixture.adapter, + messages: [], + threadId: fixture.threadId, + runId: 'run-new', + parentRunId: fixture.interruptedRunId, + resume: [resumeItem], + tools: fixture.tools, + middleware: [withChatPersistence(fixture.persistence)], + })) + expect(chunks).toContainEqual( + expect.objectContaining({ + type: 'RUN_ERROR', + 'tanstack:interruptErrors': expect.arrayContaining([ + expect.objectContaining({ code }), + ]), + }), + ) + expect(await fixture.store.listPendingByRun(fixture.interruptedRunId)).toHaveLength(1) + expect(fixture.adapterCalls).toHaveLength(0) +}) +``` + +Separate tests cover missing `parentRunId`, exact replay, a conflicting second batch, and a persistence object with no interrupt store, because those cases use different request/setup shapes. Each asserts one terminal, the named code, zero adapter calls, and unchanged pending rows. Recovery handler tests use the full/redacted authorization contract from Step 6. + +- [ ] **Step 2: Run the red gateway tests** + +Run: + +```powershell +$env:CI='true' +pnpm --filter @tanstack/ai-persistence test:lib -- --run tests/interrupts.test.ts tests/with-persistence.test.ts tests/persistence-validation.test.ts +``` + +Expected: FAIL because middleware still uses fail-fast `validatePendingResumes`, sequential `applyPendingResumes`, and same-run lookup. + +- [ ] **Step 3: Provide the core capability and atomically open descriptors** + +In middleware `setup`, call `provideInterruptPersistence(ctx, persistence.stores.interrupts)` only after store validation proves all atomic methods exist. In `onChunk`, replace the per-descriptor `create` loop with one call: + +```ts +if ( + chunk.type === EventType.RUN_FINISHED && + chunk.outcome?.type === 'interrupt' +) { + const gateway = getInterruptPersistence(ctx) + const bindings = chunk.outcome.interrupts.map(readServerBinding) + const opened = await gateway.openInterruptBatch({ + threadId: ctx.threadId, + interruptedRunId: ctx.runId, + descriptors: chunk.outcome.interrupts, + bindings, + }) + await interruptRun(runs, ctx.runId) + return { + ...chunk, + outcome: { type: 'interrupt', interrupts: [...opened.descriptors] }, + } +} +``` + +`readServerBinding` accepts only the reserved server-produced metadata shape created in Task 9; missing/malformed bindings fail before this terminal chunk is yielded. `openInterruptBatch` is all-or-none, so an exception cannot leave a partial descriptor set. + +- [ ] **Step 4: Rebind configured tool validators and collect every error** + +Replace `validatePendingResumes` with an async exhaustive function. The authoritative lookup key is stored tool name plus schema hashes, never client metadata: + +```ts +async function validateInterruptResumeBatch(input: { + pending: readonly InterruptRecord[] + resume: readonly RunAgentResumeItem[] + tools: readonly Tool[] + now: number + correlation: InterruptCorrelation +}): Promise { + const itemErrors: ItemInterruptError[] = [] + const batchErrors: BatchInterruptError[] = [] + const pendingIds = new Set(input.pending.map((record) => record.interruptId)) + const resumeById = new Map() + for (const entry of input.resume) { + if (resumeById.has(entry.interruptId)) { + batchErrors.push(batchError('protocol', [entry.interruptId], 'Duplicate response.', input.correlation)) + continue + } + resumeById.set(entry.interruptId, entry) + if (!pendingIds.has(entry.interruptId)) { + itemErrors.push(itemError(entry.interruptId, 'unknown-interrupt', 'Unknown interrupt.', input.correlation)) + batchErrors.push(batchError('protocol', [entry.interruptId], 'Extra response.', input.correlation)) + } + } + const missing = [...pendingIds].filter((id) => !resumeById.has(id)).sort() + if (missing.length > 0) { + batchErrors.push(batchError('incomplete-batch', missing, 'Missing responses.', input.correlation)) + } + + for (const record of [...input.pending].sort((a, b) => + a.interruptId.localeCompare(b.interruptId), + )) { + const entry = resumeById.get(record.interruptId) + if (!entry) continue + const rebound = rebindAuthoritativeValidator(record.binding, input.tools) + if (rebound.status === 'stale') { + itemErrors.push(staleSchemaError(record, input.correlation)) + continue + } + itemErrors.push(...await validateOneResume(record, entry, rebound, input)) + } + itemErrors.sort((left, right) => left.interruptId.localeCompare(right.interruptId)) + return itemErrors.length > 0 || batchErrors.length > 0 + ? { ok: false, itemErrors, batchErrors, errors: [...itemErrors, ...batchErrors] } + : { ok: true, state: buildResumeState(input.pending, resumeById) } +} +``` + +`batchError` and `itemError` are constructors that copy the full correlation and set `source: 'server'`/`retryable: false`. `validateOneResume` is exhaustive: cancelled entries reject any payload; tool approval validates the response envelope, then edited arguments and selected nested payload; rejection forbids edits; client-tool execution validates output; generic validates the descriptor response schema. Standard Schema validators are awaited and their returned issue order is preserved. Only the outer `itemErrors` array is sorted by interrupt ID. Missing tool/hash drift returns one `stale` item error and never an unconstrained validator. + +- [ ] **Step 5: Commit once using `parentRunId` as the CAS key** + +In `onConfig`, reject `resume` without `ctx.parentRunId`; load only `listPendingByRun(ctx.parentRunId)`, validate every item, and compute the fingerprint only after validation. Commit exactly once with the candidate continuation `ctx.runId`: + +```ts +const candidate = canonicalizeInterruptResolutions(config.resume) +const committed = await store.commitInterruptResolutions({ + threadId: ctx.threadId, + interruptedRunId: ctx.parentRunId, + continuationRunId: ctx.runId, + expectedGeneration: pending[0]?.generation ?? 0, + expectedInterruptIds: pending.map((record) => record.interruptId), + resolutions: candidate.resolutions, + fingerprint: candidate.fingerprint, + canonicalResolutions: candidate.canonicalResolutions, +}) +``` + +On `committed`, return `resumeToolState` containing approvals with edited arguments/custom payload, client results, denied results, cancelled tool-call IDs, and generic resolutions. Later application middleware can read `genericInterrupts` and continue its own workflow without a tool event. On `replayed`, throw an internal replay signal carrying the winning continuation ID; Task 9 converts it to an accepted terminal replay result without executing tools twice. On `conflict`, throw a serializable interrupt submission failure with authoritative recovery. + +- [ ] **Step 6: Create a direct and authenticated HTTP recovery helper** + +Implement both an in-process function and an explicit handler factory: + +```ts +export async function getInterruptRecoveryState( + persistence: AIPersistence, + input: InterruptRecoveryQuery, +): Promise { + const store = persistence.stores.interrupts + if (!store) throw new Error('Interrupt persistence is not configured.') + return store.getInterruptRecoveryState(input) +} + +export function createInterruptRecoveryHandler(options: { + persistence: AIPersistence + authorize: ( + request: Request, + input: InterruptRecoveryQuery, + ) => RecoveryAuthorization | Promise +}): (request: Request) => Promise { + return async (request) => { + const input = parseInterruptRecoveryQuery(await request.json()) + const authorization = await options.authorize(request, input) + if (!authorization.allowed) { + return new Response('Forbidden', { status: 403 }) + } + const state = await getInterruptRecoveryState(options.persistence, input) + return Response.json(projectRecoveryState(state, authorization)) + } +} + +export type RecoveryAuthorization = + | { allowed: false } + | { allowed: true; includeResolutions: boolean } + +function projectRecoveryState( + state: InterruptRecoveryStateV1, + authorization: Extract, +): InterruptRecoveryStateV1 { + if ( + authorization.includeResolutions || + state.state !== 'committed' || + !state.committed + ) { + return cloneAndDeepFreezeJson(state) + } + const { resolutions: _redacted, ...committed } = state.committed + return cloneAndDeepFreezeJson({ ...state, committed }) +} +``` + +Test `{ allowed: false }` → 403, `{ allowed: true, includeResolutions: false }` → committed DTO without `resolutions`, and `includeResolutions: true` → full frozen resolutions. The helper does not register a route or infer a URL. + +- [ ] **Step 7: Run green gateway checks and refactor** + +Run: + +```powershell +$env:CI='true' +pnpm --filter @tanstack/ai-persistence test:lib -- --run tests/interrupts.test.ts tests/memory.conformance.test.ts tests/with-persistence.test.ts tests/persistence-validation.test.ts +pnpm --filter @tanstack/ai-persistence test:types +``` + +Expected: PASS; three invalid entries arrive in one error, nothing writes or executes, and schema drift is stale. Split exact-set, validator-rebind, and resume-state builders into pure private helpers, rerun both commands, and confirm core still has no persistence-package import. + +### Task 9: Correct the AG-UI interrupt lifecycle and resumed tool audit + +**Prerequisites:** Task 8. + +**Files:** +- Modify: `packages/ai/src/activities/chat/index.ts:504-980,1409-1640,1673-1888,2821-2860` +- Modify: `packages/ai/src/activities/chat/tools/tool-calls.ts:425-448,681-890` +- Modify: `packages/ai/src/types.ts:590-735,980-1010,1081-1124` +- Modify: `packages/ai/src/utilities/chat-params.ts:45-104` +- Modify: `packages/ai/tests/interrupts.test.ts` +- Modify: `packages/ai/tests/chat.test.ts:423-870` +- Modify: `packages/ai/tests/chat-params.test.ts` +- Modify: `packages/ai/tests/stream-processor.test.ts:1197-1376,2177-2220` + +- [ ] **Step 1: Write failing wire-order and continuation-audit tests** + +Add a protocol test that records exact chunk order and a continuation test with approved, denied, and cancelled tool approvals: + +```ts +expect(interruptedChunks.map((chunk) => chunk.type).slice(-3)).toEqual([ + EventType.MESSAGES_SNAPSHOT, + EventType.STATE_SNAPSHOT, + EventType.RUN_FINISHED, +]) +expect(interruptOutcome.interrupts).toEqual([ + expect.objectContaining({ reason: 'tool_call' }), + expect.objectContaining({ reason: 'tanstack:client_tool_execution' }), +]) + +const resumedToolEvents = continuationChunks.filter((chunk) => + chunk.type.startsWith('TOOL_CALL_'), +) +expect(resumedToolEvents.map((chunk) => chunk.type)).toEqual([ + EventType.TOOL_CALL_RESULT, + EventType.TOOL_CALL_RESULT, +]) +expect(resumedToolEvents.map((chunk) => chunk.toolCallId)).toEqual([ + 'approved-call', + 'denied-call', +]) +expect(continuationChunks).not.toContainEqual( + expect.objectContaining({ toolCallId: 'cancelled-call' }), +) +``` + +Assert the continuation has a new run ID, its `parentRunId` equals the interrupted run, its thread is unchanged, approved edits replace rather than merge, denied history is `{ status: 'denied', payload }`, cancellation is persisted in the interrupt audit and marks the original call not-executed during provider-history reconstruction, generic continuation reaches application middleware, and `onError` runs once for interrupt submission failure. Add a run with an approval-producing tool but no interrupt persistence capability and assert one `RUN_ERROR` with `persistence-required`, no snapshots, and no interrupt `RUN_FINISHED`. + +- [ ] **Step 2: Run the red core lifecycle tests** + +Run: + +```powershell +pnpm --filter @tanstack/ai test:lib -- --run tests/interrupts.test.ts tests/chat.test.ts tests/chat-params.test.ts +``` + +Expected: FAIL on old reasons `approval_required` / `client_tool_input`, replayed start/args/end events, missing snapshots, and missing structured error handling. + +- [ ] **Step 3: Build canonical descriptors and reserved bindings** + +Change `buildActionableInterrupts` to use normalized tool schemas and server-owned binding metadata: + +```ts +const approvalSchema = normalizeApprovalSchema( + tool.approvalSchema, + tool.inputSchema, +) +interrupts.push({ + id: approval.approvalId, + reason: 'tool_call', + message: `Approval required to run ${approval.toolName}`, + toolCallId: approval.toolCallId, + responseSchema: approvalSchema.responseSchema, + metadata: { + 'tanstack:binding': { + kind: 'tool-approval', + toolName: approval.toolName, + toolCallId: approval.toolCallId, + originalArgs: approval.input, + responseSchemaHash: approvalSchema.responseSchemaHash, + inputSchemaHash: hashSchemaInput(tool.inputSchema), + approvalSchemaHash: approvalSchema.approvalSchemaHash, + }, + }, +}) +``` + +Client-tool descriptors use `tanstack:client_tool_execution`, the original call ID, and output/response schema identities. Generic reasons retain `input_required`, `confirmation`, or an unknown namespaced/custom reason. Invalid raw schemas fail before a descriptor is emitted. + +Before returning any nonempty actionable descriptor set, require the core-owned `InterruptPersistenceCapability` from Task 3. If the capability is absent, throw `InterruptSubmissionFailure` with batch code `persistence-required` before emitting snapshots or an interrupt terminal. This check lives in core lifecycle code; merely omitting `withChatPersistence` must never create an unresumable native interrupt. + +- [ ] **Step 4: Emit required snapshots immediately before the terminal** + +Add `state?: unknown` to chat text options and pass the parsed AG-UI state from `chatParamsFromRequestBody`. Before every interrupt terminal path, yield: + +```ts +yield* this.pipeThroughMiddleware({ + type: EventType.MESSAGES_SNAPSHOT, + messages: uiMessagesToWire(modelMessagesToUIMessages(this.messages)), + timestamp: Date.now(), +}) +if (this.params.state !== undefined) { + yield* this.pipeThroughMiddleware({ + type: EventType.STATE_SNAPSHOT, + snapshot: this.params.state, + timestamp: Date.now(), + }) +} +yield* this.pipeThroughMiddleware(this.buildInterruptFinishedChunk( + finishEvent, + executionResult.needsApproval, + executionResult.needsClientExecution, +)) +``` + +Because middleware processes a chunk before the generator yields it, Task 8's atomic descriptor open must finish before `RUN_FINISHED` becomes observable. If it fails, snapshots may precede the single `RUN_ERROR`, but no interrupt terminal is emitted. + +- [ ] **Step 5: Carry rich approval decisions into tool execution** + +Change `executeToolCalls` from `Map` to `ReadonlyMap`. Select the authoritative stored original input unless an approved decision contains `editedArgs`; use edits as a complete replacement and validate them before execution. A denial produces one synthetic result: + +```ts +if (!decision.approved) { + results.push({ + toolCallId: toolCall.id, + toolName, + result: { + status: 'denied', + ...(decision.payload !== undefined && { payload: decision.payload }), + }, + state: 'output-available', + }) + continue +} +input = decision.editedArgs === undefined ? input : decision.editedArgs +``` + +Filter `cancelledToolCallIds` before execution and result construction. Record those call IDs as not-executed in the interrupt audit/provider-history reconstruction path without producing a result chunk. Client-tool resolved output follows the existing result path; generic resolutions remain only in middleware continuation state and create no tool chunk. + +- [ ] **Step 6: Make resumed tool output result-only** + +Delete the `argsMap` branch that synthesizes `TOOL_CALL_START`, `TOOL_CALL_ARGS`, and `TOOL_CALL_END` in `buildToolResultChunks`. Always emit one `TOOL_CALL_RESULT` for actual approved/client results and synthetic denied results, correlated to the original `toolCallId`. Persist corresponding actual/denied history before the resumed model begins; cancelled calls remain in the interrupt audit record and are excluded from actionable provider history. + +- [ ] **Step 7: Emit exactly one structured terminal on gateway failures and replay** + +Introduce an internal `InterruptSubmissionFailure` carrying serialized errors/recovery and an `InterruptReplaySignal` carrying the winning continuation ID. In the `TextEngine.run()` catch, call middleware `onError` once, then yield one `RUN_ERROR` for submission failure and return without rethrowing or adding `RUN_FINISHED`: + +```ts +if (error instanceof InterruptSubmissionFailure) { + await this.runInterruptOnErrorOnce(error) + yield { + type: EventType.RUN_ERROR, + threadId: this.middlewareCtx.threadId, + runId: this.middlewareCtx.runId, + timestamp: Date.now(), + message: error.message, + code: 'INTERRUPT_SUBMISSION_FAILED', + 'tanstack:interruptErrors': error.errors, + ...(error.recovery && { + 'tanstack:interruptRecovery': error.recovery, + }), + } + return +} +``` + +For exact replay, emit a successful `RUN_FINISHED` whose standard `result` is canonical JSON `{ "type": "tanstack:interrupt-replay", "continuationRunId": "winning-run" }`; do not execute tools. The client recognizes this result and joins/reloads the winner. `runStreamingText` disposes MCP resources in `finally` but does not translate or duplicate these terminal signals. + +- [ ] **Step 8: Run green lifecycle checks and audit legacy emission** + +Run: + +```powershell +pnpm --filter @tanstack/ai test:lib -- --run tests/interrupts.test.ts tests/chat.test.ts tests/chat-params.test.ts tests/stream-processor.test.ts +pnpm --filter @tanstack/ai test:types +``` + +Expected: PASS; snapshots precede a nonempty interrupt outcome, continuations use a new run with `parentRunId`, and resumed tools emit results only. Confirm native server paths emit no `approval-requested` or `tool-input-available` custom event; deprecated readers remain tested for old streams. + +- [ ] **Step 9: Focused refactor and recheck** + +Extract descriptor/binding construction and terminal-error serialization into focused helpers, rerun the same commands, and verify every terminal path calls exactly one of `onFinish`, `onAbort`, or `onError`. + +### Task 10: Define and hydrate the typed bound interrupt union + +**Prerequisites:** Tasks 2, 3, and 9. + +**Files:** +- Create: `packages/ai-client/src/interrupt-manager.ts` +- Modify: `packages/ai-client/src/types.ts:1-110,397-585` +- Modify: `packages/ai-client/src/index.ts` +- Create: `packages/ai-client/tests/chat-client-interrupts.test.ts` +- Create: `packages/ai-client/tests/interrupts-types.test-d.ts` + +- [ ] **Step 1: Write failing type and hydration tests before implementation** + +Define an approval tool with distinct branches and assert the exact resolver calls: + +```ts +const transfer = toolDefinition({ + name: 'transfer', + description: 'Transfer funds', + needsApproval: true, + inputSchema: z.object({ cents: z.number() }), + outputSchema: z.object({ receipt: z.string() }), + approvalSchema: { + approve: z.object({ note: z.string() }), + reject: z.object({ reason: z.string() }), + }, +}).client() + +declare const interrupt: Extract< + ChatInterrupt, + { kind: 'tool-approval'; toolName: 'transfer' } +> + +interrupt.resolveInterrupt(true, { + editedArgs: { cents: 500 }, + payload: { note: 'Reviewed' }, +}) +interrupt.resolveInterrupt(false, { payload: { reason: 'Policy' } }) +// @ts-expect-error rejection never accepts editedArgs +interrupt.resolveInterrupt(false, { editedArgs: { cents: 1 }, payload: { reason: 'Policy' } }) +// @ts-expect-error custom data belongs under payload +interrupt.resolveInterrupt(true, { editedArgs: { cents: 1 }, note: 'Reviewed' }) + +const noInput = toolDefinition({ + name: 'noInput', + description: 'No editable input', + needsApproval: true, +}).client() +declare const noInputInterrupt: Extract< + ChatInterrupt, + { kind: 'tool-approval'; toolName: 'noInput' } +> +noInputInterrupt.resolveInterrupt(true) +// @ts-expect-error omitted inputSchema forbids editedArgs +noInputInterrupt.resolveInterrupt(true, { editedArgs: {} }) + +declare const generic: Extract< + ChatInterrupt, + { kind: 'generic' } +> +generic.resolveInterrupt({ applicationValue: 'runtime-validated' }) +``` + +In `chat-client-interrupts.test.ts`, construct the manager with the literal `transfer` tool and hydrate one descriptor whose metadata binding contains matching `toolCallId`, `interruptedRunId`, `generation`, tool name, and hashes. Clone the case while changing each field independently and assert only the fully matching case becomes `tool-approval`; every mismatch becomes `generic`. Add matching/mismatched client-tool, unknown reason, invalid generic schema, and expired descriptor cases before production code. + +- [ ] **Step 2: Run the red client type target** + +Run both suites red: + +```powershell +$env:CI='true' +pnpm --filter @tanstack/ai-client test:types +pnpm --filter @tanstack/ai-client test:lib -- --run tests/chat-client-interrupts.test.ts +``` + +Expected: FAIL because `ChatInterrupt`, bound variants, `InterruptManager`, and hydration do not exist. + +- [ ] **Step 3: Add the public base and mapped union** + +Define the public surface in `types.ts` and keep behavior private to the manager: + +```ts +export interface BoundInterruptBase { + id: string + reason: string + message?: string + toolCallId?: string + responseSchema?: JSONSchema + expiresAt?: string + metadata?: Record + kind: 'tool-approval' | 'client-tool-execution' | 'generic' + status: 'pending' | 'validating' | 'staged' | 'submitting' | 'error' + provenance: 'native' | 'legacy' + generation: number + errors: readonly ItemInterruptError[] + stagedResponse?: TDraft + clearResolution(): void + cancelInterrupt(): void +} + +export interface GenericAGUIInterrupt extends BoundInterruptBase< + | { status: 'resolved'; payload: unknown } + | { status: 'cancelled' } +> { + kind: 'generic' + toolName?: never + resolveInterrupt(payload: unknown): void +} + +export type BoundInterrupts< + TTools extends readonly AnyClientTool[], +> = readonly ChatInterrupt[] + +declare const noSchema: unique symbol +type NoSchema = typeof noSchema +type InputSchemaOf = TTool extends { inputSchema?: infer TSchema } + ? TSchema extends undefined ? NoSchema : TSchema + : NoSchema +type OutputSchemaOf = TTool extends { outputSchema?: infer TSchema } + ? TSchema extends undefined ? NoSchema : TSchema + : NoSchema +type ApproveSchemaOf = ApprovalSchemaOf extends { + approve?: infer TSchema +} ? TSchema extends undefined ? NoSchema : TSchema + : ApprovalSchemaOf extends undefined ? NoSchema + : ApprovalSchemaOf +type RejectSchemaOf = ApprovalSchemaOf extends { + reject?: infer TSchema +} ? TSchema extends undefined ? NoSchema : TSchema + : ApprovalSchemaOf extends undefined ? NoSchema + : ApprovalSchemaOf +type EditedArgsField = TSchema extends NoSchema + ? { editedArgs?: never } + : { editedArgs?: InferSchemaType } +type PayloadField = TSchema extends NoSchema + ? { payload?: never } + : TSchema extends JSONSchema + ? { payload: unknown } + : undefined extends InferSchemaType + ? { payload?: InferSchemaType } + : { payload: InferSchemaType } +type RequiredKeys = { + [TKey in keyof T]-?: Record extends Pick + ? never + : TKey +}[keyof T] +type OptionsTuple = [RequiredKeys] extends [never] + ? [options?: T] + : [options: T] + +export interface ToolApprovalInterrupt + extends BoundInterruptBase< + | ({ status: 'resolved'; approved: true } & + EditedArgsField> & + PayloadField>) + | ({ status: 'resolved'; approved: false } & + PayloadField>) + | { status: 'cancelled' } + > { + kind: 'tool-approval' + reason: 'tool_call' + toolName: TTool['name'] + toolCallId: string + resolveInterrupt( + approved: true, + ...options: OptionsTuple< + EditedArgsField> & PayloadField> + > + ): void + resolveInterrupt( + approved: false, + ...options: OptionsTuple>> + ): void +} + +export interface ClientToolExecutionInterrupt + extends BoundInterruptBase< + | { status: 'resolved'; payload: InferSchemaType> } + | { status: 'cancelled' } + > { + kind: 'client-tool-execution' + reason: 'tanstack:client_tool_execution' + toolName: TTool['name'] + toolCallId: string + resolveInterrupt(output: InferSchemaType>): void +} + +type ToolByName = + Extract +type ToolNames = TTools[number]['name'] +type ApprovalNames = { + [TName in ToolNames]: ApprovalCapabilityOf< + ToolByName + > extends true ? TName : never +}[ToolNames] +type ToolApprovalInterruptsFor = { + [TName in ApprovalNames]: ToolApprovalInterrupt> +}[ApprovalNames] +type ClientToolExecutionInterruptsFor = { + [TName in ToolNames]: ClientToolExecutionInterrupt> +}[ToolNames] +export type ChatInterrupt = + | ToolApprovalInterruptsFor + | ClientToolExecutionInterruptsFor + | GenericAGUIInterrupt +``` + +Use this contiguous type block verbatim and add the shared/branch/omitted/raw/optional assertions named in Step 1. `OutputSchemaOf` maps to `unknown` for client-tool execution rather than exposing the private sentinel. + +- [ ] **Step 4: Hydrate only trusted known-tool matches** + +Create `InterruptManager` with injected tools, submit, recovery, state-change, and draft-persistence callbacks. Hydration follows this decision: + +```ts +function classifyDescriptor( + descriptor: Interrupt, + registry: ToolRegistry, + correlation: { + interruptedRunId: string + generation: number + }, +): HydratedKind { + const binding = readUntrustedDescriptorBinding(descriptor.metadata) + if ( + descriptor.reason === 'tool_call' && + descriptor.toolCallId && + binding?.kind === 'tool-approval' && + descriptor.toolCallId === binding.toolCallId && + binding.interruptedRunId === correlation.interruptedRunId && + binding.generation === correlation.generation + ) { + const tool = registry.approvalTools.get(binding.toolName) + if (tool && registry.matchesHashes(tool, binding)) { + return { kind: 'tool-approval', tool, binding } + } + } + if ( + descriptor.reason === 'tanstack:client_tool_execution' && + descriptor.toolCallId && + binding?.kind === 'client-tool-execution' && + descriptor.toolCallId === binding.toolCallId && + binding.interruptedRunId === correlation.interruptedRunId && + binding.generation === correlation.generation + ) { + const tool = registry.tools.get(binding.toolName) + if (tool && registry.matchesHashes(tool, binding)) { + return { kind: 'client-tool-execution', tool, binding } + } + } + return { kind: 'generic' } +} +``` + +`InterruptManager.hydrate` passes its own authoritative correlation into this function; it never reads the expected run/generation from the descriptor alone. Bind methods to manager/item IDs, compile generic response schemas with Task 1, retain configured Standard Schema validators for known tools, and expose core-cloned/deep-frozen snapshots. + +- [ ] **Step 5: Run the already-red hydration tests green** + +Run the tests written in Step 1: + +```powershell +$env:CI='true' +pnpm --filter @tanstack/ai-client test:lib -- --run tests/chat-client-interrupts.test.ts +``` + +Expected: PASS; forged/mismatched descriptors are generic and an invalid generic schema can only be cancelled. + +- [ ] **Step 6: Run green type checks and refactor** + +Run: + +```powershell +$env:CI='true' +pnpm --filter @tanstack/ai-client test:types +pnpm --filter @tanstack/ai-client test:lib -- --run tests/chat-client-interrupts.test.ts +``` + +Expected: PASS. Extract type helpers (`NoSchema`, branch selectors, required-key tuple) into one contiguous section and registry hashing into one private class; rerun both commands. + +### Task 11: Implement staging, callback transactions, bulk operations, and retry + +**Prerequisites:** Task 10. + +**Files:** +- Modify: `packages/ai-client/src/interrupt-manager.ts` +- Modify: `packages/ai-client/src/types.ts` +- Modify: `packages/ai-client/tests/chat-client-interrupts.test.ts` + +- [ ] **Step 1: Add failing state-machine tests** + +Cover singleton resolve/cancel auto-submit; three-item wait; replacement; clear; async validator generations; invalid replacement preserving the previous valid draft; submitting mutation rejection; root boolean eligibility; callback visit-once/submit-once; throw/non-`undefined`/thenable/incomplete rollback; late post-await token use; transport frozen retry; mutation invalidating retry; server item/root errors; acceptance removal; and status/error notifications. + +Use a deferred async-callback regression: + +```ts +function deferred() { + let resolve!: (value: T | PromiseLike) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +const testTools = [transferTool, auditTool, notificationTool] as const +type TestTools = typeof testTools + +function createThreeInterruptManager(options: { + submit: InterruptManagerOptions['submit'] +}) { + const manager = new InterruptManager({ + tools: testTools, + submit: options.submit, + onStateChange: vi.fn(), + }) + manager.hydrate({ + threadId: 'thread-1', + interruptedRunId: 'run-old', + generation: 1, + descriptors: threeApprovalDescriptors, + }) + return manager +} + +it('invalidates a callback token before a late async item call', async () => { + const gate = deferred() + const submit = vi.fn() + const manager = createThreeInterruptManager({ submit }) + let captured: ChatInterrupt | undefined + + await expect( + manager.resolveInterrupts(((interrupt: ChatInterrupt) => { + captured = interrupt + return gate.promise.then(() => { + interrupt.cancelInterrupt() + }) + }) as (interrupt: ChatInterrupt) => undefined), + ).rejects.toMatchObject({ code: 'async-resolver' }) + + gate.resolve() + await gate.promise + captured?.cancelInterrupt() + expect(submit).not.toHaveBeenCalled() + expect(manager.interruptErrors).toContainEqual( + expect.objectContaining({ code: 'inactive-transaction' }), + ) +}) +``` + +Define `transferTool`, `auditTool`, and `notificationTool` immediately above this block with literal names and payload-less approval branches. Define `threeApprovalDescriptors` as three native `tool_call` descriptors whose reserved bindings and schema hashes match those tools. `InterruptManagerOptions`, `hydrate`, and `onStateChange` are the concrete public-to-package-private constructor seam established in Task 10; use these exact names in implementation and tests so the fixture does not depend on hidden maps. + +- [ ] **Step 2: Run the red state-machine test** + +Run: + +```powershell +pnpm --filter @tanstack/ai-client test:lib -- --run tests/chat-client-interrupts.test.ts +``` + +Expected: FAIL because bound methods do not yet stage, gate, roll back, freeze, or retry. + +- [ ] **Step 3: Implement synchronous candidate staging with async validation generations** + +Item methods return `void`. Each candidate increments an item generation, sets `validating` when a Standard Schema result is thenable, and ignores a result whose captured generation is stale. A valid candidate replaces the draft; an invalid candidate keeps the last valid `stagedResponse`, sets item errors/status, and blocks submission. Cancellation is payloadless and needs no schema validation. `clearResolution` removes draft/errors and returns to pending. Replacement/clear/cancel while submitting throws a non-retryable protocol error. + +- [ ] **Step 4: Freeze and submit only a complete valid batch** + +Implement one submission gate: + +```ts +private maybeSubmit(): void { + if (this.transaction || this.submission || this.items.size === 0) return + const items = [...this.items.values()] + if (items.some((item) => item.status === 'validating' || item.status === 'error')) return + if (items.some((item) => item.draft === undefined)) return + const entries = items + .map((item) => item.draft?.response) + .filter((entry): entry is RunAgentResumeItem => entry !== undefined) + .sort((a, b) => a.interruptId.localeCompare(b.interruptId)) + const candidate = canonicalizeInterruptResolutions(entries) + const frozen = cloneAndDeepFreezeJson({ + generation: this.batchGeneration, + entries: candidate.resolutions, + fingerprint: candidate.fingerprint, + canonicalResolutions: candidate.canonicalResolutions, + }) + void this.submitFrozenBatch(frozen) +} +``` + +A singleton reaches this gate after its first valid decision. Multiple items wait for complete coverage. During submission every item is `submitting`. Acceptance removes active descriptors/drafts. A retryable transport/availability failure restores `staged`, retains the exact frozen batch, and appends a root transport/server error. + +- [ ] **Step 5: Implement root boolean and synchronous callback transactions** + +The boolean overload first verifies every open item is a tool approval and the chosen branch has no required payload; only then stage all and submit once. For callbacks, capture one stable item snapshot, clone all drafts/errors/statuses, install a unique token, suppress auto-submit, and invoke once per item. Require literal runtime return `undefined`; reject thenables and other values. On throw, invalid return, or incomplete coverage, restore the clone, seal the token, increment generation, and start no request. Transaction-scoped item methods check the active token and generation; late calls add `inactive-transaction` without mutation. + +- [ ] **Step 6: Implement cancel-all, exact retry, and error mapping** + +`cancelInterrupts()` stages payloadless cancellation for the stable full set and submits once. `retryInterrupts()` is allowed only while an intact retryable frozen batch exists. Any edit/clear/cancel after failure increments batch generation, clears the frozen fingerprint and superseded transport error, and disables retry. Map item server errors by `interruptId`; map batch/transport errors only to `interruptErrors`. Expired/stale/conflict results invoke recovery instead of retry. + +- [ ] **Step 7: Run green state-machine tests and refactor** + +Run: + +```powershell +pnpm --filter @tanstack/ai-client test:lib -- --run tests/chat-client-interrupts.test.ts +pnpm --filter @tanstack/ai-client test:types +``` + +Expected: PASS; one interrupt submits immediately, three wait, callback failures roll back, and late async calls cannot submit. Refactor snapshots, token sealing, and status notifications into focused private helpers and rerun both commands. + +### Task 12: Integrate the manager into ChatClient and legacy compatibility + +**Prerequisites:** Task 11. + +**Files:** +- Modify: `packages/ai-client/src/chat-client.ts:41-146,191-275,510-706,967-1070,1403-1585` +- Modify: `packages/ai-client/src/connection-adapters.ts:353-450,1171-1205` +- Modify: `packages/ai-client/src/types.ts:397-585` +- Modify: `packages/ai-client/src/index.ts` +- Modify: `packages/ai-client/tests/chat-client-interrupts.test.ts` +- Modify: `packages/ai-client/tests/chat-client-resume.test.ts` +- Modify: `packages/ai-client/tests/chat-client-client-tool-status.test.ts` +- Modify: `packages/ai-client/tests/connection-adapters.test.ts` + +- [ ] **Step 1: Add failing transport/integration/compatibility tests** + +Prove that a native resume sends no messages, uses a fresh run ID with `parentRunId` equal to the interrupted run, parses item/root error extensions, recognizes the replay terminal, and blocks unrelated input. Add legacy approval/custom-tool events and assert one history request with no native resume. Add the sandbox collision case: + +```ts +client.processChunk({ + type: EventType.CUSTOM, + name: 'approval-requested', + value: { approvalId: 'sandbox-approval', operation: 'process.exec' }, + timestamp: Date.now(), +}) +expect(client.getInterrupts()).toEqual([]) +``` + +Also assert `addToolApprovalResponse({ approved: false })` stages a resolved denial, `cancelInterrupt` emits `cancelled`, `addToolResult` delegates to a matching client-tool interrupt, mixed provenance stops, and native server events never cause a legacy follow-up. + +- [ ] **Step 2: Run the red integration tests** + +Run: + +```powershell +pnpm --filter @tanstack/ai-client test:lib -- --run tests/chat-client-interrupts.test.ts tests/chat-client-resume.test.ts tests/chat-client-client-tool-status.test.ts tests/connection-adapters.test.ts +``` + +Expected: FAIL because ChatClient still owns raw `pendingInterrupts`, reuses the interrupted run ID, and sends compatibility responses directly. + +- [ ] **Step 3: Make InterruptManager the single normalized owner** + +Replace `private pendingInterrupts` and `pendingInterruptRunId` with one manager. `observeInterruptState` hydrates native outcomes with `{ threadId, interruptedRunId, generation }`; RUN_ERROR extensions flow to manager error/recovery handling. Expose: + +```ts +getInterrupts(): BoundInterrupts +getInterruptErrors(): readonly InterruptSubmissionError[] +getIsResuming(): boolean + +/** @deprecated Use getInterrupts(). Removed in 1.0. */ +getPendingInterrupts(): BoundInterrupts + +resolveInterrupts( + decisionOrResolver: boolean | ((interrupt: ChatInterrupt) => undefined), +): Promise + +cancelInterrupts(): Promise +retryInterrupts(): Promise +resumeInterruptsUnsafe( + entries: readonly RunAgentResumeItem[], + state?: ChatResumeState, +): Promise + +/** @deprecated Use resumeInterruptsUnsafe(). Removed in 1.0. */ +resumeInterrupts( + entries: readonly RunAgentResumeItem[], + state?: ChatResumeState, +): Promise +``` + +Add one immutable state notification so frameworks never compose three independently timed reads: + +```ts +export interface ChatInterruptState { + interrupts: BoundInterrupts + interruptErrors: readonly InterruptSubmissionError[] + isResuming: boolean +} + +onInterruptStateChange?: ( + state: ChatInterruptState, +) => void +``` + +Emit it after every hydration, draft/error/status transition, submission start/end, recovery replacement, reset, and disposal. `getInterrupts`, `getInterruptErrors`, and `getIsResuming` return the exact current immutable snapshot fields. + +Create one fresh deeply frozen `BoundInterrupts` array per notification. `getInterrupts()`, deprecated `getPendingInterrupts()`, hook `interrupts`, and hook `pendingInterrupts` all return that exact array object. Do not maintain a second raw descriptor store or project legacy DTOs into the deprecated alias. + +- [ ] **Step 4: Send continuations with new run correlation** + +When manager submits a native batch, generate a new run ID, keep the same thread, set `parentRunId` to the interrupted run, send the complete `resume` array, and send `[]` messages. `resumeInterruptsUnsafe` uses the same correlation/submission path but accepts raw entries. Keep expiry, exact set, server validation, and CAS enforced. + +- [ ] **Step 5: Handle replay and structured failures** + +Parse `tanstack:interruptErrors` and `tanstack:interruptRecovery` from RUN_ERROR. For a committed recovery DTO or canonical interrupt-replay result, attach only to the winning continuation: + +```ts +private async attachWinningContinuation( + threadId: string, + continuationRunId: string, +): Promise { + const controller = new AbortController() + const chunks = + this.connection && 'joinRun' in this.connection + ? this.connection.joinRun(continuationRunId, controller.signal) + : this.options.continuationLoader + ? this.options.continuationLoader( + { threadId, continuationRunId }, + { signal: controller.signal }, + ) + : undefined + if (!chunks) { + this.interruptManager.quarantineForRecovery({ + code: 'recovery-unavailable', + message: 'The winning continuation cannot be joined by this client.', + continuationRunId, + }) + return + } + await this.consumeReadOnlyContinuation(chunks, continuationRunId) +} + +private processIncomingChunk(chunk: StreamChunk): void { + this.callbacksRef.current.onChunk(chunk) + this.devtoolsBridge.observeChunk(chunk) + this.processor.processChunk(chunk) + this.updateRunLifecycle(chunk) + this.observeInterruptState(chunk) +} + +private async consumeReadOnlyContinuation( + chunks: AsyncIterable, + continuationRunId: string, +): Promise { + for await (const chunk of chunks) { + const chunkRunId = getChunkRunId(chunk) + if (chunkRunId !== undefined && chunkRunId !== continuationRunId) { + throw new Error('Continuation loader returned a different run.') + } + this.processIncomingChunk(chunk) + } +} +``` + +Replace the duplicated incoming-chunk statements in the existing connect/subscription loops with `processIncomingChunk(chunk)`. The read-only loader never calls `connect`, `sendMessage`, `reload`, the model endpoint, or the interrupt recovery loader. Tests provide a non-resumable connection plus a spy `continuationLoader`, assert the loader receives the stored winning ID, and assert no new model run is scheduled. A client with neither `joinRun` nor `continuationLoader` remains quarantined with `recovery-unavailable`. + +- [ ] **Step 6: Normalize legacy readers without a dual writer** + +Recognize legacy `approval-requested` only when its value has `toolCallId`, `toolName`, and `approval: { id, needsApproval: true }`; recognize `tool-input-available` only with its full historical tool shape. This prevents sandbox's path-only approval event from colliding. Hydrate `provenance: 'legacy'` and reject edits, custom payloads, generic responses, native cancellation, expiry, and conflict recovery as `legacy-unsupported`. + +When all legacy items are covered, clone current message history, update every matching approval/result part in the clone, and send one follow-up history request with no `resume`. On construction failure, change no live message. On transport failure, keep staged responses and report `legacy-submit-failed`. Reject mixed native/legacy sets before any mutation. + +- [ ] **Step 7: Deprecate approval APIs and preserve client-tool results** + +Add removal-at-1.0 JSDoc to `addToolApprovalResponse`, `pendingInterrupts`/`getPendingInterrupts`, raw `resumeInterrupts`, and legacy custom-event reader types. Implement `addToolApprovalResponse` by finding the bound item and calling `resolveInterrupt(response.approved)`; false is denial, not cancellation. Implement old `resumeInterrupts` as a compatibility wrapper over `resumeInterruptsUnsafe` while retaining its historical return type. Keep `addToolResult` supported and delegate to `resolveInterrupt(output)` for a matching native client-tool item or the legacy history backend for an old event. + +- [ ] **Step 8: Run green integration checks and refactor** + +Run: + +```powershell +pnpm --filter @tanstack/ai-client test:lib -- --run tests/chat-client-interrupts.test.ts tests/chat-client-resume.test.ts tests/chat-client-client-tool-status.test.ts tests/connection-adapters.test.ts +pnpm --filter @tanstack/ai-client test:types +``` + +Expected: PASS; native continuation context has a new run ID/old parent, denial and cancellation differ, sandbox events do not hydrate, and `addToolResult` remains supported. Remove duplicated approval batching logic from ChatClient and rerun both commands. + +### Task 13: Persist raw V2 drafts and recover through explicit adapters + +**Prerequisites:** Tasks 11 and 12. + +**Files:** +- Modify: `packages/ai-client/src/chat-persistence-controller.ts` +- Modify: `packages/ai-client/src/connection-adapters.ts:353-450,574-700,1171-1205` +- Modify: `packages/ai-client/src/types.ts:18-110,397-435` +- Modify: `packages/ai-client/src/chat-client.ts:250-275,485-520,698-706,1393-1410` +- Modify: `packages/ai-client/src/index.ts` +- Modify: `packages/ai-client/tests/chat-persistence-controller.test.ts` +- Modify: `packages/ai-client/tests/chat-client-resume.test.ts` +- Modify: `packages/ai-client/tests/connection-adapters.test.ts` + +- [ ] **Step 1: Add failing V1/V2 reconciliation and recovery tests** + +Test a partial one-of-three draft reload, V1 migration, mismatched thread/run/generation/ID/schema hash, expiry, committed winner, accepted tombstone after failed removal, reset/dispose stale-write suppression, event-extension recovery, fallback fetch, unauthorized/newer generation checks, and no-loader quarantine. + +Use an assertion that persisted values contain no functions or hydrated fields: + +```ts +const stored = JSON.parse(JSON.stringify(resumeAdapter.setItem.mock.calls.at(-1)?.[1])) +expect(stored.schemaVersion).toBe(2) +expect(stored.interruptState.drafts).toHaveLength(1) +expect(JSON.stringify(stored)).not.toContain('resolveInterrupt') +expect(JSON.stringify(stored)).not.toContain('errors') +expect(JSON.stringify(stored)).not.toContain('kind') +``` + +- [ ] **Step 2: Run the red durability tests** + +Run: + +```powershell +pnpm --filter @tanstack/ai-client test:lib -- --run tests/chat-client-resume.test.ts tests/chat-persistence-controller.test.ts tests/connection-adapters.test.ts +``` + +Expected: FAIL because snapshots are unversioned, store raw pending descriptors only, and no recovery loader exists. + +- [ ] **Step 3: Define the raw V2 snapshot and V1 migration** + +Add the exact JSON DTO: + +```ts +export interface ChatResumeSnapshotV2 { + schemaVersion: 2 + resumeState: ChatResumeState + interruptState?: { + provenance: 'native' | 'legacy' + phase: 'pending' | 'accepted' + interruptedRunId: string + generation: number + descriptorsHash: string + descriptors: readonly Interrupt[] + drafts: readonly { + interruptId: string + responseSchemaHash: string + response: RunAgentResumeItem + savedAt: string + }[] + continuationRunId?: string + } +} +``` + +Treat the PR-head unversioned `{ resumeState, pendingInterrupts }` shape as V1. Convert it to V2 generation `0`, native descriptors, no drafts, and `requiresRecovery: true` internally. Remove invalid/unrelatable V1 storage and report a root protocol error. Never serialize methods, validators, configured tools, kind, or error objects. + +- [ ] **Step 4: Reconcile drafts only after authoritative recovery** + +On native hydration, call recovery before binding. Accept only matching `(threadId, interruptedRunId)` and a generation not older than local. For `pending`, bind returned descriptors against the current tool registry, then retain a draft only when generation, interrupt ID, canonical response-schema hash, and expiry all match. For `committed`, clear drafts and attach/join the winning continuation. For `expired`, `missing`, and `legacy-committed`, clear unsafe retry state and expose the specified non-retryable error. Without extension or loader, quarantine drafts, disable submission, and expose `recovery-unavailable`. + +- [ ] **Step 5: Add explicit connection and fetcher recovery contracts** + +Extend connection adapters with an optional method: + +```ts +export interface InterruptRecoveryConnection { + loadInterruptState( + input: InterruptRecoveryQuery, + options: { signal: AbortSignal }, + ): Promise +} + +export type InterruptContinuationLoader = ( + input: { + threadId: string + continuationRunId: string + }, + options: { signal: AbortSignal }, +) => AsyncIterable +``` + +Add `interruptStateFetcher?: InterruptStateFetcher` and `continuationLoader?: InterruptContinuationLoader` beside `fetcher` in fetcher mode. Provide explicit helpers that require caller-supplied URLs: + +```ts +export function createInterruptStateFetcher( + url: string | (() => string), + options: FetchConnectionOptions = {}, +): InterruptStateFetcher { + return async (input, { signal }) => { + const response = await (options.fetchClient ?? fetch)( + typeof url === 'function' ? url() : url, + { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...mergeHeaders(options.headers) }, + credentials: options.credentials ?? 'same-origin', + signal, + body: JSON.stringify(input), + }, + ) + if (!response.ok) throw new Error(`Interrupt recovery failed: ${response.status}`) + return parseInterruptRecoveryState(await response.json()) + } +} + +export function createInterruptContinuationLoader( + url: string | (() => string), + options: FetchConnectionOptions = {}, +): InterruptContinuationLoader { + return async function* loadContinuation(input, { signal }) { + const requestUrl = withSearchParams( + typeof url === 'function' ? url() : url, + { + runId: input.continuationRunId, + offset: '-1', + }, + ) + yield* resumableServerSentEvents( + options.fetchClient ?? fetch, + requestUrl, + { + method: 'GET', + headers: mergeHeaders(options.headers), + credentials: options.credentials ?? 'same-origin', + }, + signal, + ) + } +} +``` + +Implement `parseInterruptRecoveryState(value: unknown)` beside the fetcher with explicit object, version, state, correlation, generation, descriptor, and committed-shape checks; it returns `InterruptRecoveryStateV1` only after narrowing and throws a protocol error otherwise. `mergeHeaders`, `withSearchParams`, and `resumableServerSentEvents` are existing functions in this module. `fetchServerSentEvents` already provides `joinRun`; raw-stream/fetcher adapters expose only explicitly supplied `loadInterruptState` and `continuationLoader`. They never derive either path from the chat URL, and continuation loading is always a read-only GET of the winning run. + +- [ ] **Step 6: Write accepted tombstones before cleanup** + +After acceptance, remove active descriptors/drafts, persist `{ phase: 'accepted', continuationRunId, drafts: [] }`, then queue `removeItem`. If removal fails, the tombstone prevents stale pending UI on reload and cleanup retries after authoritative committed recovery. Thread reset and controller disposal increment generations so old async reads/writes cannot restore state. + +- [ ] **Step 7: Run green durability checks and refactor** + +Run: + +```powershell +pnpm --filter @tanstack/ai-client test:lib -- --run tests/chat-client-interrupts.test.ts tests/chat-client-resume.test.ts tests/chat-persistence-controller.test.ts tests/connection-adapters.test.ts +pnpm --filter @tanstack/ai-client test:types +``` + +Expected: PASS; one draft survives a safe reload, drifted drafts are removed, committed state attaches to the winning run, and no URL is guessed. Extract V1 parsing, V2 cloning, and reconciliation predicates into pure functions and rerun both commands. + +### Task 14: Write every framework behavior and type contract red + +**Prerequisites:** Tasks 12 and 13. This task changes tests only; no framework source or public type is modified until every runtime and type suite has failed for the intended missing API. + +**Files:** + +- Modify: `packages/ai-react/tests/use-chat.test.ts` +- Modify: `packages/ai-react/tests/use-chat-types.test.ts` +- Modify: `packages/ai-preact/tests/use-chat.test.ts` +- Modify: `packages/ai-preact/tests/use-chat-types.test.ts` +- Modify: `packages/ai-solid/tests/use-chat.test.ts` +- Modify: `packages/ai-solid/tests/use-chat-types.test.ts` +- Modify: `packages/ai-vue/tests/use-chat.test.ts` +- Modify: `packages/ai-vue/tests/use-chat-types.test.ts` +- Modify: `packages/ai-svelte/tests/use-chat.test.ts` +- Modify: `packages/ai-svelte/tests/create-chat-types.test.ts` +- Modify: `packages/ai-angular/tests/inject-chat.test.ts` +- Modify: `packages/ai-angular/tests/inject-chat-types.test.ts` + +- [ ] **Step 1: Write the bound-state behavior contract in all six runtime suites** + +In each framework test utility, capture the `ChatClientOptions.onInterruptStateChange` callback and return root spies for `resolveInterrupts`, `cancelInterrupts`, `retryInterrupts`, and `resumeInterruptsUnsafe`. The callback accepts one `ChatInterruptState` object. Use two bound items so calling one synchronous item resolver cannot represent the singleton auto-submit case: + +```ts +it('reactively projects immutable interrupt state and delegates root methods', async () => { + const { result } = renderHook(() => useChat({ tools: [transferTool] })) + const first = createBoundToolInterrupt({ + id: 'approval-1', + status: 'staged', + stagedResponse: { + status: 'resolved', + approved: true, + editedArgs: { amount: 10 }, + }, + }) + const second = createBoundToolInterrupt({ + id: 'approval-2', + status: 'pending', + }) + const staged: ChatInterruptState = { + interrupts: Object.freeze([first, second]), + interruptErrors: [], + isResuming: false, + } + act(() => chatClientCallbacks.onInterruptStateChange?.(staged)) + + expect(result.current.interrupts).toBe(staged.interrupts) + expect(result.current.pendingInterrupts).toBe(staged.interrupts) + expect(result.current.interrupts[0]?.resolveInterrupt(true)).toBeUndefined() + expect(first.resolveInterrupt).toHaveBeenCalledWith(true) + + const invalid = createBoundToolInterrupt({ + id: 'approval-1', + status: 'error', + stagedResponse: first.stagedResponse, + errors: [ + itemError('approval-1', 'invalid-edited-args', ['amount']), + ], + }) + const transport = batchError('transport', ['approval-1', 'approval-2']) + const failed: ChatInterruptState = { + interrupts: Object.freeze([invalid, second]), + interruptErrors: [transport], + isResuming: true, + } + act(() => chatClientCallbacks.onInterruptStateChange?.(failed)) + expect(result.current.interrupts[0]?.status).toBe('error') + expect(result.current.interrupts[0]?.errors).toEqual(invalid.errors) + expect(result.current.interruptErrors).toEqual([transport]) + expect(result.current.isResuming).toBe(true) + + const resolver = (item: ChatInterrupt) => { + item.cancelInterrupt() + return undefined + } + await act(() => result.current.resolveInterrupts(resolver)) + expect(chatClientRootSpies.resolveInterrupts).toHaveBeenCalledWith(resolver) + expect(resolver(second)).toBeUndefined() + await act(() => result.current.cancelInterrupts()) + await act(() => result.current.retryInterrupts()) + expect(chatClientRootSpies.cancelInterrupts).toHaveBeenCalledOnce() + expect(chatClientRootSpies.retryInterrupts).toHaveBeenCalledOnce() +}) +``` + +Define `transferTool` with `toolDefinition({ name: 'transfer', needsApproval: true, inputSchema: z.object({ amount: z.number() }) })`. The test-only `createBoundToolInterrupt`, `itemError`, and `batchError` functions return fully correlated frozen public DTOs; their bound methods are `vi.fn<() => void>()`. They live in each package's existing `tests/test-utils.ts`, not in production. React and Preact use the displayed `renderHook`/their imported `act`; Solid reads `result.current.interrupts()`; Vue reads `result.current.interrupts.value` after `flushPromises()`; Svelte reads `chat.interrupts` after `await tick()`; Angular reads `result.interrupts()` after `TestBed.flushEffects()`. Each file performs every displayed identity, status, error, draft, root-error, and root-spy assertion with that exact native accessor. + +- [ ] **Step 2: Add branch-aware type failures to all six type suites** + +In each type-test file define this local tool and run the assertions against that framework's public result: + +```ts +const transferTool = toolDefinition({ + name: 'transfer', + description: 'Transfer funds', + inputSchema: z.object({ amount: z.number(), recipient: z.string() }), + needsApproval: true, + approvalSchema: { + approve: z.object({ note: z.string() }), + reject: z.object({ reason: z.string() }), + }, +}) +const tools = [transferTool] as const + +function assertInterruptTypes( + interrupt: ChatInterrupt | undefined, +): void { + if (!interrupt) return + if (interrupt.kind === 'tool-approval' && interrupt.toolName === 'transfer') { + interrupt.resolveInterrupt(true, { + editedArgs: { amount: 12, recipient: 'Ada' }, + payload: { note: 'Reviewed' }, + }) + interrupt.resolveInterrupt(false, { + payload: { reason: 'Limit exceeded' }, + }) + // @ts-expect-error approval cannot use the rejection payload + interrupt.resolveInterrupt(true, { payload: { reason: 'wrong branch' } }) + // @ts-expect-error editedArgs preserve the input schema + interrupt.resolveInterrupt(true, { + editedArgs: { amount: '12', recipient: 'Ada' }, + payload: { note: 'Reviewed' }, + }) + // @ts-expect-error rejection never accepts editedArgs + interrupt.resolveInterrupt(false, { + editedArgs: { amount: 12, recipient: 'Ada' }, + payload: { reason: 'No' }, + }) + } + if (interrupt.kind === 'generic') { + type GenericPayload = Parameters[0] + expectTypeOf().toEqualTypeOf() + interrupt.resolveInterrupt({ applicationValue: 'runtime validated' }) + } +} +``` + +Call `assertInterruptTypes` with these exact public expressions: React `renderHook(() => useChat({ tools })).result.current.interrupts[0]`; Preact `renderHook(() => useChat({ tools })).result.current.interrupts[0]`; Solid `useChat({ tools }).interrupts()[0]` inside its existing root; Vue `useChat({ tools }).interrupts.value[0]` inside its effect scope; Svelte `createChat({ tools }).interrupts[0]` inside its rune fixture; Angular `injectChat({ tools }).interrupts()[0]` inside its injection context. + +- [ ] **Step 3: Run all twelve framework suites red** + +Run sequentially to cap native Windows memory use: + +```powershell +$env:CI='true' +pnpm --filter @tanstack/ai-react test:lib -- --run tests/use-chat.test.ts +pnpm --filter @tanstack/ai-react test:types +pnpm --filter @tanstack/ai-preact test:lib -- --run tests/use-chat.test.ts +pnpm --filter @tanstack/ai-preact test:types +pnpm --filter @tanstack/ai-solid test:lib -- --run tests/use-chat.test.ts +pnpm --filter @tanstack/ai-solid test:types +pnpm --filter @tanstack/ai-vue test:lib -- --run tests/use-chat.test.ts +pnpm --filter @tanstack/ai-vue test:types +pnpm --filter @tanstack/ai-svelte test:lib -- --run tests/use-chat.test.ts +pnpm --filter @tanstack/ai-svelte test:types +pnpm --filter @tanstack/ai-angular test:lib -- --run tests/inject-chat.test.ts +pnpm --filter @tanstack/ai-angular test:types +``` + +Expected: all runtime and type suites FAIL because `interrupts`, `resolveInterrupts`, `cancelInterrupts`, `retryInterrupts`, `interruptErrors`, `isResuming`, `resumeInterruptsUnsafe`, and branch-specific inference are not exposed. Save the failing command/output in the task transcript; do not modify framework source in Task 14. + +- [ ] **Step 4: Confirm the red commit boundary** + +Run `git diff --name-only` and confirm Task 14 changed only the twelve named runtime/type test files and the six existing test-utility files. Do not commit. Framework source and public type declarations remain untouched until Task 15, so every type-red assertion predates the implementation it drives. + +### Task 15: Implement reactive and typed framework parity + +**Prerequisites:** Task 14. + +**Files:** + +- Modify: `packages/ai-react/src/use-chat.ts:52,87-212,426-444` +- Modify: `packages/ai-react/src/types.ts:102-173` +- Modify: `packages/ai-preact/src/use-chat.ts:51,86-190,349-367` +- Modify: `packages/ai-preact/src/types.ts:102-173` +- Modify: `packages/ai-solid/src/use-chat.ts:60-70,83-166,323-344` +- Modify: `packages/ai-solid/src/types.ts:8-76,138-159` +- Modify: `packages/ai-vue/src/use-chat.ts:60-64,83-162,328-349` +- Modify: `packages/ai-vue/src/types.ts:116-159` +- Modify: `packages/ai-svelte/src/create-chat.svelte.ts:79-82,98-178,333-376` +- Modify: `packages/ai-svelte/src/types.ts:116-161` +- Modify: `packages/ai-angular/src/inject-chat.ts:57-65,75-114,250-268` +- Modify: `packages/ai-angular/src/types.ts:98-141` + +- [ ] **Step 1: Add the public generic control fields** + +Import all interrupt types from `@tanstack/ai-client` and add this shape to each framework return type: + +```ts +export interface InterruptControls { + interrupts: BoundInterrupts + /** @deprecated Use `interrupts`. Removed in 1.0. */ + pendingInterrupts: BoundInterrupts + resolveInterrupts: ResolveInterrupts + cancelInterrupts(): Promise + retryInterrupts(): Promise + interruptErrors: readonly InterruptSubmissionError[] + isResuming: boolean + resumeInterruptsUnsafe( + entries: readonly RunAgentResumeItem[], + state?: ChatResumeState, + ): Promise + /** @deprecated Use a bound resolver. Removed in 1.0. */ + addToolApprovalResponse(response: { + id: string + approved: boolean + }): Promise + /** @deprecated Use `resumeInterruptsUnsafe`. Removed in 1.0. */ + resumeInterrupts( + entries: readonly RunAgentResumeItem[], + state?: ChatResumeState, + ): Promise +} +``` + +React/Preact expose the plain fields. Solid wraps `interrupts`, `pendingInterrupts`, `interruptErrors`, and `isResuming` in accessors. Vue uses readonly refs, Svelte uses getters, and Angular uses readonly signals. Root methods remain functions in every package. No framework declares its own interrupt conditional type. + +- [ ] **Step 2: Wire React in the existing instance-holder order** + +Place state before `useMemo`, put the callback inside the existing `new ChatClient` options after `getActiveInstance` exists, and synchronize after the instance is active: + +```ts +const [interruptState, setInterruptState] = useState< + ChatInterruptState +>(() => ({ + interrupts: Object.freeze([]), + interruptErrors: Object.freeze([]), + isResuming: false, +})) + +const syncInterruptState = useCallback((target: ChatClient) => { + setInterruptState(target.getInterruptState()) +}, []) + +// Inside the existing useMemo, immediately after getActiveInstance: +const handleInterruptStateChange = (next: ChatInterruptState) => { + if (!getActiveInstance()) return + setInterruptState(next) + optionsRef.current.onInterruptStateChange?.(next) +} + +// Inside the existing new ChatClient options, beside onResumeStateChange: +onInterruptStateChange: handleInterruptStateChange, + +useEffect(() => { + syncInterruptState(client) +}, [client, syncInterruptState]) + +const resolveInterrupts = useCallback>( + (decisionOrResolver) => client.resolveInterrupts(decisionOrResolver), + [client], +) +const cancelInterrupts = useCallback(() => client.cancelInterrupts(), [client]) +const retryInterrupts = useCallback(() => client.retryInterrupts(), [client]) +const resumeInterruptsUnsafe = useCallback( + (entries: readonly RunAgentResumeItem[], state?: ChatResumeState) => + client.resumeInterruptsUnsafe(entries, state), + [client], +) +``` + +Keep the existing `instanceHolder.current = instance` and `activeClientRef.current = instance` assignments before the memo returns. Add `syncInterruptState` to the memo dependency list. Return `interrupts: interruptState.interrupts` and `pendingInterrupts: interruptState.interrupts`, preserving exact array identity. + +- [ ] **Step 3: Wire Preact, Solid, Vue, Svelte, and Angular with concrete native state** + +In Preact, add the callback to its existing constructor closure and set the initial snapshot in the existing post-construction effect: + +```ts +const [interruptState, setInterruptState] = useState>({ + interrupts: Object.freeze([]), + interruptErrors: Object.freeze([]), + isResuming: false, +}) +// inside the existing new ChatClient options: +onInterruptStateChange: (next) => { + if (activeClientRef.current !== instance) return + setInterruptState(next) + optionsRef.current.onInterruptStateChange?.(next) +}, +// inside the existing useEffect([client]): +setInterruptState(client.getInterruptState()) +``` + +In Solid, add one signal before `createMemo`, update it in the constructor callback, and initialize it after `client()` exists: + +```ts +const [interruptState, setInterruptState] = + createSignal>({ + interrupts: Object.freeze([]), + interruptErrors: Object.freeze([]), + isResuming: false, + }) +// inside new ChatClient: +onInterruptStateChange: (next) => { + setInterruptState(next) + options.onInterruptStateChange?.(next) +}, +setInterruptState(client().getInterruptState()) +const resolveInterrupts: ResolveInterrupts = (value) => + client().resolveInterrupts(value) +``` + +In Vue, Svelte, and Angular use these exact declarations/callbacks: + +```ts +// Vue +const interruptState = shallowRef>({ + interrupts: Object.freeze([]), + interruptErrors: Object.freeze([]), + isResuming: false, +}) +// in new ChatClient: +onInterruptStateChange: (next) => { + interruptState.value = next + options.onInterruptStateChange?.(next) +}, +interruptState.value = client.getInterruptState() + +// Svelte +let interruptState = $state>({ + interrupts: Object.freeze([]), + interruptErrors: Object.freeze([]), + isResuming: false, +}) +// in new ChatClient: +onInterruptStateChange: (next) => { + interruptState = next + options.onInterruptStateChange?.(next) +}, +interruptState = client.getInterruptState() + +// Angular +const interruptState = signal>({ + interrupts: Object.freeze([]), + interruptErrors: Object.freeze([]), + isResuming: false, +}) +// in new ChatClient: +onInterruptStateChange: (next) => { + interruptState.set(next) + options.onInterruptStateChange?.(next) +}, +interruptState.set(client.getInterruptState()) +``` + +For each constructor, add `onInterruptStateChange` beside the existing `onResumeStateChange`; preserve every existing callback. Delegate `resolveInterrupts`, `cancelInterrupts`, `retryInterrupts`, and `resumeInterruptsUnsafe` directly to that package's existing `client`/`client()`. Vue returns `readonly(interruptState)` projections, Svelte getters return fields from `interruptState`, and Angular uses `computed(() => interruptState().interrupts)`; deprecated `pendingInterrupts` reads the identical field in all cases. + +- [ ] **Step 4: Run all runtime and type suites green** + +```powershell +$env:CI='true' +pnpm --filter @tanstack/ai-react test:lib -- --run tests/use-chat.test.ts +pnpm --filter @tanstack/ai-react test:types +pnpm --filter @tanstack/ai-preact test:lib -- --run tests/use-chat.test.ts +pnpm --filter @tanstack/ai-preact test:types +pnpm --filter @tanstack/ai-solid test:lib -- --run tests/use-chat.test.ts +pnpm --filter @tanstack/ai-solid test:types +pnpm --filter @tanstack/ai-vue test:lib -- --run tests/use-chat.test.ts +pnpm --filter @tanstack/ai-vue test:types +pnpm --filter @tanstack/ai-svelte test:lib -- --run tests/use-chat.test.ts +pnpm --filter @tanstack/ai-svelte test:types +pnpm --filter @tanstack/ai-angular test:lib -- --run tests/inject-chat.test.ts +pnpm --filter @tanstack/ai-angular test:types +``` + +Expected: every command PASS; each `@ts-expect-error` is consumed, generic payload is `unknown`, callbacks observe staged/error/replaced states, root callback side effects return `undefined`, and `pendingInterrupts === interrupts` in plain-value packages (or their unwrapped values are strictly equal in reactive packages). + +### Task 16: Exercise the full interrupt lifecycle in a deterministic browser fixture + +**Prerequisites:** Tasks 5-9 and 13-15. + +**Files:** + +- Create: `testing/e2e/src/lib/interrupts-v2-fixture.ts` +- Create: `testing/e2e/src/routes/api.interrupts-v2.ts` +- Create: `testing/e2e/src/routes/api.interrupts-v2.recovery.ts` +- Create: `testing/e2e/src/routes/interrupts-v2.tsx` +- Create: `testing/e2e/tests/interrupts.spec.ts` +- Modify (generated by the router command): `testing/e2e/src/routeTree.gen.ts` + +- [ ] **Step 1: Write the browser scenarios against stable selectors** + +Create one serial `interrupts.spec.ts` describe block only where a test deliberately shares a thread; all unrelated tests retain Playwright's normal parallelism and use a unique ID: + +```ts +import { expect, test } from '@playwright/test' + +function interruptUrl(testId: string, scenario: string): string { + const search = new URLSearchParams({ testId, scenario }) + return `/interrupts-v2?${search.toString()}` +} + +test('waits for all three cards before one atomic submission', async ({ page }) => { + const testId = `interrupts-v2-batch-${test.info().workerIndex}-${Date.now()}` + await page.goto(interruptUrl(testId, 'three-tool-approvals')) + await page.getByTestId('start-run').click() + await expect(page.getByTestId('interrupt-card')).toHaveCount(3) + + await page.getByTestId('interrupt-card').nth(0).getByRole('button', { name: 'Approve' }).click() + await expect(page.getByTestId('continuation-count')).toHaveText('0') + await page.getByTestId('interrupt-card').nth(1).getByRole('button', { name: 'Deny' }).click() + await expect(page.getByTestId('continuation-count')).toHaveText('0') + await page.getByTestId('interrupt-card').nth(2).getByRole('button', { name: 'Cancel' }).click() + + await expect(page.getByTestId('continuation-count')).toHaveText('1') + await expect(page.getByTestId('submitted-decisions')).toHaveText( + 'approve,deny,cancel', + ) +}) +``` + +Add the singleton omitted/edit, mixed audit history, reload, race, and payloadless scenarios with selectors for decision, staged status, continuation count, result-only event names, and stored history. Add these four tests verbatim for the previously ambiguous cases: + +```ts +test('root callback performs synchronous side effects and returns undefined', async ({ page }) => { + const testId = `interrupt-callback-${test.info().workerIndex}-${Date.now()}` + await page.goto(interruptUrl(testId, 'heterogeneous-callback')) + await page.getByTestId('start-run').click() + await expect(page.getByTestId('interrupt-card')).toHaveCount(3) + await page.getByTestId('resolve-callback').click() + await expect(page.getByTestId('callback-return-values')).toHaveText( + 'undefined,undefined,undefined', + ) + await expect(page.getByTestId('continuation-count')).toHaveText('1') + await expect(page.getByTestId('submitted-decisions')).toHaveText( + 'approve,deny,generic', + ) +}) + +test('shows every item error and the root aggregate in one response', async ({ page }) => { + const testId = `interrupt-errors-${test.info().workerIndex}-${Date.now()}` + await page.goto(interruptUrl(testId, 'two-invalid')) + await page.getByTestId('start-run').click() + await page.getByTestId('invalid-first').click() + await page.getByTestId('invalid-second').click() + await expect(page.getByTestId('interrupt-error-first')).toContainText( + 'invalid-edited-args', + ) + await expect(page.getByTestId('interrupt-error-second')).toContainText( + 'invalid-payload', + ) + await expect(page.getByTestId('interrupt-errors-root')).toContainText( + 'item-validation-failed', + ) + await expect(page.getByTestId('interrupt-errors-root')).toContainText( + 'approval-1,question-1', + ) + await page.getByTestId('correct-first').click() + await expect(page.getByTestId('interrupt-error-first')).toHaveText('') + await expect(page.getByTestId('interrupt-error-second')).toContainText( + 'invalid-payload', + ) + await page.getByTestId('correct-second').click() + await expect(page.getByTestId('continuation-count')).toHaveText('1') +}) + +test('joins the original continuation after a committed response is truncated', async ({ + page, + request, +}) => { + const testId = `interrupt-retry-${test.info().workerIndex}-${Date.now()}` + await page.goto(interruptUrl(testId, 'commit-then-truncate')) + await page.getByTestId('start-run').click() + await page.getByRole('button', { name: 'Approve' }).click() + await expect(page.getByTestId('retry-banner')).toBeVisible() + const committed = await request.get( + `/api/interrupts-v2?testId=${encodeURIComponent(testId)}&stats=1`, + ) + expect(await committed.json()).toMatchObject({ + continuationCount: 1, + continuationRunIds: [expect.any(String)], + truncatedResponses: 1, + }) + await page.getByTestId('retry-interrupts').click() + await expect(page.getByTestId('continuation-count')).toHaveText('1') + const replayed = await request.get( + `/api/interrupts-v2?testId=${encodeURIComponent(testId)}&stats=1`, + ) + const replayStats = await replayed.json() + expect(replayStats).toMatchObject({ + continuationCount: 1, + replayCount: 1, + truncatedResponses: 1, + }) + expect(replayStats.joinedContinuationRunId).toBe( + replayStats.continuationRunIds[0], + ) +}) + +test('keeps client-tool execution typed, visible, and resolvable by its bound method', async ({ + page, +}) => { + const testId = `interrupt-client-tool-${test.info().workerIndex}-${Date.now()}` + await page.goto(interruptUrl(testId, 'client-tool-and-approval')) + await page.getByTestId('start-run').click() + await expect( + page.getByTestId('interrupt-kind-client-tool-execution'), + ).toHaveCount(1) + await expect(page.getByTestId('interrupt-kind-tool-approval')).toHaveCount(1) + await page.getByTestId('resolve-client-tool-bound').click() + await expect(page.getByTestId('client-tool-status')).toHaveText('staged') + await page.getByTestId('approve-server-tool').click() + await expect(page.getByTestId('continuation-count')).toHaveText('1') + await expect(page.getByTestId('client-tool-output')).toHaveText( + '{"browserValue":"done"}', + ) +}) +``` + +The retry scenario does not abort the request before the server sees it. The route consumes the first resume through authoritative commit and continuation scheduling, then intentionally returns a truncated stream once. The second request is an exact replay and the client joins the stored continuation ID. + +- [ ] **Step 2: Run the focused E2E test red** + +```powershell +$env:CI='true' +pnpm --filter @tanstack/ai-e2e test:e2e -- tests/interrupts.spec.ts +``` + +Expected: FAIL because `/interrupts-v2`, its chat endpoint, its recovery endpoint, and all stable selectors do not exist. + +- [ ] **Step 3: Build a deterministic adapter and per-test authoritative store** + +In `interrupts-v2-fixture.ts`, create a synthetic `AnyTextAdapter` that emits exact AG-UI chunks rather than depending on a live provider or timing. Its first run emits the scenario descriptors; its resumed run records response order and emits one final text result. Key server state by `testId` so Playwright workers and aimock sequences cannot collide: + +```ts +import { memoryPersistence } from '@tanstack/ai-persistence' +import type { AIPersistence } from '@tanstack/ai-persistence' + +type InterruptFixture = { + persistence: AIPersistence + continuationCount: number + continuationRunIds: string[] + decisions: string[] + truncateCommittedResponseOnce: boolean + truncatedResponses: number + replayCount: number + joinedContinuationRunId?: string +} + +const fixtures = new Map() + +export function getInterruptFixture(testId: string): InterruptFixture { + const existing = fixtures.get(testId) + if (existing) return existing + const created: InterruptFixture = { + persistence: memoryPersistence(), + continuationCount: 0, + continuationRunIds: [], + decisions: [], + truncateCommittedResponseOnce: true, + truncatedResponses: 0, + replayCount: 0, + } + fixtures.set(testId, created) + return created +} +``` + +Define the fixture's server tools with real `toolDefinition` schemas: one shared approval schema, one `{ approve, reject }` branch schema, one editable input schema, and one client tool. Cancellation remains payloadless. Define a generic descriptor with its own Draft 2020-12 `responseSchema`. The synthetic stream must include stable `threadId`, `runId`, `interruptId`, `toolCallId`, `parentRunId`, and three distinct responses so the test asserts wire semantics rather than UI-only state. + +- [ ] **Step 4: Add separate chat and recovery endpoints** + +`api.interrupts-v2.ts` uses the real `AIPersistence` middleware and exposes POST chat, GET continuation replay, and GET test statistics: + +```ts +import { + chat, + chatParamsFromRequestBody, + toServerSentEventsResponse, + type StreamChunk, +} from '@tanstack/ai' +import { withChatPersistence } from '@tanstack/ai-persistence' +import { createFileRoute } from '@tanstack/react-router' +import { + createInterruptFixtureAdapter, + getInterruptFixture, + interruptFixtureTools, +} from '../lib/interrupts-v2-fixture' + +async function collectChunks( + stream: AsyncIterable, +): Promise { + const chunks: StreamChunk[] = [] + for await (const chunk of stream) chunks.push(chunk) + return chunks +} + +function replayChunks(chunks: readonly StreamChunk[]): AsyncIterable { + return { + async *[Symbol.asyncIterator]() { + for (const chunk of chunks) yield chunk + }, + } +} + +function truncatedSseResponse(): Response { + const encoder = new TextEncoder() + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('event: RUN_STARTED\ndata: {')) + queueMicrotask(() => controller.error(new Error('fixture truncation'))) + }, + }), + { headers: { 'Content-Type': 'text/event-stream' } }, + ) +} + +export const Route = createFileRoute('/api/interrupts-v2')({ + server: { + handlers: { + GET: async ({ request }) => { + const url = new URL(request.url) + const testId = url.searchParams.get('testId') + if (!testId) return new Response('Missing testId', { status: 400 }) + const fixture = getInterruptFixture(testId) + if (url.searchParams.get('stats') === '1') { + return Response.json({ + continuationCount: fixture.continuationCount, + continuationRunIds: fixture.continuationRunIds, + truncatedResponses: fixture.truncatedResponses, + replayCount: fixture.replayCount, + joinedContinuationRunId: fixture.joinedContinuationRunId, + }) + } + const runId = url.searchParams.get('runId') + if (!runId) return new Response('Missing runId', { status: 400 }) + const chunks = fixture.continuationChunks.get(runId) + if (!chunks) return new Response('Unknown run', { status: 404 }) + fixture.joinedContinuationRunId = runId + return toServerSentEventsResponse(replayChunks(chunks)) + }, + POST: async ({ request }) => { + const url = new URL(request.url) + const testId = url.searchParams.get('testId') + const scenario = url.searchParams.get('scenario') + if (!testId || !scenario) { + return new Response('Missing fixture correlation', { status: 400 }) + } + const fixture = getInterruptFixture(testId) + const params = await chatParamsFromRequestBody(await request.json()) + const stream = chat({ + ...params, + adapter: createInterruptFixtureAdapter(scenario, fixture), + tools: interruptFixtureTools, + middleware: [withChatPersistence(fixture.persistence)], + }) as AsyncIterable + + if ( + scenario === 'commit-then-truncate' && + params.resume && + fixture.truncateCommittedResponseOnce + ) { + fixture.truncateCommittedResponseOnce = false + const chunks = await collectChunks(stream) + fixture.truncatedResponses += 1 + fixture.continuationCount += 1 + fixture.continuationRunIds.push(params.runId) + fixture.continuationChunks.set(params.runId, chunks) + return truncatedSseResponse() + } + return toServerSentEventsResponse(stream) + }, + }, + }, +}) +``` + +When the authoritative gateway returns the replay terminal, `createInterruptFixtureAdapter` increments `replayCount` but does not increment `continuationCount` or append another continuation run. Add `continuationChunks: Map` to `InterruptFixture`. + +`api.interrupts-v2.recovery.ts` accepts only `InterruptRecoveryQuery` and uses the fixture persistence selected by the explicit `testId` query: + +```ts +import { createInterruptRecoveryHandler } from '@tanstack/ai-persistence' +import { createFileRoute } from '@tanstack/react-router' +import { getInterruptFixture } from '../lib/interrupts-v2-fixture' + +export const Route = createFileRoute('/api/interrupts-v2/recovery')({ + server: { + handlers: { + POST: async ({ request }) => { + const testId = new URL(request.url).searchParams.get('testId') + if (!testId) return new Response('Missing testId', { status: 400 }) + const fixture = getInterruptFixture(testId) + const handleRecovery = createInterruptRecoveryHandler({ + persistence: fixture.persistence, + authorize: () => ({ allowed: true, includeResolutions: true }), + }) + return handleRecovery(request) + }, + }, + }, +}) +``` + +Never infer `/recovery` or a continuation replay URL from the chat URL in library code; the test page explicitly configures recovery and the SSE connection itself supplies `joinRun`. + +- [ ] **Step 5: Build the test page using only public APIs** + +Configure `useChat` with the fixture's typed tool tuple, `fetchServerSentEvents(() => `/api/interrupts-v2?testId=${encodeURIComponent(testId)}&scenario=${encodeURIComponent(scenario)}`)`, `createInterruptStateFetcher(() => `/api/interrupts-v2/recovery?testId=${encodeURIComponent(testId)}`)`, and a local draft store. Read `testId` and `scenario` from the page search params before constructing the adapters. This is an explicit caller-supplied chat/recovery correlation; `testId` is fixture routing data and is not added to `InterruptRecoveryQuery`. Because `fetchServerSentEvents` preserves existing query parameters when it appends `runId`, the same explicit connection also supplies `joinRun`. Render every `interrupts` item with its `kind`, `errors`, current staged response, edit fields, and per-item resolve/clear controls. Render root controls for callback resolution, retry, and approve/deny/cancel all. Use only public bound methods: + +```tsx +{interrupts.map((interrupt) => ( +
+ + {interrupt.errors.map((issue) => issue.message).join('|')} + + {interrupt.kind === 'tool-approval' ? ( + + ) : ( + + )} +
+))} +``` + +Declare `genericDraft` as `unknown` from the page's JSON editor state and let the built-in validator surface errors. Resolve the server-emitted `client-tool-execution` item through its typed bound `resolveInterrupt(output)` method in the scenario above. Add one separate assertion that the retained `addToolResult` API delegates to the same staged item, rather than creating a duplicate interrupt or a second submission. + +- [ ] **Step 6: Regenerate the route tree and run E2E green** + +The E2E package has no standalone route-generator script; its one-shot Vite build invokes the configured TanStack Router plugin and refreshes `routeTree.gen.ts`. Run: + +```powershell +pnpm --filter @tanstack/ai-e2e build +pnpm --filter @tanstack/ai-e2e test:types +$env:CI='true' +pnpm --filter @tanstack/ai-e2e test:e2e -- tests/interrupts.spec.ts +``` + +Expected: the build exits 0 and changes only `routeTree.gen.ts` as generated route output; type checks and all 11 scenarios PASS with no retries required locally. As the focused refactor, remove only duplicated fixture builders, keep selectors and assertions explicit, and rerun all three commands. + +### Task 17: Publish the API, validation recipe, migration guide, and changesets + +**Prerequisites:** Tasks 1-16. Document only the settled and tested public surface. + +**Files:** + +- Create: `docs/chat/interrupts.md` +- Create: `docs/migration/interrupts.md` +- Modify: `docs/tools/tool-approval.md` +- Modify: `docs/tools/client-tools.md` +- Modify: `docs/chat/persistence.md` +- Modify: `docs/persistence/custom-stores.md` +- Modify: `docs/persistence/migrations.md` +- Modify: `docs/persistence/delivery-durability.md` +- Modify: `docs/architecture/approval-flow-processing.md` +- Modify: `docs/reference/functions/toolDefinition.md` +- Modify: `docs/config.json` +- Create: `.changeset/ag-ui-interrupts.md` + +- [ ] **Step 1: Make the documentation link test fail for the planned pages** + +Add `chat/interrupts` and `migration/interrupts` to the appropriate `docs/config.json` groups before creating the files. Also add a canonical `/chat/interrupts` link to `docs/tools/tool-approval.md` and a `/migration/interrupts` link to `docs/chat/persistence.md`; do not create the destination files yet. Then run: + +```powershell +pnpm test:docs +``` + +Expected: FAIL with missing-page/link errors for both new destinations. `scripts/verify-links.ts` validates Markdown links, not navigation-only `docs/config.json` entries, so the two deliberate links are required for this red step. Do not weaken the verifier. + +- [ ] **Step 2: Write the end-to-end interrupt guide with server and client halves** + +`docs/chat/interrupts.md` must cover: + +- AG-UI descriptor/result lifecycle and the PR-head breaking change; +- tool versus generic interrupt discrimination; +- `responseSchema` as the wire/runtime-validation foundation; +- `approvalSchema`, `{ approve, reject }`, optional `editedArgs`, grouped `payload`, and payloadless cancellation; +- one-item auto-submit, multi-item staging, root callback transactions, bulk operations, `clearResolution`, retry, and `interrupt.errors`/root `interruptErrors`; +- native recovery, V2 raw-draft persistence, idempotency, and tab conflicts; +- the separate client-tool `addToolResult` path. + +Use a current model only when a provider-backed server example is necessary: + +```ts +const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages, + tools: [transferTool], + middleware: [withChatPersistence(persistence)], + threadId, +}) +``` + +The matching client example must show the inferred branch payload and no type casts: + +```ts +const { + interrupts, + resolveInterrupts, + cancelInterrupts, + retryInterrupts, + interruptErrors, + isResuming, + resumeInterruptsUnsafe, +} = useChat({ tools: [transferTool] as const }) + +const interrupt = interrupts.find( + (item) => item.kind === 'tool-approval' && item.toolName === 'transfer', +) +if ( + interrupt?.kind === 'tool-approval' && + interrupt.toolName === 'transfer' +) { + interrupt.resolveInterrupt(true, { + editedArgs: { amount: 12, recipient: 'Ada' }, + payload: { note: 'Reviewed' }, + }) +} +``` + +Keep `as const` (a const assertion) but use no `as SomeType` assertion in docs. + +- [ ] **Step 3: Document converting received JSON Schema before app-level validation** + +Generic payloads stay `unknown` statically. Show users how to turn the received Draft 2020-12 schema into their chosen runtime validator before resolving. Use a real Zod 4 conversion example and narrow the conversion result without a type assertion: + +```ts +import { z } from 'zod' +import type { GenericAGUIInterrupt } from '@tanstack/ai-client' + +function resolveGenericEditorValue( + interrupt: GenericAGUIInterrupt, + editorValue: string, +): readonly string[] { + if (!interrupt.responseSchema) return ['This interrupt has no response schema.'] + + let candidateResponse: unknown + try { + candidateResponse = JSON.parse(editorValue) + } catch { + return ['Enter valid JSON.'] + } + + const responseValidator = z.fromJSONSchema(interrupt.responseSchema) + const parsed = responseValidator.safeParse(candidateResponse) + if (!parsed.success) { + return parsed.error.issues.map( + (issue) => `${issue.path.join('.')}: ${issue.message}`, + ) + } + interrupt.resolveInterrupt(parsed.data) + return [] +} +``` + +Explain that the conversion library returns a runtime schema rather than a wire-derived static application type, the client still performs canonical built-in Draft 2020-12 validation, and server validation remains authoritative. + +- [ ] **Step 4: Write the migration and specialized pages** + +`docs/migration/interrupts.md` maps every deprecated surface to the new API: + +```ts +// Deprecated +await addToolApprovalResponse({ id: approval.id, approved: true }) + +// Current +interrupt.resolveInterrupt(true) +``` + +State explicitly that there is no codemod. Add recipes for batched staging, branch payloads, optional edits, generic unknown payloads, legacy persistence behavior, and opt-in native recovery. + +Update the specialized pages as follows: + +| Page | Required content | +| --- | --- | +| `tools/tool-approval.md` | shorthand, approve/reject payloads, and payloadless cancel examples | +| `tools/client-tools.md` | approval interrupts and client execution are separate axes | +| `chat/persistence.md` | raw drafts only, V2 envelope, recovery before rebinding | +| `persistence/custom-stores.md` | atomic `acceptInterruptBatch` capability and receipt contract | +| `persistence/migrations.md` | new table/columns/indexes for every adapter | +| `persistence/delivery-durability.md` | idempotency key, accepted tombstone, replay rules | +| `architecture/approval-flow-processing.md` | descriptor → validate-all → CAS → continuation → history | +| `reference/functions/toolDefinition.md` | conditional `approvalSchema` signature and inferred overloads | + +- [ ] **Step 5: Update docs metadata exactly once** + +In `docs/config.json`: + +- give both new pages `addedAt: "2026-07-13"`; +- set `updatedAt: "2026-07-13"` on each existing page receiving a content change while preserving its existing `addedAt`; +- add the currently unlisted `architecture/approval-flow-processing` entry under the existing `Advanced` section with its repository creation date `addedAt: "2026-04-15"` and `updatedAt: "2026-07-13"`; +- do not change timestamps for unrelated pages or factual-only fixes. + +- [ ] **Step 6: Add one release changeset and run docs green** + +Create `.changeset/ag-ui-interrupts.md` with minor bumps for `@tanstack/ai`, `@tanstack/ai-client`, `@tanstack/ai-react`, `@tanstack/ai-preact`, `@tanstack/ai-solid`, `@tanstack/ai-vue`, `@tanstack/ai-svelte`, and `@tanstack/ai-angular`; add patch bumps for `@tanstack/ai-persistence`, `@tanstack/ai-persistence-drizzle`, `@tanstack/ai-persistence-prisma`, and `@tanstack/ai-persistence-cloudflare`. Describe the breaking migration and deprecated compatibility surfaces. Do not edit existing changesets. + +Run: + +```powershell +pnpm test:docs +pnpm test:types +``` + +Expected: PASS; all links resolve, examples are syntactically valid under doc checks, timestamps are valid, and public declarations build. Refactor repeated explanation into links to `chat/interrupts` without removing the server/client halves from the main guide. + +### Task 18: Converge review to zero, run CI parity, and make the only implementation commit + +**Prerequisites:** Tasks 0-17 are green. Do not start this task while an implementation or test process is still active. + +**Files:** + +- Review: every file changed by Tasks 1-17 +- Modify: only files required to fix findings + +- [ ] **Step 1: Format the actual diff and inspect its shape** + +Use the repository formatter on changed supported files only, then inspect rather than blindly accepting bulk edits: + +```powershell +$changed = git ls-files --modified --others --exclude-standard +pnpm exec prettier --experimental-cli --ignore-unknown --write $changed +git diff --check +git status --short +git diff --stat +``` + +Expected: Prettier succeeds, `git diff --check` is silent, and the status contains only the paths named in this plan plus generated lockfile, migration, and route-tree files. If PowerShell argument expansion exceeds its limit, format one package group at a time; do not switch shells. + +- [ ] **Step 2: Perform the focused self-review ledger** + +Review the diff against the approved spec and record a pass/fix entry for every row below in the task transcript; do not commit a report file: + +| Ledger item | Required evidence | +| --- | --- | +| Protocol | canonical reasons, descriptor/result/error DTOs, one structured failure event | +| Types | conditional schema availability, three branches, optional edits, generic `unknown`, builder preservation | +| Client | singleton auto-submit, batch wait, replacement/clear, callback rollback and late-call guard, retry statuses | +| Validation | same Draft 2020-12 compiler client/server, all items validated, deterministic paths, no mutation | +| Persistence | exact-set CAS, first writer wins, idempotent replay, committed recovery, migrations/assets | +| Recovery | explicit handler/fetcher, current tool/schema rebinding, V2 draft quarantine and tombstones | +| Continuation | same thread, new run, `parentRunId`, generic middleware seam, result-only history | +| Compatibility | all three old APIs deprecated, legacy read only, no dual writer, sandbox guard, no codemod | +| Frameworks | runtime and type parity across all six packages | +| Documentation | server/client halves, schema conversion, no assertion casts, dates, current model, changeset | + +Search for omissions and accidental placeholders: + +```powershell +rg -n "TO[D]O|FIXME|IMPLEMENT_ME|throw new Error\(['\"]Not implemented" packages testing/e2e docs +rg -n "addToolApprovalResponse|pendingInterrupts|resumeInterrupts" packages/ai-client packages/ai-react packages/ai-preact packages/ai-solid packages/ai-vue packages/ai-svelte packages/ai-angular docs +rg -n "responseSchema|approvalSchema|parentRunId|interruptErrors|clearResolution|retryInterrupts" packages testing/e2e docs +``` + +Expected: no implementation placeholders; every legacy hit has a deprecation/compatibility purpose; every required new symbol has implementation, test, and documentation hits. Also search touched docs/model examples for repository-disallowed model names and replace any such existing line being edited with `gpt-5.5`. + +- [ ] **Step 3: Run the execution-time test-hygiene and debugging audits** + +Ask a `test-hygiene` peer to review new/modified tests for reusable fixtures, type-safe mocks, semantic assertions, deterministic timing, and absence of sleep-based synchronization. Fix every actionable finding and rerun the narrow owning test. + +Ask a `debugging-discipline` peer to audit any unexpected-failure entries retained from Task 0 onward. Each entry must show reproduced symptom, repository prior-art search, root cause, smallest fix, and regression test. If there were no unexpected failures, record that fact in the task transcript; do not fabricate an investigation. + +- [ ] **Step 4: Run the unbiased code-review/fix loop to zero** + +Use the `cr-loop` skill after implementation and self-review. Give reviewers the approved spec, this plan, and the complete diff; require coverage across protocol, type inference, browser state, security/validation, transactional persistence, concurrency/replay, framework parity, tests, and docs. Use xhigh reasoning for investigation and review agents as required by repository instructions. + +Classify every finding with file/line evidence. Fix all actionable findings, rerun the smallest owning command after each fix batch, and repeat independent review until the convergence report has zero actionable findings. Do not dismiss a finding merely because an existing test passes. + +- [ ] **Step 5: Run the complete focused package matrix sequentially** + +Set CI once and run native PowerShell one-shot commands. Await each process before starting the next: + +```powershell +$env:CI='true' +pnpm --filter @tanstack/ai test:lib -- --run tests/interrupts.test.ts tests/tool-definition.test.ts tests/chat-resume-interrupts.test.ts tests/ag-ui-event-order.test.ts +pnpm --filter @tanstack/ai test:types +pnpm --filter @tanstack/ai-persistence test:lib -- --run tests/interrupts.test.ts tests/interrupt-conformance.test.ts tests/middleware.test.ts +pnpm --filter @tanstack/ai-persistence test:types +pnpm --filter @tanstack/ai-persistence-drizzle test:lib +pnpm --filter @tanstack/ai-persistence-drizzle test:types +pnpm --filter @tanstack/ai-persistence-prisma test:lib +pnpm --filter @tanstack/ai-persistence-prisma test:types +pnpm --filter @tanstack/ai-persistence-cloudflare test:lib +pnpm --filter @tanstack/ai-persistence-cloudflare test:types +pnpm --filter @tanstack/ai-client test:lib -- --run tests/interrupt-manager.test.ts tests/chat-client-interrupts.test.ts tests/chat-client-resume.test.ts tests/chat-persistence-controller.test.ts tests/connection-adapters.test.ts +pnpm --filter @tanstack/ai-client test:types +pnpm --filter @tanstack/ai-react test:lib -- --run tests/use-chat.test.ts +pnpm --filter @tanstack/ai-react test:types +pnpm --filter @tanstack/ai-preact test:lib -- --run tests/use-chat.test.ts +pnpm --filter @tanstack/ai-preact test:types +pnpm --filter @tanstack/ai-solid test:lib -- --run tests/use-chat.test.ts +pnpm --filter @tanstack/ai-solid test:types +pnpm --filter @tanstack/ai-vue test:lib -- --run tests/use-chat.test.ts +pnpm --filter @tanstack/ai-vue test:types +pnpm --filter @tanstack/ai-svelte test:lib -- --run tests/use-chat.test.ts +pnpm --filter @tanstack/ai-svelte test:types +pnpm --filter @tanstack/ai-angular test:lib -- --run tests/inject-chat.test.ts +pnpm --filter @tanstack/ai-angular test:types +pnpm test:docs +pnpm --filter @tanstack/ai-e2e test:types +pnpm --filter @tanstack/ai-e2e test:e2e -- tests/interrupts.spec.ts +``` + +Expected: every command exits 0. On any unexpected failure, stop the matrix, apply `debugging-discipline`, fix the root cause, rerun the owning command, then restart from the first command whose output could be affected. + +- [ ] **Step 6: Run the mandatory repository-wide pre-PR gates** + +These commands are mandatory even after the focused matrix: + +```powershell +$env:CI='true' +pnpm test:pr +pnpm --filter @tanstack/ai-e2e test:e2e +``` + +Expected: both exit 0. `pnpm test:pr` is the canonical CI-equivalent Nx affected target set, and the full E2E command is mandatory for this behavior change. Do not commit, push, or rely on remote CI if either fails. + +- [ ] **Step 7: Complete the verification checklist and inspect process hygiene** + +Use the `completion-checklist` and `verification-before-completion` skills against fresh Step 5/6 output. Then, at quiescence only, inspect for task-owned processes: + +```powershell +Get-Process node,esbuild,vitest -ErrorAction SilentlyContinue | + Select-Object Id, ProcessName, StartTime, Path +git diff --check +git status --short --branch +``` + +Expected: no task-owned orphan process remains, `git diff --check` is silent, and the branch is `codex/interrupts-full-spec-design`. Never blanket-kill `node.exe`; identify ownership and wait for active commands. Do not run `wsl --shutdown` as part of this task. + +- [ ] **Step 8: Stage and create the single final implementation commit** + +Only after every review and gate is green, stage the planned implementation scope and inspect the staged diff: + +```powershell +$implementationFiles = git ls-files --modified --others --exclude-standard +$implementationFiles +git add -- $implementationFiles +git diff --cached --check +git diff --cached --stat +git status --short +git commit -m "feat: implement durable AG-UI interrupts" +git status --short --branch +``` + +Before `git add`, compare the printed list with the reviewed Task 1-17 status and remove any preserved user-owned path from `$implementationFiles`. Expected: one final commit succeeds, no generated plan/review/report artifact is accidentally staged, and the working tree is clean except for any explicitly preserved user-owned file. Do not add a co-author trailer. Do not push and do not open a PR in this execution; hand the verified local branch and commit back to the user. diff --git a/docs/superpowers/specs/2026-07-13-ag-ui-interrupts-design.md b/docs/superpowers/specs/2026-07-13-ag-ui-interrupts-design.md new file mode 100644 index 000000000..37e17fc78 --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-ag-ui-interrupts-design.md @@ -0,0 +1,1036 @@ +# AG-UI Interrupts: Full-Spec, Typed, Durable Resolution + +Status: Approved design + +Date: 2026-07-13 + +Implementation base: [TanStack AI PR #785](https://github.com/TanStack/ai/pull/785), commit `61026d52669fc261b7a051ba20ba4bd806ce84fd` + +## Summary + +TanStack AI will adopt AG-UI native interrupts as the only interrupt protocol emitted by new servers. The client API will expose bound interrupt objects whose methods stage typed responses, validate them, and submit a complete response batch only after every simultaneously open interrupt has been answered or cancelled. + +The selected design is staged bound interrupts with auto-submit: + +- A single interrupt auto-submits after `resolveInterrupt` or `cancelInterrupt`. +- A batch waits until every open interrupt has a staged resolution or cancellation, then submits exactly once. +- `resolveInterrupts(callback)` provides a synchronous root transaction for heterogeneous batches. +- Tool approvals infer edited arguments from `inputSchema` and custom approval data from `approvalSchema`. +- Generic AG-UI interrupts retain `unknown` payloads statically and validate them from `responseSchema` at runtime. +- The server validates the entire batch, reports all validation errors together, and executes no tools when any entry is invalid. +- Final response batches use a mandatory atomic compare-and-swap persistence operation. Exact replays are idempotent, conflicting responses are rejected, and the first valid writer wins. + +This is a breaking protocol and API transition relative to the pre-PR approval flow and parts of PR #785's current surface. Compatibility is handled by a pre-1.0 deprecation period, retained legacy readers in new clients, coordinated client and server upgrades, and a manual migration guide. There will be no codemod. + +## Normative basis and requirement labels + +This design separates protocol requirements from TanStack guarantees: + +| Label | Meaning | +| --- | --- | +| `[AG-UI]` | Required for AG-UI protocol conformance. The normative source is the interrupts document at immutable commit [`2ed25b2009ff900315ab686dc9f86147b48fe382`](https://github.com/ag-ui-protocol/ag-ui/blob/2ed25b2009ff900315ab686dc9f86147b48fe382/docs/concepts/interrupts.mdx). The PR-head type baseline is `@ag-ui/core@0.0.57`. | +| `[TanStack]` | A stronger TanStack API, validation, safety, persistence, or durability guarantee. It is not claimed as an AG-UI protocol requirement. | +| `[Compatibility]` | Temporary pre-1.0 behavior for old TanStack clients or servers. It is outside the native AG-UI interrupt protocol. | + +The AG-UI requirements used here are: an interrupted run terminates with a nonempty interrupt outcome; required message and state snapshots precede that terminal event; the continuation uses the same thread and a new run; one resume array covers every open interrupt; pending interrupts block unrelated input; `responseSchema` describes resolved payloads; exact replay is idempotent; expiry rejects stale resumes; denial is a resolved payload; cancellation has no payload; and a resumed tool emits only its result against the original `toolCallId`. + +TanStack additionally guarantees typed tool shorthands, client preflight validation, authoritative server validation, exhaustive batch errors, full-set atomic compare-and-swap persistence, first-writer conflict handling, local draft durability, recovery state, and cross-framework API parity. In particular, AG-UI describes validation against `responseSchema`, but this design's mandatory server-side validation of every entry, all-errors response, and no-write-on-any-invalid rule are explicit TanStack guarantees. + +## Evidence and breaking-change analysis + +The protocol requirements in this design follow the primary [AG-UI interrupts specification](https://docs.ag-ui.com/concepts/interrupts) and its [immutable source revision](https://github.com/ag-ui-protocol/ag-ui/blob/2ed25b2009ff900315ab686dc9f86147b48fe382/docs/concepts/interrupts.mdx). The installed `@ag-ui/core@0.0.57` types define an interrupt descriptor, `resolved` and `cancelled` resume statuses, a `resume` array on run input, and an interrupt outcome on `RUN_FINISHED`. + +Before PR #785, TanStack AI used approval-specific custom events and `addToolApprovalResponse`. The old server emitted `approval-requested` and `tool-input-available` custom events, as shown in the [pre-PR chat implementation](https://github.com/TanStack/ai/blob/5fcaf90dc82bc20b8c7a75faa3c129da04858af5/packages/ai/src/activities/chat/index.ts#L1719-L1772). That flow represented approval state in message parts and resumed through updated conversation history. + +PR #785 changes the lifecycle to native interrupt outcomes and resumable run state. That is a breaking protocol change for an old client connected to a new server: the old client depends on custom approval events that the new server will no longer emit. Emitting both paths is not safe because duplicate prompts, duplicate callbacks, and ordering ambiguity can cause two responses to the same logical decision. No capability negotiation exists to make dual emission reliable. + +PR #785 also has correctness gaps that this design closes: + +- [`addToolApprovalResponse`](https://github.com/TanStack/ai/blob/61026d52669fc261b7a051ba20ba4bd806ce84fd/packages/ai-client/src/chat-client.ts#L1514-L1534) currently maps denial to `cancelled`. AG-UI represents denial as a resolved response such as `{ "approved": false }`; cancellation abandons an interrupt and has no payload. +- The [resume request path](https://github.com/TanStack/ai/blob/61026d52669fc261b7a051ba20ba4bd806ce84fd/packages/ai-client/src/chat-client.ts#L1054-L1067) reuses the interrupted run ID. AG-UI ends the interrupted run and resumes in a new run on the same thread. +- The [interrupt construction path](https://github.com/TanStack/ai/blob/61026d52669fc261b7a051ba20ba4bd806ce84fd/packages/ai/src/activities/chat/index.ts#L1762-L1825) uses noncanonical reasons. Tool approvals must use `tool_call`; a TanStack-specific client-tool interruption must use a namespaced reason. +- The [resumed tool path](https://github.com/TanStack/ai/blob/61026d52669fc261b7a051ba20ba4bd806ce84fd/packages/ai/src/activities/chat/index.ts#L1837-L1873) can replay tool call start, arguments, and end events. A resumed edited tool call emits only the result against the original `toolCallId`. +- The [persistence middleware](https://github.com/TanStack/ai/blob/61026d52669fc261b7a051ba20ba4bd806ce84fd/packages/ai-persistence/src/middleware.ts#L93-L154) validates fail-fast and stores responses sequentially. A response batch needs exhaustive validation followed by one atomic commit. +- The [interrupt store contract](https://github.com/TanStack/ai/blob/61026d52669fc261b7a051ba20ba4bd806ce84fd/packages/ai-persistence/src/types.ts#L47-L73) exposes per-item `resolve` and `cancel`, so it cannot guarantee all-or-none batch persistence or first-writer conflict handling. + +Because PR #785 has not established a stable released API for native interrupt resumption, its raw `resumeInterrupts` surface can be replaced by the safe bound API in this work. An explicitly unsafe raw escape hatch remains available. + +## Goals + +- Fully implement the AG-UI interrupt lifecycle, event ordering, resume batching, cancellation, expiry, and idempotency requirements. +- Make tool approvals ergonomic and type-safe from definition through every framework hook. +- Support edited tool arguments as an optional full replacement of the original arguments. +- Support branch-specific typed custom approval and rejection payloads. +- Support arbitrary AG-UI interrupt reasons and response schemas without pretending runtime JSON Schema creates static TypeScript types. +- Make multiple simultaneous interrupts safe and easy to resolve individually or as a root transaction. +- Validate every entry on the server and return all errors in one response. +- Preserve client response drafts across reloads while keeping final response authority on the server. +- Prevent partial response persistence, duplicate continuation scheduling, and last-writer overwrites. +- Give React, Preact, Solid, Vue, Svelte, and Angular equivalent behavior through the headless client. +- Provide a clear pre-1.0 deprecation and migration path. + +## Non-goals + +- A codemod for approval APIs. +- Static type inference from an interrupt's runtime JSON Schema. +- Protocol negotiation or dual emission of native and legacy approval events. +- Partial submission of a batch while sibling interrupts remain unanswered. +- Merging `editedArgs` into original arguments. Edited arguments are always a complete replacement. +- Deprecating `addToolResult`; it remains the direct client-tool result API. +- Combining client-tool execution and human tool approval into one interrupt type. +- Exactly-once external side effects for arbitrary tools. Tool implementations still need idempotency where crash recovery can re-enter external work. +- Changing general checkpoint, tool-result, or message persistence beyond the interrupt response transaction required here. + +## Considered architectures + +### 1. Staged bound interrupts with auto-submit, selected + +Every exposed interrupt carries methods bound to the owning chat client. Item methods stage a decision. Once all currently open interrupts are covered, the client submits one batch. A singleton is therefore both staged and submitted by one call. + +This design makes the common case concise, supports per-card user interfaces, and preserves the AG-UI all-open-interrupt batch invariant. It also provides a natural place for typed tool overloads, validation status, item errors, replacement, and clearing. + +### 2. Pure response builders plus explicit submit + +Item methods could return response objects and require a separate root submit call. This makes batching explicit but adds boilerplate to the singleton case, makes per-card code manage a second response collection, and does not match the requested binding behavior. + +### 3. Root-only reducer + +The hook could expose only `resolveInterrupts(interrupts => responses)`. This centralizes atomicity but makes independent interrupt components awkward, prevents direct item binding, and forces a heterogeneous response construction problem into every consumer. + +The selected architecture keeps atomic submission centralized internally while exposing both convenient item bindings and an explicit root transaction. + +## Public type model + +The examples below describe the public contract. Implementation helper types may use different internal names, but they must preserve these inference and assignability rules. + +### Common bound interrupt fields + +```ts +type InterruptStatus = + | 'pending' + | 'validating' + | 'staged' + | 'submitting' + | 'error' + +interface InterruptCorrelation { + threadId: string + interruptedRunId: string + generation: number + submissionId?: string + continuationRunId?: string +} + +type ItemInterruptErrorCode = + | 'invalid-payload' + | 'invalid-edited-args' + | 'invalid-tool-output' + | 'invalid-response-schema' + | 'unknown-interrupt' + | 'expired' + | 'stale' + | 'conflict' + | 'legacy-unsupported' + +interface ItemInterruptError extends InterruptCorrelation { + scope: 'item' + interruptId: string + code: ItemInterruptErrorCode + message: string + path?: readonly (string | number)[] + source: 'client' | 'server' + retryable: boolean +} + +type BatchInterruptErrorCode = + | 'incomplete-batch' + | 'item-validation-failed' + | 'unsupported-bulk-operation' + | 'async-resolver' + | 'inactive-transaction' + | 'mixed-provenance' + | 'transport' + | 'server' + | 'protocol' + | 'invalid-response-schema' + | 'expired' + | 'stale' + | 'conflict' + | 'persistence-required' + | 'atomic-commit-unsupported' + | 'recovery-unavailable' + | 'legacy-submit-failed' + +interface BatchInterruptError extends InterruptCorrelation { + scope: 'batch' + code: BatchInterruptErrorCode + message: string + source: 'client' | 'server' | 'transport' + retryable: boolean + interruptIds: readonly string[] +} + +type InterruptSubmissionError = ItemInterruptError | BatchInterruptError + +interface BoundInterruptBase { + id: string + reason: string + message?: string + toolCallId?: string + responseSchema?: JSONSchema + expiresAt?: string + metadata?: Record + status: InterruptStatus + provenance: 'native' | 'legacy' + generation: number + errors: readonly ItemInterruptError[] + stagedResponse?: TStagedResponse + clearResolution(): void + cancelInterrupt(): void +} +``` + +The AG-UI fields retain the installed core package's actual exported types. A typed `JSONSchema` descriptor still does not create a static application payload type. `stagedResponse` is a read-only view of the local draft and never includes bound functions when persisted. + +Every public variant has a TanStack-owned `kind` discriminator. Known tool variants also carry the configured tool's literal `toolName`; neither field is read from untrusted resume metadata. + +```ts +declare const toolApprovalCapability: unique symbol + +interface ToolApprovalCapabilityMarker { + readonly [toolApprovalCapability]: TNeedsApproval +} + +type ApprovalCapabilityOf = + TTool extends ToolApprovalCapabilityMarker + ? TNeedsApproval + : false + +type ToolName = TTools[number]['name'] + +type ToolByName< + TTools extends readonly AnyClientTool[], + TName extends ToolName, +> = Extract + +type ApprovalToolName = { + [TName in ToolName]: ApprovalCapabilityOf< + ToolByName + > extends true + ? TName + : never +}[ToolName] + +type ToolApprovalInterruptsFor< + TTools extends readonly AnyClientTool[], +> = { + [TName in ApprovalToolName]: ToolApprovalInterrupt< + ToolByName + > & { + kind: 'tool-approval' + toolName: TName + } +}[ApprovalToolName] + +type ClientToolExecutionInterruptsFor< + TTools extends readonly AnyClientTool[], +> = { + [TName in ToolName]: ClientToolExecutionInterrupt< + ToolByName + > & { + kind: 'client-tool-execution' + toolName: TName + } +}[ToolName] + +type ChatInterrupt = + | ToolApprovalInterruptsFor + | ClientToolExecutionInterruptsFor + | GenericAGUIInterrupt +``` + +A generic interrupt has `kind: 'generic'` and no `toolName`. A known tool interrupt narrows by `kind` and literal `toolName` to the configured tool's input, output, and approval payload types. `ToolApprovalInterruptsFor` contains only names whose preserved `TNeedsApproval` capability is the literal `true`; tools with literal `false`, absent approval capability, or a widened nonliteral capability cannot enter the approval variant. A native descriptor that cannot be matched safely remains generic even if it happens to contain a tool-like reason. + +At submission, the server looks up the authoritative original call by `(threadId, interruptedRunId, interruptId)` and obtains its stored `toolCallId`, `toolName`, original arguments, schema hashes, and generation. It never selects a tool or arguments from client-supplied `kind`, `toolName`, `toolCallId`, or metadata. Any conflicting client correlation field is ignored for binding and reported as `stale` or `protocol` when it indicates a mismatched descriptor. + +### Tool definition configuration + +`approvalSchema` is legal only when `needsApproval` is statically `true`. + +```ts +const transferFunds = toolDefinition({ + name: 'transferFunds', + inputSchema: transferInputSchema, + outputSchema: transferOutputSchema, + needsApproval: true, + approvalSchema: { + approve: approvalPayloadSchema, + reject: rejectionPayloadSchema, + }, +}) +``` + +Supported forms are: + +```ts +approvalSchema: sharedPayloadSchema + +approvalSchema: { + approve: approvalPayloadSchema, + reject: rejectionPayloadSchema, +} +``` + +The branch object must contain at least one branch. A shared schema applies to both approval and rejection. In a branch object, an omitted branch forbids a custom payload for that decision. If `approvalSchema` is absent, both branches forbid a custom payload. `approvalSchema` by itself never enables approval; `needsApproval: true` is required. + +Each shared or branch schema accepts the full existing `SchemaInput` family: Standard JSON Schema, Standard Schema, or TanStack's raw `JSONSchema`. Standard JSON Schema and raw JSON Schema are serialized into the AG-UI `responseSchema`. A Standard Schema validator that also exports Standard JSON Schema uses that export. A Standard Schema validator without JSON Schema export remains supported: matching TanStack clients and the server use the configured validator, while the wire schema expresses the branch envelope with an unconstrained nested payload. Generic clients therefore keep that nested payload as `unknown`, and server validation remains authoritative. + +Schema inference rules are explicit: + +- A Standard Schema or Standard JSON Schema input contributes its declared input type. +- A raw `JSONSchema` contributes `unknown`; TanStack does not infer a TypeScript type from JSON Schema syntax. +- An absent `inputSchema` forbids `editedArgs` in types and at runtime. The original stored arguments are used unchanged. +- A present raw `inputSchema` permits `editedArgs: unknown`, then validates the replacement at runtime. +- An absent `outputSchema` makes client-tool output `unknown` and performs no output-schema validation. +- A present raw `outputSchema` keeps output `unknown` and validates it at runtime. +- An absent approval branch schema forbids nested `payload`. +- A present raw approval schema requires `payload: unknown`; Standard Schema input that includes `undefined` makes the property optional, while other typed schema inputs require it. + +The branch-map form is recognized only when its own keys are a nonempty subset of `approve` and `reject` and its values are `SchemaInput` values. A Standard Schema object is recognized by its standard marker first, and an object containing JSON Schema keywords is a shared raw schema. Ambiguous or malformed configuration fails tool-definition construction. + +The tool-definition builder carries its `TNeedsApproval` generic through the non-runtime `ToolApprovalCapabilityMarker` and preserves that marker, the input schema, output schema, and approval schema generics through client and server tool transformations. The mapped union reads this type marker rather than the optional runtime `needsApproval` property. Configurations with `needsApproval: false` or without `needsApproval` reject `approvalSchema` at compile time and runtime. + +### Typed tool approval signatures + +A tool approval interrupt has this normative branch-aware public contract: + +```ts +interface ToolApprovalInterrupt< + TTool extends AnyClientTool, +> extends BoundInterruptBase, + ApproveSchema, + RejectSchema + >> { + kind: 'tool-approval' + reason: 'tool_call' + toolName: TTool['name'] + toolCallId: string + + resolveInterrupt( + approved: true, + ...options: OptionsTuple, + ApproveSchema + >> + ): void + + resolveInterrupt( + approved: false, + ...options: OptionsTuple>> + ): void +} + +type EditedArgsField = TInputSchema extends NoSchema + ? { editedArgs?: never } + : { editedArgs?: InferSchemaInput } + +type PayloadField = TPayloadSchema extends NoSchema + ? { payload?: never } + : TPayloadSchema extends JSONSchema + ? { payload: unknown } + : undefined extends InferSchemaInput + ? { payload?: InferSchemaInput } + : { payload: InferSchemaInput } + +type ApprovalFields = + EditedArgsField & PayloadField + +type RejectionFields = PayloadField & { + editedArgs?: never +} + +declare const noSchema: unique symbol +type NoSchema = typeof noSchema + +type RequiredKeys = { + [TKey in keyof T]-?: {} extends Pick ? never : TKey +}[keyof T] + +type OptionsTuple = [RequiredKeys] extends [never] + ? [options?: TFields] + : [options: TFields] + +type ToolApprovalDraft = + | ({ status: 'resolved'; approved: true } & ApprovalFields< + TInputSchema, + TApproveSchema + >) + | ({ status: 'resolved'; approved: false } & RejectionFields< + TRejectSchema + >) + | { + status: 'cancelled' + } +``` + +`OptionsTuple` makes the argument optional only when every property in `TFields` is optional; otherwise it requires exactly one options object. The resolver overloads and `ToolApprovalDraft` deliberately share `ApprovalFields` and `RejectionFields`, so `stagedResponse` cannot represent a payload that the resolver could not create. + +The conditional option tuple has these rules: + +- `editedArgs` is optional and exists only on the approval branch. +- `editedArgs` is inferred from the tool's `inputSchema` and is forbidden when that schema is absent. +- If an approval payload schema requires a value, the options object and its `payload` field are required. +- If an approval payload is optional, the options object is optional unless `editedArgs` is supplied. +- If a branch has no payload schema, that branch's options type has no `payload` field. Raw JSON Schema payloads are required but remain `unknown` statically. +- The rejection branch has no `editedArgs` field. +- Excess-property checks and runtime validation reject custom fields outside the nested `payload` object. + +The intended calls are: + +```ts +interrupt.resolveInterrupt(true) + +interrupt.resolveInterrupt(true, { + editedArgs: updatedInput, + payload: { note: 'Reviewed' }, +}) + +interrupt.resolveInterrupt(false, { + payload: { reason: 'Insufficient evidence' }, +}) +``` + +The first argument always represents approval or denial. On the AG-UI wire, both are `resolved` entries. TanStack's response schema and resume payload use this envelope: + +```ts +type ToolApprovalResumePayload = + | { + approved: true + editedArgs?: unknown + payload?: unknown + } + | { + approved: false + payload?: unknown + } +``` + +`approvalSchema` governs only the nested `payload`. The generated `responseSchema` is a JSON Schema union of the approval and rejection envelopes. The approval branch includes optional `editedArgs` governed by `inputSchema`; the rejection branch forbids `editedArgs`. Branches without an approval schema forbid `payload`. + +`editedArgs`, when present, fully replaces the original tool arguments. Neither client nor server performs a shallow or deep merge. The server validates the replacement against `inputSchema` before any response is committed or tool is executed. + +### Generic AG-UI interrupts + +An interrupt that is not a known tool approval or a known TanStack client-tool execution remains generic: + +```ts +interface GenericAGUIInterrupt + extends BoundInterruptBase< + | { status: 'resolved'; payload: unknown } + | { status: 'cancelled' } + > { + kind: 'generic' + toolName?: never + resolveInterrupt(payload: unknown): void +} +``` + +All standard descriptor fields are statically known. The response remains `unknown` because a JSON Schema received at runtime cannot safely produce a compile-time TypeScript type. The client validates against `responseSchema` as a preflight when a schema is present. The server repeats that validation authoritatively. + +The user documentation will show how to convert the received JSON Schema into a schema supported by the user's chosen validation library, validate the payload, and narrow the result before calling `resolveInterrupt`. It will also show a Standard Schema compatible validation path. Documentation examples must not claim static inference from the wire schema. + +### Client-tool execution interrupts + +A client-owned tool that is awaiting browser execution is separate from human approval: + +```ts +interface ClientToolExecutionInterrupt + extends BoundInterruptBase< + | { status: 'resolved'; payload: InferToolOutput } + | { status: 'cancelled' } + > { + kind: 'client-tool-execution' + reason: 'tanstack:client_tool_execution' + toolName: TTool['name'] + toolCallId: string + resolveInterrupt(output: InferToolOutput): void +} +``` + +The output is inferred from the tool's `outputSchema` and is validated before submission and again on the server when that schema exists. With no output schema, output is `unknown` and no schema validation is attempted. A raw output schema also remains `unknown` statically but is validated at runtime. `addToolResult` remains supported and delegates to this machinery when it targets a pending client-tool interrupt. It is not deprecated. + +### Hook and headless-client surface + +Every framework hook exposes the same logical surface from the headless client: + +```ts +const { + interrupts, + resolveInterrupts, + cancelInterrupts, + retryInterrupts, + interruptErrors, + isResuming, + resumeInterruptsUnsafe, +} = useChat(options) +``` + +The root operations are: + +```ts +resolveInterrupts(decision: boolean): Promise + +resolveInterrupts( + resolve: (interrupt: ChatInterrupt) => undefined, +): Promise + +cancelInterrupts(): Promise + +retryInterrupts(): Promise + +resumeInterruptsUnsafe( + entries: readonly RunAgentResumeItem[], + state?: ChatResumeState, +): Promise +``` + +The boolean overload is intentionally narrow. It is allowed only when every open interrupt is a tool approval and the selected decision branch requires no custom payload. It never edits arguments. The client rejects unsupported boolean bulk operations before staging anything. Heterogeneous batches and payload-bearing decisions use the callback overload. + +Root promises resolve only after the server accepts the atomic response batch. They reject with a `BatchInterruptError` when validation, transport, protocol, expiry, staleness, or conflict prevents acceptance. Bound item methods are synchronous and return `void`; they update local state and may start asynchronous submission internally. + +`resumeInterruptsUnsafe` is the raw protocol escape hatch. It bypasses the type-safe builder API but still passes through server validation, exact-set checks, expiry checks, and atomic persistence. Its name is deliberately explicit. Normal application code should use bound or root resolvers. + +## Client staging and submission state machine + +The client maintains one draft slot per open interrupt. A draft is a resolved response or a cancellation. + +```text +pending -> staged -> submitting -> accepted + ^ | | + | | +-> error + +----------+ +``` + +The state machine follows these rules: + +1. `resolveInterrupt` constructs a candidate synchronously and starts client preflight. Synchronous validators finish in the call; an async Standard Schema validator sets status to `validating` and the submission gate waits for it. +2. `cancelInterrupt` stages `status: 'cancelled'` with no payload and needs no response-schema validation. +3. A valid candidate replaces the prior draft, clears superseded item errors, and sets status to `staged` while no submission is in flight. +4. An invalid replacement does not destroy the last valid draft. It sets status to `error`, records errors for the attempted candidate, preserves the previous `stagedResponse` for display, and blocks submission until the user supplies a valid replacement or clears it. +5. Async validation captures the item's draft generation. A result from an older candidate is discarded and cannot overwrite newer state. +6. `clearResolution()` removes the draft and candidate errors, increments the draft generation, and returns the item to `pending` while no submission is in flight. +7. With one open interrupt, a valid response or cancellation immediately starts submission after preflight completes. +8. With multiple open interrupts, item calls do not submit until every interrupt has a valid draft and no item is validating or in `error`. +9. When the final uncovered interrupt becomes valid, the client freezes the complete canonical batch, its draft generations, and fingerprint, then starts one submission. +10. During `submitting`, item replacement and clearing are rejected to avoid mutating the in-flight fingerprint. +11. A transport or retryable availability failure unfreezes interaction but retains the complete valid batch and its retry fingerprint. Items return to `staged`, root `interruptErrors` receives the failure, and `retryInterrupts()` is enabled. +12. Editing, replacing, cancelling, or clearing any item after that failure increments the batch generation, discards the frozen retry fingerprint, and clears the superseded transport error. `retryInterrupts()` then rejects until a new complete batch is frozen. +13. A server validation failure unfreezes the batch, maps every item error, and sets affected items to `error` without deleting their last candidate. A corrected valid candidate replaces it. +14. Pending interrupts block unrelated new user input as required by AG-UI. +15. After acceptance, the resolved descriptors and drafts leave the active `interrupts` collection and the continuation owns subsequent state. + +The callback overload is a synchronous transaction. Its return type is `undefined`, not `void`, because TypeScript permits an async function where a callback merely returns `void`: + +```ts +await resolveInterrupts((interrupt) => { + if (interrupt.kind === 'tool-approval') { + if (interrupt.toolName === 'transferFunds') { + interrupt.resolveInterrupt(true, { + payload: { note: 'Reviewed' }, + }) + return + } + + interrupt.cancelInterrupt() + return + } + + if (interrupt.kind === 'client-tool-execution') { + interrupt.cancelInterrupt() + return + } + + interrupt.resolveInterrupt(buildGenericResponse(interrupt)) +}) +``` + +The client invokes the callback exactly once for each interrupt in a stable snapshot. It passes transaction-scoped bound wrappers carrying a unique transaction token and draft-generation number. Item auto-submission is suppressed during the callback. On normal `undefined` return, every snapshot item must be covered; the complete batch is submitted once and the token is sealed. + +If the callback throws, returns any non-`undefined` value, returns a promise or thenable, or leaves an interrupt uncovered, all staging changes made by that callback are rolled back, its token and generation are invalidated, and no network request starts. Every transaction-scoped item method checks that token before touching state. This prevents a bypassed async callback from returning a promise, awaiting, and then mutating live drafts after rollback. Such a late call is ignored, records a root `inactive-transaction` error, and never submits. The original non-transaction bound objects remain usable after the failed root operation. + +`cancelInterrupts()` stages cancellation for every open interrupt and submits one batch. A cancellation counts as coverage but is never rewritten as an approval denial. + +`retryInterrupts()` resubmits the same frozen complete batch only after a retryable transport or server availability failure. Expired, stale, and conflicting batches refresh authoritative state and cannot be retried blindly. + +## Validation and error propagation + +### JSON Schema contract + +`[TanStack]` All serialized `responseSchema`, raw input, raw output, and raw approval schemas use JSON Schema Draft 2020-12. An absent `$schema` is interpreted as Draft 2020-12. A different declared dialect is rejected rather than silently reinterpreted. + +Browser and server use Ajv 8's 2020 entry point plus `ajv-formats` in full mode with the same options: `allErrors: true`, strict schema checking, format validation enabled, type coercion disabled, defaults disabled, and data mutation disabled. Unknown formats are schema compilation errors. Only document-local references, including `#` and `#/$defs/...`, are supported. Network, file, and cross-document `$ref` resolution is disabled in both environments. Unresolved references and invalid schemas produce `invalid-response-schema`; a TanStack server rejects an invalid schema before emitting an interrupt descriptor. + +Ajv `instancePath` values are decoded using RFC 6901 escaping. Normalized public paths are arrays of decoded string segments; validators that natively report numeric array indexes may use numbers. For `required` and `additionalProperties` errors, the missing or extra property is appended to the instance path. Multiple Ajv errors remain multiple errors and retain deterministic Ajv evaluation order within an item; items are ordered by interrupt ID in the aggregate response. + +Standard Schema validators run through their own validation surface and their issue paths are normalized to the same public path form. Client preflight may await an async Standard Schema validator behind the synchronous staging API. Server validation may also await it before commit. The serialized envelope is additionally checked with the common Ajv configuration. Validation never coerces, fills defaults, strips properties, or mutates `editedArgs` or payloads. + +If a client receives an invalid generic schema from a non-TanStack source, resolved submission is disabled for that item and it receives `invalid-response-schema`; cancellation remains available because it has no payload. `resumeInterruptsUnsafe` cannot bypass authoritative schema compilation. + +Validation happens in two phases: + +- Client preflight gives immediate feedback and avoids known-invalid requests. +- `[TanStack]` Server validation is mandatory and authoritative because clients, schemas, state, and expiry can be stale or bypassed. Exhaustive validation and no-write-on-any-invalid are stronger TanStack guarantees, not additional claims about the AG-UI base protocol. + +The server validates every entry in the proposed batch before persisting or executing anything. It collects all failures rather than stopping at the first failure. Checks include: + +- The response covers exactly the current open interrupt ID set. +- Every ID belongs to the interrupted run and has not expired. +- Each entry uses an allowed `resolved` or `cancelled` status. +- A cancelled entry has no payload. +- A resolved generic payload matches `responseSchema` when present. +- A tool approval envelope selects a valid branch. +- Approved `editedArgs`, when present, match `inputSchema` as a full replacement. +- Rejection never includes `editedArgs`. +- The nested custom payload matches the selected approval schema branch. +- A client-tool output matches `outputSchema`. + +If any validation fails, the server performs no interrupt response write, schedules no continuation tools, and returns one AG-UI `RUN_ERROR`. It does not emit a second error event or a compensating `RUN_FINISHED`. Because `RUN_ERROR` is the protocol's error event and its schema permits extension fields, TanStack attaches a serialized `InterruptSubmissionError[]` at the top-level namespaced field `tanstack:interruptErrors`. Generic AG-UI consumers still receive the standard error message and code. + +`scope: 'item'` errors populate only the matching bound item's `errors`; `scope: 'batch'` errors populate only root `interruptErrors`. An unknown interrupt ID, missing item, or invalid exact-set check is a batch error unless the authoritative state can correlate the problem to one still-current item. Every error carries thread, interrupted-run, and generation correlation; submission and continuation IDs are included when known. The promise rejection is a `BatchInterruptError` with `item-validation-failed` when only item errors caused failure, and its `interruptIds` lists every affected item. + +Retryability is explicit, not inferred only from code. Transport and transient server availability errors are retryable when the frozen fingerprint remains intact. Client validation, invalid schema, incomplete batch, unsupported bulk, async resolver, inactive transaction, mixed provenance, persistence capability, expiry, staleness, and conflict errors are not retryable without a state or draft change. A server error may mark itself retryable only when it proves no commit occurred. + +`interruptErrors` is cleared or replaced by the next relevant operation. A transport failure keeps the complete valid draft batch staged and adds a retryable root error. Stale, expired, and conflict responses replace local descriptors and drafts with authoritative server state before exposing the non-retryable error. + +### Authoritative recovery state + +`[TanStack]` Stale, expired, replayed, and conflicting submissions use one versioned recovery DTO: + +```ts +interface InterruptRecoveryStateV1 { + schemaVersion: 1 + state: 'pending' | 'committed' | 'expired' | 'missing' | 'legacy-committed' + threadId: string + interruptedRunId: string + generation: number + pendingInterrupts: readonly Interrupt[] + committed?: { + fingerprint: string + resolutions: readonly RunAgentResumeItem[] + continuationRunId?: string + committedAt: string + } +} +``` + +For every newly committed batch, `state: 'committed'` requires `committed.continuationRunId`; that is the winning continuation ID. `resolutions` is included only for authorized callers and is redacted according to the same policy as interrupt payload persistence. `legacy-committed` exists only for migrated pre-batch records whose historical continuation ID cannot be reconstructed. `pendingInterrupts` is nonempty only in `pending`; expired, missing, and committed states return an empty set. + +The primary recovery wire path is the top-level `tanstack:interruptRecovery` field on the same `RUN_ERROR` that reports stale, expired, or conflict. The server obtains this DTO in the same persistence read or transaction that detects the condition, so its generation and winning commit are consistent with the error. + +If the stream ends before that event or a transport cannot carry extension fields, the client calls an explicit recovery fetch contract: + +```ts +type InterruptStateFetcher = ( + input: { + threadId: string + interruptedRunId: string + knownGeneration: number + }, + options: { signal: AbortSignal }, +) => Promise +``` + +Connection adapters expose the equivalent `loadInterruptState` method. Fetcher-mode clients receive `interruptStateFetcher` beside `fetcher`. Server integrations expose the matching authenticated `getInterruptRecoveryState` handler over their chosen transport; the HTTP helper returns the DTO as JSON. The standard fetch and connection bridges wire these contracts automatically. This is a TanStack recovery extension, not an AG-UI run request. + +The client accepts only a matching thread and interrupted-run ID and a generation at least as new as its current generation. `pending` replaces descriptors, rebinds methods from the configured tool registry, and reconciles drafts. `committed` clears drafts and attaches to or reloads the winning continuation. `expired` clears drafts and reports expiry. `missing` and `legacy-committed` clear unsafe retry state and report a non-retryable protocol or migration error. If neither the event extension nor a configured recovery fetcher is available, state is not guessed: drafts remain quarantined, submission is disabled, and root receives `recovery-unavailable`. + +## Client draft durability + +Draft decisions are client state, not final server responses. `ChatPersistenceController` owns serialization, hydration, generation invalidation, and cleanup. The configured `persistence.server` `ChatStorageAdapter` remains an opaque get/set/remove transport keyed by thread ID; it never creates bound objects or decides reconciliation. + +The stored DTO is versioned and contains raw JSON only: + +```ts +interface ChatResumeSnapshotV2 { + schemaVersion: 2 + resumeState: ChatResumeState + interruptState?: { + provenance: 'native' | 'legacy' + phase: 'pending' | 'accepted' + interruptedRunId: string + generation: number + descriptorsHash: string + descriptors: readonly Interrupt[] + drafts: readonly { + interruptId: string + responseSchemaHash: string + response: RunAgentResumeItem + savedAt: string + }[] + continuationRunId?: string + } +} +``` + +The effective draft identity is `(threadId, interruptedRunId, generation, interruptId, responseSchemaHash)`. Functions, validators, tool implementations, `kind`, and hydrated error objects are never serialized. On hydration, the controller validates the DTO shape and version, obtains authoritative recovery state when native interrupts are present, then rebuilds bound variants from descriptors plus the configured tool registry. A draft survives only when the authoritative run, generation, exact interrupt ID, canonical response schema hash, and expiry match. Everything else is discarded. + +After acceptance, the client clears active in-memory descriptors and drafts immediately, writes an `accepted` tombstone containing the continuation ID and no drafts, then queues adapter removal. If removal fails, the tombstone prevents an old pending UI from reappearing; a later authoritative recovery confirms the commit and cleanup retries. Thread reset and disposal also invalidate pending writes by generation. + +The unversioned PR #785 snapshot shape is treated as version 1. Migration copies `resumeState` and raw `pendingInterrupts` into a version 2 native descriptor set with no drafts, generation `0`, and a mandatory recovery refresh before resolution. Invalid or uncorrelatable version 1 snapshots are removed and produce a root `protocol` error. Legacy CUSTOM-event state continues to hydrate from message history through the compatibility backend, not by pretending it is a native resume snapshot. + +`pendingInterrupts` is a deprecated getter alias over the same hydrated `interrupts` array. It does not read a second snapshot, does not persist functions, and does not return raw descriptor DTOs. + +This persistence allows a user to answer part of a multi-interrupt batch, reload, and continue. It does not make a partial response visible to the server. Only the final complete batch is committed server-side. + +## Atomic server persistence and concurrency + +Every persistence adapter must implement an atomic batch compare-and-swap operation. Sequential per-item `resolve` and `cancel` calls are not an acceptable fallback. + +The store contract adds this normative operation: + +```ts +commitInterruptResolutions({ + threadId, + interruptedRunId, + continuationRunId, + expectedGeneration, + expectedInterruptIds, + resolutions, + fingerprint, +}): Promise< + | { status: 'committed'; continuationRunId: string } + | { status: 'replayed'; continuationRunId: string } + | { status: 'conflict'; authoritativeState: InterruptBatchState } +> +``` + +The commit invariant is: + +```text +All currently pending interrupt responses are persisted, or none are. +``` + +The server computes a stable canonical fingerprint over the sorted complete resolution set. Property ordering and interrupt input order do not affect it. The transaction compares the exact expected pending ID set, records the response batch and continuation run ID together, and transitions every item atomically. + +Concurrency semantics are: + +- The first valid complete batch commits and becomes authoritative. +- An exact replay with the same fingerprint is accepted idempotently as `replayed` and returns the first continuation run ID. +- A later different batch for the same interrupted run returns `conflict` and the authoritative state. +- A replay attaches to or replays the existing continuation. It never schedules the tools a second time. +- A missing, extra, expired, already-transitioned, or otherwise stale expected ID set cannot commit. + +Database adapters use a transaction with a uniqueness or version predicate. The memory adapter uses a per-interrupted-run critical section and versioned batch record. Custom persistence adapters must implement the atomic operation; servers reject safe interrupt resumption when the configured adapter cannot provide it. + +The atomic response record prevents duplicate continuation scheduling at the resume gateway. Existing run locks and checkpoints remain responsible for continuation recovery. Arbitrary external tools must still be idempotent when exactly-once side effects matter across process crashes. + +This operation applies only to final client interrupt responses. Client drafts, general messages, checkpoints, and tool-result persistence retain their existing ownership and contracts unless separately changed. + +### Persistence availability and adapter work + +`[TanStack]` Safe native interrupt resumption requires an `InterruptStore` with atomic batch capability. Without configured persistence, the server must not emit an interrupt outcome that it cannot later resolve safely. At the point an operation would interrupt, it emits `RUN_ERROR` with `persistence-required` instead. Development applications may configure the existing memory persistence explicitly; production and multi-process applications use a durable adapter. There is no hidden process-global fallback. + +Built-in work is mandatory: + +- Memory: update `packages/ai-persistence/src/memory.ts` with a per-interrupted-run critical section, generation counter, immutable batch record, canonical fingerprint, replay lookup, and recovery DTO projection. It is single-process and loses state on restart, which documentation must state. +- Drizzle: update schema and stores in `packages/ai-persistence-drizzle`, add a numbered migration and packaged migration asset, and perform interrupt row transitions plus batch insert in one database transaction. +- Prisma: update the packaged and development Prisma models in `packages/ai-persistence-prisma`, its stores and model CLI, and document the required user migration before deploying the new package. +- Cloudflare: add the equivalent D1 migration and packaged asset in `packages/ai-persistence-cloudflare`, use a D1 transaction for the CAS, and leave R2 artifact storage unchanged. + +Durable schemas add an `interrupt_batches` record keyed by interrupted run ID with thread ID, generation, expected ID set, fingerprint, serialized resolutions, winning continuation run ID, and commit time. Interrupt rows gain a generation column. Indexes cover `(run_id, status, generation)`, `(thread_id, status)`, and unique continuation run ID. The CAS transaction checks generation and the exact pending set, transitions rows, and inserts the batch record together. + +Migration behavior is deterministic. Existing pending interrupt rows backfill to generation `1` and can enter the new CAS. Existing resolved or cancelled rows remain historical and are marked as legacy committed; because no reliable winning continuation ID can be inferred, recovery returns `legacy-committed` and never replays them. New writes always have a batch record. Migrations must be idempotent under the repository migration tooling and safe on nonempty databases. + +The shared persistence testkit gains interrupt batch conformance covering commit, rollback, replay, conflict, recovery, generation, and concurrent writers. Memory, Drizzle, Prisma, and Cloudflare must all run it. A custom adapter lacking the capability fails server construction when interrupt middleware is configured, or fails the first dynamically discovered interrupt with `atomic-commit-unsupported` before emitting an interrupt outcome. It never falls back to sequential writes. + +## AG-UI wire lifecycle + +### Interrupted run + +The server emits any required state before terminating the run: + +1. Emit `MESSAGES_SNAPSHOT` when continuation needs updated message state. +2. Emit `STATE_SNAPSHOT` when continuation needs updated agent state. +3. Emit one `RUN_FINISHED` whose outcome is `type: 'interrupt'` and whose interrupt list is nonempty. + +The interrupted run is terminal. Its interrupt descriptors contain globally unique IDs, canonical or namespaced reasons, optional `responseSchema`, optional expiry, and enough metadata to bind known tools without weakening generic fallback behavior. + +Tool approvals use reason `tool_call`. General requests use the canonical `input_required` or `confirmation` reason when their semantics match it. TanStack client-tool execution uses `tanstack:client_tool_execution`. Other TanStack-specific reasons use the `tanstack:` namespace. Unknown reasons remain generic. + +### Resume request + +The client starts a new continuation run on the same thread. It sends one `resume` array containing every open interrupt exactly once. No new user message is required. + +- Approval is `status: 'resolved'` with `{ approved: true, ... }`. +- Denial is `status: 'resolved'` with `{ approved: false, ... }`. +- Cancellation is `status: 'cancelled'` with no payload. +- An approved edit is a complete `editedArgs` replacement in the resolved payload. + +The client checks `expiresAt` before sending. The server checks expiry authoritatively. An expired or stale resume ends with `RUN_ERROR`, refreshes authoritative state, and executes nothing. + +### Continuation run + +The continuation has a new run ID and the same thread ID. After the batch commits, a resumed server tool emits only `TOOL_CALL_RESULT` for the original `toolCallId`. It does not replay `TOOL_CALL_START`, `TOOL_CALL_ARGS`, or `TOOL_CALL_END`. Subsequent model output and tool calls proceed normally in the new run. + +Downstream behavior is defined per entry after the complete batch commits: + +- Approved tool approval: invoke the authoritative tool once with `editedArgs` when supplied, otherwise with the stored original arguments. Persist its actual tool-result history part and emit one `TOOL_CALL_RESULT` with the original `toolCallId`. +- Denied tool approval: do not invoke the tool. Persist a terminal tool-result history part whose value is `{ status: 'denied', payload?: unknown }` and emit one result-only `TOOL_CALL_RESULT` whose string content is the canonical JSON serialization of that value. This gives the continued model a resolved tool outcome without confusing denial with cancellation. +- Cancelled tool approval: do not invoke the tool and emit no `TOOL_CALL_RESULT`. Persist the cancellation in the interrupt batch audit record and mark the original call not-executed when reconstructing provider history. This follows the AG-UI parallel-interrupt example, which emits results for approved calls and treats the cancelled call as not-executed. +- Client-tool execution: treat the resolved output as the actual result, persist one tool-result history part, and emit one result-only `TOOL_CALL_RESULT`. Cancellation is not-executed and emits no result. +- Generic interrupt: pass the resolved payload or cancellation state to the continuation handler and emit no tool event unless the application itself creates a later tool call. + +A mixed batch may therefore produce actual approved results, synthetic denied results, and no event for cancelled entries. Denied synthetic results are available immediately after commit; approved tool results are emitted as executions finish, so cross-tool result order is not guaranteed. Correlation is solely by the original `toolCallId`, and every result-producing entry emits exactly once. The resumed model starts only after required terminal history records for the batch are available; cancelled calls are excluded from its actionable tool-call set. + +Snapshots and persisted resume state must make a refresh capable of reconstructing the pending interrupt set before response, and the authoritative resolution or continuation after response. + +## Compatibility, deprecation, and migration + +`[Compatibility]` New servers emit only native AG-UI interrupts. New clients temporarily retain legacy custom-event readers and a real legacy submission backend so they can consume an older server during migration. Old clients cannot consume the new native-only server approval flow, so the recommended deployment is a coordinated server and client upgrade. + +Every hydrated item has `provenance: 'native' | 'legacy'`. Native items submit an AG-UI `resume` batch. Legacy `approval-requested` and `tool-input-available` events normalize into bound objects with `provenance: 'legacy'`, but they submit through the old history protocol: + +1. Stage all current legacy approval decisions with the same singleton and all-items-covered gate used by native items. +2. On completion, clone the current message history and update every matching approval part in one in-memory transaction. +3. Send that complete updated history in one legacy follow-up request, using no native `resume` array. +4. If history construction fails, change no live message. If transport fails, restore the staged batch and expose `legacy-submit-failed`. + +This legacy backend supports the behavior the old protocol can represent: boolean approve or deny and existing client-tool results. Edited arguments, custom approval payloads, generic interrupts, native cancellation, expiry, atomic server CAS, and native conflict recovery are rejected with `legacy-unsupported`; they are not silently dropped. Multiple legacy approvals received together are updated in one history transaction and one request. Native and legacy provenance cannot coexist in one batch; if malformed input creates such a set, submission stops with `mixed-provenance`. + +The following are deprecated immediately and scheduled for removal at 1.0: + +- `addToolApprovalResponse` +- `pendingInterrupts` +- Legacy approval custom-event types +- Legacy approval custom-event readers + +`pendingInterrupts` remains a deprecated alias of the new bound `interrupts` collection during the transition. It does not expose a second state store. `addToolApprovalResponse` selects the native bound backend or legacy history backend from provenance and emits a deprecation notice in development. `addToolResult` similarly preserves the legacy history path for an old client-tool event and the typed native path for a native client-tool interrupt. Legacy readers never cause new servers to dual-emit. + +The raw PR #785 `resumeInterrupts` name is replaced by `resumeInterruptsUnsafe`. The normal API is `interrupts`, item resolvers, and root resolvers. + +There is no codemod. The migration guide will cover: + +1. Coordinated client and server package upgrades. +2. Replacing `pendingInterrupts` reads with `interrupts`. +3. Replacing `addToolApprovalResponse` with bound `resolveInterrupt` calls. +4. The distinction between denial and cancellation. +5. Adding `approvalSchema` only beside `needsApproval: true`. +6. Moving custom response data under nested `payload`. +7. Treating `editedArgs` as an optional full replacement. +8. Resolving simultaneous interrupts individually or with the root callback. +9. Validating generic `unknown` payloads from `responseSchema`. +10. Upgrading custom persistence adapters to atomic batch commit. +11. Using `resumeInterruptsUnsafe` only for raw protocol integration. + +The implementation will include a changeset that calls out the breaking protocol/API transition under the repository's pre-1.0 release convention. + +## Documentation Impact + +Implementation adds two user-facing pages: + +- `docs/chat/interrupts.md`: the native AG-UI lifecycle and complete client API, with server and client examples. +- `docs/migration/interrupts.md`: the manual pre-1.0 migration from custom approval events and PR #785 raw resume APIs. There is no codemod. + +Implementation updates these existing pages: + +- `docs/tools/tool-approval.md`: `needsApproval`, shared and branch-specific `approvalSchema`, nested payloads, full-replacement edits, denial, and cancellation. +- `docs/tools/client-tools.md`: separate client-tool execution interrupts and retained `addToolResult`. +- `docs/chat/persistence.md`: version 2 client resume snapshots and draft hydration. +- `docs/persistence/custom-stores.md`: mandatory atomic interrupt batch capability and conformance kit. +- `docs/persistence/migrations.md`: Drizzle, Prisma, and Cloudflare schema migration steps. +- `docs/persistence/delivery-durability.md`: replay, first-writer conflicts, process-memory limitations, and tool idempotency. +- `docs/architecture/approval-flow-processing.md`: native event flow, new-run continuation, and legacy compatibility boundary. +- `docs/reference/functions/toolDefinition.md`: the `approvalSchema` type surface and `SchemaInput` behavior. + +`docs/config.json` adds navigation entries and `addedAt: "2026-07-13"` for both new pages. It refreshes `updatedAt` to `2026-07-13` for every content-updated page listed above, leaving `addedAt` intact. The page examples show both server and client, avoid type-assertion casts, and demonstrate user-library conversion and validation of generic JSON Schema payloads. They distinguish `[AG-UI]` protocol rules from TanStack guarantees in prose rather than presenting CAS or exhaustive server validation as base-protocol requirements. + +This internal file, `docs/superpowers/specs/2026-07-13-ag-ui-interrupts-design.md`, stays ignored and is not added to `docs/config.json`. + +## Framework parity + +Interrupt state, staging, validation, persistence, batching, and submission live in `@tanstack/ai-client`. Framework packages only adapt reactivity and preserve generics. + +React, Preact, Solid, Vue, Svelte, and Angular must expose equivalent: + +- Bound `interrupts` +- Deprecated `pendingInterrupts` alias +- Item resolver, clearer, and canceller behavior +- Root resolve, cancel, and retry functions +- `interruptErrors` +- `isResuming` +- `resumeInterruptsUnsafe` +- Tool and approval-schema inference + +Framework-specific tests must prove that updates to staged status, errors, and replacement are observable in the framework's native reactive model. Behavior must not be reimplemented independently in wrappers. + +## Implementation sequence + +1. Extend core tool-definition types to preserve approval schema generics and generate branch-aware response schemas. +2. Add the atomic interrupt batch persistence contract and implement it in built-in adapters. +3. Correct server interrupt reasons, run lifecycle, exhaustive validation, batch commit, continuation IDs, and result-only resumed tool events. +4. Build the headless client's bound interrupt normalization, type mapping, staging transaction, validation, error mapping, draft persistence, and retry behavior. +5. Add root operations, the unsafe escape hatch, and the `addToolResult` delegation path. +6. Thread the headless surface and generics through all framework packages. +7. Add legacy client readers and deprecation annotations without server dual emission. +8. Add unit, integration, persistence, type, framework, and end-to-end coverage. +9. Publish the dedicated interrupts documentation, manual migration guide, docs metadata updates, and changeset. + +Protocol and persistence foundations precede client convenience APIs so tests can exercise one authoritative wire contract. Compatibility shims are added after the new internal model exists, ensuring they delegate to one implementation. + +## Test Strategy + +Tests are layered so type construction and state-machine failures are found before persistence races and end-to-end transport runs. Existing suites are extended where they already own the behavior; new focused files are added only where mixing concerns would obscure failures. + +Suite ownership is exact: + +- Core protocol and execution: `packages/ai/tests/interrupts.test.ts` and new `packages/ai/tests/interrupts-types.test-d.ts`. +- Headless API and state machine: new `packages/ai-client/tests/chat-client-interrupts.test.ts`, plus `packages/ai-client/tests/chat-client-resume.test.ts` and `packages/ai-client/tests/chat-persistence-controller.test.ts`. +- Persistence middleware and memory: `packages/ai-persistence/tests/interrupts.test.ts`, `packages/ai-persistence/tests/memory.conformance.test.ts`, and interrupt coverage added to `packages/ai-persistence/src/testkit/conformance.ts`. +- Durable adapters: `packages/ai-persistence-drizzle/tests/drizzle.conformance.test.ts`, `packages/ai-persistence-drizzle/tests/migrations.test.ts`, `packages/ai-persistence-prisma/tests/prisma.conformance.test.ts`, `packages/ai-persistence-prisma/tests/models.test.ts`, `packages/ai-persistence-cloudflare/tests/runtime.conformance.test.ts`, and `packages/ai-persistence-cloudflare/tests/migrations.test.ts`. +- Framework behavior and types: `packages/ai-react/tests/use-chat.test.ts`, `packages/ai-react/tests/use-chat-types.test.ts`, the equivalent `use-chat` files in `ai-preact`, `ai-solid`, and `ai-vue`, `packages/ai-svelte/tests/use-chat.test.ts`, `packages/ai-svelte/tests/create-chat-types.test.ts`, `packages/ai-angular/tests/inject-chat.test.ts`, and `packages/ai-angular/tests/inject-chat-types.test.ts`. +- Browser-to-server behavior: new `testing/e2e/tests/interrupts.spec.ts` with its route fixture under `testing/e2e/src/routes/`. +- Documentation and links: the repository `test:docs` target after the pages and `docs/config.json` entries in Documentation Impact are changed. + +### Type tests + +- `approvalSchema` is accepted only with `needsApproval: true`. +- A tool with preserved `TNeedsApproval: false` or no approval marker produces `never` when extracted by `{ kind: 'tool-approval'; toolName: thatName }`, while a literal-true tool appears in the mapped approval union. +- Shared schema types both branches. +- Branch schemas type approval and rejection independently. +- An omitted branch forbids custom payload. +- Required and optional payload schemas produce the correct options arity. +- Approved resolution accepts optional full-replacement `editedArgs` inferred from `inputSchema`. +- Rejection rejects `editedArgs`. +- Custom fields are accepted only under nested `payload`. +- Client-tool resolution infers `outputSchema`. +- Generic interrupt resolution accepts `unknown` without unsafe inferred narrowing. +- Generics survive definition, client tool, server tool, headless client, and every framework hook. + +### Client unit tests + +- Singleton resolve and cancel auto-submit. +- Multi-item resolution waits for all items. +- Staged responses can be replaced and cleared before submission. +- Root callback visits each item once, suppresses intermediate submission, and submits once. +- Root callback rollback covers throws, non-`undefined` returns, promises, and incomplete batches. +- A deliberately cast async callback returns a promise, awaits a deferred gate, then calls an item method; token invalidation prevents the delayed post-await call from changing drafts or submitting. +- Boolean bulk resolution accepts only the allowed homogeneous case. +- Invalid replacement preserves the prior valid draft, sets item error state, and blocks submission until corrected or cleared. +- Editing or clearing after transport failure invalidates the frozen retry fingerprint. +- Transport failure retains a retryable frozen batch. +- Stale, expired, and conflict responses refresh authoritative state. +- Item and root errors map correctly and clear when superseded. +- Drafts survive reload and invalid drafts are discarded during reconciliation. +- Pending interrupts block unrelated new input. +- `addToolResult` and deprecated compatibility entry points delegate without duplicate submission. + +### Server and protocol tests + +- Snapshots precede interrupt `RUN_FINISHED`. +- Interrupted and continuation run IDs differ while thread ID remains stable. +- Resume covers the exact open set. +- Approval, denial, and cancellation produce distinct correct wire entries. +- Canonical and namespaced reasons are used. +- Edited arguments fully replace original arguments. +- Resumed tools emit result only against the original `toolCallId`. +- Generic payloads, tool envelopes, edited arguments, and client-tool outputs validate against their schemas. +- All invalid items are returned in one `RUN_ERROR` and no tool executes. +- Expired and stale resumes fail without writes or execution. +- Stale, expired, and conflict failures carry a correlated recovery DTO; fallback recovery fetch applies only newer matching generations. +- Invalid Draft 2020-12 schemas, remote references, unknown formats, and normalized paths behave identically in browser and server. + +### Persistence and concurrency tests + +- All responses commit or none do. +- A failure in any validation leaves every interrupt pending. +- Exact replay returns the original continuation run ID and does not schedule twice. +- Two different concurrent batches produce one commit and one conflict. +- Property and input ordering do not change the canonical fingerprint. +- Missing, extra, stale, and expired ID sets cannot commit. +- Built-in memory and durable adapters meet identical semantics. +- A restart after commit reconnects to the authoritative continuation. +- Custom adapters without atomic batch support fail loudly. +- No configured persistence fails before an unresumable interrupt outcome can be emitted. +- Nonempty-database migrations backfill pending generation state and mark historical commits as legacy. + +### Compatibility and framework tests + +- New clients translate legacy approval custom events into the new bound model. +- New servers do not emit legacy approval events. +- Deprecated aliases share state with the new APIs. +- Every framework observes the same staging, error, replacement, resolve, cancel, and retry behavior. +- Framework type tests preserve tool-specific approval payload inference. + +### End-to-end scenarios + +- One approval with and without edited arguments. +- Three simultaneous approvals resolved one card at a time. +- A heterogeneous batch resolved through the root callback. +- Approve, deny, and cancel decisions in one batch. +- In a mixed batch, approved and denied entries each emit one result-only event, cancelled entries emit none, no tool start/args/end event is replayed, and history contains the specified actual, denied, and not-executed audit states. +- Generic runtime-schema validation failure followed by correction. +- Multiple invalid entries returned together and corrected in one follow-up. +- Reload after one of three local drafts, then complete the batch. +- Network failure, explicit retry, and exact replay. +- Two browser tabs submit different complete batches and observe first-writer conflict behavior. +- Client-tool execution remains separate and type-safe. + +Validation runs narrow package and type tests first, then the repository's mandatory pre-PR gate: + +```text +pnpm test:pr +pnpm --filter @tanstack/ai-e2e test:e2e +``` + +The end-to-end suite is mandatory because this changes observable behavior. + +## Risks and mitigations + +### Type complexity leaks into user errors + +Branch-aware conditional types can produce unreadable diagnostics. Keep public helper aliases named, document the two schema forms, and add compile-failure tests for the most common mistakes. + +### Runtime and static schema behavior diverge + +Generate the response JSON Schema from the same normalized approval-schema representation used by TypeScript helper types. Test every shared, branch-specific, absent, required, and optional combination at both type and runtime levels. + +### Partial persistence or duplicate continuations + +Sequential writes and best-effort fallback are forbidden. Require atomic adapter support, fingerprint the complete batch, store the continuation run ID in the same transaction, and test races with deterministic barriers. + +### Duplicate external tool side effects after crashes + +The response gateway prevents duplicate scheduling for exact replays, while run locks and checkpoints govern recovery. Documentation must still require idempotency keys for tools whose external side effects cannot tolerate retry. + +### Stale client drafts overwrite authoritative responses + +Draft keys include the interrupted run and interrupt ID, and reload reconciliation checks the authoritative set, schema identity, and expiry. CAS rejects any different response after the first commit. + +### Compatibility shims create two sources of truth + +Legacy readers and deprecated methods normalize into the same bound model. New servers emit only native interrupts. There is one client state store and one submission pipeline. + +### Structured validation errors exceed the base protocol + +Use standard `RUN_ERROR` fields for generic clients and a namespaced TanStack extension for the exhaustive error array. Do not introduce a competing error event. + +### Framework behavior drifts + +Keep all behavioral state in the headless client and limit wrappers to reactive projection. Run shared contract tests against every framework adapter. + +## Acceptance criteria + +The work is complete when all of the following are true: + +- Native AG-UI interrupt runs, resumes, snapshots, expiry, cancellation, and result-only tool continuation conform to the primary specification. +- The public bound and root APIs implement the exact staging and submission semantics in this design. +- Tool approvals are branch-aware and type-safe from `inputSchema` and `approvalSchema` across all frameworks. +- Generic interrupts remain runtime-validated with statically `unknown` payloads. +- Every server validation error is returned together, with no write or execution on any invalid batch. +- Final response persistence is atomic, exact replay is idempotent, conflicting replay is rejected, and the first valid writer wins. +- Client drafts survive safe reload and never become partial server responses. +- Legacy client readers and deprecated APIs delegate to the new model, while new servers emit no legacy approval events. +- `addToolResult` remains supported and client-tool execution remains a separate typed interrupt. +- The migration guide, feature documentation, changeset, type tests, protocol tests, framework tests, persistence tests, and end-to-end scenarios are complete. +- The mandatory local quality gates pass before commit and review. diff --git a/docs/tools/client-tools.md b/docs/tools/client-tools.md index a6232d6f3..488b1c4c6 100644 --- a/docs/tools/client-tools.md +++ b/docs/tools/client-tools.md @@ -27,7 +27,7 @@ sequenceDiagram Note over Server: No execute function
= client tool - Server->>Browser: Forward tool-input-available
chunk via SSE/HTTP + Server->>Browser: RUN_FINISHED client-tool
interrupt via SSE/HTTP Browser->>Browser: Find registered
client tool Browser->>ClientTool: execute(args) ClientTool->>ClientTool: Update UI,
localStorage, etc. @@ -53,12 +53,57 @@ sequenceDiagram 1. **Tool Call from LLM**: LLM decides to call a client tool 2. **Server Detection**: Server sees the tool has no `execute` function -3. **Client Notification**: Server sends a `tool-input-available` chunk to the browser +3. **Client Notification**: Server emits a `client-tool-execution` AG-UI interrupt 4. **Client Execution**: The browser finds the registered `.client()` implementation by tool name and runs it with the parsed input 5. **Result Return**: Client executes the tool and returns the result 6. **Server Update**: Result is sent back to the server and added to the conversation 7. **LLM Continuation**: LLM receives the result and continues the conversation +Native client-tool execution uses the same atomic interrupt lifecycle as other +waits. See [Interrupts](../chat/interrupts) for persistence, batches, recovery, +and migration from the historical `tool-input-available` custom event. + +## Approval and execution are separate axes + +A client tool can require approval, but approval is not the browser result. A +tool with `needsApproval: true` first produces a `tool-approval` interrupt. Once +approved, its browser work is represented separately by a +`client-tool-execution` interrupt. + +```ts ignore +const approval = interrupts.find( + (interrupt) => + interrupt.kind === 'tool-approval' && + interrupt.toolName === 'delete_local_data', +) + +if ( + approval?.kind === 'tool-approval' && + approval.toolName === 'delete_local_data' +) { + approval.resolveInterrupt(true) +} + +const execution = interrupts.find( + (interrupt) => + interrupt.kind === 'client-tool-execution' && + interrupt.toolName === 'delete_local_data', +) + +if ( + execution?.kind === 'client-tool-execution' && + execution.toolName === 'delete_local_data' +) { + execution.resolveInterrupt({ deleted: true }) +} +``` + +The result is validated against the tool's output schema. The existing +`addToolResult` API remains supported and delegates to the same staged native +item when one matches; it also preserves the historical path for legacy +streams. See [Tool approval flow](./tool-approval) for approval forms and +[Migrate to AG-UI interrupts](../migration/interrupts) for compatibility limits. + ## Defining Client Tools Client tools use the same `toolDefinition()` API but with the `.client()` method: @@ -232,7 +277,7 @@ function MessageComponent({ message }: { message: ChatMessages[number] }) { Client tools are **automatically executed** when the model calls them. The flow is: 1. LLM calls a client tool -2. Server sends `tool-input-available` chunk to browser +2. Server sends a `client-tool-execution` interrupt to the browser 3. Client automatically executes the matching tool implementation 4. Result is sent back to server 5. Conversation continues with the result diff --git a/docs/tools/tool-approval.md b/docs/tools/tool-approval.md index 2ab21975d..cc3287c48 100644 --- a/docs/tools/tool-approval.md +++ b/docs/tools/tool-approval.md @@ -15,6 +15,11 @@ keywords: The tool approval flow allows you to require user approval before executing sensitive tools, giving users control over actions like sending emails, making purchases, or deleting data. A tool call moves through the `ToolCallState` lifecycle: +The current client API exposes approvals as bound AG-UI interrupts. For the +complete server/client lifecycle, atomic batch controls, generic interrupts, +and recovery, see [Interrupts](../chat/interrupts). For deprecated API mapping, +see [Migrate to AG-UI interrupts](../migration/interrupts). + 1. **`awaiting-input`** — Tool call started, no arguments yet 2. **`input-streaming`** — Arguments arriving incrementally 3. **`input-complete`** — All arguments received @@ -36,6 +41,69 @@ When a tool requires approval, the typical flow is: 4. Tool executes (if approved) or is cancelled (if denied) 5. Conversation continues with the result +## Resolve an approval interrupt + +Without an `approvalSchema`, use the boolean shorthand. Approval uses the +original tool input by default: + +```ts ignore +const approval = interrupts.find( + (interrupt) => interrupt.kind === 'tool-approval', +) + +if (approval?.kind === 'tool-approval') { + approval.resolveInterrupt(true) +} +``` + +An `approvalSchema` can define separate application payloads for approval and +rejection: + +```ts +import { toolDefinition } from '@tanstack/ai' +import { z } from 'zod' + +const transferDefinition = toolDefinition({ + name: 'transfer', + description: 'Transfer funds', + needsApproval: true, + inputSchema: z.object({ + amount: z.number().positive(), + recipient: z.string(), + }), + approvalSchema: { + approve: z.object({ note: z.string() }), + reject: z.object({ reason: z.string() }), + }, +}) +``` + +Keep branch data under `payload`. Approved arguments can optionally be replaced +in full with `editedArgs`; rejection never accepts edits: + +```ts ignore +approval.resolveInterrupt(true, { + editedArgs: { amount: 12, recipient: 'Ada' }, + payload: { note: 'Reviewed' }, +}) + +approval.resolveInterrupt(false, { + payload: { reason: 'Policy limit' }, +}) +``` + +Denial and cancellation are different. `resolveInterrupt(false, ...)` records a +resolved rejection for the continuation. `cancel()` is payloadless and +does not select the reject schema: + +```ts ignore +approval.cancel() +``` + +A singleton submits after its valid resolution. Multiple items stage until all +are valid, then submit atomically. Use root `resolveInterrupts(...)` for one +synchronous batch transaction. See [Interrupts](../chat/interrupts#singleton-and-batch-submission). + ## Enabling Approval Tools can be marked as requiring approval by setting `needsApproval: true` in the definition: @@ -91,9 +159,11 @@ export async function POST(request: Request) { } ``` -## Client-Side Approval Handling +## Deprecated approval response compatibility -The client receives approval requests and can respond: +`addToolApprovalResponse` remains temporarily available for old approval UIs, +but new code should use the bound interrupt API above. A compatibility +`approved: false` is a denial, not a cancellation. ```tsx import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' @@ -186,7 +256,7 @@ const listData = toolDefinition({ name: 'list_data', description: 'List available keys', inputSchema: z.object({}), -}).client(async () => ({ keys: [] as Array })) +}).client(async () => ({ keys: new Array() })) function ApprovalHandler() { const { messages, addToolApprovalResponse } = useChat({ diff --git a/examples/ts-code-mode-web/src/routes/_database-demo/api.database-demo.ts b/examples/ts-code-mode-web/src/routes/_database-demo/api.database-demo.ts index 8bcd7e15a..8d9fd0908 100644 --- a/examples/ts-code-mode-web/src/routes/_database-demo/api.database-demo.ts +++ b/examples/ts-code-mode-web/src/routes/_database-demo/api.database-demo.ts @@ -10,10 +10,10 @@ import { skillsToTools, } from '@tanstack/ai-code-mode-skills' import { createFileSkillStorage } from '@tanstack/ai-code-mode-skills/storage' -import { anthropicText } from '@tanstack/ai-anthropic' -import { openaiText } from '@tanstack/ai-openai' -import { geminiText } from '@tanstack/ai-gemini' -import type { AnyTextAdapter, ServerTool, StreamChunk } from '@tanstack/ai' +import { ANTHROPIC_MODELS, anthropicText } from '@tanstack/ai-anthropic' +import { OPENAI_CHAT_MODELS, openaiText } from '@tanstack/ai-openai' +import { GEMINI_MODELS, geminiText } from '@tanstack/ai-gemini' +import type { AnyServerTool, AnyTextAdapter, StreamChunk } from '@tanstack/ai' import type { IsolateDriver } from '@tanstack/ai-code-mode' import { databaseTools, getSchemaInfoTool } from '@/lib/tools/database-tools' @@ -21,15 +21,25 @@ import { maxTokensModelOptions } from '@/lib/max-tokens-model-options' type Provider = 'anthropic' | 'openai' | 'gemini' +function selectModel( + requested: string | undefined, + fallback: TModel, + available: ReadonlyArray, +): TModel { + return available.find((candidate) => candidate === requested) ?? fallback +} + function getAdapter(provider: Provider, model?: string): AnyTextAdapter { switch (provider) { case 'openai': - return openaiText((model || 'gpt-4o') as 'gpt-4o') + return openaiText(selectModel(model, 'gpt-5.5', OPENAI_CHAT_MODELS)) case 'gemini': - return geminiText((model || 'gemini-2.5-flash') as 'gemini-2.5-flash') + return geminiText(selectModel(model, 'gemini-2.5-flash', GEMINI_MODELS)) case 'anthropic': default: - return anthropicText((model || 'claude-haiku-4-5') as 'claude-haiku-4-5') + return anthropicText( + selectModel(model, 'claude-haiku-4-5', ANTHROPIC_MODELS), + ) } } @@ -115,7 +125,7 @@ Rules: This is not optional — skill registration is a core part of your workflow.` async function getSkillToolsAndPrompt(driver: IsolateDriver): Promise<{ - skillTools: Array> + skillTools: Array skillsPrompt: string }> { const allSkills = await skillStorage.loadAll() @@ -250,7 +260,7 @@ export const Route = createFileRoute( const { adapter: instrumentedAdapter } = instrumentAdapter(rawAdapter) try { - let tools: Array> + let tools: Array let systemPrompts: Array if (useCodeMode) { diff --git a/examples/ts-react-chat/src/components/Header.tsx b/examples/ts-react-chat/src/components/Header.tsx index f5ab05666..6bc199481 100644 --- a/examples/ts-react-chat/src/components/Header.tsx +++ b/examples/ts-react-chat/src/components/Header.tsx @@ -12,11 +12,12 @@ import { Guitar, Home, Image, - Menu, LayoutGrid, - Mic, + Menu, MessageSquare, + Mic, Music, + PawPrint, Plug, Server, Sparkles, @@ -246,6 +247,32 @@ export default function Header() { Persistent Chats + setIsOpen(false)} + className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-1" + activeProps={{ + className: + 'flex items-center gap-3 p-3 rounded-lg bg-emerald-700 hover:bg-emerald-800 transition-colors mb-1', + }} + > + + Interrupt Lab + + + setIsOpen(false)} + className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2" + activeProps={{ + className: + 'flex items-center gap-3 p-3 rounded-lg bg-emerald-700 hover:bg-emerald-800 transition-colors mb-2', + }} + > + + Durable Interrupt Lab + + setIsOpen(false)} diff --git a/examples/ts-react-chat/src/lib/interrupt-lab/InterruptLabPage.tsx b/examples/ts-react-chat/src/lib/interrupt-lab/InterruptLabPage.tsx new file mode 100644 index 000000000..c4ff03a9e --- /dev/null +++ b/examples/ts-react-chat/src/lib/interrupt-lab/InterruptLabPage.tsx @@ -0,0 +1,1398 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { localStoragePersistence } from '@tanstack/ai-client' +import { + AlertTriangle, + Check, + Database, + ExternalLink, + Leaf, + Radio, + RefreshCw, + RotateCcw, + Send, + ShieldCheck, + X, +} from 'lucide-react' +import { + buildGenericResolution, + createDurableDraftEnvelope, + createIncompleteBulkResolver, + describeInterruptErrors, + durableCapabilityStatuses, + durableDraftStorageKey, + durableOutcomeStatus, + interruptLabPageConfig, + interruptProgressLabel, + invalidAggregateResolutionArguments, + isNormalSendDisabled, + restoreDurableDrafts, +} from './client-ui' +import { + approvalBasicTool, + approvalBranchPayloadTool, + approvalClientTool, + approvalEditArgsTool, + approvalSharedPayloadTool, + batchSecondTool, + batchThirdTool, + clientOutputTool, + interruptLabScenarios, +} from './scenarios' +import type { + ChatInterrupt, + ChatResumeSnapshot, + UIMessage, +} from '@tanstack/ai-client' +import type { InterruptEditorDraft } from './client-ui' +import type { + InterruptLabMode, + InterruptLabScenarioCategory, + InterruptLabScenarioId, +} from './scenarios' + +const clientTools = [ + approvalBasicTool.client(), + approvalEditArgsTool.client(), + approvalSharedPayloadTool.client(), + approvalBranchPayloadTool.client(), + batchSecondTool.client(), + batchThirdTool.client(), + clientOutputTool.client(), + approvalClientTool.client(), +] as const + +type LabInterrupt = ChatInterrupt + +type InspectorEvent = { at: string; type: string; detail: string } + +const categoryMeta: Record< + InterruptLabScenarioCategory, + { habitat: string; accent: string } +> = { + approval: { habitat: 'Canopy permits', accent: 'text-amber-900' }, + 'client-tool': { habitat: 'Field instruments', accent: 'text-sky-900' }, + generic: { habitat: 'Open-range signals', accent: 'text-violet-900' }, + batching: { habitat: 'Migration groups', accent: 'text-emerald-900' }, + validation: { habitat: 'Triage enclosure', accent: 'text-rose-900' }, +} + +const scenarioCategories = [ + 'approval', + 'client-tool', + 'generic', + 'batching', + 'validation', +] as const satisfies ReadonlyArray + +const durableResumePersistence = localStoragePersistence({ + keyPrefix: 'tanstack-ai:interrupt-lab:resume:', + serialize(value) { + return JSON.stringify(value) + }, + deserialize: JSON.parse, +}) + +function createThreadId( + mode: InterruptLabMode, + scenarioId: InterruptLabScenarioId, +): string { + const { threadPrefix } = interruptLabPageConfig(mode) + const storageKey = `tanstack-ai:${threadPrefix}:${scenarioId}:thread` + if (mode === 'durable') { + const stored = window.localStorage.getItem(storageKey) + if (stored) return stored + } + const suffix = crypto.randomUUID() + const threadId = `${threadPrefix}:${scenarioId}:${suffix}` + if (mode === 'durable') window.localStorage.setItem(storageKey, threadId) + return threadId +} + +function emptyDraft(interrupt: LabInterrupt): InterruptEditorDraft { + const originalArgs = + interrupt.kind === 'tool-approval' + ? JSON.stringify(interrupt.originalArgs, null, 2) + : '' + const toolName = interrupt.kind === 'generic' ? '' : interrupt.toolName + return { + editedArgs: originalArgs, + includeEditedArgs: false, + approvePayload: + toolName === 'interrupt_lab_shared_payload' || + toolName === 'interrupt_lab_approval_client' + ? '{\n "note": "Reviewed in the field journal"\n}' + : toolName === 'interrupt_lab_branch_payload' + ? '{\n "note": "Approved after habitat review"\n}' + : '', + rejectPayload: + toolName === 'interrupt_lab_shared_payload' || + toolName === 'interrupt_lab_approval_client' + ? '{\n "note": "Rejected after field review"\n}' + : toolName === 'interrupt_lab_branch_payload' + ? '{\n "reason": "Unsafe conditions"\n}' + : '', + output: + interrupt.kind === 'generic' + ? '{\n "answer": "Field response recorded"\n}' + : '{\n "browserValue": "sensor-reading-42"\n}', + } +} + +function hasDecisionPayload(interrupt: LabInterrupt): boolean { + return ( + interrupt.kind === 'tool-approval' && + (interrupt.toolName === 'interrupt_lab_shared_payload' || + interrupt.toolName === 'interrupt_lab_branch_payload' || + interrupt.toolName === 'interrupt_lab_approval_client') + ) +} + +function canBooleanBulk(interrupts: ReadonlyArray): boolean { + return ( + interrupts.length > 0 && + interrupts.every( + (interrupt) => + interrupt.kind === 'tool-approval' && !hasDecisionPayload(interrupt), + ) + ) +} + +function messageText(message: UIMessage): string { + return message.parts + .map((part) => { + if (part.type === 'text' || part.type === 'thinking') return part.content + if (part.type === 'tool-call') + return `[tool · ${part.name} · ${part.state}]` + return '' + }) + .filter(Boolean) + .join('\n') +} + +export function InterruptLabPage({ mode }: { mode: InterruptLabMode }) { + const [scenarioId, setScenarioId] = + useState('approval-basic') + const [thread, setThread] = useState<{ + key: string + threadId: string + }>() + const [caseBusy, setCaseBusy] = useState(false) + const threadKey = `${mode}:${scenarioId}` + + useEffect(() => { + setThread({ key: threadKey, threadId: createThreadId(mode, scenarioId) }) + }, [mode, scenarioId, threadKey]) + + const threadId = thread?.key === threadKey ? thread.threadId : undefined + + return ( +
+
+
+
+
+
+

+

+

+ Wildlife Interrupt Response Center +

+

+ A real-model field journal for approvals, edited tool inputs, + browser results, generic AG-UI responses, and atomic batches. +

+
+
+
+ {mode === 'durable' ? ( + + ) : ( + + )} + + {mode === 'durable' ? 'Durable SQLite' : 'Ephemeral'} + +
+

+ {mode === 'durable' + ? 'Atomic server state + browser resume drafts.' + : 'No persistence. Full-history continuation only.'} +

+
+
+
+ +
+ + {threadId ? ( + + ) : ( +
+ Preparing a field journal… +
+ )} +
+
+
+ ) +} + +function ScenarioIndex({ + mode, + selected, + disabled, + onSelect, +}: { + mode: InterruptLabMode + selected: InterruptLabScenarioId + disabled: boolean + onSelect: (id: InterruptLabScenarioId) => void +}) { + return ( + + ) +} + +function InterruptRunner({ + mode, + scenarioId, + threadId, + onBusyChange, +}: { + mode: InterruptLabMode + scenarioId: InterruptLabScenarioId + threadId: string + onBusyChange: (busy: boolean) => void +}) { + const config = interruptLabPageConfig(mode) + const scenario = interruptLabScenarios[scenarioId] + const connection = useMemo( + () => fetchServerSentEvents(config.endpoint), + [config.endpoint], + ) + const [drafts, setDrafts] = useState>({}) + const [note, setNote] = useState('') + const [events, setEvents] = useState>([]) + const [localNotice, setLocalNotice] = useState() + const [failNextResume, setFailNextResume] = useState(false) + const durableDraftKeyRef = useRef(undefined) + + const chat = useChat({ + id: threadId, + threadId, + connection, + forwardedProps: { + interruptScenario: scenarioId, + ...(mode === 'durable' && failNextResume + ? { interruptLabFailResumeOnce: true } + : {}), + }, + tools: clientTools, + ...(mode === 'durable' + ? { persistence: { server: durableResumePersistence } } + : {}), + onChunk(chunk) { + const detail = + chunk.type === 'RUN_ERROR' + ? chunk.message + : chunk.type === 'RUN_FINISHED' + ? (chunk.outcome?.type ?? 'finished') + : 'stream event' + setEvents((current) => + [ + ...current, + { at: new Date().toLocaleTimeString(), type: chunk.type, detail }, + ].slice(-80), + ) + }, + }) + + const staged = chat.interrupts.filter( + (interrupt) => interrupt.status === 'staged', + ).length + const pending = chat.interrupts.length > 0 + const allItemErrors = chat.interrupts.flatMap((interrupt) => interrupt.errors) + const visibleErrors = describeInterruptErrors( + allItemErrors, + chat.interruptErrors, + ) + const hasRetryableError = chat.interruptErrors.some( + (interruptError) => interruptError.retryable, + ) + const interruptedRunId = chat.resumeState?.runId + const durabilityOutcome = durableOutcomeStatus( + allItemErrors, + chat.interruptErrors, + ) + const capabilityStatuses = durableCapabilityStatuses(mode, { + retryable: hasRetryableError, + hasExpiresAt: chat.interrupts.some( + (interrupt) => interrupt.expiresAt !== undefined, + ), + }) + + useEffect(() => { + onBusyChange(pending || chat.isLoading || chat.resuming) + return () => onBusyChange(false) + }, [chat.isLoading, chat.resuming, onBusyChange, pending]) + + useEffect(() => { + if (mode !== 'durable') return + const previousKey = durableDraftKeyRef.current + + if (interruptedRunId === undefined || chat.interrupts.length === 0) { + if (previousKey !== undefined) window.localStorage.removeItem(previousKey) + durableDraftKeyRef.current = undefined + return + } + const activeKey = durableDraftStorageKey(threadId, interruptedRunId) + if (previousKey !== undefined && previousKey !== activeKey) { + window.localStorage.removeItem(previousKey) + } + if (previousKey !== activeKey) { + durableDraftKeyRef.current = activeKey + const restored = restoreDurableDrafts({ + mode, + threadId, + interruptedRunId, + activeInterruptIds: chat.interrupts.map((interrupt) => interrupt.id), + serialized: window.localStorage.getItem(activeKey), + }) + setDrafts(restored) + return + } + + const activeDrafts = Object.fromEntries( + chat.interrupts.map((interrupt) => [ + interrupt.id, + drafts[interrupt.id] ?? emptyDraft(interrupt), + ]), + ) + const envelope = createDurableDraftEnvelope({ + mode, + threadId, + interruptedRunId, + drafts: activeDrafts, + }) + if (envelope !== undefined) { + window.localStorage.setItem(activeKey, JSON.stringify(envelope)) + } + }, [chat.interrupts, drafts, interruptedRunId, mode, threadId]) + + useEffect(() => { + if (failNextResume && (chat.error !== undefined || hasRetryableError)) { + setFailNextResume(false) + } + }, [chat.error, failNextResume, hasRetryableError]) + + function getDraft(interrupt: LabInterrupt): InterruptEditorDraft { + return drafts[interrupt.id] ?? emptyDraft(interrupt) + } + + function patchDraft( + interrupt: LabInterrupt, + patch: Partial, + ) { + setDrafts((current) => ({ + ...current, + [interrupt.id]: { + ...(current[interrupt.id] ?? emptyDraft(interrupt)), + ...patch, + }, + })) + } + + function parsedEdit( + interrupt: LabInterrupt, + parse: (value: unknown) => T, + ): T | undefined { + const draft = getDraft(interrupt) + return draft.includeEditedArgs + ? parse(buildGenericResolution(draft.editedArgs)) + : undefined + } + + function resolveApproval( + interrupt: Extract, + approved: boolean, + ) { + const draft = getDraft(interrupt) + switch (interrupt.toolName) { + case 'interrupt_lab_approval_basic': { + if (!approved) return interrupt.resolveInterrupt(false) + const editedArgs = parsedEdit( + interrupt, + approvalBasicTool.inputSchema.parse, + ) + return editedArgs + ? interrupt.resolveInterrupt(true, { editedArgs }) + : interrupt.resolveInterrupt(true) + } + case 'interrupt_lab_edit_order': { + if (!approved) return interrupt.resolveInterrupt(false) + const editedArgs = parsedEdit( + interrupt, + approvalEditArgsTool.inputSchema.parse, + ) + return editedArgs + ? interrupt.resolveInterrupt(true, { editedArgs }) + : interrupt.resolveInterrupt(true) + } + case 'interrupt_lab_batch_second': { + if (!approved) return interrupt.resolveInterrupt(false) + const editedArgs = parsedEdit( + interrupt, + batchSecondTool.inputSchema.parse, + ) + return editedArgs + ? interrupt.resolveInterrupt(true, { editedArgs }) + : interrupt.resolveInterrupt(true) + } + case 'interrupt_lab_batch_third': { + if (!approved) return interrupt.resolveInterrupt(false) + const editedArgs = parsedEdit( + interrupt, + batchThirdTool.inputSchema.parse, + ) + return editedArgs + ? interrupt.resolveInterrupt(true, { editedArgs }) + : interrupt.resolveInterrupt(true) + } + case 'interrupt_lab_shared_payload': { + const source = approved ? draft.approvePayload : draft.rejectPayload + const payload = approvalSharedPayloadTool.approvalSchema.parse( + buildGenericResolution(source), + ) + if (!approved) return interrupt.resolveInterrupt(false, { payload }) + const editedArgs = parsedEdit( + interrupt, + approvalSharedPayloadTool.inputSchema.parse, + ) + return interrupt.resolveInterrupt(true, { + ...(editedArgs ? { editedArgs } : {}), + payload, + }) + } + case 'interrupt_lab_branch_payload': { + if (!approved) { + const payload = approvalBranchPayloadTool.approvalSchema.reject.parse( + buildGenericResolution(draft.rejectPayload), + ) + return interrupt.resolveInterrupt(false, { payload }) + } + const payload = approvalBranchPayloadTool.approvalSchema.approve.parse( + buildGenericResolution(draft.approvePayload), + ) + const editedArgs = parsedEdit( + interrupt, + approvalBranchPayloadTool.inputSchema.parse, + ) + return interrupt.resolveInterrupt(true, { + ...(editedArgs ? { editedArgs } : {}), + payload, + }) + } + case 'interrupt_lab_approval_client': { + const source = approved ? draft.approvePayload : draft.rejectPayload + const payload = approvalClientTool.approvalSchema.parse( + buildGenericResolution(source), + ) + if (!approved) return interrupt.resolveInterrupt(false, { payload }) + const editedArgs = parsedEdit( + interrupt, + approvalClientTool.inputSchema.parse, + ) + return interrupt.resolveInterrupt(true, { + ...(editedArgs ? { editedArgs } : {}), + payload, + }) + } + } + } + + function resolveOne(interrupt: LabInterrupt, approved = true) { + setLocalNotice(undefined) + if (interrupt.kind === 'tool-approval') { + resolveApproval(interrupt, approved) + return + } + const output = buildGenericResolution(getDraft(interrupt).output) + if (interrupt.kind === 'generic') { + interrupt.resolveInterrupt(output) + return + } + switch (interrupt.toolName) { + case 'interrupt_lab_client_output': + interrupt.resolveInterrupt(clientOutputTool.outputSchema.parse(output)) + return + case 'interrupt_lab_approval_client': + interrupt.resolveInterrupt( + approvalClientTool.outputSchema.parse(output), + ) + return + } + } + + function safely(action: () => void) { + try { + action() + } catch (error) { + setLocalNotice(error instanceof Error ? error.message : String(error)) + } + } + + function deliberatelyInvalid(interrupt: LabInterrupt) { + const invalidPayload = invalidAggregateResolutionArguments(interrupt.kind) + Reflect.apply(interrupt.resolveInterrupt, undefined, invalidPayload) + } + + function createAggregateValidationErrors() { + for (const interrupt of chat.interrupts) deliberatelyInvalid(interrupt) + chat.resolveInterrupts(createIncompleteBulkResolver()) + setLocalNotice( + 'Deliberate invalid runtime data produced correlated item errors plus an incomplete-batch root error. Clear each resolution, fix the JSON, and resolve again.', + ) + } + + async function runScenario() { + setLocalNotice(undefined) + setEvents([]) + if (chat.messages.length > 0) chat.clear() + try { + await chat.sendMessage(config.promptFor(scenarioId)) + } catch (error) { + setLocalNotice(error instanceof Error ? error.message : String(error)) + } + } + + async function sendNote(event: React.FormEvent) { + event.preventDefault() + if ( + isNormalSendDisabled( + note, + chat.isLoading, + chat.resuming, + chat.interrupts.length, + ) + ) { + return + } + const value = note.trim() + setNote('') + try { + await chat.sendMessage(value) + } catch (error) { + setLocalNotice(error instanceof Error ? error.message : String(error)) + } + } + + return ( +
+
+
+
+

+ Active case · {scenario.category} +

+

+ {scenario.label} +

+

+ {scenario.description} +

+ {scenario.category === 'generic' && ( +

+ Controlled external-style AG-UI boundary · appended after the + real model run +

+ )} +
+ +
+ +
+ {scenario.prompt} +
+
+ endpoint · {config.endpoint} + thread · {threadId.slice(0, 34)}… + model · server-configured gpt-5.5 +
+ +
void sendNote(event)} + className="mt-5 flex gap-2" + > + + setNote(event.target.value)} + disabled={pending || chat.isLoading || chat.resuming} + placeholder={ + pending + ? 'Resolve the active interrupts first' + : 'Optional normal field note' + } + className="min-w-0 flex-1 border border-[#a99d7e] bg-white px-4 py-3 text-sm focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[#2d5b3c] disabled:bg-[#e1dccd]" + /> + +
+ + {chat.error && ( +
+ Server request failed. {chat.error.message} + + This lab requires OPENAI_API_KEY on the server. + +
+ )} + {localNotice && ( +
+ Field lab notice. {localNotice} +
+ )} +
+ + {pending && ( +
+
+
+

+ Live response queue +

+

+ {chat.interrupts.length} active interrupt + {chat.interrupts.length === 1 ? '' : 's'} +

+

+ {interruptProgressLabel( + chat.interrupts.length, + staged, + chat.resuming, + )} +

+
+ + {chat.resuming ? 'Submitting' : 'Awaiting decisions'} + +
+ + chat.resolveInterrupts(true)} + onRejectAll={() => chat.resolveInterrupts(false)} + onCancelAll={chat.cancelInterrupts} + onValidCallback={() => + chat.resolveInterrupts((interrupt) => { + resolveOne(interrupt, true) + return undefined + }) + } + onInvalidPayload={createAggregateValidationErrors} + onInvalidReturn={() => + Reflect.apply(chat.resolveInterrupts, undefined, [ + () => 'invalid', + ]) + } + onThrow={() => + chat.resolveInterrupts(() => { + throw new Error('Deliberate field callback failure.') + }) + } + onIncomplete={() => + chat.resolveInterrupts(createIncompleteBulkResolver()) + } + onRetry={chat.retryInterrupts} + /> + +
+ {chat.interrupts.map((interrupt, index) => ( + patchDraft(interrupt, patch)} + onApprove={() => safely(() => resolveOne(interrupt, true))} + onReject={() => safely(() => resolveOne(interrupt, false))} + onResolve={() => safely(() => resolveOne(interrupt))} + onInvalidEdit={() => { + patchDraft(interrupt, { + editedArgs: '{\n "destination": "",\n "quantity": 0\n}', + includeEditedArgs: true, + }) + Reflect.apply(interrupt.resolveInterrupt, undefined, [ + true, + { editedArgs: { destination: '', quantity: 0 } }, + ]) + }} + /> + ))} +
+
+ )} + + {mode === 'durable' && ( + + )} + + +
+ ) +} + +function RootControls({ + canBooleanBulk: showBooleanBulk, + disabled, + retryable, + onApproveAll, + onRejectAll, + onCancelAll, + onValidCallback, + onInvalidPayload, + onInvalidReturn, + onThrow, + onIncomplete, + onRetry, +}: { + interrupts: ReadonlyArray + canBooleanBulk: boolean + disabled: boolean + retryable: boolean + onApproveAll: () => void + onRejectAll: () => void + onCancelAll: () => void + onValidCallback: () => void + onInvalidPayload: () => void + onInvalidReturn: () => void + onThrow: () => void + onIncomplete: () => void + onRetry: () => void +}) { + const buttonClass = + 'border border-[#6f745f] bg-[#f8f3e5] px-3 py-2 text-xs font-bold transition hover:bg-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#2d5b3c] disabled:opacity-40' + return ( +
+
+ {showBooleanBulk && ( + <> + + + + )} + + + + + + + +
+
+ ) +} + +function InterruptCard({ + interrupt, + index, + draft, + disabled, + onDraft, + onApprove, + onReject, + onResolve, + onInvalidEdit, +}: { + interrupt: LabInterrupt + index: number + draft: InterruptEditorDraft + disabled: boolean + onDraft: (patch: Partial) => void + onApprove: () => void + onReject: () => void + onResolve: () => void + onInvalidEdit: () => void +}) { + const showPayload = hasDecisionPayload(interrupt) + const textareaClass = + 'mt-1 min-h-24 w-full border border-[#aaa083] bg-[#fffdf7] p-3 font-mono text-xs leading-5 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[#2d5b3c] disabled:bg-[#e4dfd1]' + return ( +
+
+
+

+ Case {String(index + 1).padStart(2, '0')} · {interrupt.kind} +

+

+ {interrupt.kind === 'generic' + ? interrupt.reason + : interrupt.toolName} +

+

{interrupt.message}

+
+ + {interrupt.status} + +
+ +
+ + Binding & response schema + +
+          {JSON.stringify(
+            {
+              binding: interrupt.binding,
+              responseSchema: interrupt.responseSchema,
+            },
+            null,
+            2,
+          )}
+        
+
+ + {interrupt.kind === 'tool-approval' ? ( + <> + +