diff --git a/.changeset/ag-ui-interrupts.md b/.changeset/ag-ui-interrupts.md new file mode 100644 index 000000000..a3a23ff85 --- /dev/null +++ b/.changeset/ag-ui-interrupts.md @@ -0,0 +1,21 @@ +--- +'@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 +--- + +Adopt the AG-UI interrupt lifecycle for tool approvals, generic responses, and +client-tool execution, with typed bound resolvers, atomic batches, and +structured errors. Interrupts run ephemerally by resuming from the full client +message history in a fresh child run — no persistence required. + +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 04632bbe6..eeca6cb3b 100644 --- a/docs/architecture/approval-flow-processing.md +++ b/docs/architecture/approval-flow-processing.md @@ -1,448 +1,272 @@ --- title: Approval Flow Processing Architecture id: approval-flow-processing -description: "Internal architecture of TanStack AI's tool approval system — state machine, streaming protocol, concurrency control, and chained approval mechanics." +description: "Internal architecture of TanStack AI tool approvals, interrupts, persistence, and typed client resume handling." keywords: - tanstack ai - approval flow - - tool approval - - architecture + - interrupts - state machine - - streaming protocol - - internals - - concurrency + - persistence --- # Approval Flow Processing Architecture -> Internal architecture reference for the tool approval system in TanStack AI. -> Covers the full lifecycle from stream event to continuation, with emphasis on -> concurrency control and the chained approval mechanism. - ---- - -## Table of Contents - -1. [Overview](#overview) -2. [Component Responsibilities](#component-responsibilities) -3. [Type System](#type-system) -4. [State Machine](#state-machine) -5. [Single Approval Lifecycle](#single-approval-lifecycle) -6. [Chained Approvals and Continuation Control](#chained-approvals-and-continuation-control) -7. [Stream Event Protocol](#stream-event-protocol) -8. [Key Source Files](#key-source-files) - ---- - -## Overview - -The approval flow allows tools marked with `needsApproval: true` to pause -execution until the user explicitly approves or denies the action. This -creates a human-in-the-loop checkpoint for sensitive operations (sending -emails, making purchases, deleting data). - -The flow spans three layers: - -```mermaid -flowchart TD - A[Server - TextEngine] - B[StreamProcessor] - C[ChatClient] - - A -- "AG-UI stream · SSE / HTTP" --> B - B -- "events" --> C -``` - -| Layer | Responsibility | -|-------|----------------| -| **Server (TextEngine)** | `chat()` detects `needsApproval` → emits CUSTOM `approval-requested` instead of executing the tool | -| **StreamProcessor** | Receives CUSTOM chunk → `updateToolCallApproval()` → fires `onApprovalRequest` callback | -| **ChatClient** | Exposes `addToolApprovalResponse()` → updates message state → triggers `checkForContinuation()` → sends new stream | - -Framework hooks (`useChat` in React, Solid, Vue, Svelte) delegate to -`ChatClient`, which owns all concurrency and continuation logic. - ---- - -## Component Responsibilities - -### TextEngine (server) - -- Runs the agent loop: calls the LLM adapter, accumulates tool calls, executes - tools, and re-invokes the adapter with results. -- When a tool has `needsApproval: true`, the engine emits a - `CUSTOM { name: "approval-requested" }` event instead of executing the tool. -- The stream ends with `RUN_FINISHED { finishReason: "tool_calls" }` so the - client knows tools are pending. - -### StreamProcessor (`packages/ai/src/activities/chat/stream/processor.ts`) - -- Single source of truth for `UIMessage[]` state. -- On `approval-requested` custom event: - 1. Calls `updateToolCallApproval()` to set the tool-call part's state to - `approval-requested` and attach approval metadata. - 2. Fires `onApprovalRequest` so the ChatClient can emit devtools events. -- On `addToolApprovalResponse(approvalId, approved)`: - 1. Calls `updateToolCallApprovalResponse()` to set state to - `approval-responded` and record `approval.approved`. -- Provides `areAllToolsComplete()` which the ChatClient uses to decide whether - to auto-continue the conversation. - -### ChatClient (`packages/ai-client/src/chat-client.ts`) - -- Owns the streaming lifecycle (`streamResponse`), post-stream action queue, - and continuation control flags. -- Exposes `addToolApprovalResponse()` as the public API for responding to - approval requests. -- Manages two critical flags for continuation: - - `continuationPending` — prevents concurrent `streamResponse` calls. - - `continuationSkipped` — detects when a queued continuation check was - suppressed by `continuationPending` and needs re-evaluation. - -### Framework Hooks (React `useChat`, Solid `useChat`, etc.) - -- Wrap `ChatClient` methods in framework-specific reactive primitives. -- Expose `addToolApprovalResponse` directly to the component. -- No approval-specific logic beyond delegation. - ---- - -## Type System - -### ToolCallState - -```typescript group=approval-flow-processing -type ToolCallState = - | 'awaiting-input' // TOOL_CALL_START received, no arguments yet - | 'input-streaming' // Partial arguments being received - | 'input-complete' // All arguments received (TOOL_CALL_END) - | 'approval-requested' // Waiting for user approval - | 'approval-responded' // User has approved or denied -``` - -### ToolCallPart (approval-relevant fields) - -```typescript group=approval-flow-processing -interface ToolCallPart { - type: 'tool-call' - id: string // Unique tool call ID - name: string // Tool name - arguments: string // JSON string of arguments - state: ToolCallState - - approval?: { - id: string // Unique approval ID (NOT the toolCallId) - needsApproval: boolean // Always true when present - approved?: boolean // undefined until user responds - } - - output?: any // Set after execution (client tools) +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', + timestamp: Date.now(), + outcome: { + type: 'interrupt', + interrupts: [ + { + id: 'approval-1', + reason: 'tool_call', + toolCallId: 'call-1', + responseSchema: { + oneOf: [ + { type: 'object', properties: { approved: { const: true } } }, + { type: 'object', properties: { approved: { const: false } } }, + ], + }, + }, + ], + }, } ``` -### Key distinction: approval ID vs tool call ID - -The `approval.id` is a separate identifier generated per approval request. -All user-facing APIs (`addToolApprovalResponse`) use the **approval ID**, -not the tool call ID. This allows the system to correlate approval responses -even when multiple tools share similar call IDs across different messages. - ---- - -## State Machine - -```mermaid -flowchart TD - S1[awaiting-input] - S2[input-streaming] - S3[input-complete] - S4[approval-requested] - S5([execute directly]) - S6[approval-responded] - S7([execute tool]) - S8([cancelled]) - - S1 -- "TOOL_CALL_START" --> S2 - S2 -- "TOOL_CALL_ARGS" --> S3 - S3 -- "TOOL_CALL_END · needsApproval: true" --> S4 - S3 -- "TOOL_CALL_END · needsApproval: false" --> S5 - S4 -- "addToolApprovalResponse()" --> S6 - S6 -- "approved: true" --> S7 - S6 -- "approved: false" --> S8 -``` - -### Terminal states for `areAllToolsComplete()` - -A tool call is considered complete (and eligible for auto-continuation) when -any of the following is true: - -1. `state === 'approval-responded'` — user approved or denied -2. `output !== undefined && !approval` — client tool finished (no approval flow) -3. A corresponding `tool-result` part exists — server tool finished - ---- +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. -## Single Approval Lifecycle +See [Interrupts](../interrupts/overview) for the public server/client guide and +[Migrate to AG-UI interrupts](../interrupts/migration) for deprecated readers. -Step-by-step flow for a single tool requiring approval: +## Responsibilities -### 1. Server emits approval request during stream - -``` -TOOL_CALL_START { toolCallId: "tc-1", toolName: "send_email" } -TOOL_CALL_ARGS { toolCallId: "tc-1", delta: '{"to":"..."}' } -TOOL_CALL_END { toolCallId: "tc-1" } -CUSTOM { name: "approval-requested", value: { - toolCallId: "tc-1", - toolName: "send_email", - input: { to: "..." }, - approval: { id: "appr-1", needsApproval: true } - }} -RUN_FINISHED { finishReason: "tool_calls" } -``` - -### 2. StreamProcessor processes the CUSTOM chunk - -``` -handleCustomEvent(): - 1. updateToolCallApproval(messages, messageId, "tc-1", "appr-1") - → Sets part.state = "approval-requested" - → Sets part.approval = { id: "appr-1", needsApproval: true } - 2. emitMessagesChange() - 3. fires onApprovalRequest({ toolCallId, toolName, input, approvalId }) -``` - -### 3. Stream ends, ChatClient processes - -``` -streamResponse() finally block: - 1. setIsLoading(false) - 2. drainPostStreamActions() → (nothing queued) - 3. streamCompletedSuccessfully check: - lastPart is tool-call (not tool-result) → no auto-continue - → Returns to caller (sendMessage resolves) -``` - -The conversation is now paused. The UI renders the approval prompt. - -### 4. User approves - -``` -addToolApprovalResponse({ id: "appr-1", approved: true }): - 1. processor.addToolApprovalResponse("appr-1", true) - → updateToolCallApprovalResponse(): - part.approval.approved = true - part.state = "approval-responded" - 2. isLoading is false → call checkForContinuation() directly -``` - -### 5. Continuation - -``` -checkForContinuation(): - 1. continuationPending = false, isLoading = false → proceed - 2. shouldAutoSend() → areAllToolsComplete(): - part.state === "approval-responded" → true - 3. continuationPending = true - 4. streamResponse() → new stream to server with approval in messages - 5. Server sees approval, executes tool, returns result + LLM response - 6. continuationPending = false -``` - ---- - -## Chained Approvals and Continuation Control - -The most complex scenario: a continuation stream produces **another** tool call -that also needs approval, and the user responds to it while the stream is still -active. - -### The Problem - -``` -Timeline: -───────────────────────────────────────────────────────────── - -1. User approves tool A - └─ checkForContinuation() sets continuationPending = true - └─ streamResponse() starts (stream 2) - -2. Stream 2 produces tool B needing approval - └─ approval-requested chunk processed - └─ UI shows approval prompt for tool B - -3. User approves tool B WHILE stream 2 is still active - └─ addToolApprovalResponse(): - └─ processor state updated (approval-responded) - └─ isLoading is true → queues checkForContinuation - -4. Stream 2 ends - └─ streamResponse() finally block: - └─ setIsLoading(false) - └─ drainPostStreamActions(): - └─ Runs queued checkForContinuation() - └─ BUT continuationPending is STILL TRUE (from step 1) - └─ *** EARLY RETURN — approval swallowed *** - └─ Returns to step 1's checkForContinuation() - └─ continuationPending = false - -5. Nobody re-checks → tool B's approval is lost +| Layer | Responsibility | +| --- | --- | +| Tool definition | Declares `needsApproval: true` for a sensitive operation. | +| Chat engine | Stops before tool execution and emits the interrupt outcome. | +| 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 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 deleteProjectDefinition = toolDefinition({ + name: 'delete_project', + description: 'Delete a project permanently', + inputSchema: z.object({ projectId: z.string() }), + outputSchema: z.object({ deleted: z.boolean() }), + needsApproval: true, +}) + +export const deleteProject = deleteProjectDefinition.server(async ({ projectId }) => { + await deleteProjectFromDatabase(projectId) + return { deleted: true } +}) + +declare function deleteProjectFromDatabase(projectId: string): Promise ``` -### The Solution: `continuationSkipped` Flag - -Two flags work together to handle this: - -- **`continuationPending`** — prevents concurrent `streamResponse()` calls. - Set to `true` when entering `checkForContinuation`'s streaming path, cleared - in the `finally` block. - -- **`continuationSkipped`** — set to `true` whenever `checkForContinuation()` - returns early due to `continuationPending` or `isLoading` being true. - Checked after `continuationPending` is cleared to trigger a re-evaluation. - -```typescript -class ChatClient { - private continuationPending = false - private continuationSkipped = false - private isLoading = false - - private shouldAutoSend(): boolean { return false } - private async streamResponse(): Promise {} - - private async checkForContinuation(): Promise { - if (this.continuationPending || this.isLoading) { - this.continuationSkipped = true // ← Mark that a check was suppressed - return - } - - if (this.shouldAutoSend()) { - this.continuationPending = true - this.continuationSkipped = false // ← Reset before entering stream - try { - await this.streamResponse() - } finally { - this.continuationPending = false - } - // If a check was skipped during the stream, re-evaluate now - if (this.continuationSkipped) { - this.continuationSkipped = false - await this.checkForContinuation() // ← Recurse safely - } - } - } +```ts +// app/api/chat/route.ts +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { deleteProject } from './tools' + +export async function POST(request: Request) { + const params = await chatParamsFromRequest(request) + const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages: params.messages, + threadId: params.threadId, + runId: params.runId, + parentRunId: params.parentRunId, + ...(params.resume ? { resume: params.resume } : {}), + tools: [deleteProject], + }) + + return toServerSentEventsResponse(stream) } ``` -### Why the recursion is safe - -The recursion terminates because: - -1. **`continuationSkipped` is only set when a real check was suppressed.** After - the final stream (e.g., a text-only response), no new approvals arrive, so - `continuationSkipped` stays `false` and the recursion stops. - -2. **`shouldAutoSend()` returns `false` when tools are still pending approval.** - If a new approval arrives that hasn't been responded to yet, `areAllToolsComplete()` - returns `false` and the method exits without streaming. - -3. **Each recursion level sets `continuationPending = true`**, preventing any - concurrent checks from entering the streaming path. - -### Corrected Timeline - -``` -Timeline (with fix): -───────────────────────────────────────────────────────────── - -1. User approves tool A - └─ checkForContinuation() [OUTER] - └─ continuationPending = true, continuationSkipped = false - └─ streamResponse() starts (stream 2) - -2. Stream 2 produces tool B, user approves during stream - └─ Queues checkForContinuation as post-stream action - -3. Stream 2 ends - └─ drainPostStreamActions(): - └─ checkForContinuation(): continuationPending is true - └─ continuationSkipped = true ← MARKED - └─ returns early - └─ Back in OUTER: continuationPending = false - -4. OUTER checks continuationSkipped → true - └─ continuationSkipped = false - └─ Recurses into checkForContinuation() [INNER] - └─ shouldAutoSend() → true (tool B is approval-responded) - └─ continuationPending = true - └─ streamResponse() → stream 3 (final text response) - └─ continuationPending = false - └─ continuationSkipped is false → no further recursion - -5. Done. All three streams completed correctly. +Persistence is not required to emit or resolve interrupts — the route above is +the complete ephemeral flow. Durable interrupt persistence is a separate opt-in +layer added with the persistence middleware and documented with the persistence +guides. + +## Client state machine + +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 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. + +## React approval UI + +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 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.interrupts.map((interrupt) => ( +
+

