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/.changeset/interrupt-binding-ownership.md b/.changeset/interrupt-binding-ownership.md new file mode 100644 index 000000000..b681fe9dd --- /dev/null +++ b/.changeset/interrupt-binding-ownership.md @@ -0,0 +1,33 @@ +--- +'@tanstack/ai-client': minor +'@tanstack/ai': minor +--- + +Make interrupt ownership explicit rather than assumed. + +An AG-UI `Interrupt` is a shared envelope — a workflow engine's durable +approval or another agent framework's pause can arrive on the same stream. What +makes a pause resumable through `chat()` is the binding this package attaches +under `tanstack:interruptBinding`. + +- Interrupts that carry no binding this client understands now surface as + `kind: 'unbound'` with `canResolve: false`, instead of being given a + synthesized binding and rendered as resolvable generic interrupts. Resolving + those produced an answer submitted against a run with nothing pending, which + failed as `unknown-interrupt` only after the user had filled in the form. + Unbound items never block submission of the interrupts that are yours. +- The binding carries a wire version (`INTERRUPT_BINDING_VERSION`). Readers + reject a version they don't recognise rather than duck-typing its fields. A + binding written before the field existed is still read. +- `INTERRUPT_BINDING_METADATA_KEY`, `withInterruptBinding()` and + `readInterruptBinding()` are exported, so anything producing an interrupt this + package must later resume attaches the binding through a supported API + instead of copying the metadata key. +- Interrupt classification is driven by the binding alone. `Interrupt.reason` is + free-form AG-UI text another producer can also use, so it is now a display + hint only and never decides ownership. +- The interrupt protocol surface is enumerated instead of `export *`. The + unimplemented durable-recovery contract (`InterruptRecoveryStateV1`, + `InterruptRecoveryQuery`, the never-called `loadInterruptState` adapter hook, + and the `persistence-required` / `atomic-commit-unsupported` / + `recovery-unavailable` error codes) is removed rather than published. diff --git a/.changeset/interrupts-validation-ownership.md b/.changeset/interrupts-validation-ownership.md new file mode 100644 index 000000000..122b0687b --- /dev/null +++ b/.changeset/interrupts-validation-ownership.md @@ -0,0 +1,20 @@ +--- +'@tanstack/ai': minor +'@tanstack/ai-client': minor +--- + +Interrupts: the application owns wire-schema validation, and the hashing +dependency is gone. + +The library no longer transforms a generic interrupt's wire JSON Schema into a +validator or validates the resolved value against it, on either the client or +the server. Whatever you pass to `resolveInterrupt` (client) or send in the +`resume` batch (server) flows through as-is. Validate it yourself if you need to +trust it, e.g. with `z.fromJSONSchema(interrupt.responseSchema).safeParse(value)` +on the client and your own check on the server. Validation of a tool's +code-authored Standard Schema (`approvalSchema` / `inputSchema`) is unchanged. + +This drops the `ajv` and `ajv-formats` dependencies. Interrupt binding hashes and +resolution fingerprints now use a small bundled SHA-256 instead of +`@noble/hashes`, so that dependency is gone too. The wire hash shape +(`sha256:`) is unchanged. diff --git a/docs/architecture/approval-flow-processing.md b/docs/architecture/approval-flow-processing.md index 04632bbe6..44f74ccfb 100644 --- a/docs/architecture/approval-flow-processing.md +++ b/docs/architecture/approval-flow-processing.md @@ -1,448 +1,243 @@ --- 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()` +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. -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 - ---- - -## Single Approval Lifecycle - -Step-by-step flow for a single tool requiring approval: - -### 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 }) -``` +See [Interrupts](../interrupts/overview) for the public server/client guide and +[Migrate to AG-UI interrupts](../interrupts/migration) for deprecated readers. -### 3. Stream ends, ChatClient processes +## Responsibilities -``` -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 -``` - -### 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 - } - } - } +| Layer | Responsibility | +| --- | --- | +| Tool definition | Declares `needsApproval: true` for a sensitive operation. | +| Chat engine | Stops before tool execution and emits the interrupt outcome. | +| 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 invariant is **descriptor → validate all → continuation → history**: + +1. The engine builds public descriptors and bindings. Output includes + `MESSAGES_SNAPSHOT`, optional `STATE_SNAPSHOT`, and the interrupt + `RUN_FINISHED` terminal. +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, reconstructing the expected batch + from the client-provided history and its current tool definitions. +6. Resumed tool calls emit results only; they do not replay synthetic tool-call + start/argument events. Successful history belongs to the continuation run. + +Because the batch is rebuilt from client-provided history, ephemeral mode does +not provide replay, exactly-once, restart, or cross-instance guarantees; the +message history is validated but remains client-provided input. + +## Server setup + +Define the tool normally. The following route is the **ephemeral** flow (no +persistence middleware): `chat` + `chatParamsFromRequest` resume the interrupt +batch from client message history and tool definitions. Durable recovery is a +separate optional layer and is not required for tool approvals. + +```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 +``` + +```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. -``` - ---- - -## 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 - } - } +No server storage is required to emit or resolve interrupts. The route above is +the complete flow: the browser sends the full message history back on the +continuation request, and the engine rebuilds the paused call from it. + +## 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. + +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} +

+ ))} +
+ ))} +
+ ) } ``` -**Processor handling:** `handleCustomEvent()` → `updateToolCallApproval()` → -`onApprovalRequest` callback. - -#### Relation to other tool events - -A complete approval tool call in the stream looks like: - -``` -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" +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 ( + + ) +} ``` -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 - ---- +## 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: the paused call is rebuilt from the message history +the browser replays on the continuation request. That is separate from +*delivery* durability, which makes the live byte stream replayable after a +dropped connection. Delivery durability is configured on +`toServerSentEventsResponse` and assigns one opaque SSE id per chunk (it is not +available for NDJSON). See [Resumable Streams](../resumable-streams/overview). diff --git a/docs/config.json b/docs/config.json index 55a365282..592302cbc 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-21" }, { "label": "Tool Approval Flow", "to": "tools/tool-approval", "addedAt": "2026-04-15", - "updatedAt": "2026-07-08" + "updatedAt": "2026-07-21" }, { "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,43 @@ { "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", + "updatedAt": "2026-07-22" + }, + { + "label": "Tool Approval", + "to": "interrupts/tool-approval", + "addedAt": "2026-07-16", + "updatedAt": "2026-07-21" + }, + { + "label": "Multiple Interrupts", + "to": "interrupts/multiple", + "addedAt": "2026-07-16", + "updatedAt": "2026-07-21" + }, + { + "label": "Generic Interrupts", + "to": "interrupts/generic", + "addedAt": "2026-07-16", + "updatedAt": "2026-07-21" + }, + { + "label": "Migration", + "to": "interrupts/migration", + "addedAt": "2026-07-14", + "updatedAt": "2026-07-21" } ] }, @@ -326,7 +362,8 @@ { "label": "Generation Hooks", "to": "media/generation-hooks", - "addedAt": "2026-04-15" + "addedAt": "2026-04-15", + "updatedAt": "2026-07-10" } ] }, @@ -359,7 +396,7 @@ "label": "Overview", "to": "sandbox/overview", "addedAt": "2026-06-16", - "updatedAt": "2026-06-30" + "updatedAt": "2026-07-03" }, { "label": "Quick Start", @@ -401,7 +438,8 @@ { "label": "Lifecycle & Snapshots", "to": "sandbox/lifecycle", - "addedAt": "2026-06-29" + "addedAt": "2026-06-29", + "updatedAt": "2026-07-09" }, { "label": "Events", @@ -465,6 +503,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-21" } ] }, @@ -489,7 +533,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 +875,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..b9d57daf2 --- /dev/null +++ b/docs/interrupts/generic.md @@ -0,0 +1,125 @@ +--- +title: Generic Interrupts +id: interrupts-generic +order: 4 +description: "Pause a run to ask the user something that isn't a tool call, validate their answer, and continue." +keywords: + - tanstack ai + - generic interrupt + - responseSchema + - fromJSONSchema + - resolveInterrupt +--- + +# Generic Interrupts + +Sometimes the agent needs an answer that isn't a tool call at all. Mid-run it has +to ask the user to pick a shipping speed, confirm an address, or choose which of +two drafts to keep. There's no tool to approve here, just a question your app +asks and the user answers. + +A generic interrupt is that question. You end the run with a pause that carries a +`responseSchema` describing the answer you expect, render a form for it, and +continue the run once the user submits a valid value. + +Because the pause is defined by your app, you own both ends: the server emits it, +and the client resolves it. + +## Resolve it on the client + +The schema arrives over the wire, so its value is `unknown` at compile time. +Validate the user's answer against it before resolving. Build the value from your +form fields and pass it straight to the schema: + +```tsx +// app/refund-reason.tsx +import { useState } from 'react' +import type { GenericAGUIInterrupt } from '@tanstack/ai-client' +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { z } from 'zod' + +// You emitted this pause, so you know the shape of the answer. Here it is a +// single reason string chosen from a dropdown. +function RefundReasonForm({ interrupt }: { interrupt: GenericAGUIInterrupt }) { + const [reason, setReason] = useState('damaged') + const [errors, setErrors] = useState>([]) + + const submit = () => { + if (!interrupt.responseSchema) { + setErrors(['This interrupt has no response schema.']) + return + } + const result = z + .fromJSONSchema(interrupt.responseSchema) + .safeParse({ reason }) + if (!result.success) { + setErrors(result.error.issues.map((issue) => issue.message)) + return + } + interrupt.resolveInterrupt(result.data) + setErrors([]) + } + + return ( +
+

{interrupt.message ?? interrupt.reason}

+ + + {errors.map((message) => ( +

{message}

+ ))} +
+ ) +} + +export function RefundReasons() { + const { interrupts } = useChat({ + threadId: 'order-7', + connection: fetchServerSentEvents('/api/chat'), + }) + + return ( + <> + {interrupts.map((interrupt) => + interrupt.kind === 'generic' ? ( + + ) : null, + )} + + ) +} +``` + +`z.fromJSONSchema` gives you a runtime validator, not a trustworthy static type. +The library does not validate the wire schema for you. Whatever you pass to +`resolveInterrupt` is sent as-is, so validate the value here on the client, and +again on the server if you need to trust it, the same way you would treat any +other user input. + +## Emit it on the server + +Tool approvals are rebuilt by `chat()` from message history for free. Generic +pauses are not, because only your app knows when to ask and what to ask. You emit +the descriptor and validate the answer yourself: + +1. End a run with `RUN_FINISHED` and `outcome.type === 'interrupt'`, carrying a + `generic` descriptor with your `responseSchema`. A small middleware is the + usual place to do this. +2. On the continuation request, correlate the incoming `resume` against that + same pending descriptor with `validateInterruptResumeBatch`. It checks the + batch is complete and matches the pending item; it does not validate your + generic value, that is yours to do. Then append the answer and continue. + +The interrupt lab in `examples/ts-react-chat` has a complete middleware that +emits a generic pause and correlates its answer. Without the server half, a +generic answer fails resume validation with `unknown-interrupt` or +`incomplete-batch`. + +> Gating a tool instead of asking a free-form question? A tool +> [approval](./tool-approval) gives you typed branches on top of validation. diff --git a/docs/interrupts/migration.md b/docs/interrupts/migration.md new file mode 100644 index 000000000..bdd4d867f --- /dev/null +++ b/docs/interrupts/migration.md @@ -0,0 +1,144 @@ +--- +title: Migration +id: interrupts-migration +order: 6 +description: "Migrate 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 +--- + +# Migration + +TanStack AI now models approvals, generic pauses, and client-tool execution as +AG-UI interrupt descriptors. Native runs end with +`RUN_FINISHED.outcome.type === 'interrupt'`, and the continuation is a new run +whose `parentRunId` is the interrupted run. + +There's no codemod. Migrate the server lifecycle and client rendering together. +Legacy readers stay temporarily for old streams but can't provide the full +native contract. Start from [Overview](./overview). + +## API mapping + +| Deprecated / legacy | Current | +| --- | --- | +| `pendingInterrupts` | `interrupts` (`pendingInterrupts` is a deprecated alias of the same array) | +| `ChatClient.getPendingInterrupts()` | `ChatClient.getInterrupts()` | +| `addToolApprovalResponse({ id, approved })` | Find the bound `tool-approval` item, call `interrupt.resolveInterrupt(approved)` | +| Raw `resumeInterrupts(entries, state)` | Bound item methods or root `resolveInterrupts(...)`; reserve `resumeInterruptsUnsafe` for validated recovery tooling | +| `approval-requested` custom event | `RUN_FINISHED` interrupt descriptor, reason `tool_call` | +| `tool-input-available` custom event | `RUN_FINISHED` interrupt descriptor, reason `tanstack:client_tool_execution` | +| Boolean denial treated as cancellation | `resolveInterrupt(false)` for denial; `cancel()` for payloadless cancellation | + +`addToolResult` is **not** removed. It still handles client-tool results and +delegates to a matching native item. `needsApproval` remains the tool-definition +switch for approvals. + +## Single approval + +```ts ignore +// Before +await addToolApprovalResponse({ id: approval.id, approved: true }) + +// After +const interrupt = interrupts.find( + (item) => item.kind === 'tool-approval' && item.toolName === 'transfer', +) +if (interrupt?.kind === 'tool-approval' && interrupt.toolName === 'transfer') { + interrupt.resolveInterrupt(true) +} +``` + +A valid singleton submits automatically. For the full render/resolve component +see [Tool Approval](./tool-approval). + +## Branch payloads and edits + +Legacy boolean approvals couldn't carry typed data. Add `approvalSchema` and +resolve the selected branch with data under `payload`: + +```ts ignore +interrupt.resolveInterrupt(true, { + editedArgs: { amount: 12, recipient: 'Ada' }, // optional, approval-only, full replacement + payload: { note: 'Reviewed' }, +}) +interrupt.resolveInterrupt(false, { payload: { reason: 'Policy limit' } }) +``` + +Rejection never accepts edits; top-level custom fields are invalid. A single +`approvalSchema` (not `{ approve, reject }`) applies to the selected decision; +with no schema the boolean shorthand stays valid. + +## Denial vs cancellation + +`resolveInterrupt(false, ...)` continues the model with an explicit rejected +decision. `cancel()` emits AG-UI `status: 'cancelled'` and never validates or +selects the reject branch. Deprecated `addToolApprovalResponse({ approved: false })` +maps to denial, not cancellation. + +## Batches + +Native batches are all-or-nothing. Replace approval-ID loops with staged items +(the last valid item auto-submits) or one synchronous root callback: + +```ts ignore +resolveInterrupts((interrupt) => { + if (interrupt.kind === 'tool-approval') { + interrupt.resolveInterrupt(true, { payload: { note: 'Batch review' } }) + return + } + interrupt.cancel() +}) +``` + +`resolveInterrupts(true|false)` is shorthand only for all-approval batches with +no payload/edits. Use `cancelInterrupts()` for payloadless all-items cancel, +`clearResolution()` to drop one draft, `retryInterrupts()` only when every item +is still validly staged and the root error is retryable. See +[Multiple Interrupts](./multiple). + +## Generic responses + +Don't derive a static type from a received `responseSchema`. Parse as +`unknown`, convert with `z.fromJSONSchema`, then resolve the validated value. +Full form example in [Generic Interrupts](./generic). + +## Server events + +A native server emits, in order: `MESSAGES_SNAPSHOT` → optional +`STATE_SNAPSHOT` → `RUN_FINISHED` with a nonempty interrupt outcome. +Continuations use a fresh `runId`, the same `threadId`, and the interrupted run +as `parentRunId`, with every pending ID present exactly once. + +Interrupts run **ephemerally**: the server reconstructs and validates the +expected batch from the submitted history and its current tool definitions, so +a stateless route needs no persistence. Because the batch is rebuilt from +client-provided input, this mode does not provide authoritative recovery, +exactly-once, replay protection, or restart recovery. + +`resumeInterruptsUnsafe` is a low-level escape hatch for submitting validated +raw resume entries directly, not the normal target for approval UI. + +## Legacy limits + +Deprecated readers recognize well-formed historical `approval-requested` and +`tool-input-available` events and convert a fully-covered legacy batch into one +cloned-history follow-up. They do **not** support edited arguments, custom +approval payloads, generic responses, payloadless cancellation, or expiry/ +schema-hash reconciliation; those fail with `legacy-unsupported`. Native and +legacy items can't mix in one batch; a failed legacy transport keeps staged +decisions and reports `legacy-submit-failed`. + +## Checklist + +1. Replace native custom-event writers with the interrupt terminal. +2. Render bound `interrupts` instead of `pendingInterrupts`. +3. Replace boolean approval helpers with `resolveInterrupt` + explicit + denial/cancellation. +4. Replace approval loops with atomic batch staging or root `resolveInterrupts`. +5. Keep `addToolResult` for client-tool results where useful. +6. Test expired items and failed transport before removing legacy support. diff --git a/docs/interrupts/multiple.md b/docs/interrupts/multiple.md new file mode 100644 index 000000000..0b0ebd78b --- /dev/null +++ b/docs/interrupts/multiple.md @@ -0,0 +1,238 @@ +--- +title: Multiple Interrupts +id: interrupts-multiple +order: 3 +description: "Render a queue of pending decisions and resolve them item by item or all at once as one atomic batch." +keywords: + - tanstack ai + - ag-ui interrupts + - resolveInterrupts + - batch approval + - cancelInterrupts +--- + +# Multiple Interrupts + +One run can pause on several decisions at once. The model lines up three +transfers, or an approval and a question land together. You want to show the +whole queue and send the answers back together, not one round trip each. + +## Two ways to resolve + +You have already seen the first one on the [Tool Approval](./tool-approval) page: +call a method on the item itself. + +```ts ignore +// Per item: resolve each one where you render it. +interrupt.resolveInterrupt(true) +``` + +When several are pending, it is often easier to answer them all from one place. +The `useChat` hook gives you root helpers that act on the whole queue: + +```ts ignore +// All at once: one callback decides every pending item. +resolveInterrupts((interrupt) => { + if (interrupt.kind === 'tool-approval') { + interrupt.resolveInterrupt(true) + return + } + interrupt.cancel() +}) +``` + +Both stage local drafts. Nothing goes to the server until every pending item has +an answer, then the whole set submits at once. The server accepts all of them or +none, so you never end up with half a batch applied. + +## Render the queue + +Map over `interrupts` and switch on `kind`. Each item carries its own +`canResolve` and `errors`: + +```tsx +// app/decision-queue.tsx +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { transferTool } from '../tools/transfer' + +export function DecisionQueue() { + const { interrupts, resolveInterrupts, cancelInterrupts, resuming } = useChat({ + threadId: 'account-42', + connection: fetchServerSentEvents('/api/chat'), + tools: [transferTool] as const, + }) + + if (interrupts.length === 0) return null + + return ( +
+

{interrupts.length} decision(s) needed

