Skip to content

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

Merged
AlemTuzlak merged 94 commits into
mainfrom
feat/interrupts-overhaul
Jul 22, 2026
Merged

feat(interrupts): AG-UI interrupt lifecycle overhaul (ephemeral)#970
AlemTuzlak merged 94 commits into
mainfrom
feat/interrupts-overhaul

Conversation

@AlemTuzlak

@AlemTuzlakAlemTuzlak 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.

AlemTuzlakand 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:
#	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
autofix-ciBotand others added 8 commits July 21, 2026 11:17
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
Co-authored-by: Cursor <cursoragent@cursor.com>
@tombeckenham
tombeckenham self-requested a review July 22, 2026 06:56
tombeckenham
tombeckenham previously requested changes Jul 22, 2026

@tombeckenhamtombeckenham 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.

Requesting changes until we resolve what @tannerlinsley raised regarding how interupts interact with durable workflows

Comment threaddocs/interrupts/overview.md
@tombeckenham
tombeckenham self-requested a review July 22, 2026 09:11
AlemTuzlakand others added 9 commits July 22, 2026 11:40
test(interrupts): port wildlife scenarios into the e2e harness
Add a deterministic interrupt playground (route /interrupts-test) that mirrors
the ts-react-chat wildlife example, plus 31 aimock-backed specs covering every
AG-UI interrupt shape across allow / deny / cancel via both per-item and root
batch resolution:
- server + client tool approvals: boolean, shared payload, branch payload,
edited args
- a generic (non-tool) interrupt (resolve-with-payload and cancel)
- batch sets: root approve-all / deny-all / cancel-all, per-item, and the
mixed resolver form
Server tool results are asserted via the approval-responded state (they are not
surfaced as client message parts); server edited-args are verified through an
emitCustomEvent echo. A shared approvalSchema requires a payload on the deny
decision too, so those scenarios carry a denyPayload. The ts-react-chat example
is unchanged.
@
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 to the interrupt's metadata — nothing else.
The client did not enforce that. `hydrateInterrupt` synthesised a binding
for any descriptor that lacked one, so a foreign interrupt rendered as a
resolvable generic interrupt whose answer was submitted against a run
with nothing pending — failing as `unknown-interrupt` only after the user
had filled in the form. That is the silent translation between approval
models the orchestration RFC warns about.
- Interrupts carrying no binding we understand surface as
`kind: 'unbound'`, `canResolve: false`, with no resolver. Pre-binding
TanStack descriptors keep their legacy metadata route. Unbound items
are excluded from the batch-completeness gate so they cannot deadlock
the interrupts that are ours.
- The binding carries a wire version. Readers reject a version they do
not recognise instead of duck-typing its fields; a binding written
before the field existed still reads. This is what lets a future
workflow-owned binding be rejected cleanly rather than mis-read.
- Export `INTERRUPT_BINDING_METADATA_KEY`, `withInterruptBinding()` and
`readInterruptBinding()` so other producers attach a binding through a
supported API rather than copying the metadata key, which was a
duplicated private const in three files.
- Classify off the binding alone. `Interrupt.reason` is free-form AG-UI
text another producer can also spell `tool_call`, so it is a display
hint only and never decides ownership. The emitted values are
unchanged.
- Enumerate the interrupt export surface instead of `export *`.
REVERSES A DELIBERATE DECISION — please review: 2d26b39 kept
`ConnectionAdapter.loadInterruptState` plus `InterruptRecoveryStateV1` /
`InterruptRecoveryQuery` as "a dormant extension seam for a future
persistence layer", after stripping the recovery state machine that used
them. This commit removes them, along with the `persistence-required` /
`atomic-commit-unsupported` / `recovery-unavailable` batch codes.
Reasoning: they describe a persisted, AI-domain interrupt object resumed
through an AI-owned recovery endpoint — which is option 1 of the three
the orchestration RFC (#979) has yet to choose between. Publishing them
as stable types pre-decides that. Nothing consumes the seam today, and
`origin/feat/persistence` (#785) has zero references to any of the three.
Easy to restore if the RFC lands on option 1; hard to unpublish if it
does not. Happy to put them back if the seam is wanted.
Also fixes two pre-existing repo failures the gate surfaced: unresolved
relative tool imports in two interrupt doc snippets (kiira), and knip
flagging the type-only `toolApprovalCapability` brand symbol.
…eat/interrupts-overhaul-slim
# Conflicts:
#	testing/e2e/src/routeTree.gen.ts
…options
Replace the two-overload resolveInterrupt signature with a single generic
call signature discriminated on `approved`. Overloads broke editor
autocomplete: a half-typed options literal satisfies neither overload, so
TS resolved no signature and offered no contextual completions. A generic
`TApproved extends boolean` is inferred from the first argument and selects
the matching branch for the rest params, so `payload`/`editedArgs` and the
correct approve/reject schema fields complete per-branch while the wrong
branch's fields are still rejected.
…wrong-branch payload
The generic resolveInterrupt signature now reports the wrong-branch payload
error precisely on the offending inner payload property rather than as a
blanket 'no overload matches' on the whole call. Move the @ts-expect-error
directive above that inner line in the framework type tests so it still
suppresses the (now more precise) error.
@AlemTuzlak
AlemTuzlak dismissed tombeckenham’s stale reviewJuly 22, 2026 11:21

Dismissing to unblock merge at repo owner's explicit request; autocomplete/type fixes landed and CI is green.

@AlemTuzlak
AlemTuzlak merged commit 3301398 into mainJul 22, 2026
10 checks passed
@AlemTuzlak
AlemTuzlak deleted the feat/interrupts-overhaul branch July 22, 2026 11:22
@github-actionsgithub-actionsBot mentioned this pull request Jul 22, 2026
tombeckenham pushed a commit that referenced this pull request Aug 17, 2026
Adds `@tanstack/ai-octane`, an Octane binding for TanStack AI. This is a
port of `@octanejs/tanstack-ai@0.0.11`, which lived in the octanejs/octane
repo as a temporary stopgap; the code moves here essentially unchanged
apart from the rename.
Covers the `@tanstack/ai-react` hook surface (useChat, useRealtimeChat,
useMcpAppBridge, useGeneration, useGenerateImage/Audio/Speech/Video,
useTranscription, useSummarize, useAudioRecorder) plus the 30
`@tanstack/ai-client` convenience re-exports, reusing `@tanstack/ai` and
`@tanstack/ai-client` unchanged. SSR through `octane/server` is tested.
Packaging: like Svelte packages shipping `.svelte`, this one publishes
uncompiled source. The hook modules are `.tsrx`, compiled by the
consumer's Octane plugin, so there is no `dist`/`build` target and no
publint `test:build`; `octane` is a required peer. The `.tsrx.d.ts`
companions are checked declaration emits, so `tsc` still verifies the
full public generic surface.
Deviations from a byte-for-byte move, all recorded in status.json:
- Conformance runs on happy-dom rather than jsdom. This repo pins jsdom
^27, whose `Blob` has no `arrayBuffer()` (Octane pins ^29, which does)
and the useAudioRecorder cases need it. Bumping jsdom for one package
would break sherif's cross-package version consistency.
- Two useChat cases asserted that a second concurrent sendMessage is
dropped. #900 made client-side queueing the default, so both now
assert queue-and-deliver-in-order, mirroring the current ai-react
tests. The hook needed no change — queueing lives in ChatClient.
- The differential parity test stays in the Octane repo: it byte-compares
streamed output against real @tanstack/ai-react, which would make a
sibling workspace package a pinned test dependency.
- Unused vendored test helpers were dropped rather than carried as dead
code (knip).
- The generated .tsrx.d.ts companions are excluded from lint; a
regeneration would undo any hand-fix.
Known gap: the port is baselined against @tanstack/ai-react 0.17.0 while
this repo is at 0.18.1. The interrupts overhaul (#970) and server
persistence / browser-refresh durability (#984) are not yet reflected in
the Octane hooks, and `typetests/` is therefore not wired into CI — its 4
errors are confined to one tool-input-inference case that fails
identically against @tanstack/ai-react, so it is not a port defect.
status.json lists the exact type-surface delta. Parity catch-up is
follow-up work.
pnpm-workspace.yaml excludes octane@0.1.17 and
@octanejs/testing-library@0.1.14 from minimumReleaseAge: Octane publishes
the pair in lockstep (testing-library pins an exact octane peer) and
releases roughly daily, so the newest pair is essentially always inside
the 24h window.
143 tests pass (142 conformance + 1 SSR), no skips or todos.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for freeto 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

@AlemTuzlak@jherr@tombeckenham