Approval required: {interrupt.reason}

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

+ {error.message} +

+ ))} +
+ ))} +
+ ) +} ``` ---- - -## Stream Event Protocol - -### Approval-related AG-UI events - -These are `CUSTOM` events emitted by the TextEngine, not by adapters directly. - -#### `approval-requested` - -Emitted when a tool with `needsApproval: true` has its arguments finalized. - -```typescript ignore -{ - type: 'CUSTOM', - name: 'approval-requested', - value: { - toolCallId: string, // ID of the tool call - toolName: string, // Name of the tool - input: any, // Parsed arguments - approval: { - id: string, // Unique approval ID - needsApproval: true - } - } +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 ( + + ) } ``` -**Processor handling:** `handleCustomEvent()` → `updateToolCallApproval()` → -`onApprovalRequest` callback. - -#### Relation to other tool events +## Optional persistence and concurrency -A complete approval tool call in the stream looks like: +When configured, `withChatPersistence` performs these state transitions: -``` -TOOL_CALL_START → creates tool-call part (state: awaiting-input) -TOOL_CALL_ARGS* → accumulates arguments (state: input-streaming) -TOOL_CALL_END → finalizes arguments (state: input-complete) -CUSTOM → approval-requested (state: approval-requested) -RUN_FINISHED → finishReason: "tool_calls" -``` +| Run boundary | Run status | Other writes | +| --- | --- | --- | +| Start | `running` | Load and merge stored messages. | +| Interrupt outcome | `interrupted` | Atomically open the descriptor/binding batch and save messages before emission. | +| Accepted resume | `running` continuation | Validate all entries, CAS the generation/current run, store the receipt, and link the new run to its parent. | +| Successful finish | `completed` | Save messages and usage. | +| Provider/server error | `failed` | Save the error. | +| Abort | `interrupted` | Mark the run interrupted. | -After the stream ends and the user responds, the ChatClient: -1. Updates the tool-call part (state: `approval-responded`) -2. Sends a new stream request with the full conversation (including approval) -3. The server sees the approval and either executes or cancels the tool +Durable interrupt persistence — atomic compare-and-swap, idempotent receipts, +and multi-instance coordination — is a separate opt-in layer documented with +the persistence guides. This page covers the ephemeral interrupt lifecycle, +which resumes from full client history and requires no persistence. ---- +## State durability versus delivery durability -## Key Source Files - -| File | Role | -|------|------| -| `packages/ai/src/types.ts` | `ToolCallState`, `ToolCallPart`, tool approval types | -| `packages/ai/src/activities/chat/stream/processor.ts` | `handleCustomEvent()` (approval-requested), `areAllToolsComplete()`, `addToolApprovalResponse()` | -| `packages/ai/src/activities/chat/stream/message-updaters.ts` | `updateToolCallApproval()`, `updateToolCallApprovalResponse()` | -| `packages/ai-client/src/chat-client.ts` | `addToolApprovalResponse()`, `checkForContinuation()`, continuation flags | -| `packages/ai-react/src/use-chat.ts` | React hook: exposes `addToolApprovalResponse` | -| `packages/ai-solid/src/use-chat.ts` | Solid hook: exposes `addToolApprovalResponse` | -| `packages/ai-vue/src/use-chat.ts` | Vue composable: exposes `addToolApprovalResponse` | -| `packages/ai-svelte/src/create-chat.svelte.ts` | Svelte: exposes `addToolApprovalResponse` | -| `packages/ai-client/tests/chat-client.test.ts` | Chained approval test (`describe('chained tool approvals')`) | -| `packages/ai/docs/chat-architecture.md` | Internal stream processing architecture | +Interrupts run ephemerally by default. A separate persistence layer can make +interrupts survive page reloads and server restarts, but it does not make the +live byte stream replayable. Delivery durability is configured on +`toServerSentEventsResponse` and assigns one opaque SSE id per chunk (it is not +available for NDJSON). diff --git a/docs/config.json b/docs/config.json index 55a365282..146ce6216 100644 --- a/docs/config.json +++ b/docs/config.json @@ -1,4 +1,4 @@ -{ +{ "$schema": "https://raw.githubusercontent.com/TanStack/tanstack.com/main/tanstack-docs-config.schema.json", "docSearch": { "appId": "FQ0DQ6MA3C", @@ -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-08" + "updatedAt": "2026-07-14" }, { "label": "Lazy Tool Discovery", @@ -145,7 +145,7 @@ "label": "MCP Apps", "to": "mcp/apps", "addedAt": "2026-06-24", - "updatedAt": "2026-06-26" + "updatedAt": "2026-07-03" } ] }, @@ -178,7 +178,40 @@ { "label": "Persistence", "to": "chat/persistence", - "addedAt": "2026-06-02" + "addedAt": "2026-06-02", + "updatedAt": "2026-07-14" + } + ] + }, + { + "label": "Interrupts", + "children": [ + { + "label": "Overview", + "to": "interrupts/overview", + "addedAt": "2026-07-16" + }, + { + "label": "Tool Approval", + "to": "interrupts/tool-approval", + "addedAt": "2026-07-16" + }, + { + "label": "Multiple Interrupts", + "to": "interrupts/multiple", + "addedAt": "2026-07-16", + "updatedAt": "2026-07-20" + }, + { + "label": "Generic Interrupts", + "to": "interrupts/generic", + "addedAt": "2026-07-16" + }, + { + "label": "Migration", + "to": "interrupts/migration", + "addedAt": "2026-07-14", + "updatedAt": "2026-07-20" } ] }, @@ -326,7 +359,8 @@ { "label": "Generation Hooks", "to": "media/generation-hooks", - "addedAt": "2026-04-15" + "addedAt": "2026-04-15", + "updatedAt": "2026-07-10" } ] }, @@ -359,7 +393,7 @@ "label": "Overview", "to": "sandbox/overview", "addedAt": "2026-06-16", - "updatedAt": "2026-06-30" + "updatedAt": "2026-07-03" }, { "label": "Quick Start", @@ -401,7 +435,8 @@ { "label": "Lifecycle & Snapshots", "to": "sandbox/lifecycle", - "addedAt": "2026-06-29" + "addedAt": "2026-06-29", + "updatedAt": "2026-07-09" }, { "label": "Events", @@ -465,6 +500,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" } ] }, @@ -489,7 +530,7 @@ "updatedAt": "2026-07-08" }, { - "label": "Sampling → modelOptions", + "label": "Sampling \u2192 modelOptions", "to": "migration/sampling-options-to-model-options", "addedAt": "2026-06-03" } @@ -831,7 +872,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/interrupts/generic.md b/docs/interrupts/generic.md new file mode 100644 index 000000000..4989d0381 --- /dev/null +++ b/docs/interrupts/generic.md @@ -0,0 +1,104 @@ +--- +title: Generic Interrupts +id: interrupts-generic +order: 4 +description: "Resolve a schema-driven application pause by validating the wire responseSchema at runtime before submitting." +keywords: + - tanstack ai + - generic interrupt + - responseSchema + - fromJSONSchema + - resolveInterrupt +--- + +# Generic Interrupts + +A generic interrupt is any application pause that isn't tied to a tool. It may +carry a Draft 2020-12 `responseSchema`. Because that schema arrives over the +wire, its payload is `unknown` at compile time — so you validate it at runtime +before resolving. You have a schema-bearing `generic` item; you want a form that +only submits valid input. + +## Render a validating form + +Convert the received schema to a validator with `z.fromJSONSchema`, parse the +editor value as `unknown`, and resolve only on success: + +```tsx +// app/generic-interrupt.tsx +import { useState } from 'react' +import type { GenericAGUIInterrupt } from '@tanstack/ai-client' +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { z } from 'zod' + +// A generic item owns its own editor state, so give each one its own component. +function GenericInterruptForm({ + interrupt, +}: { + interrupt: GenericAGUIInterrupt +}) { + const [value, setValue] = useState('') + const [errors, setErrors] = useState>([]) + + const submit = () => { + if (!interrupt.responseSchema) { + setErrors(['This interrupt has no response schema.']) + return + } + let candidate: unknown + try { + candidate = JSON.parse(value) + } catch { + setErrors(['Enter valid JSON.']) + return + } + const result = z.fromJSONSchema(interrupt.responseSchema).safeParse(candidate) + if (!result.success) { + setErrors(result.error.issues.map((issue) => issue.message)) + return + } + interrupt.resolveInterrupt(result.data) + setErrors([]) + } + + return ( +
+

{interrupt.message ?? interrupt.reason}

+