+ + {interrupts.map((interrupt) => { + if ( + interrupt.kind === 'tool-approval' && + interrupt.toolName === 'transfer' + ) { + return ( +
+

+ {interrupt.originalArgs.amount} to{' '} + {interrupt.originalArgs.recipient} +

+ + +
+ ) + } + return
Unsupported: {interrupt.kind}
+ })} + + + +
+ ) +} +``` + +## Resolve every item from one callback + +`resolveInterrupts(callback)` runs your callback once per item inside a single +synchronous transaction. It must resolve or cancel every item. If it throws or +leaves one item unanswered, nothing submits: + +```ts ignore +resolveInterrupts((interrupt) => { + if (interrupt.kind === 'tool-approval') { + interrupt.resolveInterrupt(true, { payload: { note: 'Batch review' } }) + return + } + interrupt.cancel() +}) +``` + +Two shortcuts cover the common cases: + +- `resolveInterrupts(true)` / `resolveInterrupts(false)` approves or rejects the + whole queue. It works only when every item is a tool approval that needs no + payload or edits. Generic items, mixed queues, or required payloads are + rejected. +- `cancelInterrupts()` cancels every item with no payload. + +## When an answer is wrong + +A bad answer does not tear down the queue. The item keeps your last valid draft, +shows what went wrong, and lets you fix it and resubmit. Errors come in two +places, and you render both. + +Each item carries its own `errors`: a bad payload, invalid edited args, or an +expired item. The root `interruptErrors` carries failures for the whole batch: +transport problems, server rejections, and errors for the internal client-tool +steps that never show up as their own item. + +This component renders both, gates its buttons correctly, and offers the two +recovery paths: + +```tsx +// app/robust-queue.tsx +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { toolDefinition } from '@tanstack/ai' +import { z } from 'zod' + +const transferTool = toolDefinition({ + name: 'transfer', + description: 'Move money between accounts', + needsApproval: true, + inputSchema: z.object({ + recipient: z.string(), + amount: z.number(), + }), + outputSchema: z.object({ receiptId: z.string() }), +}).client() + +export function RobustQueue() { + const { interrupts, interruptErrors, retryInterrupts, resuming } = useChat({ + threadId: 'account-42', + connection: fetchServerSentEvents('/api/chat'), + tools: [transferTool] as const, + }) + + // Retry only helps a transport failure. Expired or stale batches can't be + // retried, so don't offer it for those. + const canRetry = interruptErrors.some((error) => error.code === 'transport') + + return ( +
+ {interrupts.map((interrupt) => { + if ( + interrupt.kind !== 'tool-approval' || + interrupt.toolName !== 'transfer' + ) { + return null + } + + // canResolve reflects the schema and binding, not the live phase, so + // also gate on the item's status and the run being busy. + const busy = interrupt.status === 'submitting' || resuming + + return ( +
+

+ {interrupt.originalArgs.amount} to{' '} + {interrupt.originalArgs.recipient} +

+ + + + {/* Item errors: bad payload, bad edited args, expired. */} + {interrupt.errors.map((error) => ( +

+ {error.message} +

+ ))} +
+ ) + })} + + {/* Batch errors: transport, server, and hidden client-tool steps. */} + {interruptErrors.map((error) => ( +

{error.message}

+ ))} + {canRetry ? ( + + ) : null} +
+ ) +} +``` + +The two recovery paths, side by side: + +- `interrupt.clearResolution()` drops one item's draft so the user can answer it + again from scratch. Fixing a form and calling `resolveInterrupt` again works + too, the draft is replaced, not stacked. +- `retryInterrupts()` re-sends the whole staged batch after a transport failure. + It does nothing for expired or stale batches, start a fresh run to get a new + set of interrupts for those. diff --git a/docs/interrupts/overview.md b/docs/interrupts/overview.md new file mode 100644 index 000000000..62b6fd595 --- /dev/null +++ b/docs/interrupts/overview.md @@ -0,0 +1,187 @@ +--- +title: Overview +id: interrupts-overview +order: 1 +description: "Pause an agent run for a human or application decision, then continue it exactly where it stopped." +keywords: + - tanstack ai + - ag-ui interrupts + - human in the loop + - tool approval + - resolveInterrupt +--- + +# Interrupts + +Most agent runs are fire and forget. The model calls tools, they run, you get an +answer back. But some steps shouldn't happen on their own: moving money, +deleting a project, sending an email. And sometimes the agent needs an answer +only the user can give before it can go on. + +An interrupt is a pause. The run stops, hands you a decision to make, and then +picks up exactly where it left off once you answer. + +## How it works + +1. The server reaches a step that needs a decision and ends the run with an + `interrupt` outcome instead of a final answer. +2. The client gives you the pending decisions as `interrupts`. +3. You resolve each one (approve, reject, submit a value, or cancel). +4. The client starts a fresh continuation run that carries your answers and + continues the agent. + +No database is required. The browser sends the full message history back on the +continuation request, so a stateless server can rebuild the paused step and keep +going. + +## What pauses a run + +Two kinds of interrupt show up in the `interrupts` array for you to resolve: + +| `kind` | You get a pause when | Guide | +| --- | --- | --- | +| `tool-approval` | A tool is marked `needsApproval` and the model calls it | [Tool Approval](./tool-approval) | +| `generic` | Your app ends a run to ask the user something that isn't a tool | [Generic Interrupts](./generic) | + +## Interrupts that aren't ours: `unbound` + +An interrupt is a standard AG-UI object, and TanStack AI is not the only thing +that can put one on a stream. A workflow engine pausing for a durable approval, +or another agent framework sharing the same connection, emits the same envelope. + +What makes a pause resumable *here* is a binding this library attaches to the +interrupt's metadata, under a key exported as `INTERRUPT_BINDING_METADATA_KEY`. +It records which run and generation the pause belongs to, so your answer can be +matched back to the paused step. + +When an interrupt arrives without one, you get it with `kind: 'unbound'` and +`canResolve: false`, and there is no `resolveInterrupt` to call: + +```tsx +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { toolDefinition } from '@tanstack/ai' +import { z } from 'zod' + +const transferTool = toolDefinition({ + name: 'transfer', + description: 'Move money between accounts', + needsApproval: true, + inputSchema: z.object({ recipient: z.string(), amount: z.number() }), + outputSchema: z.object({ receiptId: z.string() }), +}).client() + +export function Pauses() { + const { interrupts } = useChat({ + threadId: 'thread-1', + connection: fetchServerSentEvents('/api/chat'), + tools: [transferTool] as const, + }) + + return ( + <> + {interrupts.map((interrupt) => { + // Someone else owns this pause: show it, but offer no way to answer it. + if (interrupt.kind === 'unbound') { + return ( +

+ Paused elsewhere: {interrupt.message ?? interrupt.reason} +

+ ) + } + if (interrupt.kind === 'generic') { + return ( + + ) + } + return ( + + ) + })} + + ) +} +``` + +The library will not invent a binding to make these resolvable. Doing so would +render a form whose answer gets submitted against a run that has nothing pending +— failing only after the user has filled it in. `unbound` says plainly that the +pause belongs to something else, and unbound items never block you from +resolving the ones that are yours. + +If you emit your own pauses and want them resumable here, attach the binding +with `withInterruptBinding` rather than writing the metadata key by hand: + +```ts +import { + INTERRUPT_BINDING_VERSION, + canonicalInterruptJson, + digestInterruptJson, + withInterruptBinding, +} from '@tanstack/ai' + +const responseSchema = { + type: 'object', + properties: { speed: { type: 'string' } }, + required: ['speed'], +} + +const descriptor = withInterruptBinding( + { + id: 'shipping-1', + reason: 'confirmation', + message: 'Which shipping speed?', + responseSchema, + }, + { + v: INTERRUPT_BINDING_VERSION, + kind: 'generic', + interruptId: 'shipping-1', + // The server checks the schema it hands out still matches the one it + // validates against, so the hash is computed from the schema itself. + responseSchemaHash: digestInterruptJson( + canonicalInterruptJson(responseSchema), + ), + }, +) +``` + +`v` is the binding's wire version. Readers reject a version they don't +recognise instead of guessing at the fields, which is what keeps another +producer's binding from being mistaken for one of ours. + +## What about client tools? + +A tool with a `.client()` implementation runs in the browser on its own and +reports its own result. That is not a decision you make, so it never appears in +`interrupts`. See [Client Tools](../tools/client-tools). + +The one time a tool pauses is when you mark it `needsApproval: true`. Then it +stops for a yes or no first, whether it runs on the server or in the browser: + +| Tool | What you handle | +| --- | --- | +| Server tool | Nothing, unless `needsApproval` adds a `tool-approval` pause. It then runs on the server after you approve. | +| Client tool | Nothing, it runs in the browser automatically. With `needsApproval` it pauses for approval first, then runs in the browser. | + +So approval is the only thing you resolve for either kind of tool, and both use +the same `tool-approval` interrupt. + +## Where to go next + +| You want to | Page | +| --- | --- | +| Approve or reject a single tool call | [Tool Approval](./tool-approval) | +| Resolve several pending decisions at once | [Multiple Interrupts](./multiple) | +| Ask the user something that isn't a tool | [Generic Interrupts](./generic) | +| Run a tool in the browser | [Client Tools](../tools/client-tools) | +| Move off the old `approval-requested` events | [Migration](./migration) | diff --git a/docs/interrupts/tool-approval.md b/docs/interrupts/tool-approval.md new file mode 100644 index 000000000..0437e40b6 --- /dev/null +++ b/docs/interrupts/tool-approval.md @@ -0,0 +1,270 @@ +--- +title: Tool Approval +id: interrupts-tool-approval +order: 2 +description: "Pause a tool call for a human yes or no, render it inside the chat, and continue the run once the user decides." +keywords: + - tanstack ai + - tool approval + - needsApproval + - approvalSchema + - resolveInterrupt +--- + +# Tool Approval + +You have a tool that shouldn't run until a person says yes: transferring money, +deleting a record, sending a message. You want the model to plan the call, then +wait for a human to approve it before anything happens. + +By the end of this page the chat pauses on that call, shows an approve or reject +prompt inline, and continues the run with the user's decision. + +## Define the tool + +`needsApproval: true` turns the call into an approval pause. Define the tool once +and share it, so the server and the browser infer the same types: + +```ts +// tools/transfer.ts +import { toolDefinition } from '@tanstack/ai' +import { z } from 'zod' + +export const transferTool = toolDefinition({ + name: 'transfer', + description: 'Transfer funds to a recipient', + needsApproval: true, + inputSchema: z.object({ + amount: z.number().positive(), + recipient: z.string().min(1), + }), + outputSchema: z.object({ receiptId: z.string() }), +}) +``` + +## Serve it + +The server runs the tool only after the user approves. It needs no database: the +browser sends the message history and the `resume` decision back, so forward +`parentRunId` and `resume` into `chat()` and it rebuilds the paused call. + +```ts +// app/api/chat/route.ts +import { + chat, + chatParamsFromRequest, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { transferTool } from '../../../tools/transfer' + +const transfer = transferTool.server( + async (input: { amount: number; recipient: string }) => ({ + receiptId: `${input.recipient}-${input.amount}-${crypto.randomUUID()}`, + }), +) + +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: [transfer], + }) + return toServerSentEventsResponse(stream) +} +``` + +## Render it in the chat + +Pass the shared tool to `useChat` so `toolName` and `originalArgs` are typed. +Render your messages as usual, and when the run pauses, the pending approval +shows up in `interrupts` right alongside the conversation. Resolve it straight +from the item with `interrupt.resolveInterrupt(...)`: + +```tsx +// app/transfer-chat.tsx +import { useState } from 'react' +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { transferTool } from '../tools/transfer' + +export function TransferChat() { + const { messages, sendMessage, interrupts, resuming } = useChat({ + threadId: 'account-42', + connection: fetchServerSentEvents('/api/chat'), + tools: [transferTool] as const, + }) + const [input, setInput] = useState('') + + return ( +
+ {messages.map((message) => ( +
+ {message.role}: + {message.parts.map((part, i) => + part.type === 'text' ? {part.content} : null, + )} +
+ ))} + + {interrupts.map((interrupt) => { + if ( + interrupt.kind !== 'tool-approval' || + interrupt.toolName !== 'transfer' + ) { + return null + } + return ( +
+

+ Send {interrupt.originalArgs.amount} to{' '} + {interrupt.originalArgs.recipient}? +

+ + +
+ ) + })} + +
{ + event.preventDefault() + void sendMessage(input) + setInput('') + }} + > + setInput(event.target.value)} /> + +
+
+ ) +} +``` + +The approve and reject buttons call `resolveInterrupt` on the item itself. For a +single pending decision that submits right away, no extra step. Resolving +several at once is covered in [Multiple Interrupts](./multiple). + +## Server tools and client tools + +Approval works the same for both. The difference is only where the tool runs +after the user says yes. + +- A **server tool** (`.server()`) runs on the server once approved. +- A **client tool** (`.client()`) runs in the browser once approved. + +The approval interrupt is identical in both cases, so the UI above does not +change. A client tool without `needsApproval` runs on its own and never pauses, +see [Client Tools](../tools/client-tools). + +## Carry data on the decision + +Attach an `approvalSchema` when the decision itself needs typed data, like a +review note or a rejection reason. Add it to the tool definition. Use one schema +for both branches, or an `{ approve, reject }` map for different payloads: + +```ts ignore +export const transferTool = toolDefinition({ + name: 'transfer', + // ...same inputSchema and outputSchema as above + needsApproval: true, + approvalSchema: { + approve: z.object({ note: z.string().min(1) }), + reject: z.object({ reason: z.string().min(1) }), + }, +}) +``` + +Now the decision carries a payload, and approval can also replace the arguments: + +```ts ignore +// Approve as-is, with the approve-branch payload. +interrupt.resolveInterrupt(true, { payload: { note: 'Reviewed' } }) + +// Approve, but replace the arguments first. editedArgs is a full replacement, +// not a merge, and is validated against the tool's inputSchema. +interrupt.resolveInterrupt(true, { + editedArgs: { amount: 12, recipient: 'Ada' }, + payload: { note: 'Capped to policy' }, +}) + +// Reject, with the reject-branch payload. +interrupt.resolveInterrupt(false, { payload: { reason: 'Too large' } }) +``` + +Only approval accepts `editedArgs`. Without an `approvalSchema` the boolean +shorthand `resolveInterrupt(true)` / `resolveInterrupt(false)` is all you need. +The server re-validates the whole decision before it runs the tool. + +## Consume the decision on the server + +The two fields you sent land in two different places, so pick the one that fits +what you need. + +`editedArgs` become the arguments the tool runs with. This is how a human +reshapes what the tool does before it runs. There is nothing to wire up: your +`execute` always receives the final input, edited or not, already validated +against `inputSchema`: + +```ts +// server/transfer-tool.ts +import { toolDefinition } from '@tanstack/ai' +import { z } from 'zod' + +const transferTool = toolDefinition({ + name: 'transfer', + description: 'Move money between accounts', + needsApproval: true, + inputSchema: z.object({ + recipient: z.string(), + amount: z.number(), + }), + outputSchema: z.object({ receiptId: z.string() }), +}) + +export const transfer = transferTool.server(async (input) => { + // input.amount / input.recipient are the model's arguments, or the + // approver's editedArgs when they changed them. Same code either way. + return { + receiptId: `${input.recipient}-${input.amount}-${crypto.randomUUID()}`, + } +}) +``` + +The `payload` is decision data, not tool input, and the two branches use it +differently: + +- The **reject** payload comes back as the tool's failed result, so the model + reads why it was refused and can respond. `resolveInterrupt(false, { payload: + { reason: 'Too large' } })` hands `{ reason: 'Too large' }` to the model as + the result of that call. +- The **approve** payload is validated decision data for your own app: an audit + log, a "reviewed by" record, an analytics event. It is not passed to + `execute`. If the tool itself needs a value from the approver, put it in + `editedArgs` (part of the tool input) rather than the payload. + +## Reject is not cancel + +`resolveInterrupt(false, ...)` is a resolved no. The run continues and the model +sees the rejection (and its reject payload as the tool result), so it can +respond to it. + +`interrupt.cancel()` abandons the pause. It carries no payload and does not pick +the reject branch. Reject when the user answered no; cancel when the workflow is +dropped without an answer. + +> Resolving a queue of approvals together? See [Multiple Interrupts](./multiple). diff --git a/docs/reference/functions/toolDefinition.md b/docs/reference/functions/toolDefinition.md index f2932214f..59451f529 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:209](https://github.com/TanStack/ai/blob/main/packages/ai/src/activities/chat/tools/tool-definition.ts#L209) @@ -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](../../interrupts/overview) for the +full lifecycle. + ## Type Parameters ### TInput @@ -37,7 +77,13 @@ or plain JSON Schema objects. ### TNeedsApproval -`TNeedsApproval` *extends* `boolean` = `false` +`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 @@ -69,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) @@ -93,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/tools/client-tools.md b/docs/tools/client-tools.md index a6232d6f3..8f21dc04a 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,51 @@ 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 -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 +3. **Client Notification**: Server emits an internal `client-tool-execution` + pause on the interrupt wire (not a public item in `interrupts`) +4. **Client Execution**: The browser finds the registered `.client()` + implementation by tool name and runs it with the parsed input +5. **Result Return**: Client auto-submits the result via the resume batch +6. **Server Update**: Result is validated and added to the conversation 7. **LLM Continuation**: LLM receives the result and continues the conversation +Native client-tool execution shares the atomic interrupt **batch** lifecycle +(it can gate multi-item submits) but is **auto-resolved** — you do not call +`resolveInterrupt` for it. See [Interrupts](../interrupts/overview) for the +ephemeral lifecycle, batches, and migration from the historical +`tool-input-available` custom event. Durable recovery is optional and not part +of the default client-tool path. + +## Approval is a separate axis + +A client tool can also require approval, and approval is separate from the +browser result. Add `needsApproval: true` and the tool pauses on a +`tool-approval` interrupt first. You resolve **that decision only** — once +approved, the client runs the `.client()` implementation automatically and +returns its result: + +```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) +} +``` + +You never resolve the execution by hand — that is what the `.client()` +implementation is for. If you register a tool **without** a `.client()` +implementation and want to supply the result yourself, use `addToolResult` +(validated against the tool's output schema); it also preserves the historical +path for legacy streams. See [Tool approval flow](./tool-approval) for approval +forms and [Interrupts](../interrupts/overview) for the approval lifecycle. + ## Defining Client Tools Client tools use the same `toolDefinition()` API but with the `.client()` method: @@ -232,7 +271,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 c4520fce2..0970495f0 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](../interrupts/overview). For deprecated API mapping, +see [Migrate to AG-UI interrupts](../interrupts/migration). + 1. **`awaiting-input`** — Tool call started, no arguments yet 2. **`input-streaming`** — Arguments arriving incrementally 3. **`input-complete`** — All arguments received @@ -23,6 +28,10 @@ The tool approval flow allows you to require user approval before executing sens After `approval-responded` the call executes (if approved). Although `complete` exists in the `ToolCallState` union, the runtime never transitions the tool-call part to it — the result surfaces as a populated `part.output` plus a sibling `tool-result` part whose own state is `complete` or `error`. +Approvals run ephemerally: the run resumes from the full client message +history that the browser sends back, so a stateless route needs no server +storage to rebuild the paused call. + When a tool requires approval, the typical flow is: 1. Model calls the tool @@ -31,6 +40,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 [Multiple Interrupts](../interrupts/multiple). + ## Enabling Approval Tools can be marked as requiring approval by setting `needsApproval: true` in the definition: @@ -86,9 +158,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' @@ -181,7 +255,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..16313747b 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 @@ -13,7 +13,7 @@ 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 type { AnyServerTool, AnyTextAdapter, StreamChunk } from '@tanstack/ai' import type { IsolateDriver } from '@tanstack/ai-code-mode' import { databaseTools, getSchemaInfoTool } from '@/lib/tools/database-tools' @@ -115,7 +115,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 +250,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-code-mode-web/src/routes/_home/api.product-codemode.ts b/examples/ts-code-mode-web/src/routes/_home/api.product-codemode.ts index f6156c1bb..1972c171a 100644 --- a/examples/ts-code-mode-web/src/routes/_home/api.product-codemode.ts +++ b/examples/ts-code-mode-web/src/routes/_home/api.product-codemode.ts @@ -14,7 +14,7 @@ import { } from '@tanstack/ai-code-mode-skills' import { createFileSkillStorage } from '@tanstack/ai-code-mode-skills/storage' import { maxTokensModelOptions } from '@/lib/max-tokens-model-options' -import type { AnyTextAdapter, ServerTool, StreamChunk } from '@tanstack/ai' +import type { AnyServerTool, AnyTextAdapter, StreamChunk } from '@tanstack/ai' import type { IsolateDriver } from '@tanstack/ai-code-mode' import { productTools } from '@/lib/tools/product-tools' @@ -109,7 +109,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() @@ -248,7 +248,7 @@ export const Route = createFileRoute('/_home/api/product-codemode')({ driver, } = await getCodeModeTools() - let tools: Array> = [codeModeTool] + let tools: Array = [codeModeTool] let systemPrompts = [PRODUCT_CODE_MODE_SYSTEM_PROMPT, codeModePrompt] if (withSkills) { diff --git a/examples/ts-react-chat/src/components/Header.tsx b/examples/ts-react-chat/src/components/Header.tsx index 9c71d1577..62ebc7939 100644 --- a/examples/ts-react-chat/src/components/Header.tsx +++ b/examples/ts-react-chat/src/components/Header.tsx @@ -17,6 +17,7 @@ import { MessageSquare, Mic, Music, + PauseCircle, Plug, RefreshCw, Server, @@ -312,6 +313,19 @@ export default function Header() { Resumable Streams + 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-cyan-600 hover:bg-cyan-700 transition-colors mb-2', + }} + > + + Interrupts Lab + + setIsOpen(false)} diff --git a/examples/ts-react-chat/src/lib/interrupt-tools.ts b/examples/ts-react-chat/src/lib/interrupt-tools.ts new file mode 100644 index 000000000..a3103d3bd --- /dev/null +++ b/examples/ts-react-chat/src/lib/interrupt-tools.ts @@ -0,0 +1,227 @@ +import { toolDefinition } from '@tanstack/ai' +import { z } from 'zod' + +/** + * Willowbrook Wildlife Sanctuary interrupt playground. + * + * Each tool isolates one interrupt behavior. Server tools run on the server + * after approval; client tools run in the browser after approval. A tool is + * "client" purely by having a `.client()` implementation and no `.server()`. + */ + +// 1. Server tool, approval only (boolean approve / reject). +export const admitRescue = toolDefinition({ + name: 'admitRescue', + description: 'Admit a rescued animal into the sanctuary intake ward.', + inputSchema: z.object({ + species: z.string(), + name: z.string(), + }), + outputSchema: z.object({ intakeId: z.string(), status: z.string() }), + needsApproval: true, +}) + +// 2. Server tool, approval + one shared payload schema for both branches. +export const scheduleVetCheck = toolDefinition({ + name: 'scheduleVetCheck', + description: 'Schedule a veterinary check for an animal in care.', + inputSchema: z.object({ + animal: z.string(), + urgency: z.enum(['routine', 'urgent']), + }), + outputSchema: z.object({ visitId: z.string() }), + needsApproval: true, + approvalSchema: z.object({ note: z.string().min(1) }), +}) + +// 3. Server tool, approval + separate approve / reject payload schemas. +export const finalizeAdoption = toolDefinition({ + name: 'finalizeAdoption', + description: 'Finalize the adoption of an animal to a new home.', + inputSchema: z.object({ + animal: z.string(), + adopter: z.string(), + }), + outputSchema: z.object({ certificateId: z.string() }), + needsApproval: true, + approvalSchema: { + approve: z.object({ + adopterName: z.string().min(1), + homeCheckPassed: z.boolean(), + }), + reject: z.object({ reason: z.string().min(1) }), + }, +}) + +// 4. Server tool, approval with edited arguments (change the plan before it runs). +export const assignEnclosure = toolDefinition({ + name: 'assignEnclosure', + description: 'Assign an animal to an enclosure.', + inputSchema: z.object({ + animal: z.string(), + enclosure: z.string(), + sizeSqm: z.number().positive(), + }), + outputSchema: z.object({ + assignmentId: z.string(), + enclosure: z.string(), + sizeSqm: z.number(), + }), + needsApproval: true, +}) + +// 5. Client tool, approval only. Runs in the browser after approval. +export const printIntakeTag = toolDefinition({ + name: 'printIntakeTag', + description: "Print an intake tag on this device's label printer.", + inputSchema: z.object({ animal: z.string() }), + outputSchema: z.object({ tag: z.string() }), + needsApproval: true, +}) + +// 6. Client tool, approval + shared payload schema. +export const logFieldSighting = toolDefinition({ + name: 'logFieldSighting', + description: 'Log a field sighting captured from this device.', + inputSchema: z.object({ species: z.string(), location: z.string() }), + outputSchema: z.object({ sightingId: z.string() }), + needsApproval: true, + approvalSchema: z.object({ note: z.string().min(1) }), +}) + +// 7. Client tool, approval + branch payload schemas. +export const shareAdoptionStory = toolDefinition({ + name: 'shareAdoptionStory', + description: 'Share an adoption story to a social channel from this device.', + inputSchema: z.object({ animal: z.string() }), + outputSchema: z.object({ url: z.string() }), + needsApproval: true, + approvalSchema: { + approve: z.object({ channel: z.enum(['instagram', 'newsletter']) }), + reject: z.object({ reason: z.string().min(1) }), + }, +}) + +// 8. Client tool, approval with edited arguments. +export const printCertificate = toolDefinition({ + name: 'printCertificate', + description: 'Print an adoption certificate on this device.', + inputSchema: z.object({ + animal: z.string(), + adopter: z.string(), + date: z.string(), + }), + outputSchema: z.object({ certificate: z.string() }), + needsApproval: true, +}) + +/** The generic interrupt's response schema (a non-tool application pause). */ +export const feedingScheduleResponseSchema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + mealsPerDay: { type: 'integer', minimum: 1, maximum: 6 }, + diet: { type: 'string', minLength: 2 }, + }, + required: ['mealsPerDay', 'diet'], + additionalProperties: false, +} as const + +export type ScenarioGroup = 'server' | 'client' | 'generic' | 'batch' + +export interface Scenario { + id: string + group: ScenarioGroup + title: string + blurb: string + /** The message the button sends to the chat. */ + message: string + /** When set, the server forces this tool via provider tool_choice. */ + forceTool?: string + /** When true, the server attaches the generic-interrupt middleware. */ + generic?: boolean +} + +export const scenarios: ReadonlyArray = [ + { + id: 'admit', + group: 'server', + title: 'Admit a rescue', + blurb: 'Server tool, plain approve / reject.', + message: "Admit the rescued red fox named 'Rusty' to intake.", + forceTool: admitRescue.name, + }, + { + id: 'vet', + group: 'server', + title: 'Schedule a vet check', + blurb: 'Server tool, one shared note on the decision.', + message: 'Book an urgent vet check for Rusty.', + forceTool: scheduleVetCheck.name, + }, + { + id: 'adopt', + group: 'server', + title: 'Finalize an adoption', + blurb: 'Server tool, different approve vs reject payloads.', + message: "Finalize Luna the barn owl's adoption by Dana Rivers.", + forceTool: finalizeAdoption.name, + }, + { + id: 'enclosure', + group: 'server', + title: 'Assign an enclosure', + blurb: 'Server tool, edit the plan before approving.', + message: 'Assign Rusty to enclosure Aspen with 18 square metres.', + forceTool: assignEnclosure.name, + }, + { + id: 'tag', + group: 'client', + title: 'Print an intake tag', + blurb: 'Client tool, plain approve / reject, runs in the browser.', + message: 'Print an intake tag for Rusty.', + forceTool: printIntakeTag.name, + }, + { + id: 'sighting', + group: 'client', + title: 'Log a field sighting', + blurb: 'Client tool, one shared note.', + message: 'Log a field sighting of a barn owl near the North Meadow.', + forceTool: logFieldSighting.name, + }, + { + id: 'story', + group: 'client', + title: 'Share an adoption story', + blurb: 'Client tool, different approve vs reject payloads.', + message: "Share Luna the owl's adoption story.", + forceTool: shareAdoptionStory.name, + }, + { + id: 'certificate', + group: 'client', + title: 'Print a certificate', + blurb: 'Client tool, edit the details before approving.', + message: + 'Print an adoption certificate for Luna, adopted by Dana Rivers today.', + forceTool: printCertificate.name, + }, + { + id: 'feeding', + group: 'generic', + title: 'Set a feeding schedule', + blurb: 'Generic interrupt, no tool. The app asks and validates.', + message: 'Help me set up a feeding schedule for Rusty.', + generic: true, + }, + { + id: 'batch', + group: 'batch', + title: 'Admit three rescues', + blurb: 'Several approvals at once. Resolve each or resolve all.', + message: + 'Admit three rescued animals to intake: a red fox named Rusty, a barn owl named Luna, and a hedgehog named Pip.', + }, +] diff --git a/examples/ts-react-chat/src/routeTree.gen.ts b/examples/ts-react-chat/src/routeTree.gen.ts index 0f05c32c4..2b0dfdb40 100644 --- a/examples/ts-react-chat/src/routeTree.gen.ts +++ b/examples/ts-react-chat/src/routeTree.gen.ts @@ -19,6 +19,7 @@ import { Route as QueueingRouteImport } from './routes/queueing' import { Route as McpDemoRouteImport } from './routes/mcp-demo' import { Route as McpAppsRouteImport } from './routes/mcp-apps' import { Route as Issue176ToolResultRouteImport } from './routes/issue-176-tool-result' +import { Route as InterruptsRouteImport } from './routes/interrupts' import { Route as ImageToolReproRouteImport } from './routes/image-tool-repro' import { Route as ImageGenRouteImport } from './routes/image-gen' import { Route as GenerationHooksRouteImport } from './routes/generation-hooks' @@ -48,6 +49,7 @@ import { Route as ApiMcpAppsWeatherServerRouteImport } from './routes/api.mcp-ap import { Route as ApiMcpAppsShopServerRouteImport } from './routes/api.mcp-apps-shop-server' import { Route as ApiMcpAppsChatRouteImport } from './routes/api.mcp-apps-chat' import { Route as ApiMcpAppsCallRouteImport } from './routes/api.mcp-apps-call' +import { Route as ApiInterruptsRouteImport } from './routes/api.interrupts' import { Route as ApiImageToolReproRouteImport } from './routes/api.image-tool-repro' import { Route as ApiImageGenRouteImport } from './routes/api.image-gen' import { Route as ApiCapabilityDemoRouteImport } from './routes/api.capability-demo' @@ -108,6 +110,11 @@ const Issue176ToolResultRoute = Issue176ToolResultRouteImport.update({ path: '/issue-176-tool-result', getParentRoute: () => rootRouteImport, } as any) +const InterruptsRoute = InterruptsRouteImport.update({ + id: '/interrupts', + path: '/interrupts', + getParentRoute: () => rootRouteImport, +} as any) const ImageToolReproRoute = ImageToolReproRouteImport.update({ id: '/image-tool-repro', path: '/image-tool-repro', @@ -256,6 +263,11 @@ const ApiMcpAppsCallRoute = ApiMcpAppsCallRouteImport.update({ path: '/api/mcp-apps-call', getParentRoute: () => rootRouteImport, } as any) +const ApiInterruptsRoute = ApiInterruptsRouteImport.update({ + id: '/api/interrupts', + path: '/api/interrupts', + getParentRoute: () => rootRouteImport, +} as any) const ApiImageToolReproRoute = ApiImageToolReproRouteImport.update({ id: '/api/image-tool-repro', path: '/api/image-tool-repro', @@ -308,6 +320,7 @@ export interface FileRoutesByFullPath { '/generation-hooks': typeof GenerationHooksRoute '/image-gen': typeof ImageGenRoute '/image-tool-repro': typeof ImageToolReproRoute + '/interrupts': typeof InterruptsRoute '/issue-176-tool-result': typeof Issue176ToolResultRoute '/mcp-apps': typeof McpAppsRoute '/mcp-demo': typeof McpDemoRoute @@ -321,6 +334,7 @@ export interface FileRoutesByFullPath { '/api/capability-demo': typeof ApiCapabilityDemoRoute '/api/image-gen': typeof ApiImageGenRoute '/api/image-tool-repro': typeof ApiImageToolReproRoute + '/api/interrupts': typeof ApiInterruptsRoute '/api/mcp-apps-call': typeof ApiMcpAppsCallRoute '/api/mcp-apps-chat': typeof ApiMcpAppsChatRoute '/api/mcp-apps-shop-server': typeof ApiMcpAppsShopServerRoute @@ -358,6 +372,7 @@ export interface FileRoutesByTo { '/generation-hooks': typeof GenerationHooksRoute '/image-gen': typeof ImageGenRoute '/image-tool-repro': typeof ImageToolReproRoute + '/interrupts': typeof InterruptsRoute '/issue-176-tool-result': typeof Issue176ToolResultRoute '/mcp-apps': typeof McpAppsRoute '/mcp-demo': typeof McpDemoRoute @@ -371,6 +386,7 @@ export interface FileRoutesByTo { '/api/capability-demo': typeof ApiCapabilityDemoRoute '/api/image-gen': typeof ApiImageGenRoute '/api/image-tool-repro': typeof ApiImageToolReproRoute + '/api/interrupts': typeof ApiInterruptsRoute '/api/mcp-apps-call': typeof ApiMcpAppsCallRoute '/api/mcp-apps-chat': typeof ApiMcpAppsChatRoute '/api/mcp-apps-shop-server': typeof ApiMcpAppsShopServerRoute @@ -409,6 +425,7 @@ export interface FileRoutesById { '/generation-hooks': typeof GenerationHooksRoute '/image-gen': typeof ImageGenRoute '/image-tool-repro': typeof ImageToolReproRoute + '/interrupts': typeof InterruptsRoute '/issue-176-tool-result': typeof Issue176ToolResultRoute '/mcp-apps': typeof McpAppsRoute '/mcp-demo': typeof McpDemoRoute @@ -422,6 +439,7 @@ export interface FileRoutesById { '/api/capability-demo': typeof ApiCapabilityDemoRoute '/api/image-gen': typeof ApiImageGenRoute '/api/image-tool-repro': typeof ApiImageToolReproRoute + '/api/interrupts': typeof ApiInterruptsRoute '/api/mcp-apps-call': typeof ApiMcpAppsCallRoute '/api/mcp-apps-chat': typeof ApiMcpAppsChatRoute '/api/mcp-apps-shop-server': typeof ApiMcpAppsShopServerRoute @@ -461,6 +479,7 @@ export interface FileRouteTypes { | '/generation-hooks' | '/image-gen' | '/image-tool-repro' + | '/interrupts' | '/issue-176-tool-result' | '/mcp-apps' | '/mcp-demo' @@ -474,6 +493,7 @@ export interface FileRouteTypes { | '/api/capability-demo' | '/api/image-gen' | '/api/image-tool-repro' + | '/api/interrupts' | '/api/mcp-apps-call' | '/api/mcp-apps-chat' | '/api/mcp-apps-shop-server' @@ -511,6 +531,7 @@ export interface FileRouteTypes { | '/generation-hooks' | '/image-gen' | '/image-tool-repro' + | '/interrupts' | '/issue-176-tool-result' | '/mcp-apps' | '/mcp-demo' @@ -524,6 +545,7 @@ export interface FileRouteTypes { | '/api/capability-demo' | '/api/image-gen' | '/api/image-tool-repro' + | '/api/interrupts' | '/api/mcp-apps-call' | '/api/mcp-apps-chat' | '/api/mcp-apps-shop-server' @@ -561,6 +583,7 @@ export interface FileRouteTypes { | '/generation-hooks' | '/image-gen' | '/image-tool-repro' + | '/interrupts' | '/issue-176-tool-result' | '/mcp-apps' | '/mcp-demo' @@ -574,6 +597,7 @@ export interface FileRouteTypes { | '/api/capability-demo' | '/api/image-gen' | '/api/image-tool-repro' + | '/api/interrupts' | '/api/mcp-apps-call' | '/api/mcp-apps-chat' | '/api/mcp-apps-shop-server' @@ -612,6 +636,7 @@ export interface RootRouteChildren { GenerationHooksRoute: typeof GenerationHooksRoute ImageGenRoute: typeof ImageGenRoute ImageToolReproRoute: typeof ImageToolReproRoute + InterruptsRoute: typeof InterruptsRoute Issue176ToolResultRoute: typeof Issue176ToolResultRoute McpAppsRoute: typeof McpAppsRoute McpDemoRoute: typeof McpDemoRoute @@ -625,6 +650,7 @@ export interface RootRouteChildren { ApiCapabilityDemoRoute: typeof ApiCapabilityDemoRoute ApiImageGenRoute: typeof ApiImageGenRoute ApiImageToolReproRoute: typeof ApiImageToolReproRoute + ApiInterruptsRoute: typeof ApiInterruptsRoute ApiMcpAppsCallRoute: typeof ApiMcpAppsCallRoute ApiMcpAppsChatRoute: typeof ApiMcpAppsChatRoute ApiMcpAppsShopServerRoute: typeof ApiMcpAppsShopServerRoute @@ -729,6 +755,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof Issue176ToolResultRouteImport parentRoute: typeof rootRouteImport } + '/interrupts': { + id: '/interrupts' + path: '/interrupts' + fullPath: '/interrupts' + preLoaderRoute: typeof InterruptsRouteImport + parentRoute: typeof rootRouteImport + } '/image-tool-repro': { id: '/image-tool-repro' path: '/image-tool-repro' @@ -932,6 +965,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiMcpAppsCallRouteImport parentRoute: typeof rootRouteImport } + '/api/interrupts': { + id: '/api/interrupts' + path: '/api/interrupts' + fullPath: '/api/interrupts' + preLoaderRoute: typeof ApiInterruptsRouteImport + parentRoute: typeof rootRouteImport + } '/api/image-tool-repro': { id: '/api/image-tool-repro' path: '/api/image-tool-repro' @@ -1004,6 +1044,7 @@ const rootRouteChildren: RootRouteChildren = { GenerationHooksRoute: GenerationHooksRoute, ImageGenRoute: ImageGenRoute, ImageToolReproRoute: ImageToolReproRoute, + InterruptsRoute: InterruptsRoute, Issue176ToolResultRoute: Issue176ToolResultRoute, McpAppsRoute: McpAppsRoute, McpDemoRoute: McpDemoRoute, @@ -1017,6 +1058,7 @@ const rootRouteChildren: RootRouteChildren = { ApiCapabilityDemoRoute: ApiCapabilityDemoRoute, ApiImageGenRoute: ApiImageGenRoute, ApiImageToolReproRoute: ApiImageToolReproRoute, + ApiInterruptsRoute: ApiInterruptsRoute, ApiMcpAppsCallRoute: ApiMcpAppsCallRoute, ApiMcpAppsChatRoute: ApiMcpAppsChatRoute, ApiMcpAppsShopServerRoute: ApiMcpAppsShopServerRoute, diff --git a/examples/ts-react-chat/src/routes/api.interrupts.test.ts b/examples/ts-react-chat/src/routes/api.interrupts.test.ts new file mode 100644 index 000000000..f8b30d74a --- /dev/null +++ b/examples/ts-react-chat/src/routes/api.interrupts.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest' +import { EventType } from '@tanstack/ai' +import { + createFeedingMiddleware, + feedingInterruptId, + resolveToolChoice, +} from './api.interrupts' +import type { + ChatMiddlewareConfig, + ChatMiddlewareContext, + StreamChunk, +} from '@tanstack/ai' + +function makeCtx( + overrides: Partial, +): ChatMiddlewareContext { + return { + requestId: 'req', + streamId: 'stream', + runId: 'run-1', + threadId: 'thread-1', + phase: 'init', + iteration: 0, + chunkIndex: 0, + abort: () => {}, + defer: () => {}, + context: undefined, + ...overrides, + } as unknown as ChatMiddlewareContext +} + +describe('resolveToolChoice', () => { + // Regression: forcing a tool on the continuation made the model re-call the + // just-approved tool instead of answering, leaving an empty reply. + it('never forces a tool on a continuation (resume)', () => { + expect( + resolveToolChoice({ isResume: true, forceTool: 'admitRescue' }), + ).toBeUndefined() + expect(resolveToolChoice({ isResume: true, generic: true })).toBeUndefined() + }) + + it('forces the requested tool on the first turn', () => { + expect( + resolveToolChoice({ isResume: false, forceTool: 'admitRescue' }), + ).toEqual({ type: 'function', name: 'admitRescue' }) + }) + + it('forbids tools for the generic scenario', () => { + expect(resolveToolChoice({ isResume: false, generic: true })).toBe('none') + }) + + it('defaults to auto (undefined) with no hints', () => { + expect(resolveToolChoice({ isResume: false })).toBeUndefined() + }) +}) + +describe('feeding interrupt correlation', () => { + // Regression: the interrupt id was built from the provider chunk.runId + // (e.g. `openai-...`), but the client resumes with the request runId as + // parentRunId, so the ids never matched and resume failed as + // `unknown-interrupt`. It must key off ctx.runId. + it('keys the interrupt id off the request run id, not the provider chunk id', () => { + const middleware = createFeedingMiddleware() + const chunk = { + type: EventType.RUN_FINISHED, + runId: 'openai-provider-9', + threadId: 'thread-1', + timestamp: 0, + outcome: { type: 'success' }, + } as unknown as StreamChunk + + const result = middleware.onChunk?.(makeCtx({ runId: 'req-1' }), chunk) + const outcome = ( + result as { outcome?: { interrupts?: Array<{ id: string }> } } | undefined + )?.outcome + expect(outcome?.interrupts?.[0]?.id).toBe(feedingInterruptId('req-1')) + expect(outcome?.interrupts?.[0]?.id).not.toContain('openai-provider-9') + }) + + it('accepts a resume that references feeding_', () => { + const middleware = createFeedingMiddleware() + const config = { + messages: [{ role: 'user', content: 'set a feeding schedule' }], + resume: [ + { + interruptId: feedingInterruptId('req-1'), + status: 'resolved', + payload: { mealsPerDay: 2, diet: 'mice and berries' }, + }, + ], + } as unknown as ChatMiddlewareConfig + + const patch = middleware.onConfig?.( + makeCtx({ phase: 'init', parentRunId: 'req-1' }), + config, + ) + const messages = (patch as { messages?: Array } | undefined) + ?.messages + // original message plus the appended confirmation prompt + expect(messages?.length).toBe(2) + }) + + it('rejects a resume that references a different run id', () => { + const middleware = createFeedingMiddleware() + const config = { + messages: [], + resume: [ + { + interruptId: feedingInterruptId('openai-provider-9'), + status: 'resolved', + payload: { mealsPerDay: 2, diet: 'mice and berries' }, + }, + ], + } as unknown as ChatMiddlewareConfig + + expect(() => + middleware.onConfig?.( + makeCtx({ phase: 'init', parentRunId: 'req-1' }), + config, + ), + ).toThrow(/must resolve only/) + }) +}) diff --git a/examples/ts-react-chat/src/routes/api.interrupts.ts b/examples/ts-react-chat/src/routes/api.interrupts.ts new file mode 100644 index 000000000..a0ce0d69e --- /dev/null +++ b/examples/ts-react-chat/src/routes/api.interrupts.ts @@ -0,0 +1,237 @@ +import { createFileRoute } from '@tanstack/react-router' +import { + EventType, + canonicalInterruptJson, + chat, + chatParamsFromRequestBody, + digestInterruptJson, + maxIterations, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { createOpenaiChat } from '@tanstack/ai-openai' +import { z } from 'zod' +import { + admitRescue, + assignEnclosure, + feedingScheduleResponseSchema, + finalizeAdoption, + logFieldSighting, + printCertificate, + printIntakeTag, + scheduleVetCheck, + shareAdoptionStory, +} from '@/lib/interrupt-tools' +import type { ChatMiddleware } from '@tanstack/ai' + +const SYSTEM_PROMPT = + 'You are the coordinator at Willowbrook Wildlife Sanctuary. When the keeper ' + + 'asks for an action, call exactly the tool that matches it with sensible ' + + 'arguments drawn from their message. When you confirm a completed action, ' + + 'describe it using the values in the tool result (which may differ from the ' + + 'original request if the keeper edited them). Keep spoken replies short.' + +// Server tools run on the server once approved. The client tools are passed as +// bare definitions so the server pauses on them (client-tool-execution) and the +// browser runs them after approval. +const tools = [ + admitRescue.server(async ({ name }) => ({ + intakeId: `intake_${name.toLowerCase()}`, + status: 'admitted to intake ward', + })), + scheduleVetCheck.server(async ({ animal, urgency }) => ({ + visitId: `visit_${animal.toLowerCase()}_${urgency}`, + })), + finalizeAdoption.server(async ({ animal, adopter }) => ({ + certificateId: `cert_${animal.toLowerCase()}_${adopter.split(' ')[0]?.toLowerCase() ?? 'home'}`, + })), + assignEnclosure.server(async ({ animal, enclosure, sizeSqm }) => ({ + assignmentId: `${enclosure.toLowerCase()}_${animal.toLowerCase()}`, + enclosure, + sizeSqm, + })), + printIntakeTag, + logFieldSighting, + shareAdoptionStory, + printCertificate, +] + +export const feedingInterruptId = (runId: string): string => `feeding_${runId}` + +/** + * Decide the provider tool_choice for a run. Only the FIRST turn is shaped: + * a button may force one tool, or the generic scenario forbids tools ('none'). + * A continuation (resume) never forces a choice, otherwise the model would + * re-call the approved tool instead of answering. + */ +export function resolveToolChoice(input: { + isResume: boolean + generic?: boolean + forceTool?: string +}): 'none' | { type: 'function'; name: string } | undefined { + if (input.isResume) return undefined + if (input.generic) return 'none' + if (typeof input.forceTool === 'string') { + return { type: 'function', name: input.forceTool } + } + return undefined +} + +/** + * Emits the generic "feeding schedule" interrupt: a non-tool application pause. + * The plain success terminal of the run is replaced with an interrupt outcome + * carrying a wire responseSchema, and on the continuation the keeper's answer is + * validated here (the library does not validate generic values) and appended. + */ +export function createFeedingMiddleware(): ChatMiddleware { + const responseSchemaHash = digestInterruptJson( + canonicalInterruptJson(feedingScheduleResponseSchema), + ) + const validate = z.fromJSONSchema( + feedingScheduleResponseSchema as unknown as Parameters< + typeof z.fromJSONSchema + >[0], + ) + let isContinuation = false + + return { + name: 'sanctuary-feeding-schedule', + onConfig(ctx, config) { + if (ctx.phase !== 'init' || (config.resume?.length ?? 0) === 0) return + isContinuation = true + const interruptedRunId = ctx.parentRunId + if (!interruptedRunId) { + throw new Error('Feeding continuation requires parentRunId.') + } + const expectedId = feedingInterruptId(interruptedRunId) + const resolution = config.resume?.[0] + if ( + config.resume?.length !== 1 || + resolution?.interruptId !== expectedId + ) { + throw new Error(`Feeding continuation must resolve only ${expectedId}.`) + } + + let prompt: string + if (resolution.status === 'cancelled') { + prompt = + 'The keeper cancelled the feeding schedule. Acknowledge briefly.' + } else { + const parsed = validate.safeParse(resolution.payload) + if (!parsed.success) { + throw new Error( + `Invalid feeding schedule: ${parsed.error.issues + .map((issue) => issue.message) + .join('; ')}`, + ) + } + prompt = `The keeper set this feeding schedule: ${JSON.stringify(parsed.data)}. Confirm it briefly.` + } + + return { + messages: [...config.messages, { role: 'user', content: prompt }], + resume: undefined, + } + }, + onChunk(ctx, chunk) { + if ( + isContinuation || + chunk.type !== EventType.RUN_FINISHED || + (chunk.outcome !== undefined && chunk.outcome.type !== 'success') + ) { + return + } + // Correlate on the client's request run id (`ctx.runId`), which is what + // the client sends back as `parentRunId` on the continuation. A provider + // may stamp `chunk.runId` with its own id (e.g. `openai-…`) that differs. + const interruptedRunId = ctx.runId + const interruptId = feedingInterruptId(interruptedRunId) + return { + ...chunk, + outcome: { + type: 'interrupt', + interrupts: [ + { + id: interruptId, + reason: 'sanctuary:feeding_schedule', + message: 'Choose a feeding schedule for this animal.', + responseSchema: feedingScheduleResponseSchema, + metadata: { + kind: 'generic', + 'tanstack:interruptBinding': { + kind: 'generic', + interruptId, + interruptedRunId, + generation: 0, + responseSchemaHash, + }, + }, + }, + ], + }, + } + }, + } +} + +async function handle(request: Request): Promise { + const apiKey = process.env.OPENAI_API_KEY + if (!apiKey) { + return new Response( + JSON.stringify({ + error: 'Set OPENAI_API_KEY in examples/ts-react-chat/.env', + }), + { status: 500, headers: { 'content-type': 'application/json' } }, + ) + } + + let params + try { + params = await chatParamsFromRequestBody(await request.json()) + } catch (error) { + return new Response( + error instanceof Error ? error.message : 'Bad request', + { + status: 400, + }, + ) + } + + const forwarded = params.forwardedProps as { + forceTool?: string + generic?: boolean + } + const abortController = new AbortController() + + const toolChoice = resolveToolChoice({ + isResume: (params.resume?.length ?? 0) > 0, + generic: forwarded.generic, + forceTool: forwarded.forceTool, + }) + + const stream = chat({ + adapter: createOpenaiChat('gpt-5.5', apiKey), + messages: params.messages, + tools, + systemPrompts: [SYSTEM_PROMPT], + agentLoopStrategy: maxIterations(8), + threadId: params.threadId, + runId: params.runId, + ...(params.parentRunId ? { parentRunId: params.parentRunId } : {}), + ...(params.resume ? { resume: params.resume } : {}), + ...(forwarded.generic ? { middleware: [createFeedingMiddleware()] } : {}), + ...(toolChoice !== undefined + ? { modelOptions: { tool_choice: toolChoice } } + : {}), + abortController, + }) + + return toServerSentEventsResponse(stream) +} + +export const Route = createFileRoute('/api/interrupts')({ + server: { + handlers: { + POST: ({ request }) => handle(request), + }, + }, +}) diff --git a/examples/ts-react-chat/src/routes/index.tsx b/examples/ts-react-chat/src/routes/index.tsx index b3228dd67..417512554 100644 --- a/examples/ts-react-chat/src/routes/index.tsx +++ b/examples/ts-react-chat/src/routes/index.tsx @@ -10,6 +10,7 @@ import { ImagePlus, Mic, Music, + PauseCircle, Send, Square, Video, @@ -28,6 +29,7 @@ import { } from '@tanstack/ai-react' import { clientTools } from '@tanstack/ai-client' import { ThinkingPart } from '@tanstack/ai-react-ui' +import type { BoundInterrupts } from '@tanstack/ai-client' import type { UIMessage } from '@tanstack/ai-react' import type { ContentPart } from '@tanstack/ai' import type { GeminiInteractionsCustomEventValue } from '@tanstack/ai-gemini/experimental' @@ -81,6 +83,8 @@ const tools = clientTools( recommendGuitarToolClient, ) +type ChatTools = typeof tools + function ChatInputArea({ children }: { children: React.ReactNode }) { return (
@@ -91,13 +95,10 @@ function ChatInputArea({ children }: { children: React.ReactNode }) { function Messages({ messages, - addToolApprovalResponse, + interrupts, }: { messages: Array - addToolApprovalResponse: (response: { - id: string - approved: boolean - }) => Promise + interrupts: BoundInterrupts }) { const messagesContainerRef = useRef(null) const hasRenderablePart = (message: UIMessage): boolean => { @@ -105,13 +106,6 @@ function Messages({ if (part.type === 'thinking') return true if (part.type === 'image') return true if (part.type === 'text' && part.content.trim()) return true - if ( - part.type === 'tool-call' && - part.state === 'approval-requested' && - part.approval - ) { - return true - } if ( part.type === 'tool-call' && part.name === 'recommendGuitar' && @@ -129,9 +123,9 @@ function Messages({ messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight } - }, [visibleMessages]) + }, [visibleMessages, interrupts.length]) - if (!visibleMessages.length) { + if (!visibleMessages.length && interrupts.length === 0) { return (
@@ -207,6 +201,13 @@ function Messages({ Type-Safe Tools + + + Interrupts Lab + + {interrupts.map((interrupt) => { + if (interrupt.kind !== 'tool-approval') return null + return ( +
+

+ Approval required: {interrupt.toolName} +

+
+
+                {JSON.stringify(interrupt.originalArgs, null, 2)}
+              
+
+
+ + + +
+
+ ) + })} {visibleMessages.map((message) => { return (
-

- 🔒 Approval Required: {part.name} -

-
-
-                            {JSON.stringify(
-                              JSON.parse(part.arguments),
-                              null,
-                              2,
-                            )}
-                          
-
-
- - -
-
- ) + return null } // Guitar recommendation card @@ -419,7 +419,7 @@ function ChatPage() { sendMessage, isLoading, error, - addToolApprovalResponse, + interrupts, stop, } = useChat({ connection: fetchServerSentEvents('/api/tanchat'), @@ -619,6 +619,13 @@ function ChatPage() { ))}
+ + + Interrupts Lab +
- + {error && (
diff --git a/examples/ts-react-chat/src/routes/interrupts.tsx b/examples/ts-react-chat/src/routes/interrupts.tsx new file mode 100644 index 000000000..ecb18003a --- /dev/null +++ b/examples/ts-react-chat/src/routes/interrupts.tsx @@ -0,0 +1,824 @@ +import { useEffect, useState } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import type { ReactNode } from 'react' +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { + Check, + Leaf, + PawPrint, + RadioTower, + RotateCcw, + Sparkles, + Trash2, + X, +} from 'lucide-react' +import { + admitRescue, + assignEnclosure, + finalizeAdoption, + logFieldSighting, + printCertificate, + printIntakeTag, + scenarios, + scheduleVetCheck, + shareAdoptionStory, +} from '@/lib/interrupt-tools' +import type { Scenario, ScenarioGroup } from '@/lib/interrupt-tools' +import type { ChatInterrupt, UnboundInterrupt } from '@tanstack/ai-client' + +export const Route = createFileRoute('/interrupts')({ + component: SanctuaryPage, +}) + +// Client tools: server tools get an argless `.client()` so the browser knows +// their schemas to render approvals; the four client tools get a real browser +// implementation that runs after approval. +const clientTools = [ + admitRescue.client(), + scheduleVetCheck.client(), + finalizeAdoption.client(), + assignEnclosure.client(), + printIntakeTag.client(async ({ animal }) => ({ tag: `TAG-${animal}` })), + logFieldSighting.client(async ({ species, location }) => ({ + sightingId: `${species}-${location}`.toLowerCase().replace(/\s+/g, '-'), + })), + shareAdoptionStory.client(async ({ animal }) => ({ + url: `https://willowbrook.example/stories/${animal.toLowerCase()}`, + })), + printCertificate.client(async ({ animal, adopter }) => ({ + certificate: `${adopter} adopted ${animal}`, + })), +] as const + +type Interrupt = ChatInterrupt +type ResolveMode = 'each' | 'all' + +const connection = fetchServerSentEvents('/api/interrupts') + +const groupMeta: Record = { + server: { label: 'Server actions', icon: Leaf }, + client: { label: 'On this device', icon: RadioTower }, + generic: { label: 'Ask the keeper', icon: Sparkles }, + batch: { label: 'Whole intake', icon: PawPrint }, +} + +function SanctuaryPage() { + const [threadId] = useState(() => crypto.randomUUID()) + const [active, setActive] = useState(null) + const [pending, setPending] = useState(null) + const [resolveMode, setResolveMode] = useState('each') + // What you submitted for each decision, so payloads/edits are visible even + // when the tool never receives them (an approve payload is decision metadata, + // not tool input). + const [decisions, setDecisions] = useState>([]) + const record = (message: string) => + setDecisions((prev) => [message, ...prev].slice(0, 8)) + + const chat = useChat({ + id: threadId, + threadId, + connection, + tools: clientTools, + forwardedProps: { + ...(active?.forceTool ? { forceTool: active.forceTool } : {}), + ...(active?.generic ? { generic: true } : {}), + }, + }) + + // Send after the forwardedProps effect has pushed the active scenario to the + // client, so each button carries its own forceTool / generic flag. + useEffect(() => { + if (pending === null) return + void chat.sendMessage(pending) + setPending(null) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pending]) + + const runScenario = (scenario: Scenario) => { + if (chat.messages.length > 0) chat.clear() + setActive(scenario) + setPending(scenario.message) + } + + const interrupts = chat.interrupts + const grouped = (['server', 'client', 'generic', 'batch'] as const).map( + (group) => ({ + group, + items: scenarios.filter((scenario) => scenario.group === group), + }), + ) + + return ( +
+
+ + +
+
+
+ Resolve pending decisions: +
+
+ {(['each', 'all'] as const).map((mode) => ( + + ))} +
+
+ + + + {chat.interruptErrors.length > 0 ? ( +
+ {chat.interruptErrors.map((error) => ( +
{error.message}
+ ))} + +
+ ) : null} + + {interrupts.length > 0 ? ( +
+ {resolveMode === 'all' ? ( +
+ + {interrupts.length} pending. Resolve the whole batch: + + + +
+ ) : null} + +
+ {interrupts.map((interrupt) => ( + + ))} +
+
+ ) : null} + + {decisions.length > 0 ? ( +
+

+ Your decisions +

+
    + {decisions.map((decision, i) => ( +
  • {decision}
  • + ))} +
+
+ ) : null} +
+
+
+ ) +} + +function Transcript({ + chat, +}: { + chat: ReturnType> +}) { + if (chat.messages.length === 0) { + return ( +
+ Pick an action on the left to start. +
+ ) + } + return ( +
+ {chat.messages.map((message) => ( +
+ + {message.role}:{' '} + + {message.parts.map((part, i) => { + if (part.type === 'text') return {part.content} + if (part.type === 'tool-call') { + return ( +
+ 🔧 {part.name}({JSON.stringify(part.input ?? part.arguments)}) + {part.output !== undefined + ? ` → ${JSON.stringify(part.output)}` + : ` · ${part.state}`} +
+ ) + } + if (part.type === 'tool-result') { + const body = + typeof part.content === 'string' + ? part.content + : JSON.stringify(part.content) + return ( +
+ ↳ {part.error ?? body} +
+ ) + } + return null + })} +
+ ))} +
+ ) +} + +function InterruptCard({ + interrupt, + disabled, + record, +}: { + interrupt: Interrupt + disabled: boolean + record: (message: string) => void +}) { + if (interrupt.kind === 'generic') { + return ( + + ) + } + // Not ours to resolve — something else on the stream owns this pause, so + // there is nothing to approve or submit here. + if (interrupt.kind === 'unbound') { + return + } + return ( + + ) +} + +function UnboundCard({ interrupt }: { interrupt: UnboundInterrupt }) { + return ( +
+

Paused elsewhere

+

+ {interrupt.message ?? interrupt.reason} +

+

+ This interrupt carries no resume binding for this chat, so it can't be + answered here. +

+
+ ) +} + +// Pick an animal from whatever the tool call is about, for a photo + an emoji +// fallback if the photo can't load. +const ANIMALS: Array<[test: RegExp, keyword: string, emoji: string]> = [ + [/fox/, 'red-fox', '🦊'], + [/owl/, 'barn-owl', '🦉'], + [/hedgehog/, 'hedgehog', '🦔'], + [/deer|fawn/, 'deer', '🦌'], + [/rabbit|bunny|hare/, 'rabbit', '🐰'], + [/badger/, 'badger', '🦡'], + [/turtle|tortoise/, 'turtle', '🐢'], + [/otter/, 'otter', '🦦'], + [/bird|sparrow|robin/, 'bird', '🐦'], +] + +function animalOf(interrupt: Interrupt): { keyword: string; emoji: string } { + if (interrupt.kind === 'generic' || interrupt.kind === 'unbound') { + return { keyword: 'wildlife', emoji: '🍽️' } + } + const hay = JSON.stringify(interrupt.originalArgs).toLowerCase() + for (const [test, keyword, emoji] of ANIMALS) { + if (test.test(hay)) return { keyword, emoji } + } + return { keyword: 'wildlife-rescue', emoji: '🐾' } +} + +// A real photo of the animal (loremflickr, keyed by species), with the emoji as +// an offline/failure fallback so the card always shows something. +function AnimalAvatar({ interrupt }: { interrupt: Interrupt }) { + const { keyword, emoji } = animalOf(interrupt) + const [failed, setFailed] = useState(false) + if (failed) { + return ( + + {emoji} + + ) + } + return ( + {keyword.replace(/-/g, setFailed(true)} + className="h-12 w-12 shrink-0 rounded-full object-cover ring-2 ring-gray-600" + /> + ) +} + +function Shell({ + title, + subtitle, + children, + interrupt, +}: { + title: string + subtitle?: string + children: ReactNode + interrupt: Interrupt +}) { + return ( +
+
+ +
+

{title}

+ {subtitle ? ( +

{subtitle}

+ ) : null} +
+
+ {children} + {interrupt.errors.map((error) => ( +

+ {error.message} +

+ ))} +
+ ) +} + +function ApproveRejectRow({ + onApprove, + onReject, + onCancel, + disabled, + approveLabel = 'Approve', +}: { + onApprove: () => void + onReject: () => void + onCancel: () => void + disabled: boolean + approveLabel?: string +}) { + return ( +
+ + + +
+ ) +} + +const textInput = + 'w-full rounded-md border border-gray-700 bg-gray-800 px-2 py-1 text-sm text-white placeholder-gray-500 focus:border-orange-500/50 focus:outline-none' + +function ApprovalCard({ + interrupt, + disabled, + record, +}: { + interrupt: Extract + disabled: boolean + record: (message: string) => void +}) { + const [note, setNote] = useState('') + const [reason, setReason] = useState('Not this time') + const [adopterName, setAdopterName] = useState('') + const [homeCheck, setHomeCheck] = useState(true) + const [channel, setChannel] = useState<'instagram' | 'newsletter'>( + 'instagram', + ) + const [enclosure, setEnclosure] = useState('') + const [sizeSqm, setSizeSqm] = useState('') + const [adopter, setAdopter] = useState('') + const [certDate, setCertDate] = useState('') + + const args = JSON.stringify(interrupt.originalArgs) + // Log exactly what was submitted, so payloads/edits are visible (an approve + // payload never reaches the tool, so this is the only place it shows). + const decided = (verb: string, detail?: unknown) => + record( + `${verb} ${interrupt.toolName}${ + detail === undefined ? '' : ` · ${JSON.stringify(detail)}` + }`, + ) + const cancel = () => { + decided('✋ cancelled') + interrupt.cancel() + } + + // Note: each tool gets its own `case` (no shared fall-through). Sharing a + // block would leave `interrupt` a union of tools, and calling + // `resolveInterrupt` on that union collapses the parameter to `never`. + switch (interrupt.toolName) { + case 'admitRescue': + return ( + + { + decided('✅ approved') + interrupt.resolveInterrupt(true) + }} + onReject={() => { + decided('❌ rejected') + interrupt.resolveInterrupt(false) + }} + onCancel={cancel} + /> + + ) + + case 'printIntakeTag': + return ( + + { + decided('✅ approved') + interrupt.resolveInterrupt(true) + }} + onReject={() => { + decided('❌ rejected') + interrupt.resolveInterrupt(false) + }} + onCancel={cancel} + /> + + ) + + case 'scheduleVetCheck': + return ( + + setNote(event.target.value)} + /> + { + decided('✅ approved', { note }) + interrupt.resolveInterrupt(true, { payload: { note } }) + }} + onReject={() => { + decided('❌ rejected', { note }) + interrupt.resolveInterrupt(false, { payload: { note } }) + }} + onCancel={cancel} + /> + + ) + + case 'logFieldSighting': + return ( + + setNote(event.target.value)} + /> + { + decided('✅ approved', { note }) + interrupt.resolveInterrupt(true, { payload: { note } }) + }} + onReject={() => { + decided('❌ rejected', { note }) + interrupt.resolveInterrupt(false, { payload: { note } }) + }} + onCancel={cancel} + /> + + ) + + case 'finalizeAdoption': + return ( + + setAdopterName(event.target.value)} + /> + + setReason(event.target.value)} + /> + { + decided('✅ approved', { + adopterName, + homeCheckPassed: homeCheck, + }) + interrupt.resolveInterrupt(true, { + payload: { adopterName, homeCheckPassed: homeCheck }, + }) + }} + onReject={() => { + decided('❌ rejected', { reason }) + interrupt.resolveInterrupt(false, { payload: { reason } }) + }} + onCancel={cancel} + /> + + ) + + case 'shareAdoptionStory': + return ( + + + setReason(event.target.value)} + /> + { + decided('✅ approved', { channel }) + interrupt.resolveInterrupt(true, { payload: { channel } }) + }} + onReject={() => { + decided('❌ rejected', { reason }) + interrupt.resolveInterrupt(false, { payload: { reason } }) + }} + onCancel={cancel} + /> + + ) + + case 'assignEnclosure': + return ( + + setEnclosure(event.target.value)} + /> + setSizeSqm(event.target.value)} + /> + { + const editedArgs = { + animal: interrupt.originalArgs.animal, + enclosure: enclosure || interrupt.originalArgs.enclosure, + sizeSqm: sizeSqm + ? Number(sizeSqm) + : interrupt.originalArgs.sizeSqm, + } + decided('✅ approved (edited)', editedArgs) + interrupt.resolveInterrupt(true, { editedArgs }) + }} + onReject={() => { + decided('❌ rejected') + interrupt.resolveInterrupt(false) + }} + onCancel={cancel} + /> + + ) + + case 'printCertificate': + return ( + + setAdopter(event.target.value)} + /> + setCertDate(event.target.value)} + /> + { + const editedArgs = { + animal: interrupt.originalArgs.animal, + adopter: adopter || interrupt.originalArgs.adopter, + date: certDate || interrupt.originalArgs.date, + } + decided('✅ approved (edited)', editedArgs) + interrupt.resolveInterrupt(true, { editedArgs }) + }} + onReject={() => { + decided('❌ rejected') + interrupt.resolveInterrupt(false) + }} + onCancel={cancel} + /> + + ) + + default: + return null + } +} + +function GenericCard({ + interrupt, + disabled, + record, +}: { + interrupt: Extract + disabled: boolean + record: (message: string) => void +}) { + const [meals, setMeals] = useState('2') + const [diet, setDiet] = useState('') + + return ( + + setMeals(event.target.value)} + /> + setDiet(event.target.value)} + /> +
+ + +
+
+ ) +} diff --git a/knip.json b/knip.json index 032c3911e..015ff8f09 100644 --- a/knip.json +++ b/knip.json @@ -41,9 +41,6 @@ "packages/ai-openai": { "ignore": ["src/tools/**"] }, - "packages/ai-client": { - "ignoreDependencies": ["@standard-schema/spec"] - }, "packages/ai-sandbox": { "ignoreDependencies": ["@ngrok/ngrok"] }, diff --git a/packages/ai-angular/package.json b/packages/ai-angular/package.json index 7b87c189d..0cc622277 100644 --- a/packages/ai-angular/package.json +++ b/packages/ai-angular/package.json @@ -50,7 +50,7 @@ "test:lib:dev": "vitest", "test:types": "tsc", "test:build": "publint --strict", - "build": "ng-packagr -p ng-package.json -c tsconfig.build.json && rm -rf ./dist/package.json" + "build": "ng-packagr -p ng-package.json -c tsconfig.build.json && premove ./dist/package.json" }, "dependencies": { "@tanstack/ai-client": "workspace:*", diff --git a/packages/ai-angular/src/inject-chat.ts b/packages/ai-angular/src/inject-chat.ts index bdb7c957d..abce97690 100644 --- a/packages/ai-angular/src/inject-chat.ts +++ b/packages/ai-angular/src/inject-chat.ts @@ -15,11 +15,15 @@ import type { AnyClientTool, InferSchemaType, ModelMessage, + RunAgentResumeItem, SchemaInput, StreamChunk, } from '@tanstack/ai' import type { ChatClientState, + ChatInterrupt, + ChatInterruptState, + ChatResumeState, ConnectionStatus, InferredClientContext, QueuedMessage, @@ -34,6 +38,9 @@ import type { UIMessage, } from './types' +const EMPTY_INTERRUPTS = Object.freeze([]) +const EMPTY_INTERRUPT_ERRORS = Object.freeze([]) + let nextId = 0 export function injectChat< @@ -66,6 +73,12 @@ export function injectChat< const connectionStatus = signal('disconnected') const sessionGenerating = signal(false) const queue = signal>([]) + const interruptState = signal>({ + interrupts: EMPTY_INTERRUPTS, + pendingInterrupts: EMPTY_INTERRUPTS, + interruptErrors: EMPTY_INTERRUPT_ERRORS, + resuming: false, + }) // Reactive option sources. Plain values become constant computeds. const bodySource = @@ -93,6 +106,9 @@ export function injectChat< ...(options.persistence !== undefined && { persistence: options.persistence, }), + ...(options.initialResumeSnapshot !== undefined && { + initialResumeSnapshot: options.initialResumeSnapshot, + }), ...(bodySource !== undefined && { body: bodySource() }), ...(options.threadId !== undefined && { threadId: options.threadId }), ...(forwardedPropsSource !== undefined && { @@ -109,6 +125,13 @@ export function injectChat< onChunk: (chunk: StreamChunk) => options.onChunk?.(chunk), onFinish: (message) => options.onFinish?.(message), onError: (err) => options.onError?.(err), + onResumeStateChange: (resumeState, pendingInterrupts) => { + options.onResumeStateChange?.(resumeState, pendingInterrupts) + }, + onInterruptStateChange: (nextInterruptState) => { + interruptState.set(nextInterruptState) + options.onInterruptStateChange?.(nextInterruptState) + }, tools: options.tools, onCustomEvent: (eventType, data, context) => options.onCustomEvent?.(eventType, data, context), @@ -127,6 +150,7 @@ export function injectChat< }) messages.set(client.getMessages()) + interruptState.set(client.getInterruptState()) // Sync reactive body / forwardedProps / context to the client. if (bodySource || forwardedPropsSource || contextSource) { @@ -161,7 +185,15 @@ export function injectChat< ) } - afterNextRender(() => client.mountDevtools(), { injector }) + afterNextRender( + () => { + client.mountDevtools() + // Delivery-durability resume is transparent: the resumable SSE + // connection adapter reattaches via the browser's native Last-Event-ID + // on reconnect. No client-side auto-resume wiring is needed. + }, + { injector }, + ) destroyRef.onDestroy(() => { if (liveSource?.()) { @@ -240,6 +272,25 @@ export function injectChat< }) => { await client.addToolApprovalResponse(response) } + const interrupts = computed(() => interruptState().interrupts) + const pendingInterrupts = computed(() => interruptState().interrupts) + const interruptErrors = computed(() => interruptState().interruptErrors) + const resuming = computed(() => interruptState().resuming) + const resolveInterrupts = ( + resolution: boolean | ((interrupt: ChatInterrupt) => undefined), + ) => { + if (typeof resolution === 'boolean') { + client.resolveInterrupts(resolution) + } else { + client.resolveInterrupts(resolution) + } + } + const cancelInterrupts = () => client.cancelInterrupts() + const retryInterrupts = () => client.retryInterrupts() + const resumeInterruptsUnsafe = ( + resumeItems: Array, + state?: ChatResumeState, + ) => client.resumeInterruptsUnsafe(resumeItems, state) // oxlint-disable-next-line eslint-js/no-restricted-syntax -- return shape diverges from conditional InjectChatResult; TS can't structurally narrow the TSchema-gated partial/final signals return { @@ -260,6 +311,14 @@ export function injectChat< clear, addToolResult, addToolApprovalResponse, + interrupts, + pendingInterrupts, + interruptErrors, + resuming, + resolveInterrupts, + cancelInterrupts, + retryInterrupts, + resumeInterruptsUnsafe, partial, final, } as unknown as InjectChatResult diff --git a/packages/ai-angular/src/types.ts b/packages/ai-angular/src/types.ts index 6e45b4f8d..927841f81 100644 --- a/packages/ai-angular/src/types.ts +++ b/packages/ai-angular/src/types.ts @@ -2,13 +2,18 @@ import type { AnyClientTool, InferSchemaType, ModelMessage, + RunAgentResumeItem, SchemaInput, } from '@tanstack/ai' import type { AIDevtoolsDisplayOptions, + BoundInterrupts, ChatClientOptions, ChatClientState, + ChatInterrupt, + ChatInterruptState, ChatRequestBody, + ChatResumeState, ClientContextOptionFromTools, ConnectionStatus, DistributedOmit, @@ -142,6 +147,24 @@ interface BaseInjectChatResult< id: string approved: boolean }) => Promise + /** Immutable bound interrupts for the current interrupted run. */ + interrupts: Signal> + /** @deprecated Use `interrupts`. */ + pendingInterrupts: Signal> + /** Batch-level interrupt errors. */ + interruptErrors: Signal['interruptErrors']> + /** Whether the client is submitting an interrupt batch. */ + resuming: Signal + resolveInterrupts: { + (approved: boolean): void + (resolver: (interrupt: ChatInterrupt) => undefined): void + } + cancelInterrupts: () => void + retryInterrupts: () => void + resumeInterruptsUnsafe: ( + resume: Array, + state?: ChatResumeState, + ) => Promise /** Reload the last assistant message. */ reload: () => Promise /** Stop the current response generation. */ diff --git a/packages/ai-angular/tests/inject-chat-types.test.ts b/packages/ai-angular/tests/inject-chat-types.test.ts index d56de87a9..7b46167f8 100644 --- a/packages/ai-angular/tests/inject-chat-types.test.ts +++ b/packages/ai-angular/tests/inject-chat-types.test.ts @@ -6,9 +6,11 @@ import { describe, expectTypeOf, it } from 'vitest' import { z } from 'zod' -import type { Signal } from '@angular/core' +import { toolDefinition } from '@tanstack/ai' +import { clientTools } from '@tanstack/ai-client' import type { AnyClientTool } from '@tanstack/ai' -import { injectChat } from '../src/inject-chat' +import type { injectChat } from '../src/inject-chat' +import type { Signal } from '@angular/core' import type { DeepPartial, InjectChatResult } from '../src/types' type Person = { name: string; age: number; email: string } @@ -75,3 +77,117 @@ describe('injectChat() return type (angular)', () => { }) }) }) + +describe('injectChat() interrupt types', () => { + it('preserves approval, generic, and client-tool inference', () => { + const inputSchema = { + '~standard': { + version: 1 as const, + vendor: 'test', + types: { + input: { cents: 0 }, + output: { cents: 0 }, + }, + validate: (value: unknown) => ({ + value: + value !== null && + typeof value === 'object' && + 'cents' in value && + typeof value.cents === 'number' + ? { cents: value.cents } + : { cents: 0 }, + }), + }, + } + const approveSchema = { + '~standard': { + version: 1 as const, + vendor: 'test', + types: { + input: { note: '' }, + output: { note: '' }, + }, + validate: () => ({ value: { note: '' } }), + }, + } + const rejectSchema = { + '~standard': { + version: 1 as const, + vendor: 'test', + types: { + input: { reason: '' }, + output: { reason: '' }, + }, + validate: () => ({ value: { reason: '' } }), + }, + } + const outputSchema = z.object({ accountId: z.string() }) + const transfer = toolDefinition({ + name: 'transfer', + description: 'Transfer funds', + needsApproval: true, + inputSchema, + approvalSchema: { + approve: approveSchema, + reject: rejectSchema, + }, + }).client() + const confirm = toolDefinition({ + name: 'confirm', + description: 'Confirm without schemas', + needsApproval: true, + }).client() + const lookup = toolDefinition({ + name: 'lookup', + description: 'Lookup account', + outputSchema, + }).client(() => ({ accountId: 'account-1' })) + const tools = clientTools(transfer, confirm, lookup) + type Interrupt = ReturnType< + InjectChatResult['interrupts'] + >[number] + type Transfer = Extract< + Interrupt, + { kind: 'tool-approval'; toolName: 'transfer' } + > + type Confirm = Extract< + Interrupt, + { kind: 'tool-approval'; toolName: 'confirm' } + > + type Generic = Extract + + const check = ( + transferInterrupt: Transfer, + confirmInterrupt: Confirm, + genericInterrupt: Generic, + ) => { + transferInterrupt.resolveInterrupt(true, { + editedArgs: { cents: 100 }, + payload: { note: 'approved' }, + }) + transferInterrupt.resolveInterrupt(false, { + payload: { reason: 'declined' }, + }) + // @ts-expect-error rejected approvals cannot edit tool input + transferInterrupt.resolveInterrupt(false, { editedArgs: { cents: 1 } }) + transferInterrupt.resolveInterrupt(true, { + // @ts-expect-error approve payload uses the approve branch + payload: { reason: 'wrong branch' }, + }) + + confirmInterrupt.resolveInterrupt(true) + confirmInterrupt.resolveInterrupt(false) + // @ts-expect-error omitted input schema forbids edited input + confirmInterrupt.resolveInterrupt(true, { editedArgs: { cents: 1 } }) + // @ts-expect-error omitted approval branches forbid payloads + confirmInterrupt.resolveInterrupt(false, { + payload: { reason: 'no branch' }, + }) + + expectTypeOf(genericInterrupt.resolveInterrupt) + .parameter(0) + .toEqualTypeOf() + } + void check + }) +}) diff --git a/packages/ai-angular/tests/inject-chat.test.ts b/packages/ai-angular/tests/inject-chat.test.ts index b7d0bf710..b6e044591 100644 --- a/packages/ai-angular/tests/inject-chat.test.ts +++ b/packages/ai-angular/tests/inject-chat.test.ts @@ -1,10 +1,12 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { z } from 'zod' +import { EventType } from '@tanstack/ai/client' import { Component, signal } from '@angular/core' import { TestBed } from '@angular/core/testing' import { ChatClient } from '@tanstack/ai-client' import { injectChat } from '../src/inject-chat' import { + createInterruptResumeSnapshot, createMockConnectionAdapter, createTextChunks, renderInjectChat, @@ -12,7 +14,85 @@ import { const tick = () => new Promise((r) => setTimeout(r, 0)) +afterEach(() => { + vi.restoreAllMocks() +}) + describe('injectChat', () => { + describe('interrupt state', () => { + it('projects one immutable reactive snapshot with the deprecated pending alias', () => { + const onInterruptStateChange = vi.fn() + const { result, flush } = renderInjectChat({ + connection: createMockConnectionAdapter(), + initialResumeSnapshot: createInterruptResumeSnapshot(), + onInterruptStateChange, + }) + + expect(Object.isFrozen(result.interrupts())).toBe(true) + expect(result.pendingInterrupts()).toBe(result.interrupts()) + expect(result.interrupts()[0]).toMatchObject({ + id: 'staged-interrupt', + status: 'pending', + }) + expect(result.interrupts()[1]).toMatchObject({ + id: 'invalid-interrupt', + status: 'pending', + }) + expect(result.interruptErrors()).toEqual([]) + expect(result.resuming()).toBe(false) + expect(result.interrupts()[0]).toEqual( + expect.objectContaining({ + resolveInterrupt: expect.any(Function), + cancel: expect.any(Function), + clearResolution: expect.any(Function), + }), + ) + + result.resolveInterrupts(false) + TestBed.flushEffects() + flush() + expect(result.interruptErrors()[0]?.code).toBe( + 'unsupported-bulk-operation', + ) + expect(onInterruptStateChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + interrupts: result.interrupts(), + interruptErrors: result.interruptErrors(), + }), + ) + }) + + it('delegates every root interrupt control to ChatClient', async () => { + const resolve = vi + .spyOn(ChatClient.prototype, 'resolveInterrupts') + .mockImplementation(() => {}) + const cancel = vi + .spyOn(ChatClient.prototype, 'cancelInterrupts') + .mockImplementation(() => {}) + const retry = vi + .spyOn(ChatClient.prototype, 'retryInterrupts') + .mockImplementation(() => {}) + const unsafe = vi + .spyOn(ChatClient.prototype, 'resumeInterruptsUnsafe') + .mockResolvedValue(true) + const { result } = renderInjectChat({ + connection: createMockConnectionAdapter(), + }) + const resolver = () => undefined + const resume = [{ interruptId: 'one', status: 'cancelled' as const }] + + result.resolveInterrupts(resolver) + result.cancelInterrupts() + result.retryInterrupts() + await expect(result.resumeInterruptsUnsafe(resume)).resolves.toBe(true) + + expect(resolve).toHaveBeenCalledWith(resolver) + expect(cancel).toHaveBeenCalledOnce() + expect(retry).toHaveBeenCalledOnce() + expect(unsafe).toHaveBeenCalledWith(resume, undefined) + }) + }) + it('initializes with default state', () => { const adapter = createMockConnectionAdapter() const { result } = renderInjectChat({ connection: adapter }) @@ -120,6 +200,55 @@ describe('injectChat — reactive options', () => { }) }) +describe('injectChat — resume', () => { + it('forwards onResumeStateChange to ChatClient', async () => { + const onResumeStateChange = vi.fn() + const adapter = createMockConnectionAdapter({ + chunks: [ + { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + }, + { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: 'msg-1', + timestamp: Date.now(), + delta: 'Hi', + }, + { + type: EventType.RUN_FINISHED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + outcome: { + type: 'interrupt', + interrupts: [{ id: 'interrupt-1', reason: 'client_tool_input' }], + }, + }, + ], + }) + const { result } = renderInjectChat({ + connection: adapter, + threadId: 'thread-1', + onResumeStateChange, + }) + + await result.sendMessage('Hi') + + // A run that pauses on an interrupt forwards interrupt (state) resume — + // the thread/run ids to target on a follow-up. No delivery cursor. + expect(onResumeStateChange).toHaveBeenCalledWith( + expect.objectContaining({ + threadId: 'thread-1', + runId: expect.any(String), + }), + expect.arrayContaining([expect.objectContaining({ id: 'interrupt-1' })]), + ) + }) +}) + describe('injectChat — structured output', () => { // Mount injectChat directly so the `outputSchema` generic flows through and // the schema-gated `partial` / `final` signals are present on the result. diff --git a/packages/ai-angular/tests/test-utils.ts b/packages/ai-angular/tests/test-utils.ts index 9e217a942..761cdc254 100644 --- a/packages/ai-angular/tests/test-utils.ts +++ b/packages/ai-angular/tests/test-utils.ts @@ -1,12 +1,12 @@ import { Component } from '@angular/core' -import { getTestBed, TestBed } from '@angular/core/testing' +import { TestBed, getTestBed } from '@angular/core/testing' import { BrowserDynamicTestingModule, platformBrowserDynamicTesting, } from '@angular/platform-browser-dynamic/testing' import { injectChat } from '../src/inject-chat' -import type { InjectChatOptions } from '../src/types' -import type { InjectChatResult } from '../src/types' +import type { InjectChatOptions, InjectChatResult } from '../src/types' +import type { ChatResumeSnapshotV2 } from '@tanstack/ai-client' export { createMockConnectionAdapter, @@ -14,6 +14,43 @@ export { createToolCallChunks, } from '../../ai-client/tests/test-utils' +export function createInterruptResumeSnapshot(): ChatResumeSnapshotV2 { + const pendingInterrupts = [ + { + id: 'staged-interrupt', + reason: 'confirmation', + metadata: { + 'tanstack:interruptBinding': { + kind: 'generic' as const, + interruptId: 'staged-interrupt', + interruptedRunId: 'run-1', + generation: 1, + responseSchemaHash: 'none', + }, + }, + }, + { + id: 'invalid-interrupt', + reason: 'confirmation', + metadata: { + 'tanstack:interruptBinding': { + kind: 'generic' as const, + interruptId: 'invalid-interrupt', + interruptedRunId: 'run-1', + generation: 1, + responseSchemaHash: 'none', + }, + }, + }, + ] + + return { + schemaVersion: 2, + resumeState: { threadId: 'thread-1', runId: 'run-1' }, + pendingInterrupts, + } +} + // Ensure TestBed is initialized in this module's scope, regardless of whether // the setup file's initialization was in a different module context (possible // when the Angular plugin creates separate ESM module instances for compiled diff --git a/packages/ai-client/src/chat-client.ts b/packages/ai-client/src/chat-client.ts index 5c6d45ed2..920f6cd5d 100644 --- a/packages/ai-client/src/chat-client.ts +++ b/packages/ai-client/src/chat-client.ts @@ -13,10 +13,14 @@ import { normalizeConnectionAdapter, } from './connection-adapters' import { ChatPersistor } from './client-persistor' +import { ClearedStreamTracker } from './cleared-stream-tracker' +import { InterruptManager } from './interrupt-manager' import type { AnyClientTool, ContentPart, + InterruptSubmissionError, ModelMessage, + RunAgentResumeItem, StreamChunk, } from '@tanstack/ai/client' import type { @@ -33,9 +37,15 @@ import type { ChatDevtoolsBridgeOptions, } from './devtools' import type { + BoundInterrupts, ChatClientOptions, ChatClientState, ChatFetcher, + ChatInterrupt, + ChatInterruptState, + ChatPendingInterrupt, + ChatResumeSnapshot, + ChatResumeState, ConnectionStatus, MessagePart, MultimodalContent, @@ -48,6 +58,7 @@ import type { UIMessage, WhenBusy, } from './types' +import type { InterruptManagerSubmission } from './interrupt-manager' /** Internal queue entry — public {@link QueuedMessage} plus optional per-send body. */ interface InternalQueuedMessage extends QueuedMessage { @@ -72,6 +83,11 @@ type ChatClientUpdateOptionsWithoutContext< onConnectionStatusChange?: (status: ConnectionStatus) => void onSessionGeneratingChange?: (isGenerating: boolean) => void onQueueChange?: (queue: Array) => void + onResumeStateChange?: ( + resumeState: ChatResumeState | null, + pendingInterrupts: BoundInterrupts, + ) => void + onInterruptStateChange?: (state: ChatInterruptState) => void onCustomEvent?: ( eventType: string, data: unknown, @@ -178,6 +194,52 @@ function mergeQueuedMessages(items: Array): { } } +/** + * Extract a boolean approval decision from an AG-UI resume payload, if present. + * Tool-approval resolutions carry `{ approved: boolean, ... }`; generic + * interrupt payloads do not. + */ +function readApprovalApproved(payload: unknown): boolean | undefined { + if ( + payload === null || + typeof payload !== 'object' || + Array.isArray(payload) + ) { + return undefined + } + if (!('approved' in payload) || typeof payload.approved !== 'boolean') { + return undefined + } + return payload.approved +} + +function readResumeState( + snapshot: ChatResumeSnapshot, +): ChatResumeState | undefined { + const value: unknown = snapshot + if ( + value === null || + typeof value !== 'object' || + !('resumeState' in value) + ) { + return undefined + } + const resumeState = value.resumeState + if ( + resumeState === null || + typeof resumeState !== 'object' || + !('threadId' in resumeState) || + typeof resumeState.threadId !== 'string' || + resumeState.threadId.length === 0 || + !('runId' in resumeState) || + typeof resumeState.runId !== 'string' || + resumeState.runId.length === 0 + ) { + return undefined + } + return { threadId: resumeState.threadId, runId: resumeState.runId } +} + export class ChatClient< TTools extends ReadonlyArray = any, TContext = unknown, @@ -186,11 +248,29 @@ export class ChatClient< private connection: SubscribeConnectionAdapter private readonly uniqueId: string private readonly threadId: string - // All persistence concerns (hydrate / save / clear, plus suppression of late - // chunks after a mid-stream clear) live in ChatPersistor so this class stays - // focused on streaming. Undefined when no `persistence` adapter is configured. + // Message persistence (optional). Clear-during-stream suppression is always + // on via ClearedStreamTracker so `clear()` works without a storage adapter. + // Durable resume-snapshot storage is not wired here (feat/persistence). private readonly persistor?: ChatPersistor + private readonly clearedStreamTracker = new ClearedStreamTracker() private currentRunId: string | null = null + // Interrupt-resume tracking: the run/thread of the most recent interrupted + // run, so approvals/client-tool results can be sent back. Cleared when the + // run terminates. This is STATE (interrupt) resume, not delivery/cursor. + private lastResume: ChatResumeState | null = null + private readonly interruptManager: InterruptManager + private activeInterruptSubmission: InterruptManagerSubmission | undefined + private interruptSubmissionFailure: + | { errors: ReadonlyArray } + | undefined + private readonly joinedRunWaiters = new Map void>() + // When set, the next streamResponse() continues this interrupted run instead + // of starting a fresh run (consumed once). + private pendingResumeParentRunId: string | null = null + private pendingResumeThreadId: string | null = null + private pendingResumeItems: Array | null = null + private activeResumeThreadId: string | null = null + private activeResumeRunId: string | null = null // Track the legacy `body` option and the canonical `forwardedProps` // option as separate slots so that `updateOptions({ forwardedProps })` // doesn't wipe a previously-set `body` (and vice versa). They are @@ -274,6 +354,11 @@ export class ChatClient< onConnectionStatusChange: (status: ConnectionStatus) => void onSessionGeneratingChange: (isGenerating: boolean) => void onQueueChange: (queue: Array) => void + onResumeStateChange: ( + resumeState: ChatResumeState | null, + pendingInterrupts: BoundInterrupts, + ) => void + onInterruptStateChange: (state: ChatInterruptState) => void onCustomEvent: ( eventType: string, data: unknown, @@ -332,10 +417,25 @@ export class ChatClient< onSessionGeneratingChange: options.onSessionGeneratingChange || (() => {}), onQueueChange: options.onQueueChange || (() => {}), + onResumeStateChange: options.onResumeStateChange || (() => {}), + onInterruptStateChange: options.onInterruptStateChange || (() => {}), onCustomEvent: options.onCustomEvent || (() => {}), }, } + this.interruptManager = new InterruptManager({ + ...(options.tools !== undefined ? { tools: options.tools } : {}), + submit: (submission) => this.submitInterruptBatch(submission), + onChange: () => this.notifyResumeStateChange(), + }) + + // In-memory rehydrate of interrupt descriptors (e.g. after a page reload + // when the host supplies a snapshot). Durable storage of that snapshot is + // a persistence-stack concern — not wired here. + if (options.initialResumeSnapshot) { + this.applyResumeSnapshot(options.initialResumeSnapshot) + } + // Create StreamProcessor with event handlers. // Use conditional spreads so we don't pass `undefined` into // `StreamProcessorOptions` fields under `exactOptionalPropertyTypes`. @@ -552,6 +652,29 @@ export class ChatClient< this.persistor?.hydrateAsync(persistedMessages) } + private applyResumeSnapshot(snapshot: ChatResumeSnapshot): void { + const resumeState = readResumeState(snapshot) + if (resumeState === undefined) { + this.interruptManager.reset() + return + } + this.lastResume = resumeState + const pendingInterrupts = Array.isArray(snapshot.pendingInterrupts) + ? snapshot.pendingInterrupts + : [] + if (pendingInterrupts.length === 0) { + this.interruptManager.reset() + return + } + const generation = this.interruptGeneration(pendingInterrupts) + this.interruptManager.hydrate({ + threadId: resumeState.threadId, + interruptedRunId: resumeState.runId, + generation, + interrupts: pendingInterrupts, + }) + } + mountDevtools(): void { if (this.devtoolsMounted) { return @@ -568,21 +691,38 @@ export class ChatClient< */ private drainIgnoredRunlessChunk(chunk: StreamChunk): void { if (chunk.type !== 'RUN_ERROR') return - const runId = this.persistor?.takeRunlessRunId() + const runId = this.clearedStreamTracker.takeRunlessRunId() if (!runId) return this.activeRunIds.delete(runId) this.setSessionGenerating(this.activeRunIds.size > 0) this.resolveProcessing() } + private retireIgnoredClearedTerminalChunk(chunk: StreamChunk): void { + if (chunk.type !== 'RUN_FINISHED' && chunk.type !== 'RUN_ERROR') return + const runId = + getChunkRunId(chunk) ?? this.clearedStreamTracker.takeRunlessRunId() + if (!runId) return + this.activeRunIds.delete(runId) + this.setSessionGenerating(this.activeRunIds.size > 0) + if (!getChunkRunId(chunk)) { + this.resolveProcessing() + } + } + private updateRunLifecycle( chunk: StreamChunk, options?: { resolveProcessing?: boolean }, ): void { if (chunk.type === 'RUN_STARTED') { const chunkRunId = getChunkRunId(chunk) ?? chunk.runId + this.activeResumeThreadId = + 'threadId' in chunk && typeof chunk.threadId === 'string' + ? chunk.threadId + : this.activeResumeThreadId + this.activeResumeRunId = chunkRunId this.activeRunIds.add(chunkRunId) - this.persistor?.onRunStarted(chunkRunId) + this.clearedStreamTracker.onRunStarted(chunkRunId) this.setSessionGenerating(true) return } @@ -594,11 +734,11 @@ export class ChatClient< const runId = getChunkRunId(chunk) if (runId) { this.activeRunIds.delete(runId) - this.persistor?.onRunSettled(runId) + this.clearedStreamTracker.onRunSettled(runId) } else if (chunk.type === 'RUN_ERROR') { // RUN_ERROR without runId is a session-level error; clear all runs. this.activeRunIds.clear() - this.persistor?.onSessionRunError() + this.clearedStreamTracker.onSessionRunError() } this.setSessionGenerating(this.activeRunIds.size > 0) if (options?.resolveProcessing !== false) { @@ -606,6 +746,240 @@ export class ChatClient< } } + /** + * Track interrupt state off the stream's terminal events. A RUN_FINISHED with + * an interrupt outcome records the pending interrupts + the run/thread to + * resume; any other terminal event for the tracked/current run clears that + * state. This is interrupt (state) resume — there is no delivery cursor. + */ + private observeInterruptState(chunk: StreamChunk): void { + if (chunk.type !== 'RUN_FINISHED' && chunk.type !== 'RUN_ERROR') { + return + } + + if (this.activeInterruptSubmission && chunk.type === 'RUN_ERROR') { + return + } + const runId = getChunkRunId(chunk) + const threadId = + 'threadId' in chunk && typeof chunk.threadId === 'string' + ? chunk.threadId + : this.activeResumeThreadId + + if (chunk.type === 'RUN_FINISHED' && chunk.outcome?.type === 'interrupt') { + // Track the REQUEST run id (what the client sent) so a resume targets the + // same run even when provider events carry their own run id. + const interruptedRunId = + this.currentRunId ?? runId ?? this.activeResumeRunId ?? '' + this.lastResume = { + threadId: threadId ?? this.threadId, + runId: interruptedRunId, + } + this.interruptManager.hydrate({ + threadId: this.lastResume.threadId, + interruptedRunId, + generation: this.interruptGeneration(chunk.outcome.interrupts), + interrupts: chunk.outcome.interrupts, + }) + return + } + + const isRunlessSessionError = chunk.type === 'RUN_ERROR' && !runId + const isTrackedRunTerminal = Boolean( + runId && this.lastResume?.runId === runId, + ) + const isCurrentRunTerminal = Boolean( + (runId && this.currentRunId === runId) || + (this.currentRunId && this.lastResume?.runId === this.currentRunId), + ) + // Provider adapters sometimes stamp a different run id on continuation + // events than the client-generated request id. RUN_STARTED updates + // `activeResumeRunId`, so match that too. + const isActiveStreamRunTerminal = Boolean( + this.isLoading && + runId && + (runId === this.activeResumeRunId || runId === this.currentRunId), + ) + const isCurrentStreamTerminal = + this.isLoading && chunk.type === 'RUN_FINISHED' && !runId + // A resume batch that finishes successfully (or with a non-interrupt + // terminal) must always clear pending interrupts — even when the provider + // run id does not correlate. Otherwise Approve works once but the UI + // keeps showing a stale prompt and blocks follow-up turns. + const isActiveInterruptSubmissionTerminal = Boolean( + this.activeInterruptSubmission && + this.isLoading && + chunk.type === 'RUN_FINISHED' && + chunk.outcome?.type !== 'interrupt', + ) + if ( + isRunlessSessionError || + isTrackedRunTerminal || + isCurrentRunTerminal || + isActiveStreamRunTerminal || + isCurrentStreamTerminal || + isActiveInterruptSubmissionTerminal + ) { + this.lastResume = null + this.interruptManager.reset() + return + } + this.notifyResumeStateChange() + } + + /** + * The interrupt-resume state for the active/interrupted run (its run/thread + * ids), or null when there is nothing to resume. Apps can persist this to + * resume interrupts across a full reload. + */ + getResumeState(): ChatResumeState | null { + return this.lastResume ? { ...this.lastResume } : null + } + + getInterruptState(): ChatInterruptState { + return this.interruptManager.getState() + } + + getInterrupts(): BoundInterrupts { + return this.interruptManager.getInterrupts() + } + + /** @deprecated Use getInterrupts(). */ + getPendingInterrupts(): BoundInterrupts { + return this.interruptManager.getInterrupts() + } + + resolveInterrupts(approved: boolean): void + resolveInterrupts( + resolver: (interrupt: ChatInterrupt) => undefined, + ): void + resolveInterrupts( + resolution: boolean | ((interrupt: ChatInterrupt) => undefined), + ): void { + // Branch so TypeScript can select the InterruptManager.resolve overloads. + if (typeof resolution === 'boolean') { + this.interruptManager.resolve(resolution) + return + } + this.interruptManager.resolve(resolution) + } + + cancelInterrupts(): void { + this.interruptManager.cancel() + } + + retryInterrupts(): void { + this.interruptManager.retry() + } + + /** Unsafe low-level resume escape hatch. Prefer bound interrupt methods. */ + resumeInterruptsUnsafe( + resume: Array, + state?: ChatResumeState, + ): Promise { + const target = state ?? this.lastResume + if (!target) return Promise.resolve(false) + // Auto-executed client tools resolve during the parent stream's + // `pendingToolExecutions` wait — while `isLoading` is still true. + // Defer the child continuation until that stream settles so we do not + // race the parent cleanup or return a false "could not start" failure. + if (this.isLoading) { + return new Promise((resolve, reject) => { + this.queuePostStreamAction(async () => { + try { + resolve(await this.resumeInterruptsUnsafe(resume, target)) + } catch (error) { + reject(error) + } + }) + }) + } + this.pendingResumeThreadId = target.threadId + this.pendingResumeParentRunId = target.runId + this.pendingResumeItems = [...resume] + return this.streamResponse() + } + + /** @deprecated Use bound interrupt methods or resumeInterruptsUnsafe(). */ + resumeInterrupts( + resume: Array, + state?: ChatResumeState, + ): Promise { + return this.resumeInterruptsUnsafe(resume, state) + } + + private async submitInterruptBatch( + submission: InterruptManagerSubmission, + ): Promise { + this.activeInterruptSubmission = submission + this.interruptSubmissionFailure = undefined + // Reflect approval decisions in the local message tree immediately so a + // follow-up turn does not re-serialize tool-calls still stuck in + // `approval-requested` (issue #532). + for (const resolution of submission.resolutions) { + const approved = readApprovalApproved(resolution.payload) + if (approved === undefined) continue + const approvalId = resolution.interruptId + this.processor.addToolApprovalResponse(approvalId, approved) + } + const resumed = await this.resumeInterruptsUnsafe( + [...submission.resolutions], + { + threadId: submission.threadId, + runId: submission.interruptedRunId, + }, + ).finally(() => { + this.activeInterruptSubmission = undefined + }) + const failure = this.takeInterruptSubmissionFailure() + if (failure !== undefined) { + throw { errors: failure.errors } + } + if (!resumed) { + throw new Error('Interrupt continuation could not be started.') + } + // Belt-and-suspenders: if the continuation stream finished successfully + // but correlation failed to clear resume state, drop it now so the next + // user turn is not blocked by a stale interrupt prompt. + if (this.lastResume?.runId === submission.interruptedRunId) { + this.lastResume = null + this.interruptManager.reset() + } + } + + private takeInterruptSubmissionFailure(): + | { errors: ReadonlyArray } + | undefined { + const failure = this.interruptSubmissionFailure + this.interruptSubmissionFailure = undefined + return failure + } + + private interruptGeneration( + interrupts: ReadonlyArray, + ): number { + let generation: number | undefined + for (const interrupt of interrupts) { + const candidate: unknown = + interrupt.metadata?.['tanstack:interruptBinding'] + if ( + candidate === null || + typeof candidate !== 'object' || + !('generation' in candidate) || + typeof candidate.generation !== 'number' || + !Number.isInteger(candidate.generation) || + candidate.generation < 0 + ) { + return 0 + } + if (generation !== undefined && generation !== candidate.generation) { + return 0 + } + generation = candidate.generation + } + return generation ?? 0 + } + private generateUniqueId(prefix: string): string { return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(7)}` } @@ -641,9 +1015,24 @@ export class ChatClient< this.devtoolsBridge.emitSnapshot() } - private resetSessionGenerating(): void { + private notifyResumeStateChange(): void { + const resumeState = this.getResumeState() + this.callbacksRef.current.onResumeStateChange( + resumeState, + this.interruptManager.getInterrupts(), + ) + this.callbacksRef.current.onInterruptStateChange( + this.interruptManager.getState(), + ) + } + + private resetSessionGenerating(options?: { + preserveClearedStreamTracking?: boolean + }): void { this.activeRunIds.clear() - this.persistor?.resetIgnored() + if (!options?.preserveClearedStreamTracking) { + this.clearedStreamTracker.resetActiveRuns() + } this.setSessionGenerating(false) } @@ -793,33 +1182,74 @@ export class ChatClient< const stream = this.connection.subscribe(signal) for await (const chunk of stream) { if (signal.aborted) break - if (this.connectionStatus === 'connecting') { - this.setConnectionStatus('connected') + await this.processIncomingChunk(chunk) + } + } + + private async processIncomingChunk(chunk: StreamChunk): Promise { + if ( + chunk.type === 'RUN_ERROR' && + this.isActiveInterruptSubmissionFailure(chunk) + ) { + this.interruptSubmissionFailure = { + errors: chunk['tanstack:interruptErrors'] ?? [], } - const shouldIgnore = this.persistor?.shouldIgnoreChunk(chunk) ?? false - if (shouldIgnore) { - if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') { - if (getChunkRunId(chunk)) { - this.updateRunLifecycle(chunk, { resolveProcessing: false }) - } else { - this.drainIgnoredRunlessChunk(chunk) - } + } + if (this.connectionStatus === 'connecting') { + this.setConnectionStatus('connected') + } + const shouldIgnore = this.clearedStreamTracker.shouldIgnoreChunk(chunk) + if (shouldIgnore) { + if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') { + if (getChunkRunId(chunk)) { + this.updateRunLifecycle(chunk, { resolveProcessing: false }) + } else { + this.drainIgnoredRunlessChunk(chunk) } - continue + this.retireIgnoredClearedTerminalChunk(chunk) + this.resolveJoinedRun(chunk) } - this.callbacksRef.current.onChunk(chunk) - this.devtoolsBridge.observeChunk(chunk) - this.processor.processChunk(chunk) - // Run lifecycle (active-run tracking, session-generating state, and - // processing resolution for RUN_FINISHED / RUN_ERROR) is handled in a - // single place so the ignored-chunk path above and this path can't - // diverge. RUN_ERROR carries its runId via the AG-UI passthrough so a - // per-run error only clears that run, while a runId-less RUN_ERROR is - // treated as a session-level error that clears every active run. - this.updateRunLifecycle(chunk) - // Yield control back to event loop for UI updates - await new Promise((resolve) => setTimeout(resolve, 0)) + return } + this.callbacksRef.current.onChunk(chunk) + this.devtoolsBridge.observeChunk(chunk) + this.processor.processChunk(chunk) + this.updateRunLifecycle(chunk) + this.observeInterruptState(chunk) + await new Promise((resolve) => setTimeout(resolve, 0)) + this.resolveJoinedRun(chunk) + } + + private isActiveInterruptSubmissionFailure( + chunk: Extract, + ): boolean { + const submission = this.activeInterruptSubmission + const errors = chunk['tanstack:interruptErrors'] + if (!submission || !errors || errors.length === 0) return false + const runId = getChunkRunId(chunk) + if (runId !== undefined && runId !== this.currentRunId) return false + if ( + typeof chunk.threadId === 'string' && + chunk.threadId !== submission.threadId + ) { + return false + } + return errors.every( + (error) => + error.threadId === submission.threadId && + error.interruptedRunId === submission.interruptedRunId && + error.generation === submission.generation, + ) + } + + private resolveJoinedRun(chunk: StreamChunk): void { + if (chunk.type !== 'RUN_FINISHED' && chunk.type !== 'RUN_ERROR') return + const runId = getChunkRunId(chunk) + if (runId === undefined) return + const resolve = this.joinedRunWaiters.get(runId) + if (resolve === undefined) return + this.joinedRunWaiters.delete(runId) + resolve() } /** @@ -890,7 +1320,7 @@ export class ChatClient< * ], * id: 'custom-message-id' * }, - * { model: 'gpt-4-audio' } + * { model: 'gpt-5.5' } * ) * ``` */ @@ -904,6 +1334,11 @@ export class ChatClient< if (emptyMessage) { return } + if (this.hasBlockingInterrupts()) { + throw new Error( + 'ChatClient: cannot send normal input while pending interrupts exist. Use resumeInterrupts() instead.', + ) + } if (this.isSendBusy()) { const { action, id } = this.decideWhenBusy(content, sendOptions) @@ -934,6 +1369,30 @@ export class ChatClient< } } + /** + * True when the client still has user-actionable interrupts (or is mid + * resume submission). Staged/submitting items that are already being + * continued do not block a later turn once the resume stream has cleared + * resume state. + */ + private hasBlockingInterrupts(): boolean { + if (!this.lastResume && !this.activeInterruptSubmission) { + return false + } + if (this.activeInterruptSubmission) { + return true + } + return this.interruptManager + .getInterrupts() + .some( + (item) => + item.status === 'pending' || + item.status === 'validating' || + item.status === 'error' || + item.status === 'staged', + ) + } + /** True while a stream is active, a send is claiming the client, or the queue is draining. */ private isSendBusy(): boolean { return this.isLoading || this.sendInFlight || this.messageQueueDraining @@ -1043,6 +1502,11 @@ export class ChatClient< */ async append(message: UIMessage | ModelMessage): Promise { this.mountDevtools() + if (this.hasBlockingInterrupts()) { + throw new Error( + 'ChatClient: cannot append normal input while pending interrupts exist. Use resumeInterrupts() instead.', + ) + } // Normalize the message to ensure it has id and createdAt const normalizedMessage = normalizeToUIMessage(message, generateMessageId) @@ -1085,8 +1549,18 @@ export class ChatClient< // Track generation so a superseded stream's cleanup doesn't clobber the new one const generation = ++this.streamGeneration + // Native interrupt continuation is a fresh child run. The interrupted run + // is carried as parentRunId and the complete resolution batch as resume. + const resumeThreadId = this.pendingResumeThreadId + const resumeParentRunId = this.pendingResumeParentRunId + const resumeItems = this.pendingResumeItems + this.pendingResumeThreadId = null + this.pendingResumeParentRunId = null + this.pendingResumeItems = null const runId = `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` this.currentRunId = runId + this.activeResumeThreadId = resumeThreadId ?? this.threadId + this.activeResumeRunId = runId this.setIsLoading(true) // Hand off from deliverClaim to isLoading so nested drain can call @@ -1170,8 +1644,11 @@ export class ChatClient< // JSON Schema; sending a Standard Schema instance directly would // serialize to an unusable shape. const runContext = { - threadId: this.threadId, + threadId: resumeThreadId ?? this.threadId, runId, + ...(resumeParentRunId !== null + ? { parentRunId: resumeParentRunId } + : {}), clientTools: Array.from(clientTools.values()).map((t) => ({ name: t.name, description: t.description, @@ -1180,8 +1657,9 @@ export class ChatClient< : { type: 'object' }, })), forwardedProps: { ...mergedBody }, + ...(resumeItems ? { resume: resumeItems } : {}), } - this.devtoolsBridge.beginRun(runContext.runId, this.threadId) + this.devtoolsBridge.beginRun(runContext.runId, runContext.threadId) activeDevtoolsRunId = runContext.runId this.devtoolsBridge.emitRunLifecycle( 'run:created', @@ -1198,6 +1676,11 @@ export class ChatClient< // Send through normalized connection (pushes chunks to subscription queue) await this.connection.send(messages, mergedBody, signal, runContext) + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- mutated asynchronously during await + if (generation !== this.streamGeneration || signal.aborted) { + return false + } + // Wait for subscription loop to finish processing all chunks await processingComplete @@ -1424,25 +1907,30 @@ export class ChatClient< * Clear all messages */ clear(): void { - if (this.persistor) { - this.persistor.snapshotClear({ - messages: this.processor.getMessages(), - activeRunIds: this.activeRunIds, - currentRunId: this.currentRunId, - }) - if (this.isLoading) { - this.cancelInFlightStream({ setReadyStatus: true }) - this.resetSessionGenerating() - } else if (this.activeRunIds.size > 0) { - this.resetSessionGenerating() - } - // Suppress persisting the empty snapshot that clearMessages emits, then - // remove the stored conversation outright. - this.persistor.beginClear() + const hadLocalStream = this.abortController !== null + this.clearedStreamTracker.snapshotClear({ + messages: this.processor.getMessages(), + activeRunIds: this.activeRunIds, + currentRunId: this.currentRunId, + }) + // Always cancel in-flight work so clear works without message persistence. + if (this.isLoading || hadLocalStream) { + this.cancelInFlightStream({ setReadyStatus: true }) + this.resetSessionGenerating({ preserveClearedStreamTracking: true }) + } else if (this.activeRunIds.size > 0) { + this.resetSessionGenerating({ preserveClearedStreamTracking: true }) } + // Suppress persisting the empty snapshot that clearMessages emits, then + // remove the stored conversation outright. + this.persistor?.beginClear() this.processor.clearMessages() this.discardPendingSends() this.persistor?.remove() + this.lastResume = null + this.interruptManager.reset() + this.pendingResumeThreadId = null + this.pendingResumeParentRunId = null + this.pendingResumeItems = null this.setError(undefined) this.events.messagesCleared() } @@ -1484,9 +1972,8 @@ export class ChatClient< context, ) - // Forward an error message only for output-error results (with a fallback so - // a message-less `throw new Error()` still reaches the terminal 'error' - // state); a stray errorText on a successful result must not signal an error. + // Always update local message state so the tool-call part is terminal in + // the UI even when the AG-UI interrupt path owns server continuation. this.processor.addToolResult( result.toolCallId, result.output, @@ -1494,6 +1981,19 @@ export class ChatClient< ? result.errorText || 'Tool execution failed' : undefined, ) + this.devtoolsBridge.emitSnapshot() + + const resolvedViaInterrupt = this.interruptManager.resolveClientToolOutput( + result.toolCallId, + result.state === 'output-error' + ? { error: result.errorText || 'Tool execution failed' } + : result.output, + ) + if (resolvedViaInterrupt) { + // Interrupt manager stages/submits the resume batch (deferred until the + // parent stream settles when still loading). Skip legacy continuation. + return + } // If stream is in progress, queue continuation check for after it ends if (this.isLoading) { @@ -1522,6 +2022,21 @@ export class ChatClient< id: string // approval.id, not toolCallId approved: boolean }): Promise { + // Reflect the decision on the tool-call part so approval UIs that render + // from `part.state` (the deprecated pre-interrupt pattern) clear the prompt + // and show the response. The bound interrupt resolution below drives the + // actual continuation; this keeps the legacy message-state surface in sync. + this.processor.addToolApprovalResponse(response.id, response.approved) + this.devtoolsBridge.emitSnapshot() + + if ( + this.interruptManager.resolveToolApprovalDecision( + response.id, + response.approved, + ) + ) { + return + } // Find the tool call ID from the approval ID const messages = this.processor.getMessages() let foundToolCallId: string | undefined @@ -1866,6 +2381,7 @@ export class ChatClient< this.context = options.context } if (options.tools !== undefined) { + this.interruptManager.updateTools(options.tools) this.clientToolsRef.current = new Map() for (const tool of options.tools) { this.clientToolsRef.current.set(tool.name, tool) @@ -1902,6 +2418,14 @@ export class ChatClient< if (options.onQueueChange !== undefined) { this.callbacksRef.current.onQueueChange = options.onQueueChange } + if (options.onResumeStateChange !== undefined) { + this.callbacksRef.current.onResumeStateChange = + options.onResumeStateChange + } + if (options.onInterruptStateChange !== undefined) { + this.callbacksRef.current.onInterruptStateChange = + options.onInterruptStateChange + } if (options.onCustomEvent !== undefined) { this.callbacksRef.current.onCustomEvent = options.onCustomEvent } diff --git a/packages/ai-client/src/cleared-stream-tracker.ts b/packages/ai-client/src/cleared-stream-tracker.ts new file mode 100644 index 000000000..e9471ad53 --- /dev/null +++ b/packages/ai-client/src/cleared-stream-tracker.ts @@ -0,0 +1,151 @@ +import { getChunkRunId } from './connection-adapters' +import type { StreamChunk } from '@tanstack/ai/client' +import type { UIMessage } from './types' + +function getChunkToolCallId(chunk: StreamChunk): string | undefined { + return 'toolCallId' in chunk && typeof chunk.toolCallId === 'string' + ? chunk.toolCallId + : undefined +} + +function getChunkMessageId(chunk: StreamChunk): string | undefined { + return 'messageId' in chunk && typeof chunk.messageId === 'string' + ? chunk.messageId + : undefined +} + +function getChunkParentMessageId(chunk: StreamChunk): string | undefined { + return 'parentMessageId' in chunk && typeof chunk.parentMessageId === 'string' + ? chunk.parentMessageId + : undefined +} + +/** Tracks stream chunks that must be ignored after the owning chat is cleared. */ +export class ClearedStreamTracker { + private readonly clearedMessageIds = new Set() + private readonly clearedRunIds = new Set() + private readonly ignoredActiveRunIds = new Set() + private readonly clearedToolCallIds = new Set() + private currentRunlessRunId: string | null = null + + snapshotClear(context: { + messages: Array + activeRunIds: Set + currentRunId: string | null + }): void { + for (const message of context.messages) { + this.clearedMessageIds.add(message.id) + } + for (const runId of context.activeRunIds) { + this.clearedRunIds.add(runId) + this.ignoredActiveRunIds.add(runId) + } + if (context.currentRunId) { + this.clearedRunIds.add(context.currentRunId) + this.ignoredActiveRunIds.add(context.currentRunId) + } + } + + shouldIgnoreChunk(chunk: StreamChunk): boolean { + const runId = getChunkRunId(chunk) + if (runId && this.clearedRunIds.has(runId)) { + if (chunk.type === 'RUN_STARTED') { + this.ignoredActiveRunIds.add(runId) + this.currentRunlessRunId = runId + } + this.markIgnoredChunkIds(chunk) + return true + } + + if (runId && this.ignoredActiveRunIds.has(runId)) { + this.markIgnoredChunkIds(chunk) + return true + } + + if (this.isRunlessChunkFromIgnoredRun(chunk)) { + this.markIgnoredChunkIds(chunk) + return true + } + + const toolCallId = getChunkToolCallId(chunk) + if (toolCallId && this.clearedToolCallIds.has(toolCallId)) { + return true + } + + const parentMessageId = getChunkParentMessageId(chunk) + if (parentMessageId && this.clearedMessageIds.has(parentMessageId)) { + if (toolCallId) { + this.clearedToolCallIds.add(toolCallId) + } + return true + } + + const messageId = getChunkMessageId(chunk) + return Boolean(messageId && this.clearedMessageIds.has(messageId)) + } + + onRunStarted(runId: string): void { + this.currentRunlessRunId = runId + } + + onRunSettled(runId: string): void { + this.ignoredActiveRunIds.delete(runId) + this.clearedRunIds.delete(runId) + if (this.currentRunlessRunId === runId) { + this.currentRunlessRunId = + this.ignoredActiveRunIds.values().next().value ?? null + } + } + + onSessionRunError(): void { + this.ignoredActiveRunIds.clear() + this.currentRunlessRunId = null + } + + resetActiveRuns(): void { + this.ignoredActiveRunIds.clear() + this.currentRunlessRunId = null + } + + takeRunlessRunId(): string | null { + const runId = this.currentRunlessRunId + if (!runId) return null + this.ignoredActiveRunIds.delete(runId) + this.clearedRunIds.delete(runId) + this.currentRunlessRunId = + this.ignoredActiveRunIds.values().next().value ?? null + return runId + } + + private markIgnoredChunkIds(chunk: StreamChunk): void { + const messageId = getChunkMessageId(chunk) + if (messageId) { + this.clearedMessageIds.add(messageId) + } + const toolCallId = getChunkToolCallId(chunk) + if (toolCallId) { + this.clearedToolCallIds.add(toolCallId) + } + } + + private isRunlessChunkFromIgnoredRun(chunk: StreamChunk): boolean { + const runId = getChunkRunId(chunk) + if (runId || !this.currentRunlessRunId) return false + if ( + !this.ignoredActiveRunIds.has(this.currentRunlessRunId) && + !this.clearedRunIds.has(this.currentRunlessRunId) + ) { + return false + } + return ( + chunk.type === 'TEXT_MESSAGE_START' || + chunk.type === 'TEXT_MESSAGE_CONTENT' || + chunk.type === 'TOOL_CALL_START' || + chunk.type === 'TOOL_CALL_ARGS' || + chunk.type === 'TOOL_CALL_END' || + chunk.type === 'TOOL_CALL_RESULT' || + chunk.type === 'MESSAGES_SNAPSHOT' || + chunk.type === 'RUN_ERROR' + ) + } +} diff --git a/packages/ai-client/src/connection-adapters.ts b/packages/ai-client/src/connection-adapters.ts index 0883ae77a..57997518c 100644 --- a/packages/ai-client/src/connection-adapters.ts +++ b/packages/ai-client/src/connection-adapters.ts @@ -6,6 +6,7 @@ import { import { parseSseDataLine } from './sse-utils' import type { ModelMessage, + RunAgentResumeItem, RunErrorEvent, RunFinishedEvent, StreamChunk, @@ -28,9 +29,17 @@ const chunkRunIds = new WeakMap() * run the connect wrapper stamped it with. */ export function getChunkRunId(chunk: StreamChunk): string | undefined { - return 'runId' in chunk && typeof chunk.runId === 'string' - ? chunk.runId - : chunkRunIds.get(chunk) + // Prefer the client's request run id (stamped in `chunkRunIds`) over a + // provider-assigned `chunk.runId`. Interrupt continuation correlation needs + // the client's run identity to win when a provider stamps its own id; for + // resumable reconnect/join the two ids match, so precedence is moot there. + const requestRunId = chunkRunIds.get(chunk) + return ( + requestRunId ?? + ('runId' in chunk && typeof chunk.runId === 'string' + ? chunk.runId + : undefined) + ) } /** @@ -630,6 +639,8 @@ export interface RunAgentInputContext { threadId: string runId: string parentRunId?: string + /** AG-UI interrupt resume entries returned to the server on a follow-up run. */ + resume?: Array /** Client-declared tools to advertise in the request payload. */ clientTools?: Array<{ name: string @@ -902,6 +913,7 @@ function buildRunAgentInputBody( ...(runContext?.parentRunId !== undefined && { parentRunId: runContext.parentRunId, }), + ...(runContext?.resume !== undefined && { resume: runContext.resume }), state: {}, messages: wireMessages, tools: runContext?.clientTools ?? [], diff --git a/packages/ai-client/src/index.ts b/packages/ai-client/src/index.ts index 41605186c..b03ed1480 100644 --- a/packages/ai-client/src/index.ts +++ b/packages/ai-client/src/index.ts @@ -6,6 +6,12 @@ export type { InferAudioRecordingOutput, } from './audio-recorder' export { ChatClient } from './chat-client' +export { InterruptManager } from './interrupt-manager' +export type { + InterruptManagerHydration, + InterruptManagerOptions, + InterruptManagerSubmission, +} from './interrupt-manager' export { createMcpAppBridge } from './mcp-app-bridge' export type { McpAppBridge, CreateMcpAppBridgeOptions } from './mcp-app-bridge' export { RealtimeClient } from './realtime-client' @@ -23,7 +29,20 @@ export type { // Client configuration types ChatClientPersistence, ChatClientOptions, + ChatPendingInterrupt, + BoundInterruptBase, + BoundInterrupts, + ChatInterrupt, + ChatInterruptState, + GenericAGUIInterrupt, + UnboundInterrupt, + InterruptItemStatus, + ToolApprovalInterrupt, ClientContextOptionFromTools, + ChatResumeState, + ChatResumeSnapshot, + ChatResumeSnapshotV1, + ChatResumeSnapshotV2, ChatRequestBody, InferChatMessages, InferredClientContext, @@ -79,7 +98,15 @@ export type { ExtractToolInput, ExtractToolOutput, } from './tool-types' -export type { AnyClientTool } from '@tanstack/ai/client' +export type { + AnyClientTool, + Interrupt, + PersistedArtifactActivity, + PersistedArtifactRef, + PersistedArtifactRole, + RunAgentResumeItem, + RunFinishedOutcome, +} from '@tanstack/ai/client' export type { RealtimeAdapter, RealtimeConnection, diff --git a/packages/ai-client/src/interrupt-manager.ts b/packages/ai-client/src/interrupt-manager.ts new file mode 100644 index 000000000..1524ad8bd --- /dev/null +++ b/packages/ai-client/src/interrupt-manager.ts @@ -0,0 +1,1440 @@ +import { + INTERRUPT_BINDING_METADATA_KEY, + INTERRUPT_BINDING_VERSION, + canonicalInterruptJson, + canonicalizeInterruptResolutions, + cloneAndDeepFreezeJson, + digestInterruptJson, + hashSchemaInput, + isStandardSchema, + normalizeApprovalSchema, +} from '@tanstack/ai/client' +import type { + AnyClientTool, + BatchInterruptError, + Interrupt, + InterruptBinding, + InterruptSubmissionError, + ItemInterruptError, + RunAgentResumeItem, +} from '@tanstack/ai/client' +import type { + BoundInterruptBase, + BoundInterrupts, + ChatInterrupt, + ChatInterruptState, + GenericAGUIInterrupt, + InterruptItemStatus, + UnboundInterrupt, +} from './types' + +export interface InterruptManagerHydration { + threadId: string + interruptedRunId: string + generation: number + interrupts: ReadonlyArray +} + +export interface InterruptManagerSubmission { + threadId: string + interruptedRunId: string + generation: number + resolutions: ReadonlyArray + canonicalResolutions: string + fingerprint: string +} + +export interface InterruptManagerOptions< + TTools extends ReadonlyArray, +> { + tools?: TTools + submit: (submission: InterruptManagerSubmission) => Promise + onChange?: () => void +} + +type UnknownObject = { [key: string]: unknown } + +type RuntimeKind = + | 'generic' + | 'tool-approval' + | 'client-tool-execution' + /** Carries no binding we understand — not ours to resume. */ + | 'unbound' + +interface RuntimeInterrupt { + descriptor: Interrupt + /** `undefined` only for `unbound` items. */ + binding: InterruptBinding | undefined + kind: RuntimeKind + status: InterruptItemStatus + canResolve: boolean + error?: ItemInterruptError + resolution?: RunAgentResumeItem + tool?: AnyClientTool + validationGeneration: number +} + +interface ValidationFailure { + code: ItemInterruptError['code'] + message: string + path?: ReadonlyArray +} + +type ValidationResult = { valid: true; payload: unknown } | ValidationFailure + +interface TransactionToken { + active: boolean +} + +interface RuntimeInterruptCheckpoint { + status: InterruptItemStatus + resolution?: RunAgentResumeItem + error?: ItemInterruptError + validationGeneration: number +} + +const itemErrorCodes = new Set([ + 'invalid-payload', + 'invalid-edited-args', + 'invalid-tool-output', + 'invalid-response-schema', + 'unknown-interrupt', + 'expired', + 'stale', + 'conflict', + 'legacy-unsupported', +]) + +const batchErrorCodes = new Set([ + 'incomplete-batch', + 'item-validation-failed', + 'unsupported-bulk-operation', + 'async-resolver', + 'inactive-transaction', + 'mixed-provenance', + 'transport', + 'server', + 'protocol', + 'invalid-response-schema', + 'expired', + 'stale', + 'conflict', + 'legacy-submit-failed', +]) + +function isUnknownObject(value: unknown): value is UnknownObject { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function isLegacyApprovalMetadata(value: unknown): boolean { + return ( + isUnknownObject(value) && + value['kind'] === 'approval' && + typeof value['toolName'] === 'string' && + 'input' in value + ) +} + +function isLegacyClientToolMetadata(value: unknown): boolean { + return ( + isUnknownObject(value) && + value['kind'] === 'client_tool' && + typeof value['toolName'] === 'string' && + 'input' in value + ) +} + +/** + * Does this descriptor carry the pre-binding TanStack metadata marker? + * + * Descriptors emitted before the resume binding existed are still ours to + * resume, so they must not be mistaken for another producer's interrupt. + */ +function isLegacyInterruptMetadata(interrupt: Interrupt): boolean { + return ( + isLegacyApprovalMetadata(interrupt.metadata) || + isLegacyClientToolMetadata(interrupt.metadata) + ) +} + +function isBindingBase(value: UnknownObject): boolean { + return ( + // A binding stamped with a version we don't know is another producer's. + // Reject it whole; never read our fields out of it. Missing `v` is read as + // the current version so pre-versioning bindings still resume. + (value['v'] === undefined || value['v'] === INTERRUPT_BINDING_VERSION) && + typeof value['kind'] === 'string' && + typeof value['interruptId'] === 'string' && + typeof value['interruptedRunId'] === 'string' && + typeof value['generation'] === 'number' && + Number.isInteger(value['generation']) && + value['generation'] >= 0 && + typeof value['responseSchemaHash'] === 'string' && + (value['expiresAt'] === undefined || + (typeof value['expiresAt'] === 'string' && + Number.isFinite(Date.parse(value['expiresAt'])))) + ) +} + +function readBinding(value: unknown): InterruptBinding | undefined { + if (!isUnknownObject(value) || !isBindingBase(value)) return undefined + const expiresAt = + typeof value['expiresAt'] === 'string' ? value['expiresAt'] : undefined + if (value['kind'] === 'generic') { + return { + v: INTERRUPT_BINDING_VERSION, + kind: 'generic', + interruptId: String(value['interruptId']), + interruptedRunId: String(value['interruptedRunId']), + generation: Number(value['generation']), + responseSchemaHash: String(value['responseSchemaHash']), + ...(expiresAt !== undefined ? { expiresAt } : {}), + } + } + if ( + value['kind'] === 'client-tool-execution' && + typeof value['toolName'] === 'string' && + typeof value['toolCallId'] === 'string' && + typeof value['outputSchemaHash'] === 'string' + ) { + return { + v: INTERRUPT_BINDING_VERSION, + kind: 'client-tool-execution', + interruptId: String(value['interruptId']), + interruptedRunId: String(value['interruptedRunId']), + generation: Number(value['generation']), + toolName: value['toolName'], + toolCallId: value['toolCallId'], + outputSchemaHash: value['outputSchemaHash'], + responseSchemaHash: String(value['responseSchemaHash']), + ...(expiresAt !== undefined ? { expiresAt } : {}), + } + } + if ( + value['kind'] === 'tool-approval' && + typeof value['toolName'] === 'string' && + typeof value['toolCallId'] === 'string' && + typeof value['inputSchemaHash'] === 'string' && + typeof value['approvalSchemaHash'] === 'string' && + 'originalArgs' in value + ) { + return { + v: INTERRUPT_BINDING_VERSION, + kind: 'tool-approval', + interruptId: String(value['interruptId']), + interruptedRunId: String(value['interruptedRunId']), + generation: Number(value['generation']), + toolName: value['toolName'], + toolCallId: value['toolCallId'], + originalArgs: value['originalArgs'], + inputSchemaHash: value['inputSchemaHash'], + approvalSchemaHash: value['approvalSchemaHash'], + responseSchemaHash: String(value['responseSchemaHash']), + ...(expiresAt !== undefined ? { expiresAt } : {}), + } + } + return undefined +} + +function getDescriptorBinding( + interrupt: Interrupt, +): InterruptBinding | undefined { + const candidate: unknown = + interrupt.metadata?.[INTERRUPT_BINDING_METADATA_KEY] + return readBinding(candidate) +} + +/** + * Only used to route *legacy* (pre-binding) descriptors, which have no binding + * to classify off. Current descriptors are classified by their binding alone. + */ +function isClientToolExecutionReason(reason: string): boolean { + return ( + reason === 'tanstack:client_tool_execution' || + reason === 'client_tool_input' + ) +} + +function responseSchemaHash(interrupt: Interrupt): string | undefined { + if (interrupt.responseSchema === undefined) return undefined + try { + return digestInterruptJson(canonicalInterruptJson(interrupt.responseSchema)) + } catch { + return undefined + } +} + +function isPromiseLike(value: unknown): value is PromiseLike { + return ( + value !== null && + (typeof value === 'object' || typeof value === 'function') && + 'then' in value && + typeof value.then === 'function' + ) +} + +function validateWithSchema( + schema: unknown, + value: unknown, + code: ItemInterruptError['code'], +): ValidationResult | Promise { + if (schema === undefined) return { valid: true, payload: value } + if (isStandardSchema(schema)) { + const result = schema['~standard'].validate(value) + const normalize = ( + validation: Awaited, + ): ValidationResult => { + if (!validation.issues) { + return { valid: true, payload: validation.value } + } + return { + code, + message: validation.issues[0]?.message ?? 'Schema validation failed.', + } + } + return isPromiseLike(result) + ? Promise.resolve(result).then(normalize) + : normalize(result) + } + // A non-Standard-Schema value (a raw JSON Schema arriving over the wire) is + // not validated by the library. The application transforms the schema and + // validates the value itself before resolving; whatever it passes flows + // through as-is. + return { valid: true, payload: value } +} + +function isItemErrorCode(value: string): value is ItemInterruptError['code'] { + for (const code of itemErrorCodes) if (code === value) return true + return false +} + +function isBatchErrorCode(value: string): value is BatchInterruptError['code'] { + for (const code of batchErrorCodes) if (code === value) return true + return false +} + +function isSubmissionError(value: unknown): value is InterruptSubmissionError { + if (!isUnknownObject(value)) return false + const scope = value['scope'] + const code = value['code'] + const base = + typeof code === 'string' && + typeof value['message'] === 'string' && + typeof value['retryable'] === 'boolean' && + typeof value['threadId'] === 'string' && + typeof value['interruptedRunId'] === 'string' && + typeof value['generation'] === 'number' + if (!base) return false + if (scope === 'item') { + return ( + isItemErrorCode(code) && + typeof value['interruptId'] === 'string' && + (value['source'] === 'client' || value['source'] === 'server') + ) + } + return ( + scope === 'batch' && + isBatchErrorCode(code) && + Array.isArray(value['interruptIds']) && + value['interruptIds'].every((id) => typeof id === 'string') && + (value['source'] === 'client' || + value['source'] === 'server' || + value['source'] === 'transport') + ) +} + +function readSubmissionErrors( + error: unknown, +): ReadonlyArray { + if (isSubmissionError(error)) return [error] + if (!isUnknownObject(error) || !Array.isArray(error['errors'])) return [] + return error['errors'].every(isSubmissionError) ? error['errors'] : [] +} + +function haveSameInterruptIds( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + if (left.length !== right.length) return false + const sortedLeft = [...left].sort() + const sortedRight = [...right].sort() + return sortedLeft.every((id, index) => id === sortedRight[index]) +} + +function haveSameBatchCorrelation( + left: BatchInterruptError, + right: BatchInterruptError, +): boolean { + return ( + left.threadId === right.threadId && + left.interruptedRunId === right.interruptedRunId && + left.generation === right.generation && + haveSameInterruptIds(left.interruptIds, right.interruptIds) + ) +} + +function mergeSubmissionBatchErrors( + current: ReadonlyArray, + previousSubmission: ReadonlyArray, + incoming: ReadonlyArray, +): { + rootErrors: ReadonlyArray + submissionRootErrors: ReadonlyArray +} { + const replaceableIncoming = incoming.filter( + (error) => error.source !== 'transport', + ) + const isSuperseded = (candidate: BatchInterruptError): boolean => + previousSubmission.includes(candidate) && + replaceableIncoming.some((error) => + haveSameBatchCorrelation(candidate, error), + ) + const retainedRootErrors = current.filter( + (candidate) => !isSuperseded(candidate), + ) + const retainedSubmissionRootErrors = previousSubmission.filter( + (candidate) => + !replaceableIncoming.some((error) => + haveSameBatchCorrelation(candidate, error), + ), + ) + return Object.freeze({ + rootErrors: Object.freeze([...retainedRootErrors, ...incoming]), + submissionRootErrors: Object.freeze([ + ...retainedSubmissionRootErrors, + ...replaceableIncoming, + ]), + }) +} + +function submissionErrorMatchesActiveBatch( + error: InterruptSubmissionError, + submission: InterruptManagerSubmission, +): boolean { + if ( + error.threadId !== submission.threadId || + error.interruptedRunId !== submission.interruptedRunId || + error.generation !== submission.generation + ) { + return false + } + const interruptIds = submission.resolutions.map( + (resolution) => resolution.interruptId, + ) + return error.scope === 'item' + ? interruptIds.includes(error.interruptId) + : haveSameInterruptIds(error.interruptIds, interruptIds) +} + +function genericBinding( + interrupt: Interrupt, + hydration: InterruptManagerHydration, + candidate: InterruptBinding | undefined, +): InterruptBinding { + return cloneAndDeepFreezeJson({ + v: INTERRUPT_BINDING_VERSION, + kind: 'generic', + interruptId: interrupt.id, + interruptedRunId: hydration.interruptedRunId, + generation: hydration.generation, + responseSchemaHash: + responseSchemaHash(interrupt) ?? + candidate?.responseSchemaHash ?? + 'invalid', + ...(interrupt.expiresAt !== undefined + ? { expiresAt: interrupt.expiresAt } + : {}), + }) +} + +function baseSnapshot( + item: RuntimeInterrupt, + hydration: InterruptManagerHydration, + cancel: () => void, + clearResolution: () => void, +): BoundInterruptBase { + const descriptor = cloneAndDeepFreezeJson(item.descriptor) + const errors: ReadonlyArray = + item.error === undefined + ? Object.freeze([]) + : Object.freeze([cloneAndDeepFreezeJson(item.error)]) + const error = errors[0] + return { + id: descriptor.id, + interruptId: descriptor.id, + reason: descriptor.reason, + ...(descriptor.message !== undefined + ? { message: descriptor.message } + : {}), + ...(descriptor.responseSchema !== undefined + ? { responseSchema: descriptor.responseSchema } + : {}), + ...(descriptor.expiresAt !== undefined + ? { expiresAt: descriptor.expiresAt } + : {}), + ...(descriptor.metadata !== undefined + ? { metadata: descriptor.metadata } + : {}), + threadId: hydration.threadId, + interruptedRunId: hydration.interruptedRunId, + generation: hydration.generation, + status: item.status, + errors, + ...(error !== undefined ? { error } : {}), + canResolve: item.canResolve, + cancel, + clearResolution, + } +} + +export class InterruptManager< + TTools extends ReadonlyArray = ReadonlyArray, +> { + private hydration: InterruptManagerHydration | undefined + private items: Array = [] + private snapshot: ReadonlyArray> = Object.freeze([]) + private rootErrors: ReadonlyArray = Object.freeze([]) + private submissionRootErrors: ReadonlyArray = + Object.freeze([]) + private state: ChatInterruptState = Object.freeze({ + interrupts: this.snapshot, + pendingInterrupts: this.snapshot, + interruptErrors: this.rootErrors, + resuming: false, + }) + private activeTransaction: TransactionToken | undefined + private retrySubmission: InterruptManagerSubmission | undefined + private resuming = false + private tools: TTools | undefined + + constructor(private readonly options: InterruptManagerOptions) { + this.tools = options.tools + } + + updateTools(tools: TTools): void { + this.tools = tools + } + + hydrate(hydration: InterruptManagerHydration): void { + this.hydration = { + threadId: hydration.threadId, + interruptedRunId: hydration.interruptedRunId, + generation: hydration.generation, + interrupts: cloneAndDeepFreezeJson(hydration.interrupts), + } + this.items = hydration.interrupts.map((interrupt) => + this.hydrateInterrupt(interrupt, hydration), + ) + this.rootErrors = Object.freeze([]) + this.submissionRootErrors = Object.freeze([]) + this.retrySubmission = undefined + this.resuming = false + this.publish() + } + + getInterrupts(): BoundInterrupts { + return this.snapshot + } + + getState(): ChatInterruptState { + return this.state + } + + getDescriptors(): ReadonlyArray { + return this.hydration?.interrupts ?? Object.freeze([]) + } + + reset(options?: { preserveRootErrors?: boolean }): void { + this.hydration = undefined + this.items = [] + this.snapshot = Object.freeze([]) + if (options?.preserveRootErrors !== true) { + this.rootErrors = Object.freeze([]) + this.submissionRootErrors = Object.freeze([]) + } + this.retrySubmission = undefined + this.resuming = false + this.state = Object.freeze({ + interrupts: this.snapshot, + pendingInterrupts: this.snapshot, + interruptErrors: this.rootErrors, + resuming: false, + }) + this.options.onChange?.() + } + + getInterruptErrors(): ReadonlyArray { + return this.rootErrors + } + + getResuming(): boolean { + return this.resuming + } + + resolve(approved: boolean): void + resolve(resolver: (interrupt: ChatInterrupt) => undefined): void + resolve( + resolution: boolean | ((interrupt: ChatInterrupt) => unknown), + ): void { + this.assertRootMutable() + if (typeof resolution === 'boolean') { + this.resolveBooleanBulk(resolution) + return + } + this.resolveTransaction(resolution) + } + + cancel(): void { + this.assertRootMutable() + this.invalidateRetry() + for (const item of this.items) { + item.validationGeneration++ + item.resolution = Object.freeze({ + interruptId: item.descriptor.id, + status: 'cancelled', + }) + item.status = 'staged' + item.error = undefined + } + this.publish() + this.maybeSubmit() + } + + retry(): void { + if (this.resuming) + throw new Error('Interrupt submission is already active.') + const submission = this.retrySubmission + if (!submission) { + this.addRootError( + 'transport', + 'There is no retryable interrupt submission.', + false, + ) + return + } + this.submitBatch(submission) + } + + resolveClientToolOutput(toolCallId: string, output: unknown): boolean { + const item = this.items.find( + (candidate) => + (candidate.kind === 'client-tool-execution' && + candidate.binding?.kind === 'client-tool-execution' && + candidate.binding.toolCallId === toolCallId) || + (candidate.kind === 'generic' && + isClientToolExecutionReason(candidate.descriptor.reason) && + candidate.descriptor.toolCallId === toolCallId && + isLegacyClientToolMetadata(candidate.descriptor.metadata)), + ) + if (!item) return false + this.resolveItem(item.descriptor.id, output) + return true + } + + resolveToolApprovalDecision(interruptId: string, approved: boolean): boolean { + const item = this.items.find( + (candidate) => + candidate.descriptor.id === interruptId && + (candidate.kind === 'tool-approval' || + (candidate.kind === 'generic' && + candidate.descriptor.reason === 'approval_required' && + isLegacyApprovalMetadata(candidate.descriptor.metadata))), + ) + if (!item) return false + this.resolveItem(item.descriptor.id, { approved }) + return true + } + + private hydrateInterrupt( + descriptor: Interrupt, + hydration: InterruptManagerHydration, + ): RuntimeInterrupt { + const interrupt = cloneAndDeepFreezeJson(descriptor) + const candidate = getDescriptorBinding(interrupt) + + // No binding we understand, and nothing else identifying the descriptor as + // ours, means this interrupt was not produced by this package's resume + // path — a workflow engine's durable approval projected onto the same + // AG-UI stream, a third-party agent's pause, or a binding written at a + // protocol version we don't know. + // + // Do not invent a binding for it. Synthesising one would render a + // resolvable form whose answer is submitted against a run that has no + // matching pending descriptor, failing late as `unknown-interrupt` after + // the user has already filled it in. Surface it as unresolvable instead, + // so "someone else owns this pause" is visible rather than silently + // translated into an AI-domain interrupt. + // + // Pre-binding TanStack descriptors are still ours: they carry the legacy + // `metadata.kind` marker, so they keep hydrating through the generic path + // below. + if (candidate === undefined && !isLegacyInterruptMetadata(interrupt)) { + return { + descriptor: interrupt, + binding: undefined, + kind: 'unbound', + status: 'pending', + canResolve: false, + validationGeneration: 0, + } + } + + const correlated = + candidate !== undefined && + candidate.interruptId === interrupt.id && + candidate.interruptedRunId === hydration.interruptedRunId && + candidate.generation === hydration.generation && + candidate.responseSchemaHash === + (responseSchemaHash(interrupt) ?? candidate.responseSchemaHash) + + if (correlated && candidate.kind === 'tool-approval') { + const tool = this.tools?.find( + (configured) => configured.name === candidate.toolName, + ) + // Gated on the binding and the schema hashes below, not on + // `interrupt.reason` — that string is free-form AG-UI text another + // producer can also use, so it cannot be what decides ownership. + if ( + tool?.needsApproval === true && + interrupt.toolCallId === candidate.toolCallId + ) { + try { + const approval = normalizeApprovalSchema( + tool.approvalSchema, + tool.inputSchema, + ) + if ( + hashSchemaInput(tool.inputSchema) === candidate.inputSchemaHash && + approval.approvalSchemaHash === candidate.approvalSchemaHash && + approval.responseSchemaHash === candidate.responseSchemaHash + ) { + return { + descriptor: interrupt, + binding: cloneAndDeepFreezeJson(candidate), + kind: 'tool-approval', + status: 'pending', + canResolve: true, + tool, + validationGeneration: 0, + } + } + } catch { + // Invalid configured schemas cannot safely grant typed hydration. + } + } + } + + if (correlated && candidate.kind === 'client-tool-execution') { + const tool = this.tools?.find( + (configured) => configured.name === candidate.toolName, + ) + // Binding-gated, for the same reason as tool approvals above. + if ( + tool !== undefined && + interrupt.toolCallId === candidate.toolCallId && + hashSchemaInput(tool.outputSchema) === candidate.outputSchemaHash + ) { + return { + descriptor: interrupt, + binding: cloneAndDeepFreezeJson(candidate), + kind: 'client-tool-execution', + status: 'pending', + canResolve: true, + tool, + validationGeneration: 0, + } + } + } + + return { + descriptor: interrupt, + binding: genericBinding(interrupt, hydration, candidate), + kind: 'generic', + status: 'pending', + // The library no longer validates the wire response schema, so a generic + // item is always resolvable. The application validates the value itself. + canResolve: true, + validationGeneration: 0, + } + } + + private buildSnapshot( + transaction?: TransactionToken, + ): BoundInterrupts { + const hydration = this.requireHydration() + // `client-tool-execution` items stay in `this.items` (they gate batch + // submission and are resolved internally via auto-execution / addToolResult), + // but they are never surfaced as public bound interrupts. + // + // Items with status `submitting` are also omitted: the resume stream is + // already in flight, so Approve/Deny is not actionable. Keeping them in + // the public list made UIs look stuck after a successful approve and + // blocked follow-up turns that key off `interrupts.length`. + const next = this.items + .filter( + (item) => + item.kind !== 'client-tool-execution' && item.status !== 'submitting', + ) + .map((item) => { + const base = baseSnapshot( + item, + hydration, + () => this.cancelItem(item.descriptor.id, transaction), + () => this.clearItem(item.descriptor.id, transaction), + ) + // Not ours to resume: expose the descriptor so a UI can show the run + // is paused, with no `resolveInterrupt` to call. + if (item.kind === 'unbound' || item.binding === undefined) { + const snapshot: UnboundInterrupt = { + ...base, + kind: 'unbound', + canResolve: false, + } + return Object.freeze(snapshot) + } + if ( + item.kind === 'tool-approval' && + item.binding.kind === 'tool-approval' + ) { + const binding = cloneAndDeepFreezeJson(item.binding) + const snapshot = { + ...base, + kind: 'tool-approval' as const, + binding, + toolName: item.binding.toolName, + toolCallId: item.binding.toolCallId, + originalArgs: cloneAndDeepFreezeJson(item.binding.originalArgs), + resolveInterrupt: (approved: boolean, options?: unknown) => { + const details = isUnknownObject(options) ? options : undefined + this.resolveItem( + item.descriptor.id, + { + approved, + ...(approved && details?.['editedArgs'] !== undefined + ? { editedArgs: details['editedArgs'] } + : {}), + ...(details?.['payload'] !== undefined + ? { payload: details['payload'] } + : {}), + }, + transaction, + ) + }, + } + return Object.freeze(snapshot) + } + const boundGeneric = + item.binding.kind === 'generic' + ? cloneAndDeepFreezeJson(item.binding) + : cloneAndDeepFreezeJson({ + v: INTERRUPT_BINDING_VERSION, + kind: 'generic' as const, + interruptId: item.descriptor.id, + interruptedRunId: hydration.interruptedRunId, + generation: hydration.generation, + responseSchemaHash: + typeof item.binding.responseSchemaHash === 'string' + ? item.binding.responseSchemaHash + : 'none', + }) + const snapshot: GenericAGUIInterrupt = { + ...base, + kind: 'generic', + binding: boundGeneric, + resolveInterrupt: (payload) => + this.resolveItem(item.descriptor.id, payload, transaction), + } + return Object.freeze(snapshot) + }) + + // The runtime items are created only from the exact configured TTools entry + // selected by name. TypeScript cannot preserve that per-element lookup + // through Array.map, so this generic return boundary restores the proven + // distributive public union. + return Object.freeze(next) as BoundInterrupts + } + + private publish(): void { + if (!this.hydration) { + this.snapshot = Object.freeze([]) + this.state = Object.freeze({ + interrupts: this.snapshot, + pendingInterrupts: this.snapshot, + interruptErrors: this.rootErrors, + resuming: this.resuming, + }) + this.options.onChange?.() + return + } + this.snapshot = this.buildSnapshot() + this.state = Object.freeze({ + interrupts: this.snapshot, + pendingInterrupts: this.snapshot, + interruptErrors: this.rootErrors, + resuming: this.resuming, + }) + this.options.onChange?.() + } + + private resolveItem( + interruptId: string, + payload: unknown, + transaction?: TransactionToken, + ): void { + this.assertItemMutable(transaction) + const item = this.findItem(interruptId) + this.invalidateRetry() + if (!item.canResolve) { + item.status = 'error' + item.error = this.itemError( + interruptId, + 'invalid-response-schema', + 'The interrupt response schema is invalid and cannot be resolved.', + ) + if (!transaction) this.publish() + return + } + const validationGeneration = ++item.validationGeneration + const validation = this.validateCandidate(item, payload) + if (isPromiseLike(validation)) { + item.status = 'validating' + item.error = undefined + if (!transaction) this.publish() + void Promise.resolve(validation) + .then((result) => { + if (validationGeneration !== item.validationGeneration) return + this.applyValidation(item, result, transaction) + }) + .catch((error: unknown) => { + if (validationGeneration !== item.validationGeneration) return + this.applyValidation( + item, + { + code: this.validationCode(item), + message: error instanceof Error ? error.message : String(error), + }, + transaction, + ) + }) + return + } + this.applyValidation(item, validation, transaction) + } + + private cancelItem( + interruptId: string, + transaction?: TransactionToken, + ): void { + this.assertItemMutable(transaction) + const item = this.findItem(interruptId) + this.invalidateRetry() + item.validationGeneration++ + item.resolution = Object.freeze({ interruptId, status: 'cancelled' }) + item.status = 'staged' + item.error = undefined + if (!transaction) { + this.publish() + this.maybeSubmit() + } + } + + private clearItem(interruptId: string, transaction?: TransactionToken): void { + this.assertItemMutable(transaction) + const item = this.findItem(interruptId) + this.invalidateRetry() + item.validationGeneration++ + item.resolution = undefined + item.error = undefined + item.status = 'pending' + if (!transaction) this.publish() + } + + private maybeSubmit(): void { + // Unbound items can never be resolved through this path — something else + // owns them. Including them in the completeness gate would deadlock the + // batch, so the run's own interrupts could never be answered once a + // foreign one shared the stream. + const ours = this.items.filter((item) => item.kind !== 'unbound') + if ( + ours.length === 0 || + ours.some( + (item) => item.resolution === undefined || item.status !== 'staged', + ) + ) { + return + } + const hydration = this.requireHydration() + const canonical = canonicalizeInterruptResolutions( + ours.map((item) => item.resolution).filter((item) => item !== undefined), + ) + const submission = Object.freeze({ + threadId: hydration.threadId, + interruptedRunId: hydration.interruptedRunId, + generation: hydration.generation, + resolutions: canonical.resolutions, + canonicalResolutions: canonical.canonicalResolutions, + fingerprint: canonical.fingerprint, + }) + this.submitBatch(submission) + } + + private applyValidation( + item: RuntimeInterrupt, + result: ValidationResult, + transaction?: TransactionToken, + ): void { + if (!('valid' in result)) { + item.status = 'error' + const itemError = this.itemError( + item.descriptor.id, + result.code, + result.message, + result.path, + ) + item.error = itemError + // Client-tool-execution items are hidden from the public interrupt list, + // so promote their validation failures onto interruptErrors for the UI. + if (item.kind === 'client-tool-execution') { + this.rootErrors = Object.freeze([ + ...this.rootErrors.filter( + (error) => + !( + error.code === 'item-validation-failed' && + error.interruptIds.includes(item.descriptor.id) + ), + ), + Object.freeze({ + scope: 'batch' as const, + code: 'item-validation-failed' as const, + message: itemError.message, + source: 'client' as const, + retryable: false, + interruptIds: Object.freeze([item.descriptor.id]), + threadId: itemError.threadId, + interruptedRunId: itemError.interruptedRunId, + generation: itemError.generation, + }), + ]) + } + if (!transaction) this.publish() + return + } + item.resolution = cloneAndDeepFreezeJson({ + interruptId: item.descriptor.id, + status: 'resolved', + payload: result.payload, + }) + item.status = 'staged' + item.error = undefined + if (!transaction) { + this.publish() + this.maybeSubmit() + } + } + + private validateCandidate( + item: RuntimeInterrupt, + payload: unknown, + ): ValidationResult | Promise { + if (item.kind === 'generic') { + return validateWithSchema( + item.descriptor.responseSchema, + payload, + 'invalid-payload', + ) + } + if (item.kind === 'client-tool-execution') { + return validateWithSchema( + item.tool?.outputSchema, + payload, + 'invalid-tool-output', + ) + } + return this.validateApprovalCandidate(item, payload) + } + + private validateApprovalCandidate( + item: RuntimeInterrupt, + payload: unknown, + ): ValidationResult | Promise { + if (!isUnknownObject(payload) || typeof payload['approved'] !== 'boolean') { + return { + code: 'invalid-payload', + message: 'Tool approval resolutions require an approved boolean.', + } + } + const approved = payload['approved'] + const editedArgs = payload['editedArgs'] + if (!approved && editedArgs !== undefined) { + return { + code: 'invalid-edited-args', + message: 'Rejected tool approvals cannot edit tool arguments.', + } + } + if (approved && editedArgs !== undefined) { + const editedValidation = validateWithSchema( + item.tool?.inputSchema, + editedArgs, + 'invalid-edited-args', + ) + if (isPromiseLike(editedValidation)) { + return Promise.resolve(editedValidation).then((result) => + 'valid' in result + ? this.validateApprovalPayload(item, payload, result.payload) + : result, + ) + } + if (!('valid' in editedValidation)) return editedValidation + return this.validateApprovalPayload( + item, + payload, + editedValidation.payload, + ) + } + return this.validateApprovalPayload(item, payload, undefined) + } + + private validateApprovalPayload( + item: RuntimeInterrupt, + envelope: UnknownObject, + validatedEditedArgs: unknown, + ): ValidationResult | Promise { + const approved = envelope['approved'] === true + const schema = this.approvalBranchSchema(item.tool, approved) + const branchPayload = envelope['payload'] + if (schema === undefined && branchPayload !== undefined) { + return { + code: 'invalid-payload', + message: 'This approval branch does not accept a payload.', + } + } + if (schema !== undefined && branchPayload === undefined) { + return { + code: 'invalid-payload', + message: 'This approval branch requires a payload.', + } + } + const validation = validateWithSchema( + schema, + branchPayload, + 'invalid-payload', + ) + const buildEnvelope = (result: ValidationResult): ValidationResult => { + if (!('valid' in result)) return result + return { + valid: true, + payload: { + approved, + ...(validatedEditedArgs !== undefined + ? { editedArgs: validatedEditedArgs } + : {}), + ...(schema !== undefined ? { payload: result.payload } : {}), + }, + } + } + return isPromiseLike(validation) + ? Promise.resolve(validation).then(buildEnvelope) + : buildEnvelope(validation) + } + + private approvalBranchSchema( + tool: AnyClientTool | undefined, + approved: boolean, + ): unknown { + const approvalSchema: unknown = tool?.approvalSchema + if (!isUnknownObject(approvalSchema)) return approvalSchema + const hasBranches = + 'approve' in approvalSchema || 'reject' in approvalSchema + if (!hasBranches) return approvalSchema + return approved ? approvalSchema['approve'] : approvalSchema['reject'] + } + + private validationCode(item: RuntimeInterrupt): ItemInterruptError['code'] { + return item.kind === 'client-tool-execution' + ? 'invalid-tool-output' + : 'invalid-payload' + } + + private resolveBooleanBulk(approved: boolean): void { + // `client-tool-execution` items resolve out-of-band (auto execution / + // addToolResult); they are transparent to the boolean shorthand. Eligibility + // and resolution consider only the publicly resolvable items. + const resolvable = this.items.filter( + (item) => item.kind !== 'client-tool-execution', + ) + const eligible = resolvable.every( + (item) => + item.kind === 'tool-approval' && + this.approvalBranchSchema(item.tool, approved) === undefined, + ) + if (!eligible || resolvable.length === 0) { + this.addRootError( + 'unsupported-bulk-operation', + 'Boolean bulk resolution requires payloadless tool approvals.', + false, + ) + return + } + this.invalidateRetry() + for (const item of resolvable) { + item.validationGeneration++ + item.resolution = cloneAndDeepFreezeJson({ + interruptId: item.descriptor.id, + status: 'resolved', + payload: { approved }, + }) + item.status = 'staged' + item.error = undefined + } + this.publish() + this.maybeSubmit() + } + + private resolveTransaction( + resolver: (interrupt: ChatInterrupt) => unknown, + ): void { + const checkpoints = this.items.map((item) => ({ + status: item.status, + ...(item.resolution !== undefined ? { resolution: item.resolution } : {}), + ...(item.error !== undefined ? { error: item.error } : {}), + validationGeneration: item.validationGeneration, + })) + const token: TransactionToken = { active: true } + this.activeTransaction = token + const stable = this.buildSnapshot(token) + let failure: + | { code: BatchInterruptError['code']; message: string } + | undefined + try { + for (const interrupt of stable) { + const result = resolver(interrupt) + if (result !== undefined) { + failure = { + code: isPromiseLike(result) + ? 'async-resolver' + : 'inactive-transaction', + message: isPromiseLike(result) + ? 'Interrupt transaction resolvers must be synchronous.' + : 'Interrupt transaction resolvers must return literal undefined.', + } + break + } + } + if ( + failure === undefined && + this.items.some( + (item) => + // `client-tool-execution` items are resolved out-of-band (auto + // execution / addToolResult), not by this synchronous resolver, so + // they don't count against transaction completeness. `maybeSubmit` + // still gates the actual submission on them being resolved. + item.kind !== 'client-tool-execution' && + (item.resolution === undefined || item.status !== 'staged'), + ) + ) { + failure = { + code: 'incomplete-batch', + message: 'Interrupt transaction did not resolve every item.', + } + } + } catch (error) { + failure = { + code: 'item-validation-failed', + message: error instanceof Error ? error.message : String(error), + } + } finally { + token.active = false + this.activeTransaction = undefined + } + + if (failure) { + this.restoreCheckpoints(checkpoints) + this.addRootError(failure.code, failure.message, false) + return + } + this.publish() + this.maybeSubmit() + } + + private restoreCheckpoints( + checkpoints: ReadonlyArray, + ): void { + this.items.forEach((item, index) => { + const checkpoint = checkpoints[index] + if (!checkpoint) return + item.status = checkpoint.status + item.resolution = checkpoint.resolution + item.error = checkpoint.error + item.validationGeneration = checkpoint.validationGeneration + 1 + }) + this.publish() + } + + private assertItemMutable(transaction?: TransactionToken): void { + if (transaction && !transaction.active) { + throw new Error('Interrupt transaction is inactive.') + } + if (this.activeTransaction && transaction !== this.activeTransaction) { + throw new Error('Interrupt transaction is inactive.') + } + if (this.resuming) { + throw new Error('Interrupts cannot be mutated while submitting.') + } + } + + private assertRootMutable(): void { + if (this.activeTransaction) { + throw new Error('Interrupt transaction is already active.') + } + if (this.resuming) { + throw new Error('Interrupts cannot be mutated while submitting.') + } + } + + private invalidateRetry(): void { + this.retrySubmission = undefined + } + + private submitBatch(submission: InterruptManagerSubmission): void { + this.resuming = true + this.retrySubmission = undefined + for (const item of this.items) item.status = 'submitting' + this.publish() + void this.performSubmission(submission) + } + + private async performSubmission( + submission: InterruptManagerSubmission, + ): Promise { + try { + await this.options.submit(submission) + } catch (error) { + this.handleSubmissionFailure(error, submission) + } finally { + this.resuming = false + this.publish() + } + } + + private handleSubmissionFailure( + error: unknown, + submission: InterruptManagerSubmission, + ): void { + const errors = readSubmissionErrors(error) + if (errors.length === 0) { + const message = error instanceof Error ? error.message : String(error) + this.addRootError('transport', message, true, 'transport') + this.retrySubmission = submission + for (const item of this.items) item.status = 'error' + return + } + + const correlatedErrors = errors.filter((submissionError) => + submissionErrorMatchesActiveBatch(submissionError, submission), + ) + if (correlatedErrors.length !== errors.length) { + this.addRootError( + 'protocol', + 'Interrupt submission errors did not match the active batch.', + false, + ) + } + + let nonRetryable = false + let retryable = false + const batchErrors: Array = [] + for (const submissionError of correlatedErrors) { + if ( + submissionError.code === 'stale' || + submissionError.code === 'expired' || + submissionError.code === 'conflict' + ) { + nonRetryable = true + } + retryable ||= submissionError.retryable + if (submissionError.scope === 'item') { + const item = this.items.find( + (candidate) => + candidate.descriptor.id === submissionError.interruptId, + ) + if (item) { + item.status = 'error' + item.error = cloneAndDeepFreezeJson(submissionError) + } + } else { + batchErrors.push(cloneAndDeepFreezeJson(submissionError)) + } + } + const mergedBatchErrors = mergeSubmissionBatchErrors( + this.rootErrors, + this.submissionRootErrors, + batchErrors, + ) + this.rootErrors = mergedBatchErrors.rootErrors + this.submissionRootErrors = mergedBatchErrors.submissionRootErrors + for (const item of this.items) { + if (item.status === 'submitting') item.status = 'error' + } + this.retrySubmission = retryable && !nonRetryable ? submission : undefined + } + + private addRootError( + code: BatchInterruptError['code'], + message: string, + retryable: boolean, + source: BatchInterruptError['source'] = 'client', + ): void { + const hydration = this.requireHydration() + this.rootErrors = Object.freeze([ + ...this.rootErrors, + Object.freeze({ + scope: 'batch' as const, + code, + message, + source, + retryable, + interruptIds: Object.freeze( + this.items.map((item) => item.descriptor.id), + ), + threadId: hydration.threadId, + interruptedRunId: hydration.interruptedRunId, + generation: hydration.generation, + }), + ]) + this.publish() + } + + private findItem(interruptId: string): RuntimeInterrupt { + const item = this.items.find( + (candidate) => candidate.descriptor.id === interruptId, + ) + if (!item) throw new Error(`Unknown interrupt: ${interruptId}`) + return item + } + + private requireHydration(): InterruptManagerHydration { + if (!this.hydration) throw new Error('InterruptManager is not hydrated.') + return this.hydration + } + + private itemError( + interruptId: string, + code: ItemInterruptError['code'], + message: string, + path?: ReadonlyArray, + ): ItemInterruptError { + const hydration = this.requireHydration() + return Object.freeze({ + scope: 'item', + interruptId, + code, + message, + ...(path !== undefined ? { path: Object.freeze([...path]) } : {}), + source: 'client', + retryable: false, + threadId: hydration.threadId, + interruptedRunId: hydration.interruptedRunId, + generation: hydration.generation, + }) + } +} diff --git a/packages/ai-client/src/types.ts b/packages/ai-client/src/types.ts index 8632afbd7..57a3fdd2e 100644 --- a/packages/ai-client/src/types.ts +++ b/packages/ai-client/src/types.ts @@ -1,13 +1,24 @@ import type { AnyClientTool, + ApprovalCapabilityOf, + ApprovalSchemaOf, AudioPart, + BatchInterruptError, ChunkStrategy, ContentPart, DocumentPart, ImagePart, + InferSchemaType, InferToolInput, InferToolOutput, + InputSchemaOf, + Interrupt, + InterruptBinding, + ItemInterruptError, ModelMessage, + NoSchema, + RunAgentResumeItem, + SchemaInput, StreamChunk, StructuredOutputPart, UIResourcePart, @@ -17,7 +28,184 @@ import type { ConnectionAdapter } from './connection-adapters' import type { AIDevtoolsClientMetadata } from './devtools' import type { ChatDevtoolsBridgeFactory } from './devtools-noop' -export type { StructuredOutputPart } from '@tanstack/ai/client' +export type { StructuredOutputPart } + +export interface ChatResumeState { + threadId: string + runId: string +} + +export type ChatPendingInterrupt = Interrupt + +export interface ChatResumeSnapshotV1 { + schemaVersion?: 1 + resumeState: ChatResumeState + pendingInterrupts?: Array +} + +export interface ChatResumeSnapshotV2 { + schemaVersion: 2 + resumeState: ChatResumeState + pendingInterrupts?: Array +} + +export type ChatResumeSnapshot = ChatResumeSnapshotV1 | ChatResumeSnapshotV2 + +export type InterruptItemStatus = + | 'pending' + | 'validating' + | 'staged' + | 'submitting' + | 'error' + +export interface BoundInterruptBase { + readonly id: string + readonly interruptId: string + readonly reason: string + readonly message?: string + readonly responseSchema?: Readonly> + readonly expiresAt?: string + readonly metadata?: Readonly> + readonly threadId: string + readonly interruptedRunId: string + readonly generation: number + readonly status: InterruptItemStatus + readonly errors: ReadonlyArray + /** @deprecated Use `errors[0]`. */ + readonly error?: ItemInterruptError + /** + * Whether the binding/schema allows resolution at hydrate time. + * Does not flip on submit/expiry — gate UI on `status`, `resuming`, and + * `errors` for those lifecycle states. + */ + readonly canResolve: boolean + cancel: () => void + clearResolution: () => void +} + +export interface GenericAGUIInterrupt extends BoundInterruptBase { + readonly kind: 'generic' + readonly binding: Readonly> + resolveInterrupt: (payload: unknown) => void +} + +/** + * An interrupt that arrived on the stream carrying no resume binding this + * client understands — no `tanstack:interruptBinding`, or one written at a + * protocol version we don't recognise. + * + * These are surfaced rather than hidden so a UI can show that the run is + * paused, but they are never resolvable here: something else owns them. A + * workflow engine's durable approval projected into the same AG-UI stream + * lands in this bucket, and resolving it through the chat resume path would + * send an answer no one is waiting for. Render it, or route it to whatever + * actually owns the pause. + */ +export interface UnboundInterrupt extends BoundInterruptBase { + readonly kind: 'unbound' + readonly binding?: undefined + readonly canResolve: false +} + +type ApprovalBranchSchema = + ApprovalSchemaOf extends infer TApproval + ? TApproval extends { approve?: SchemaInput; reject?: SchemaInput } + ? Exclude + : TApproval extends SchemaInput + ? TApproval + : never + : never + +type ApprovalEdits = + InputSchemaOf extends NoSchema + ? { editedArgs?: never } + : { editedArgs?: InferToolInput } + +type ApprovalPayload = [TSchema] extends [never] + ? { payload?: never } + : TSchema extends SchemaInput + ? { payload: InferSchemaType } + : { payload?: never } + +type ApproveArguments = [ + ApprovalBranchSchema, +] extends [never] + ? InputSchemaOf extends NoSchema + ? [options?: never] + : [options?: ApprovalEdits & { payload?: never }] + : [ + options: ApprovalEdits & + ApprovalPayload>, + ] + +type RejectArguments = [ApprovalBranchSchema] extends [ + never, +] + ? [options?: never] + : [ + options: { editedArgs?: never } & ApprovalPayload< + ApprovalBranchSchema + >, + ] + +export type ToolApprovalInterrupt = + TTool extends AnyClientTool + ? BoundInterruptBase & { + readonly kind: 'tool-approval' + readonly binding: Readonly< + Extract + > + readonly toolName: TTool['name'] + readonly toolCallId: string + readonly originalArgs: InferToolInput + // A single generic call signature — not two overloads. Overloads break + // editor autocomplete: a half-typed options literal (e.g. + // `resolveInterrupt(true, { payload: {` ) satisfies neither overload, + // so TS resolves no signature and offers no contextual completions. + // Making `approved` a generic discriminant lets TS infer it from the + // first argument and pick the matching branch for the rest params, so + // `payload` / `editedArgs` / the correct schema's fields complete + // per-branch (a plain union-of-tuples would offer both branches' + // fields) while still enforcing the right shape. + resolveInterrupt: ( + approved: TApproved, + ...args: TApproved extends true + ? ApproveArguments + : RejectArguments + ) => void + } + : never + +type ApprovalInterrupts> = + TTools[number] extends infer TTool + ? TTool extends AnyClientTool + ? ApprovalCapabilityOf extends true + ? ToolApprovalInterrupt + : never + : never + : never + +// Client tools resolve through their `.client()` implementation (auto-run) or +// `addToolResult` — never as a bound interrupt. The `client-tool-execution` +// pause is handled internally and is intentionally absent from this public +// union. +export type ChatInterrupt< + TTools extends ReadonlyArray = ReadonlyArray, +> = GenericAGUIInterrupt | UnboundInterrupt | ApprovalInterrupts + +export type BoundInterrupts< + TTools extends ReadonlyArray = ReadonlyArray, +> = ReadonlyArray> + +export interface ChatInterruptState< + TTools extends ReadonlyArray = ReadonlyArray, +> { + readonly interrupts: BoundInterrupts + /** @deprecated Use `interrupts`. Same snapshot today. */ + readonly pendingInterrupts: BoundInterrupts + readonly interruptErrors: ReadonlyArray + readonly resuming: boolean +} /** * `messages` is the full UIMessage history (not a delta). `data` is the @@ -31,6 +219,8 @@ export interface ChatFetcherInput { data?: Record threadId: string runId: string + parentRunId?: string + resume?: Array } export interface ChatFetcherOptions { @@ -471,7 +661,9 @@ export interface ChatClientBaseOptions< initialMessages?: Array> /** - * Optional persistence adapter for chat messages. + * Optional persistence adapter for chat messages (UIMessage[] by chat id). + * Durable interrupt resume storage is not part of this surface — use + * `initialResumeSnapshot` for in-memory rehydrate after a host-managed load. */ persistence?: ChatClientPersistence @@ -487,6 +679,13 @@ export interface ChatClientBaseOptions< */ threadId?: string + /** + * Initial resumable run state, useful when rehydrating a persisted client + * after a full page reload. This restores the client-side interrupt + * descriptors needed to send AG-UI resume entries. + */ + initialResumeSnapshot?: ChatResumeSnapshot + /** * Arbitrary client-controlled JSON forwarded to the server in the * AG-UI `RunAgentInput.forwardedProps` field. Use this for per-session @@ -591,6 +790,17 @@ export interface ChatClientBaseOptions< */ onQueueChange?: (queue: Array) => void + /** + * Callback when resumable run state or pending interrupts change. + */ + onResumeStateChange?: ( + resumeState: ChatResumeState | null, + pendingInterrupts: BoundInterrupts, + ) => void + + /** Callback when the immutable interrupt state snapshot changes. */ + onInterruptStateChange?: (state: ChatInterruptState) => void + /** * Callback when a custom event is received from a server-side tool. * Custom events are emitted by tools using `context.emitCustomEvent()` during execution. diff --git a/packages/ai-client/tests/chat-client-approval-followup.test.ts b/packages/ai-client/tests/chat-client-approval-followup.test.ts new file mode 100644 index 000000000..49e5cb390 --- /dev/null +++ b/packages/ai-client/tests/chat-client-approval-followup.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it, vi } from 'vitest' +import { + EventType, + hashSchemaInput, + normalizeApprovalSchema, + toolDefinition, +} from '@tanstack/ai/client' +import { z } from 'zod' +import { ChatClient } from '../src/chat-client' +import type { ConnectConnectionAdapter } from '../src/connection-adapters' +import type { StreamChunk } from '@tanstack/ai/client' + +const cartTool = toolDefinition({ + name: 'addToCart', + description: 'Add to cart', + inputSchema: z.object({ + guitarId: z.string(), + quantity: z.number(), + }), + needsApproval: true, +}).client((args) => ({ success: true, ...args })) + +function adapter(scripts: Array<(ctx: unknown) => Array>) { + let i = 0 + const contexts: Array = [] + const a: ConnectConnectionAdapter = { + // eslint-disable-next-line @typescript-eslint/require-await + async *connect(_messages, _d, _s, runContext) { + contexts.push(runContext) + const script = scripts[i++] + for (const c of script?.(runContext) ?? []) yield c + }, + } + return { adapter: a, contexts } +} + +describe('tool-approval follow-up after resolve', () => { + it('clears interrupts and allows a second user turn', async () => { + const approval = normalizeApprovalSchema(undefined, cartTool.inputSchema) + const input = { guitarId: '1', quantity: 1 } + type Ctx = { runId?: string; threadId?: string } | undefined + const { adapter: a, contexts } = adapter([ + // 1) interrupt + (ctx) => { + const c = ctx as Ctx + const runId = c?.runId ?? 'r1' + const threadId = c?.threadId ?? 't1' + return [ + { + type: EventType.RUN_STARTED, + runId, + threadId, + timestamp: Date.now(), + }, + { + type: EventType.RUN_FINISHED, + runId, + threadId, + timestamp: Date.now(), + outcome: { + type: 'interrupt', + interrupts: [ + { + id: 'approval_tc1', + reason: 'tool_call', + toolCallId: 'tc1', + message: 'Approval required', + responseSchema: approval.responseSchema, + metadata: { + kind: 'approval', + toolName: 'addToCart', + input, + 'tanstack:interruptBinding': { + kind: 'tool-approval', + interruptId: 'approval_tc1', + interruptedRunId: runId, + generation: 0, + toolName: 'addToCart', + toolCallId: 'tc1', + originalArgs: input, + inputSchemaHash: hashSchemaInput(cartTool.inputSchema), + approvalSchemaHash: approval.approvalSchemaHash, + responseSchemaHash: approval.responseSchemaHash, + }, + }, + }, + ], + }, + }, + ] + }, + // 2) resume continuation + (ctx) => { + const c = ctx as Ctx + const runId = c?.runId ?? 'r2' + const threadId = c?.threadId ?? 't1' + return [ + { + type: EventType.RUN_STARTED, + runId, + threadId, + timestamp: Date.now(), + }, + { + type: EventType.TEXT_MESSAGE_START, + messageId: 'm1', + role: 'assistant', + timestamp: Date.now(), + }, + { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: 'm1', + delta: "I've added the Fender Stratocaster to your cart.", + timestamp: Date.now(), + }, + { + type: EventType.TEXT_MESSAGE_END, + messageId: 'm1', + timestamp: Date.now(), + }, + { + type: EventType.RUN_FINISHED, + runId, + threadId, + timestamp: Date.now(), + outcome: { type: 'success' }, + }, + ] + }, + // 3) follow-up + (ctx) => { + const c = ctx as Ctx + const runId = c?.runId ?? 'r3' + const threadId = c?.threadId ?? 't1' + return [ + { + type: EventType.RUN_STARTED, + runId, + threadId, + timestamp: Date.now(), + }, + { + type: EventType.TEXT_MESSAGE_START, + messageId: 'm2', + role: 'assistant', + timestamp: Date.now(), + }, + { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: 'm2', + delta: 'Here is the follow-up reply: nothing else needed.', + timestamp: Date.now(), + }, + { + type: EventType.TEXT_MESSAGE_END, + messageId: 'm2', + timestamp: Date.now(), + }, + { + type: EventType.RUN_FINISHED, + runId, + threadId, + timestamp: Date.now(), + outcome: { type: 'success' }, + }, + ] + }, + ]) + + const client = new ChatClient({ + connection: a, + threadId: 't1', + tools: [cartTool], + }) + + await client.sendMessage('[approval] add the stratocaster to my cart') + expect(client.getInterrupts()).toHaveLength(1) + const interrupt = client.getInterrupts()[0] + expect(interrupt?.kind).toBe('tool-approval') + if (interrupt?.kind !== 'tool-approval') { + throw new Error('expected tool-approval') + } + interrupt.resolveInterrupt(true) + + await vi.waitFor(() => { + expect(client.getInterrupts()).toEqual([]) + expect(client.getResumeState()).toBeNull() + }) + + await client.sendMessage('[approval] follow-up: anything else?') + expect(contexts).toHaveLength(3) + const followUpText = client + .getMessages() + .flatMap((m) => m.parts) + .filter((p) => p.type === 'text') + .map((p) => ('content' in p ? String(p.content) : '')) + .join('\n') + expect(followUpText).toContain('follow-up') + }) +}) diff --git a/packages/ai-client/tests/chat-client-interrupt-correlation.test.ts b/packages/ai-client/tests/chat-client-interrupt-correlation.test.ts new file mode 100644 index 000000000..7b41260a0 --- /dev/null +++ b/packages/ai-client/tests/chat-client-interrupt-correlation.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it, vi } from 'vitest' +import { EventType } from '@tanstack/ai/client' +import { ChatClient } from '../src/chat-client' +import type { Interrupt, StreamChunk } from '@tanstack/ai/client' +import type { + RunAgentInputContext, + SubscribeConnectionAdapter, +} from '../src/connection-adapters' + +function createNativeQueue() { + const chunks: Array = [] + const contexts: Array = [] + let wake: (() => void) | undefined + const connection: SubscribeConnectionAdapter = { + async *subscribe(signal) { + while (!signal?.aborted) { + const chunk = chunks.shift() + if (chunk) { + yield chunk + continue + } + await new Promise((resolve) => { + wake = resolve + signal?.addEventListener('abort', () => resolve(), { once: true }) + }) + } + }, + send: (_messages, _data, _signal, context) => { + contexts.push(context) + return Promise.resolve() + }, + } + return { + connection, + contexts, + publish(chunk: StreamChunk) { + chunks.push(chunk) + const resolve = wake + wake = undefined + resolve?.() + }, + } +} + +const interruptedRunId = 'interrupted-run' +const pendingInterrupt: Interrupt = { + id: 'generic-1', + reason: 'confirmation', + metadata: { + 'tanstack:interruptBinding': { + kind: 'generic', + interruptId: 'generic-1', + interruptedRunId, + generation: 1, + responseSchemaHash: 'none', + }, + }, +} + +describe('ChatClient interrupt error correlation', () => { + it('does not apply a foreign shared RUN_ERROR to a local interrupt submission', async () => { + const { connection, contexts, publish } = createNativeQueue() + const onChunk = vi.fn() + const client = new ChatClient({ + connection, + onChunk, + initialResumeSnapshot: { + schemaVersion: 2, + resumeState: { threadId: 'thread-1', runId: interruptedRunId }, + pendingInterrupts: [pendingInterrupt], + }, + }) + const interrupt = client.getInterrupts()[0] + if (interrupt?.kind !== 'generic') { + throw new Error('Expected a generic interrupt') + } + + interrupt.resolveInterrupt({ answer: 'continue' }) + await vi.waitFor(() => expect(contexts).toHaveLength(1)) + publish({ + type: EventType.RUN_ERROR, + threadId: 'foreign-thread', + runId: 'foreign-child-run', + timestamp: Date.now(), + message: 'foreign run failed', + 'tanstack:interruptErrors': [ + { + scope: 'item', + interruptId: 'generic-1', + code: 'invalid-payload', + message: 'foreign item error', + source: 'server', + retryable: false, + threadId: 'foreign-thread', + interruptedRunId: 'foreign-parent-run', + generation: 7, + }, + { + scope: 'batch', + code: 'item-validation-failed', + message: 'foreign batch error', + source: 'server', + retryable: false, + interruptIds: ['generic-1'], + threadId: 'foreign-thread', + interruptedRunId: 'foreign-parent-run', + generation: 7, + }, + ], + }) + + await vi.waitFor(() => + expect(client.getInterruptState().resuming).toBe(false), + ) + expect(onChunk).toHaveBeenCalledWith( + expect.objectContaining({ runId: 'foreign-child-run' }), + ) + expect(client.getError()?.message).toBe('foreign run failed') + expect(client.getInterrupts()[0]?.errors).toEqual([]) + expect(client.getInterruptState().interruptErrors).toEqual([ + expect.objectContaining({ code: 'transport', source: 'transport' }), + ]) + + client.retryInterrupts() + await vi.waitFor(() => expect(contexts).toHaveLength(2)) + const localRunId = contexts[1]?.runId + if (!localRunId) throw new Error('Expected a local continuation run ID') + publish({ + type: EventType.RUN_ERROR, + threadId: 'thread-1', + runId: localRunId, + timestamp: Date.now(), + message: 'local validation failed', + 'tanstack:interruptErrors': [ + { + scope: 'item', + interruptId: 'generic-1', + code: 'invalid-payload', + message: 'local item error', + source: 'server', + retryable: false, + threadId: 'thread-1', + interruptedRunId, + generation: 1, + }, + { + scope: 'batch', + code: 'item-validation-failed', + message: 'local batch error', + source: 'server', + retryable: false, + interruptIds: ['generic-1'], + threadId: 'thread-1', + interruptedRunId, + generation: 1, + }, + ], + }) + + await vi.waitFor(() => + expect(client.getInterrupts()[0]?.errors).toMatchObject([ + { message: 'local item error', source: 'server' }, + ]), + ) + expect(client.getInterruptState().interruptErrors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'transport', source: 'transport' }), + expect.objectContaining({ + code: 'item-validation-failed', + message: 'local batch error', + source: 'server', + }), + ]), + ) + expect(JSON.stringify(client.getInterruptState())).not.toContain('foreign') + client.dispose() + }) +}) diff --git a/packages/ai-client/tests/chat-client-interrupts.test.ts b/packages/ai-client/tests/chat-client-interrupts.test.ts new file mode 100644 index 000000000..ec2c2f3b9 --- /dev/null +++ b/packages/ai-client/tests/chat-client-interrupts.test.ts @@ -0,0 +1,1338 @@ +import { describe, expect, it, vi } from 'vitest' +import { chat } from '@tanstack/ai' +import { + EventType, + canonicalInterruptJson, + convertSchemaToJsonSchema, + digestInterruptJson, + hashSchemaInput, + normalizeApprovalSchema, + toolDefinition, +} from '@tanstack/ai/client' +import { z } from 'zod' +import { INTERRUPT_BINDING_VERSION } from '@tanstack/ai/client' +import { InterruptManager } from '../src/interrupt-manager' +import { ChatClient } from '../src/chat-client' +import type { + AnyTextAdapter, + InterruptSubmissionError, + StreamChunk, +} from '@tanstack/ai' +import type { + Interrupt, + InterruptBinding, + ModelMessage, +} from '@tanstack/ai/client' +import type { StandardSchemaV1 } from '@standard-schema/spec' +import type { InterruptManagerSubmission } from '../src/interrupt-manager' +import type { ChatInterrupt } from '../src/types' +import type { + ConnectConnectionAdapter, + RunAgentInputContext, +} from '../src/connection-adapters' +import type { UIMessage } from '../src/types' + +const transferDefinition = 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() }), + }, +}) + +const lookupDefinition = toolDefinition({ + name: 'lookup', + description: 'Look up an account', + outputSchema: z.object({ accountId: z.string() }), +}) + +const tools = [transferDefinition.client(), lookupDefinition.client()] as const + +function descriptor( + binding: InterruptBinding, + overrides: Partial = {}, +): Interrupt { + return { + id: binding.interruptId, + reason: + binding.kind === 'tool-approval' + ? 'tool_call' + : binding.kind === 'client-tool-execution' + ? 'tanstack:client_tool_execution' + : 'confirmation', + ...(binding.kind !== 'generic' && { toolCallId: binding.toolCallId }), + metadata: { 'tanstack:interruptBinding': binding }, + ...overrides, + } +} + +function createManager() { + const submit = vi.fn( + async (_submission: InterruptManagerSubmission) => undefined, + ) + const manager = new InterruptManager({ tools, submit }) + return { manager, submit } +} + +function genericDescriptor(id: string): Interrupt { + return descriptor({ + v: INTERRUPT_BINDING_VERSION, + kind: 'generic', + interruptId: id, + interruptedRunId: 'run-1', + generation: 1, + responseSchemaHash: 'none', + }) +} + +async function settle(): Promise { + await Promise.resolve() + await Promise.resolve() +} + +describe('InterruptManager foreign-interrupt handling', () => { + it('surfaces an interrupt with no binding as unbound rather than resolvable generic', () => { + const { manager } = createManager() + manager.hydrate({ + threadId: 'thread-1', + interruptedRunId: 'run-1', + generation: 1, + interrupts: [ + { + id: 'workflow-approval-1', + reason: 'approval_requested', + message: 'Approve the deployment?', + metadata: { 'acme:workflowApproval': { stepId: 'deploy' } }, + }, + ], + }) + + const [item] = manager.getInterrupts() + expect(item?.kind).toBe('unbound') + expect(item?.canResolve).toBe(false) + // No resume affordance: resolving would submit an answer against a run + // that has no matching pending descriptor. + expect(item && 'resolveInterrupt' in item).toBe(false) + // Still visible, so a UI can show the run is paused. + expect(item?.message).toBe('Approve the deployment?') + }) + + it('rejects a binding written at an unknown protocol version', () => { + const { manager } = createManager() + manager.hydrate({ + threadId: 'thread-1', + interruptedRunId: 'run-1', + generation: 1, + interrupts: [ + { + id: 'future-1', + reason: 'confirmation', + metadata: { + 'tanstack:interruptBinding': { + v: INTERRUPT_BINDING_VERSION + 1, + kind: 'generic', + interruptId: 'future-1', + interruptedRunId: 'run-1', + generation: 1, + responseSchemaHash: 'none', + }, + }, + }, + ], + }) + + const [item] = manager.getInterrupts() + expect(item?.kind).toBe('unbound') + expect(item?.canResolve).toBe(false) + }) + + it('does not let an unbound interrupt block submission of the bound ones', async () => { + const { manager, submit } = createManager() + manager.hydrate({ + threadId: 'thread-1', + interruptedRunId: 'run-1', + generation: 1, + interrupts: [ + descriptor({ + v: INTERRUPT_BINDING_VERSION, + kind: 'generic', + interruptId: 'ours', + interruptedRunId: 'run-1', + generation: 1, + responseSchemaHash: 'none', + }), + { + id: 'theirs', + reason: 'approval_requested', + metadata: {}, + }, + ], + }) + + manager.getInterrupts().forEach((item) => { + if (item.kind === 'generic') item.resolveInterrupt({ ok: true }) + }) + await settle() + + expect(submit).toHaveBeenCalledTimes(1) + expect( + submit.mock.calls[0]?.[0].resolutions.map((r) => r.interruptId), + ).toEqual(['ours']) + }) +}) + +describe('InterruptManager hydration', () => { + it('hydrates correlated approval and client-tool bindings into frozen typed snapshots', () => { + const approval = normalizeApprovalSchema( + transferDefinition.approvalSchema, + transferDefinition.inputSchema, + ) + const approvalBinding: InterruptBinding = { + v: INTERRUPT_BINDING_VERSION, + kind: 'tool-approval', + interruptId: 'approval-1', + interruptedRunId: 'run-1', + generation: 3, + toolName: 'transfer', + toolCallId: 'call-1', + originalArgs: { cents: 100 }, + inputSchemaHash: hashSchemaInput(transferDefinition.inputSchema), + approvalSchemaHash: approval.approvalSchemaHash, + responseSchemaHash: approval.responseSchemaHash, + } + const outputSchemaHash = hashSchemaInput(lookupDefinition.outputSchema) + const clientBinding: InterruptBinding = { + v: INTERRUPT_BINDING_VERSION, + kind: 'client-tool-execution', + interruptId: 'client-1', + interruptedRunId: 'run-1', + generation: 3, + toolName: 'lookup', + toolCallId: 'call-2', + outputSchemaHash, + responseSchemaHash: outputSchemaHash, + } + const { manager } = createManager() + + manager.hydrate({ + threadId: 'thread-1', + interruptedRunId: 'run-1', + generation: 3, + interrupts: [ + descriptor(approvalBinding, { + responseSchema: approval.responseSchema, + }), + descriptor(clientBinding), + ], + }) + + // The client-tool-execution item is hydrated internally but never surfaced + // publicly — only the approval appears in the bound array. + const snapshot = manager.getInterrupts() + expect(snapshot.map((item) => item.kind)).toEqual(['tool-approval']) + expect(Object.isFrozen(snapshot)).toBe(true) + expect(Object.isFrozen(snapshot[0])).toBe(true) + expect(Object.isFrozen(snapshot[0]?.binding)).toBe(true) + }) + + it('hydrates a real core client-tool terminal with distinct schema identity hashes', async () => { + const coreChunks: Array = [ + { + type: EventType.RUN_STARTED, + runId: 'core-run', + threadId: 'core-thread', + timestamp: 1, + }, + { + type: EventType.TOOL_CALL_START, + toolCallId: 'core-call', + toolCallName: lookupDefinition.name, + toolName: lookupDefinition.name, + timestamp: 1, + }, + { + type: EventType.TOOL_CALL_ARGS, + toolCallId: 'core-call', + delta: '{}', + timestamp: 1, + }, + { + type: EventType.RUN_FINISHED, + runId: 'core-run', + threadId: 'core-thread', + finishReason: 'tool_calls', + timestamp: 1, + }, + ] + const adapter: AnyTextAdapter = { + kind: 'text', + name: 'core-interrupt-test', + model: 'test-model', + '~types': { + providerOptions: {}, + inputModalities: ['text'], + messageMetadataByModality: { + text: undefined, + image: undefined, + audio: undefined, + video: undefined, + document: undefined, + }, + toolCapabilities: [], + toolCallMetadata: undefined, + systemPromptMetadata: undefined, + }, + chatStream: () => + (async function* () { + await Promise.resolve() + for (const chunk of coreChunks) yield chunk + })(), + structuredOutput: () => Promise.resolve({ data: {}, rawText: '{}' }), + } + // The ephemeral server path stamps the interrupt binding with + // interruptedRunId + generation 0 (no persistence gateway involved). + const emitted: Array = [] + for await (const chunk of chat({ + adapter, + messages: [{ role: 'user', content: 'Look up an account' }], + tools: [tools[1]], + runId: 'core-run', + threadId: 'core-thread', + })) { + emitted.push(chunk) + } + const terminal = emitted.find( + (chunk) => + chunk.type === EventType.RUN_FINISHED && + chunk.outcome?.type === 'interrupt', + ) + if ( + terminal?.type !== EventType.RUN_FINISHED || + terminal.outcome?.type !== 'interrupt' + ) { + throw new Error('Expected a real core interrupt terminal.') + } + const interrupt = terminal.outcome.interrupts[0] + if (!interrupt) throw new Error('Expected a client-tool interrupt.') + const rawBinding = interrupt.metadata?.['tanstack:interruptBinding'] + if ( + rawBinding === null || + typeof rawBinding !== 'object' || + Array.isArray(rawBinding) + ) { + throw new Error('Expected a public interrupt binding.') + } + const binding = Object.fromEntries(Object.entries(rawBinding)) + const expectedOutputSchemaHash = hashSchemaInput( + lookupDefinition.outputSchema, + ) + const expectedResponseSchema = + convertSchemaToJsonSchema(lookupDefinition.outputSchema) ?? {} + const expectedResponseSchemaHash = digestInterruptJson( + canonicalInterruptJson(expectedResponseSchema), + ) + expect(binding.outputSchemaHash).toBe(expectedOutputSchemaHash) + expect(binding.responseSchemaHash).toBe(expectedResponseSchemaHash) + expect(binding.outputSchemaHash).not.toBe(binding.responseSchemaHash) + + const { manager } = createManager() + manager.hydrate({ + threadId: 'core-thread', + interruptedRunId: 'core-run', + generation: 0, + interrupts: terminal.outcome.interrupts, + }) + // A correctly-correlated client-tool item is internal only — not public. + expect(manager.getInterrupts()).toHaveLength(0) + + manager.hydrate({ + threadId: 'core-thread', + interruptedRunId: 'core-run', + generation: 0, + interrupts: [ + { + ...interrupt, + metadata: { + ...interrupt.metadata, + 'tanstack:interruptBinding': { + ...binding, + outputSchemaHash: 'sha256:configured-schema-drift', + }, + }, + }, + ], + }) + expect(manager.getInterrupts()[0]?.kind).toBe('generic') + }) + + it('keeps deprecated approval and client-tool reason aliases compatible', () => { + const approval = normalizeApprovalSchema( + transferDefinition.approvalSchema, + transferDefinition.inputSchema, + ) + const approvalBinding: InterruptBinding = { + v: INTERRUPT_BINDING_VERSION, + kind: 'tool-approval', + interruptId: 'approval-legacy', + interruptedRunId: 'run-legacy', + generation: 1, + toolName: 'transfer', + toolCallId: 'call-approval-legacy', + originalArgs: { cents: 100 }, + inputSchemaHash: hashSchemaInput(transferDefinition.inputSchema), + approvalSchemaHash: approval.approvalSchemaHash, + responseSchemaHash: approval.responseSchemaHash, + } + const outputSchemaHash = hashSchemaInput(lookupDefinition.outputSchema) + const clientBinding: InterruptBinding = { + v: INTERRUPT_BINDING_VERSION, + kind: 'client-tool-execution', + interruptId: 'client-legacy', + interruptedRunId: 'run-legacy', + generation: 1, + toolName: 'lookup', + toolCallId: 'call-client-legacy', + outputSchemaHash, + responseSchemaHash: outputSchemaHash, + } + const { manager } = createManager() + + manager.hydrate({ + threadId: 'thread-legacy', + interruptedRunId: 'run-legacy', + generation: 1, + interrupts: [ + descriptor(approvalBinding, { + reason: 'approval_required', + responseSchema: approval.responseSchema, + }), + descriptor(clientBinding, { reason: 'client_tool_input' }), + ], + }) + + // The legacy client-tool alias hydrates internally but stays out of the + // public bound array; only the approval is surfaced. + expect(manager.getInterrupts().map((interrupt) => interrupt.kind)).toEqual([ + 'tool-approval', + ]) + }) + + it('degrades mismatched tool correlation to generic without trusting wire correlation', () => { + const outputSchemaHash = hashSchemaInput(lookupDefinition.outputSchema) + const binding: InterruptBinding = { + v: INTERRUPT_BINDING_VERSION, + kind: 'client-tool-execution', + interruptId: 'client-1', + interruptedRunId: 'untrusted-run', + generation: 99, + toolName: 'lookup', + toolCallId: 'expected-call', + outputSchemaHash, + responseSchemaHash: outputSchemaHash, + } + const { manager } = createManager() + + manager.hydrate({ + threadId: 'thread-1', + interruptedRunId: 'run-1', + generation: 3, + interrupts: [descriptor(binding, { toolCallId: 'other-call' })], + }) + + expect(manager.getInterrupts()[0]).toMatchObject({ + kind: 'generic', + threadId: 'thread-1', + interruptedRunId: 'run-1', + generation: 3, + }) + }) + + it('resolves a generic item regardless of its wire response schema', () => { + const binding: InterruptBinding = { + v: INTERRUPT_BINDING_VERSION, + kind: 'generic', + interruptId: 'generic-1', + interruptedRunId: 'run-1', + generation: 1, + responseSchemaHash: 'any-schema', + } + const { manager } = createManager() + manager.hydrate({ + threadId: 'thread-1', + interruptedRunId: 'run-1', + generation: 1, + interrupts: [ + descriptor(binding, { + // The library does not compile or validate the wire schema, so even a + // schema in another dialect leaves the item resolvable. The + // application validates the value itself before resolving. + responseSchema: { + $schema: 'https://json-schema.org/draft/2019-09/schema', + type: 'object', + }, + }), + ], + }) + + const item = manager.getInterrupts()[0] + expect(item?.kind).toBe('generic') + expect(item?.canResolve).toBe(true) + item?.cancel() + // Cancellation immediately submits the batch; submitting items are omitted + // from the public interrupt list (not user-actionable while the resume + // stream is in flight). + expect(manager.getInterrupts()).toEqual([]) + expect(manager.getResuming()).toBe(true) + }) +}) + +describe('InterruptManager transactions', () => { + it('waits for a complete multi-item batch and submits a singleton immediately', async () => { + const multi = createManager() + multi.manager.hydrate({ + threadId: 'thread-1', + interruptedRunId: 'run-1', + generation: 1, + interrupts: [genericDescriptor('one'), genericDescriptor('two')], + }) + + const first = multi.manager.getInterrupts()[0] + if (first?.kind !== 'generic') throw new Error('Expected generic interrupt') + first.resolveInterrupt('first') + expect(multi.submit).not.toHaveBeenCalled() + expect(multi.manager.getInterrupts()[0]?.status).toBe('staged') + multi.manager.getInterrupts()[1]?.cancel() + expect(multi.submit).toHaveBeenCalledTimes(1) + + const single = createManager() + single.manager.hydrate({ + threadId: 'thread-1', + interruptedRunId: 'run-1', + generation: 1, + interrupts: [genericDescriptor('only')], + }) + const only = single.manager.getInterrupts()[0] + if (only?.kind !== 'generic') throw new Error('Expected generic interrupt') + only.resolveInterrupt('done') + expect(single.submit).toHaveBeenCalledTimes(1) + await settle() + }) + + it('validates async Standard Schema candidates and preserves a prior valid draft', async () => { + const asyncOutputSchema: StandardSchemaV1 = + { + '~standard': { + version: 1, + vendor: 'test', + validate: async (value) => + isAccountOutput(value) + ? { value } + : { issues: [{ message: 'accountId is required' }] }, + }, + } + const asyncTool = toolDefinition({ + name: 'asyncLookup', + description: 'Async validation', + outputSchema: asyncOutputSchema, + }).client() + const outputSchemaHash = hashSchemaInput(asyncOutputSchema) + const submit = vi.fn( + async (_submission: InterruptManagerSubmission) => undefined, + ) + const manager = new InterruptManager({ + tools: [asyncTool] as const, + submit, + }) + const binding: InterruptBinding = { + v: INTERRUPT_BINDING_VERSION, + kind: 'client-tool-execution', + interruptId: 'async-1', + interruptedRunId: 'run-1', + generation: 1, + toolName: 'asyncLookup', + toolCallId: 'call-1', + outputSchemaHash, + responseSchemaHash: outputSchemaHash, + } + manager.hydrate({ + threadId: 'thread-1', + interruptedRunId: 'run-1', + generation: 1, + interrupts: [descriptor(binding), genericDescriptor('other')], + }) + + // `client-tool-execution` is internal only: the public array surfaces just + // the generic item, never the client tool. + expect(manager.getInterrupts()).toHaveLength(1) + expect(manager.getInterrupts()[0]?.kind).toBe('generic') + + // The client tool result resolves through the internal path (the same one + // `addToolResult` uses) and is validated against the tool's output schema. + expect( + manager.resolveClientToolOutput('call-1', { accountId: 'valid' }), + ).toBe(true) + await settle() + + // The batch submits only once both the internal client-tool item and the + // public generic item are resolved. + expect(submit).not.toHaveBeenCalled() + const generic = manager.getInterrupts()[0] + if (generic?.kind !== 'generic') { + throw new Error('Expected generic interrupt') + } + generic.resolveInterrupt('done') + await settle() + expect(submit).toHaveBeenCalledTimes(1) + }) + + it('rolls callback transactions back on thrown, returned, thenable, or incomplete work', () => { + const { manager, submit } = createManager() + manager.hydrate({ + threadId: 'thread-1', + interruptedRunId: 'run-1', + generation: 1, + interrupts: [genericDescriptor('one'), genericDescriptor('two')], + }) + + manager.resolve(() => { + throw new Error('transaction failed') + }) + Reflect.apply(manager.resolve, manager, [ + (item: ChatInterrupt) => { + item.cancel() + return 'not undefined' + }, + ]) + Reflect.apply(manager.resolve, manager, [() => Promise.resolve()]) + manager.resolve((item) => { + if (item.id === 'one') item.cancel() + return undefined + }) + + expect(submit).not.toHaveBeenCalled() + expect( + manager.getInterrupts().every((item) => item.status === 'pending'), + ).toBe(true) + expect(manager.getInterruptErrors()).toHaveLength(4) + }) + + it('seals transaction items, invokes the callback once per item, and submits atomically', () => { + const { manager, submit } = createManager() + manager.hydrate({ + threadId: 'thread-1', + interruptedRunId: 'run-1', + generation: 1, + interrupts: [genericDescriptor('one'), genericDescriptor('two')], + }) + const calls: Array = [] + let lateCancel: (() => void) | undefined + + manager.resolve((item) => { + calls.push(item.id) + if (item.id === 'one') lateCancel = item.cancel + item.cancel() + return undefined + }) + + expect(calls).toEqual(['one', 'two']) + expect(submit).toHaveBeenCalledTimes(1) + expect(() => lateCancel?.()).toThrow('inactive') + // Submitting items are omitted from the public list while the resume + // stream is in flight. + expect(manager.getInterrupts()).toEqual([]) + expect(manager.getResuming()).toBe(true) + }) + + it('permits boolean bulk resolution only for payloadless tool approvals and cancels all payloadlessly', () => { + const approvalTool = toolDefinition({ + name: 'confirmOnly', + description: 'Confirm only', + needsApproval: true, + }).client() + const approval = normalizeApprovalSchema(undefined, undefined) + const binding: InterruptBinding = { + v: INTERRUPT_BINDING_VERSION, + kind: 'tool-approval', + interruptId: 'approval-1', + interruptedRunId: 'run-1', + generation: 1, + toolName: 'confirmOnly', + toolCallId: 'call-1', + originalArgs: {}, + inputSchemaHash: hashSchemaInput(undefined), + approvalSchemaHash: approval.approvalSchemaHash, + responseSchemaHash: approval.responseSchemaHash, + } + const submit = vi.fn( + async (_submission: InterruptManagerSubmission) => undefined, + ) + const approvals = new InterruptManager({ + tools: [approvalTool] as const, + submit, + }) + approvals.hydrate({ + threadId: 'thread-1', + interruptedRunId: 'run-1', + generation: 1, + interrupts: [ + descriptor(binding, { responseSchema: approval.responseSchema }), + ], + }) + approvals.resolve(true) + expect(submit.mock.calls[0]?.[0].resolutions).toEqual([ + { + interruptId: 'approval-1', + status: 'resolved', + payload: { approved: true }, + }, + ]) + + const generic = createManager() + generic.manager.hydrate({ + threadId: 'thread-1', + interruptedRunId: 'run-1', + generation: 1, + interrupts: [genericDescriptor('generic')], + }) + generic.manager.resolve(false) + expect(generic.submit).not.toHaveBeenCalled() + expect(generic.manager.getInterruptErrors()[0]?.code).toBe( + 'unsupported-bulk-operation', + ) + generic.manager.cancel() + expect(generic.submit.mock.calls[0]?.[0].resolutions).toEqual([ + { interruptId: 'generic', status: 'cancelled' }, + ]) + }) + + it('retries the exact frozen batch only for retryable failures', async () => { + const retryable = { + scope: 'batch' as const, + code: 'transport' as const, + message: 'offline', + source: 'transport' as const, + retryable: true, + interruptIds: ['generic'], + threadId: 'thread-1', + interruptedRunId: 'run-1', + generation: 1, + } + const submit = vi + .fn(async (_submission: InterruptManagerSubmission) => undefined) + .mockRejectedValueOnce({ errors: [retryable] }) + .mockResolvedValue(undefined) + const manager = new InterruptManager({ submit }) + manager.hydrate({ + threadId: 'thread-1', + interruptedRunId: 'run-1', + generation: 1, + interrupts: [genericDescriptor('generic')], + }) + const retryItem = manager.getInterrupts()[0] + if (retryItem?.kind !== 'generic') { + throw new Error('Expected generic interrupt') + } + retryItem.resolveInterrupt('answer') + await settle() + + const firstSubmission = submit.mock.calls[0]?.[0] + manager.retry() + expect(submit.mock.calls[1]?.[0]).toBe(firstSubmission) + await settle() + manager.getInterrupts()[0]?.clearResolution() + manager.retry() + expect(submit).toHaveBeenCalledTimes(2) + }) + + it('does not enable retry for expired/stale/conflict server failures', async () => { + for (const code of ['expired', 'stale', 'conflict'] as const) { + const submit = vi.fn(async () => { + throw { + errors: [ + { + scope: 'batch' as const, + code, + message: `${code} failure`, + source: 'server' as const, + retryable: true, // server may claim retryable; client must not + interruptIds: ['generic'], + threadId: 'thread-1', + interruptedRunId: 'run-1', + generation: 1, + }, + ], + } + }) + const manager = new InterruptManager({ submit }) + manager.hydrate({ + threadId: 'thread-1', + interruptedRunId: 'run-1', + generation: 1, + interrupts: [genericDescriptor('generic')], + }) + const item = manager.getInterrupts()[0] + if (item?.kind !== 'generic') throw new Error('Expected generic') + item.resolveInterrupt('answer') + await settle() + expect(manager.getInterruptErrors().some((e) => e.code === code)).toBe( + true, + ) + manager.retry() + expect(submit).toHaveBeenCalledTimes(1) + } + }) + + it('surfaces client-tool validation failures on interruptErrors', async () => { + const { manager } = createManager() + const outputSchemaHash = hashSchemaInput(lookupDefinition.outputSchema) + const binding: InterruptBinding = { + v: INTERRUPT_BINDING_VERSION, + kind: 'client-tool-execution', + interruptId: 'client-1', + interruptedRunId: 'run-1', + generation: 1, + toolName: 'lookup', + toolCallId: 'call-1', + outputSchemaHash, + responseSchemaHash: outputSchemaHash, + } + manager.hydrate({ + threadId: 'thread-1', + interruptedRunId: 'run-1', + generation: 1, + interrupts: [descriptor(binding)], + }) + expect(manager.resolveClientToolOutput('call-1', { wrong: true })).toBe( + true, + ) + await settle() + expect(manager.getInterrupts()).toHaveLength(0) + expect( + manager + .getInterruptErrors() + .some((error) => error.code === 'item-validation-failed'), + ).toBe(true) + }) + + it('resolves client-tool generic fallback for native reason string', () => { + const { manager, submit } = createManager() + manager.hydrate({ + threadId: 'thread-1', + interruptedRunId: 'run-1', + generation: 1, + interrupts: [ + { + id: 'client-degraded', + reason: 'tanstack:client_tool_execution', + toolCallId: 'call-degraded', + metadata: { + kind: 'client_tool', + toolName: 'lookup', + input: {}, + }, + }, + ], + }) + // Schema drift / missing tool → public generic item + expect(manager.getInterrupts()[0]?.kind).toBe('generic') + expect( + manager.resolveClientToolOutput('call-degraded', { accountId: 'a' }), + ).toBe(true) + expect(submit).toHaveBeenCalled() + }) + + it('supersedes a server batch error set without dropping local client, transport, or item errors', async () => { + const firstErrors: ReadonlyArray = [ + { + scope: 'batch', + code: 'incomplete-batch', + message: 'first incomplete batch', + source: 'client', + retryable: false, + interruptIds: ['generic'], + threadId: 'thread-1', + interruptedRunId: 'run-1', + generation: 1, + }, + { + scope: 'batch', + code: 'item-validation-failed', + message: 'first aggregate validation failure', + source: 'client', + retryable: false, + interruptIds: ['generic'], + threadId: 'thread-1', + interruptedRunId: 'run-1', + generation: 1, + }, + { + scope: 'item', + interruptId: 'generic', + code: 'unknown-interrupt', + message: 'first item failure', + source: 'client', + retryable: false, + threadId: 'thread-1', + interruptedRunId: 'run-1', + generation: 1, + }, + { + scope: 'batch', + code: 'transport', + message: 'transport failure remains locally actionable', + source: 'transport', + retryable: false, + interruptIds: ['generic'], + threadId: 'thread-1', + interruptedRunId: 'run-1', + generation: 1, + }, + ] + const secondErrors: ReadonlyArray = [ + { + scope: 'batch', + code: 'incomplete-batch', + message: 'updated incomplete batch', + source: 'client', + retryable: false, + interruptIds: ['generic'], + threadId: 'thread-1', + interruptedRunId: 'run-1', + generation: 1, + }, + { + scope: 'batch', + code: 'server', + message: 'a distinct server failure', + source: 'client', + retryable: false, + interruptIds: ['generic'], + threadId: 'thread-1', + interruptedRunId: 'run-1', + generation: 1, + }, + { + scope: 'item', + interruptId: 'generic', + code: 'unknown-interrupt', + message: 'updated item failure', + source: 'client', + retryable: false, + threadId: 'thread-1', + interruptedRunId: 'run-1', + generation: 1, + }, + ] + const submit = vi + .fn(async (_submission: InterruptManagerSubmission) => undefined) + .mockRejectedValueOnce({ errors: firstErrors }) + .mockRejectedValueOnce({ errors: secondErrors }) + const manager = new InterruptManager({ submit }) + manager.hydrate({ + threadId: 'thread-1', + interruptedRunId: 'run-1', + generation: 1, + interrupts: [genericDescriptor('generic')], + }) + + manager.resolve(() => undefined) + const firstItem = manager.getInterrupts()[0] + if (firstItem?.kind !== 'generic') { + throw new Error('Expected generic interrupt') + } + firstItem.resolveInterrupt('first answer') + await settle() + manager.getInterrupts()[0]?.clearResolution() + const secondItem = manager.getInterrupts()[0] + if (secondItem?.kind !== 'generic') { + throw new Error('Expected generic interrupt') + } + secondItem.resolveInterrupt('second answer') + await settle() + + expect(manager.getInterruptErrors()).toMatchObject([ + { + code: 'incomplete-batch', + message: 'Interrupt transaction did not resolve every item.', + source: 'client', + }, + { + code: 'transport', + message: 'transport failure remains locally actionable', + source: 'transport', + }, + { + code: 'incomplete-batch', + message: 'updated incomplete batch', + source: 'client', + }, + { + code: 'server', + message: 'a distinct server failure', + source: 'client', + }, + ]) + expect(manager.getInterrupts()[0]?.errors).toMatchObject([ + { + code: 'unknown-interrupt', + message: 'updated item failure', + source: 'client', + }, + ]) + }) + + it('rejects submission errors that do not correlate to the active interrupt batch', async () => { + const foreignErrors: ReadonlyArray = [ + { + scope: 'item', + interruptId: 'generic', + code: 'unknown-interrupt', + message: 'foreign thread', + source: 'server', + retryable: false, + threadId: 'other-thread', + interruptedRunId: 'run-1', + generation: 1, + }, + { + scope: 'item', + interruptId: 'generic', + code: 'unknown-interrupt', + message: 'foreign run', + source: 'server', + retryable: false, + threadId: 'thread-1', + interruptedRunId: 'other-run', + generation: 1, + }, + { + scope: 'item', + interruptId: 'generic', + code: 'unknown-interrupt', + message: 'foreign generation', + source: 'server', + retryable: false, + threadId: 'thread-1', + interruptedRunId: 'run-1', + generation: 2, + }, + { + scope: 'item', + interruptId: 'other-interrupt', + code: 'unknown-interrupt', + message: 'foreign item', + source: 'server', + retryable: false, + threadId: 'thread-1', + interruptedRunId: 'run-1', + generation: 1, + }, + { + scope: 'batch', + code: 'item-validation-failed', + message: 'foreign batch', + source: 'server', + retryable: false, + interruptIds: ['other-interrupt'], + threadId: 'thread-1', + interruptedRunId: 'run-1', + generation: 1, + }, + ] + const submit = vi + .fn(async (_submission: InterruptManagerSubmission) => undefined) + .mockRejectedValueOnce({ errors: foreignErrors }) + const manager = new InterruptManager({ submit }) + manager.hydrate({ + threadId: 'thread-1', + interruptedRunId: 'run-1', + generation: 1, + interrupts: [genericDescriptor('generic')], + }) + const item = manager.getInterrupts()[0] + if (item?.kind !== 'generic') { + throw new Error('Expected generic interrupt') + } + + item.resolveInterrupt('answer') + await settle() + + expect(manager.getInterrupts()[0]?.errors).toEqual([]) + expect(manager.getInterruptErrors()).toMatchObject([ + { + code: 'protocol', + message: 'Interrupt submission errors did not match the active batch.', + source: 'client', + retryable: false, + }, + ]) + }) +}) + +function isAccountOutput(value: unknown): value is { accountId: string } { + return ( + value !== null && + typeof value === 'object' && + 'accountId' in value && + typeof value.accountId === 'string' && + value.accountId.length > 0 + ) +} + +describe('ChatClient native interrupts', () => { + it('publishes the shared immutable interrupt state callback', () => { + const onInterruptStateChange = vi.fn() + const interrupt = genericDescriptor('generic-1') + + const client = new ChatClient({ + connection: { async *connect() {} }, + onInterruptStateChange, + initialResumeSnapshot: { + schemaVersion: 2, + resumeState: { threadId: 'thread-1', runId: 'run-1' }, + pendingInterrupts: [interrupt], + }, + }) + + expect(onInterruptStateChange).toHaveBeenLastCalledWith( + client.getInterruptState(), + ) + const state = onInterruptStateChange.mock.lastCall?.[0] + expect(state?.interrupts).toBe(state?.pendingInterrupts) + }) + + it('owns one immutable interrupt state and resumes with a fresh child run', async () => { + const contexts: Array = [] + const sentMessages: Array | Array> = [] + const binding: InterruptBinding = { + v: INTERRUPT_BINDING_VERSION, + kind: 'generic', + interruptId: 'generic-1', + interruptedRunId: 'placeholder', + generation: 4, + responseSchemaHash: 'none', + } + let call = 0 + const connection: ConnectConnectionAdapter = { + async *connect(messages, _data, _signal, context) { + contexts.push(context) + sentMessages.push(messages) + call++ + const runId = context?.runId ?? `run-${call}` + const threadId = context?.threadId ?? 'thread-1' + yield { + type: EventType.RUN_STARTED, + runId, + threadId, + timestamp: Date.now(), + } + if (call === 1) { + binding.interruptedRunId = runId + yield { + type: EventType.RUN_FINISHED, + runId, + threadId, + timestamp: Date.now(), + outcome: { + type: 'interrupt', + interrupts: [descriptor(binding)], + }, + } + return + } + yield { + type: EventType.RUN_FINISHED, + runId, + threadId, + timestamp: Date.now(), + outcome: { type: 'success' }, + } + }, + } + const client = new ChatClient({ connection, threadId: 'thread-1' }) + + await client.sendMessage('start') + const state = client.getInterruptState() + expect(Object.isFrozen(state)).toBe(true) + expect(state.interrupts).toBe(state.pendingInterrupts) + expect(client.getInterrupts()).toBe(state.interrupts) + expect(client.getPendingInterrupts()).toBe(state.interrupts) + const item = state.interrupts[0] + if (item?.kind !== 'generic') throw new Error('Expected generic interrupt') + item.resolveInterrupt({ answer: 42 }) + + await vi.waitFor(() => expect(contexts).toHaveLength(2)) + expect(contexts[1]).toMatchObject({ + threadId: 'thread-1', + parentRunId: contexts[0]?.runId, + resume: [ + { + interruptId: 'generic-1', + status: 'resolved', + payload: { answer: 42 }, + }, + ], + }) + expect(contexts[1]?.runId).not.toBe(contexts[0]?.runId) + expect(sentMessages[1]).not.toEqual([]) + expect(sentMessages[1]).toEqual(sentMessages[0]) + }) + + it('resumes a hydrated ephemeral batch with full history in a fresh child run', async () => { + const contexts: Array = [] + const sentMessages: Array | Array> = [] + let calls = 0 + const connection: ConnectConnectionAdapter = { + async *connect(messages, _data, _signal, context) { + contexts.push(context) + sentMessages.push(messages) + calls++ + const runId = context?.runId ?? `run-${calls}` + if (calls === 1) { + yield { + type: EventType.RUN_FINISHED, + threadId: 'thread-1', + runId, + timestamp: Date.now(), + outcome: { + type: 'interrupt', + interrupts: [ + descriptor({ + v: INTERRUPT_BINDING_VERSION, + kind: 'generic', + interruptId: 'first', + interruptedRunId: runId, + generation: 1, + responseSchemaHash: 'none', + }), + descriptor({ + v: INTERRUPT_BINDING_VERSION, + kind: 'generic', + interruptId: 'second', + interruptedRunId: runId, + generation: 1, + responseSchemaHash: 'none', + }), + ], + }, + } + return + } + yield { + type: EventType.RUN_FINISHED, + threadId: 'thread-1', + runId, + timestamp: Date.now(), + outcome: { type: 'success' }, + } + }, + } + const client = new ChatClient({ connection, threadId: 'thread-1' }) + + await client.sendMessage('start') + const visited: Array = [] + client.resolveInterrupts((interrupt) => { + visited.push(interrupt.id) + interrupt.cancel() + return undefined + }) + + await vi.waitFor(() => expect(contexts).toHaveLength(2)) + expect(visited).toEqual(['first', 'second']) + expect(contexts[1]).toMatchObject({ + threadId: 'thread-1', + parentRunId: contexts[0]?.runId, + resume: [ + { interruptId: 'first', status: 'cancelled' }, + { interruptId: 'second', status: 'cancelled' }, + ], + }) + expect(contexts[1]?.runId).not.toBe(contexts[0]?.runId) + expect(sentMessages[1]).not.toEqual([]) + expect(sentMessages[1]).toEqual(sentMessages[0]) + }) + + it('hydrates V2 fallback descriptors when recovery is unavailable', () => { + const fallback = genericDescriptor('fallback') + const malformed = JSON.parse( + JSON.stringify({ + schemaVersion: 2, + resumeState: { threadId: 'thread-1', runId: 'run-1' }, + pendingInterrupts: [fallback], + interruptState: { + recoveryState: { + schemaVersion: 1, + state: 'pending', + threadId: 'other-thread', + interruptedRunId: 'run-1', + generation: 99, + pendingInterrupts: [], + }, + drafts: [], + }, + }), + ) + const client = new ChatClient({ + connection: { async *connect() {} }, + initialResumeSnapshot: malformed, + }) + + expect(client.getResumeState()).toEqual({ + threadId: 'thread-1', + runId: 'run-1', + }) + expect(client.getInterrupts().map((item) => item.id)).toEqual(['fallback']) + expect(client.getInterruptState().interruptErrors).toEqual([]) + }) + + it.each([ + ['null recovery state', { recoveryState: null, drafts: [] }], + ['array recovery state', { recoveryState: [], drafts: [] }], + [ + 'invalid drafts', + { + recoveryState: { + schemaVersion: 1, + state: 'pending', + threadId: 'thread-1', + interruptedRunId: 'run-1', + generation: 1, + pendingInterrupts: [], + }, + drafts: { interruptId: 'not-an-array' }, + }, + ], + ])( + 'hydrates fallback descriptors for malformed V2 %s without losing resume state', + (_label, interruptState) => { + const malformed = JSON.parse( + JSON.stringify({ + schemaVersion: 2, + resumeState: { threadId: 'thread-1', runId: 'run-1' }, + pendingInterrupts: [genericDescriptor('fallback')], + interruptState, + }), + ) + + let client: ChatClient | undefined + expect(() => { + client = new ChatClient({ + connection: { async *connect() {} }, + initialResumeSnapshot: malformed, + }) + }).not.toThrow() + expect(client?.getResumeState()).toEqual({ + threadId: 'thread-1', + runId: 'run-1', + }) + expect(client?.getInterrupts().map((item) => item.id)).toEqual([ + 'fallback', + ]) + expect(client?.getInterruptState().interruptErrors).toEqual([]) + }, + ) +}) diff --git a/packages/ai-client/tests/chat-client-resume.test.ts b/packages/ai-client/tests/chat-client-resume.test.ts new file mode 100644 index 000000000..653ba0557 --- /dev/null +++ b/packages/ai-client/tests/chat-client-resume.test.ts @@ -0,0 +1,1153 @@ +import { describe, expect, it, vi } from 'vitest' +import { + EventType, + convertSchemaToJsonSchema, + digestInterruptJson, + canonicalInterruptJson, + hashSchemaInput, + toolDefinition, +} from '@tanstack/ai/client' +import { z } from 'zod' +import { ChatClient } from '../src/chat-client' +import type { + ConnectConnectionAdapter, + RunAgentInputContext, +} from '../src/connection-adapters' +import type { + ModelMessage, + RunAgentResumeItem, + StreamChunk, +} from '@tanstack/ai/client' +import type { UIMessage } from '../src/types' + +/** + * Adapter that records each connect's runContext and yields scripted chunks. + * A script can be a function of the live `runContext` (so a test can emit a + * RUN_FINISHED carrying the same runId the client generated and passed in). + */ +interface ThrowingScript { + chunks: Array + error: Error +} + +type ScriptResult = Array | ThrowingScript +type Script = + | ScriptResult + | ((ctx: RunAgentInputContext | undefined) => ScriptResult) + +function recordingAdapter(scripts: Array