Skip to content

feat(interrupts): AG-UI interrupt lifecycle overhaul (ephemeral)#970

Open
AlemTuzlak wants to merge 83 commits into
mainfrom
feat/interrupts-overhaul
Open

feat(interrupts): AG-UI interrupt lifecycle overhaul (ephemeral)#970
AlemTuzlak wants to merge 83 commits into
mainfrom
feat/interrupts-overhaul

Conversation

@AlemTuzlak

@AlemTuzlak AlemTuzlak commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Extracts the AG-UI interrupts overhaul (from #935, which was stacked on the persistence PR #785) into its own PR targeting main, excluding the persistence story. Interrupts run fully ephemeral here: resume reconstructs pending tool calls from the client's message history in a fresh child run. A follow-up stacked PR will add durable persistence back on top.

What's in scope

  • AG-UI interrupt lifecycle: RUN_FINISHED with outcome.type === 'interrupt', interrupt descriptors/bindings, ephemeral resume via parentRunId + resume batch.
  • Tool-approval and client-tool interrupt kinds.
  • Client-side interruptManager (hydrate / resolve / batch submit), useChat integration across frameworks.
  • ts-react-chat interrupt-lab example for manual testing.

Explicitly out of scope (deferred to the stacked persistence PR)

  • @tanstack/ai-persistence, -drizzle, -prisma, -cloudflare.
  • Durable interrupts (recovery via joinRun, server-side interrupt state fetchers).

Notable fixes

  • Forward parentRunId so ephemeral resume correlates the interrupted run to its continuation child run.
  • Resolve the approval tool-call's owning message from history when a MESSAGES_SNAPSHOT reset the live maps, so the approval UI state still updates.
  • Removed durable (interrupts-v2) e2e routes/fixtures/specs and persistence-era recovery tests; these return with persistence.

Testing

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added AG-UI interrupt lifecycle with support for tool approvals, generic responses, and client-tool execution, including typed payloads/edited args, cancellation, retry, and atomic multi-interrupt resolution.
    • Introduced resumable interrupt state via resume/continuation handling, plus persistence with durable interrupt recovery (ephemeral enabled by default) and new storage-adapter options.
    • Expanded interrupt/resume state and controls across React, Preact, Vue, Solid, Svelte, and Angular integrations.
    • Added an “Interrupts Lab” example and new /interrupts UI flow.
  • Documentation

    • Reworked and expanded guides covering the interrupt protocol, migration, tool approvals, generic interrupts, and durable vs ephemeral behavior.

AlemTuzlak and others added 30 commits July 1, 2026 14:33
…M-event catalog

- Add optional in-band `cursor` to StreamChunk + `cursor` input on chat().
- Add a *replayAndResume() seam: when a cursor is supplied and a ResumeSource
  capability is provided, replay the persisted event tail; if the run is still
  running and the adapter supports re-attach, continue live. No-op without a
  resume source (a non-persisted run is unchanged).
- Move the generic LockStore + LocksCapability ('locks') into core so it is a
  single shared token across the sandbox and persistence layers.
- Add ResumeSource capability/contract and a typed catalog of well-known CUSTOM
  event names (file.changed, process.*, approval.*, artifact.*, sandbox.*).
…andbox bridge

- @tanstack/ai-persistence: store contracts, withPersistence middleware,
  memoryPersistence, cursor utilities, approval controller, resume-source
  adapter, history projection. Fully optional; works with and without sandbox.
- @tanstack/ai-persistence-sql: one SQL store impl behind a minimal SqlDriver
  (sqlite|postgres dialect) + versioned migrations + DDL.
- Backends: -sqlite (node:sqlite/better-sqlite3), -postgres (pg), -cloudflare
  (D1, compile-verified), -drizzle and -prisma (BYO).
- @tanstack/ai-sandbox-persistence: durable SQL-backed SandboxStore +
  withPersistenceBridge wiring durable store/locks into withSandbox (agent mode).
- Track the latest in-band cursor per active run; expose getResumeState(),
  resume(), and maybeAutoResume() with an `autoResume` opt-out (default on).
- Pass `cursor` through the connection adapter's RunAgentInput payload so the
  server can replay a run. streamResponse reuses the original runId on resume.
…ip config

- New docs/persistence/overview.md (+ nav entry, addedAt) with server + client +
  agent-mode usage.
- New ai-core/persistence agent skill.
- Changeset covering the feature; knip ignore for the optional pg peer dep.
# Conflicts:
#	docs/media/transcription.md
# Conflicts:
#	examples/ts-react-chat/src/routes/generations.image.tsx
#	examples/ts-react-chat/src/routes/generations.video.tsx
#	testing/e2e/src/routes/$provider/index.tsx
jherr and others added 2 commits July 20, 2026 21:14
…rupts

The AG-UI interrupt lifecycle was extracted from a larger persistence stack
and shipped ephemeral-only, but carried persistence-shaped code that is never
exercised without a durable layer (which is not part of this package). Remove it.

Server (@tanstack/ai):
- Drop the never-provided InterruptPersistenceGateway capability
  (getInterruptPersistence/provideInterruptPersistence) and its input types
  (OpenInterruptBatchInput, CommitInterruptResolutionsInput). The two call
  sites now always take the ephemeral branch.
- Remove InterruptCommitResult, the InterruptResumeValidationError.recovery
  field, the recovery type guard, and the tanstack:interruptRecovery emission.

Client (@tanstack/ai-client):
- Remove the recovery state machine: getRecoveryState, getPersistedDrafts,
  reportRecoveryError, restorePersistedDrafts, recoverInterrupts,
  {start,recover}PersistedInterrupts, joinContinuationRun, the persisted-state
  branch of applyResumeSnapshot, and the conflict/replay branches of
  submitInterruptBatch (now a plain ephemeral submit).
- Delete interrupt-recovery-parse.ts; drop PersistedInterruptDraft and the
  ChatContinuationLoader option/type.

Kept as a dormant extension seam for a future persistence layer: the optional
ConnectionAdapter.loadInterruptState hook and its InterruptRecoveryStateV1 /
InterruptRecoveryQuery types.

Docs: trim the dangling withChatPersistence step and the persistence-only
migration checklist / recovery sections to match the ephemeral-only surface.

Runtime behavior is unchanged (persistence was never provided). Verified:
typecheck + lint clean across ai/ai-client/6 framework packages, full unit
suites green, docs links pass, and the interrupt lab round-trip re-verified
against the rebuilt dist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread docs/interrupts/overview.md Outdated
Comment on lines +44 to +50
**Client tools are not on this list.** Running a tool in the browser and
returning its result is not something you resolve by hand — a tool with a
`.client()` implementation runs automatically and reports its own result. See
[Client Tools](../tools/client-tools). Approval is a separate axis: add
`needsApproval: true` to any tool (server or client) and it pauses on a
`tool-approval` interrupt first; a client tool then runs automatically once
approved.

@tombeckenham tombeckenham Jul 21, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why aren't client tools included? That isn't explained here

Option A slim for #970: restore generation-resume, example/e2e media noise,
import-only churn, and unrelated provider test touch-ups to main while keeping
the AG-UI interrupt lifecycle (engine, InterruptManager, client chat
persistence needed by ChatClient, framework useChat, interrupt lab, docs).

Residue snapshot: branch chore/non-interrupt-residue-from-970 (pre-slim tip).
Restore pure import/style test churn to main. Drop dedicated storage-adapter,
chat-persistence-controller, and cleared-stream-tracker unit tests from the
ephemeral interrupts PR. Thin chat-client-resume to interrupt resume behavior
only (drop persistence.server hydrate/persist matrix). Remove unused
fake-indexeddb devDependency.
Defer interrupt resume until the parent stream settles so auto-executed
client tools can continue. Surface client-tool validation failures, match
native CTE reasons, tighten binding types, and lock resume validation with
unit tests. Update docs/skill and e2e/example UIs to the bound interrupt API,
and link the Interrupts Lab from the chat example nav and home page.
… from interrupts PR

Restore main's ChatPersistor for optional message storage and keep
ClearedStreamTracker for clear-during-stream. Durable resume adapters
(storage-adapters, ChatPersistenceController, client/server persistence
options) already live on feat/persistence and are out of the ephemeral
interrupt surface.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
docs/architecture/approval-flow-processing.md (1)

139-139: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use gpt-chat-latest in documentation examples instead of gpt-5.5.

These documentation snippets use the superseded openaiText('gpt-5.5'). As per coding guidelines and previous reviews, switch them to the latest OpenAI chat model alias based on the adapter's model-meta.ts (e.g., gpt-chat-latest).

  • docs/architecture/approval-flow-processing.md#L139-L139: replace gpt-5.5 with gpt-chat-latest.
  • packages/ai/skills/ai-core/tool-calling/SKILL.md#L78-L78: replace gpt-5.5 with gpt-chat-latest.
  • packages/ai/skills/ai-core/tool-calling/SKILL.md#L681-L681: replace gpt-5.5 with gpt-chat-latest.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/architecture/approval-flow-processing.md` at line 139, Replace the
superseded gpt-5.5 model name with gpt-chat-latest in the documentation examples
at docs/architecture/approval-flow-processing.md:139-139,
packages/ai/skills/ai-core/tool-calling/SKILL.md:78-78, and
packages/ai/skills/ai-core/tool-calling/SKILL.md:681-681, preserving the
existing adapter usage.

Source: Coding guidelines

🧹 Nitpick comments (1)
testing/e2e/src/routes/tools-test.tsx (1)

464-487: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate lookup/cast logic in Approve and Deny handlers.

Both handlers repeat the exact same as ReadonlyArray<{...}> cast + .find() lookup for the matching tool-approval interrupt. The cast also erases whatever real (likely discriminated-union) type interrupts already has. Extract a shared helper using a type predicate instead of as so both call sites stay in sync and TypeScript actually narrows the result.

♻️ Proposed refactor
+  function findApprovalInterrupt(toolCallId: string) {
+    return interrupts.find(
+      (item): item is Extract<typeof item, { kind: 'tool-approval' }> =>
+        item.kind === 'tool-approval' && item.toolCallId === toolCallId,
+    )
+  }
+
   onClick={() => {
     const approvalId = tc.approval?.id
     if (!approvalId || respondedApprovals.current.has(approvalId)) {
       return
     }
-    const interrupt = (
-      interrupts as ReadonlyArray<{
-        kind: string
-        toolCallId?: string
-        resolveInterrupt: (approved: boolean) => void
-      }>
-    ).find(
-      (item) => item.kind === 'tool-approval' && item.toolCallId === tc.id,
-    )
+    const interrupt = findApprovalInterrupt(tc.id)
     if (!interrupt) return
     ...

(mirror the same change in the Deny handler at Line 511-522)

Also applies to: 506-529

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@testing/e2e/src/routes/tools-test.tsx` around lines 464 - 487, Extract the
repeated interrupt lookup from the Approve and Deny handlers into a shared
helper near the surrounding tool-approval logic. Have the helper search
interrupts for the matching toolCallId and tool-approval kind using a type
predicate, preserving the existing narrowed interrupt type without an `as
ReadonlyArray` cast; update both handlers to call this helper and retain their
existing approval/denial behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@docs/architecture/approval-flow-processing.md`:
- Line 139: Replace the superseded gpt-5.5 model name with gpt-chat-latest in
the documentation examples at
docs/architecture/approval-flow-processing.md:139-139,
packages/ai/skills/ai-core/tool-calling/SKILL.md:78-78, and
packages/ai/skills/ai-core/tool-calling/SKILL.md:681-681, preserving the
existing adapter usage.

---

Nitpick comments:
In `@testing/e2e/src/routes/tools-test.tsx`:
- Around line 464-487: Extract the repeated interrupt lookup from the Approve
and Deny handlers into a shared helper near the surrounding tool-approval logic.
Have the helper search interrupts for the matching toolCallId and tool-approval
kind using a type predicate, preserving the existing narrowed interrupt type
without an `as ReadonlyArray` cast; update both handlers to call this helper and
retain their existing approval/denial behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ce8b6d71-6ade-4bec-a413-dc706126281f

📥 Commits

Reviewing files that changed from the base of the PR and between 8c4ef83 and 3f12314.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (23)
  • docs/architecture/approval-flow-processing.md
  • docs/config.json
  • docs/interrupts/generic.md
  • docs/interrupts/migration.md
  • docs/interrupts/multiple.md
  • docs/tools/client-tools.md
  • examples/ts-react-chat/src/components/Header.tsx
  • examples/ts-react-chat/src/lib/interrupt-lab/InterruptLabPage.tsx
  • examples/ts-react-chat/src/lib/interrupt-lab/scenarios.ts
  • examples/ts-react-chat/src/routes/index.tsx
  • packages/ai-client/src/chat-client.ts
  • packages/ai-client/src/interrupt-manager.ts
  • packages/ai-client/src/types.ts
  • packages/ai-client/tests/chat-client-interrupts.test.ts
  • packages/ai-client/tests/chat-client-resume.test.ts
  • packages/ai/skills/ai-core/tool-calling/SKILL.md
  • packages/ai/src/activities/chat/tools/tool-calls.ts
  • packages/ai/src/interrupt-resume.ts
  • packages/ai/src/interrupts.ts
  • packages/ai/tests/interrupt-resume.test.ts
  • testing/e2e/src/components/ChatUI.tsx
  • testing/e2e/src/routes/$provider/$feature.tsx
  • testing/e2e/src/routes/tools-test.tsx
🚧 Files skipped from review as they are similar to previous changes (14)
  • docs/interrupts/multiple.md
  • docs/config.json
  • docs/tools/client-tools.md
  • docs/interrupts/migration.md
  • packages/ai/src/interrupts.ts
  • testing/e2e/src/routes/$provider/$feature.tsx
  • examples/ts-react-chat/src/lib/interrupt-lab/scenarios.ts
  • packages/ai-client/tests/chat-client-interrupts.test.ts
  • packages/ai-client/tests/chat-client-resume.test.ts
  • packages/ai-client/src/types.ts
  • packages/ai/src/interrupt-resume.ts
  • packages/ai/src/activities/chat/tools/tool-calls.ts
  • packages/ai-client/src/chat-client.ts
  • packages/ai-client/src/interrupt-manager.ts

…c lab runId

Ephemeral resume now reconstructs client-tool pending items even when the
client already wrote tool results into message history for UI. Align generic
lab interrupt ids with the terminal provider runId so parentRunId correlates,
surface interrupt validation messages in the lab sanitizer, and match the
home-page Interrupts tile to other demo tiles.
Resolve conflicts with type-safe tool call events (#452):
- Keep both InterruptSubmissionError and ProviderTool imports
- Keep interrupt resume deniedToolResults + surface input on declined results
- Keep AnyTool-based mergeAgentTools typing with RunAgentResumeItem for resume
SafeToolInput/Output treat schema generics defaulting to undefined as
unknown, and MCP manager tests use AnyServerTool so fixtures can carry
JSON Schema input without fighting the no-schema ServerTool default.

@tombeckenham tombeckenham left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is looking good now. I've added a few nit pic comments, and had to fix a few things, but happy to approve

Comment thread docs/interrupts/tool-approval.md Outdated
import { transferTool } from '../tools/transfer'

export function TransferApprovals() {
const { interrupts, resuming } = useChat({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You'd probably want to render the chat in this example the approval ui would appear alongside or in the same chat

Comment thread docs/interrupts/generic.md Outdated
setErrors(['Enter valid JSON.'])
return
}
const result = z.fromJSONSchema(interrupt.responseSchema).safeParse(candidate)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couldn't you pass value directly to safeParse?

schema: unknown,
): (value: unknown) => ReadonlyArray<JsonSchemaValidationIssue> {
assertSupportedSchemaTree(schema)
const ajv = new Ajv2020({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be an expensive operation. The alternative is to initialize at module level, or create a factory but I never love that either. Happy to leave it

@@ -0,0 +1,138 @@
import Ajv2020 from 'ajv/dist/2020.js'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wondered whether we could just use zod. Zod is a peer dependency, but to keep it that way you've added arv as a direct dependency. I'm happy to keep it as arv is smaller, but maybe consider if we could just use zod. Alem and I discussed and happy to keep arv

tombeckenham and others added 17 commits July 21, 2026 19:39
Drop residual Prisma lock entries left from the persistence-era history.
Re-resolve only this branch's package.json deltas on top of main's lock.
- Drop unused PublicInterruptBinding and knip ignore for @standard-schema/spec
- Clean interrupt-manager shadowing / unnecessary conditionals / async
- Include serverPersistence in e2e provider links
- Use AnyServerTool for heterogeneous tool arrays in code-mode example
…arness

- Clear interrupt/resume state on successful continuation streams even when
  provider run ids diverge; always clear after a successful interrupt batch
- Immediately reflect approval decisions on tool-call message parts (#532)
- Hide submitting interrupts from the public list; block sends only on
  actionable pending/staged interrupts
- Register client delete_file needsApproval stub so tools-test hydrates
  tool-approval interrupts; match resolve by toolCallId/approval id
- Only render pending approval prompts in e2e ChatUI
Missing closing brace on the deny-button handler broke prettier parse.
…tovers

- rewrite the interrupt guides to lead with the problem then the API:
  overview explains what pauses a run and how client tools fit; tool-approval
  covers server and client tools and renders the approval inline in the chat;
  multiple contrasts per-item vs root-helper resolution; generic frames the
  application-pause use case
- remove stale references to a separately-shipped durable persistence layer
  from the interrupt, tools, and architecture docs (ephemeral-only in this PR)
- address review feedback: explain client tools in the overview, render the
  chat alongside the approval UI, and validate the generic value directly
- refresh docs/config.json updatedAt for the touched pages
Client-tool interrupt paths emit AG-UI MESSAGES_SNAPSHOT from ModelMessages,
which rebuild tool-call parts as input-complete without output and wipe the
complete/output state the client already applied from TOOL_CALL_END/RESULT.
Reconcile snapshots by folding tool-result content into matching tool-call
parts (and prefer pre-snapshot complete state when richer).
MESSAGES_SNAPSHOT reconciliation now folds tool-result into the
sibling tool-call; update the anchor test to match.
Avoid a mutated outer `changed` flag that control-flow analysis treats
as always falsy; detect part identity changes instead.
Replace the prose-only "when an answer is wrong" section in multiple.md with a
runnable component that renders item errors and root interruptErrors, gates
buttons on status and resuming (not just canResolve), and demonstrates both
recovery paths (clearResolution and retryInterrupts).
Add a "Consume the decision on the server" section to tool-approval.md: a
server execute snippet reading the (possibly edited) input, and an honest
account of the payload branches (reject payload becomes the tool result the
model reads; approve payload is decision metadata, not passed to execute, use
editedArgs for values the tool needs).
…noble/hashes

The library no longer transforms a generic interrupt's wire JSON Schema into a
validator or validates the resolved value against it, on the client or the
server. Values pass through as-is; the application validates them (e.g. with
z.fromJSONSchema on the client and its own check on the server). Validation of a
tool's code-authored Standard Schema (approvalSchema / inputSchema) is unchanged.

- remove the ajv-based json-schema-validator and the ajv / ajv-formats deps
- generic items are always resolvable; canResolve no longer depends on the
  library being able to compile the wire schema
- replace @noble/hashes with a small bundled SHA-256 (same sha256:<hex> wire
  shape), verified against known vectors; drop the dependency
- update the ts-react-chat interrupt lab to validate the generic payload itself
  with zod, and fix the docs that claimed the library validates
…nctuary

Replace the over-built interrupt lab (durable dead-code, error sanitizer, DI
harness, debug fetch, capability panels, ~1.1k lines of tests) with a lean,
button-driven "Willowbrook Sanctuary" playground: one tool per scenario, wired
to chat()/useChat, each triggered by a preset message.

Covers server + client tools for approval-only, shared approvalSchema, branch
(approve/reject) schemas, and edited args; a generic (app-emitted) interrupt;
and batched approvals resolved per-item or via the root resolveInterrupts.

Fixes found by clicking through every scenario:
- don't force provider tool_choice on a continuation (resume); forcing it made
  the model re-call the approved tool instead of answering (empty reply).
- key the generic interrupt id off the request runId (ctx.runId), not the
  provider chunk.runId, so the client's parentRunId correlates on resume
  (the mismatch surfaced as unknown-interrupt).
- echo edited args in assignEnclosure output so the continuation reflects them.

Add a regression test (api.interrupts.test.ts) covering both fixes, and give
the UI a cozier theme with per-card animal avatars.
A denied approval writes a final tool result into history, so the tool call
reads as completed and dropped out of the reconstructed pending batch, while
the resume batch still references its `approval_` id. That failed the resume
as `unknown-interrupt` (every deny broke). Ephemeral reconstruction now
recovers `approval_`-referenced calls from history the same way it already
recovers finalized `client_tool_` calls.

- add a regression in chat.test.ts: resume a denial whose call already has a
  result in history resolves without RUN_ERROR and skips execution.
- fix the sibling batch-validation test to use a Standard Schema input; the
  earlier ajv removal means raw JSON Schema args are no longer library-
  validated, so the atomic-batch check now exercises the Standard Schema path.
- swap the emoji card avatars for real animal photos (loremflickr, keyed by
  species) with an emoji fallback if the image can't load
- render tool calls and results in the transcript (name, input args including
  edits, output, and denial reasons) so it's clear what ran under the hood
- add a "Your decisions" log that captures exactly what was submitted, so
  approve payloads/notes are visible even though they are decision metadata
  the tool never receives
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants