From 2e8d3e678f9d7e53bb5bfc77598a15cf23c0b1f9 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 8 Sep 2026 13:32:26 -0700 Subject: [PATCH 1/6] feat(ag-ui): add interrupt durability and align adapter resume retry --- CHANGELOG.md | 17 + .../content/docs/ag-ui/api/api-docs.json | 291 +++++++++++- .../content/docs/ag-ui/guides/interrupts.mdx | 52 ++- .../docs/ag-ui/reference/event-mapping.mdx | 16 +- .../content/docs/chat/api/api-docs.json | 152 +++++- .../content/docs/langgraph/api/api-docs.json | 4 +- .../docs/langgraph/guides/interrupts.mdx | 10 + libs/ag-ui/src/lib/client-tools.ts | 2 + .../src/lib/interrupt-persistence.spec.ts | 180 ++++++++ libs/ag-ui/src/lib/interrupt-persistence.ts | 199 ++++++++ libs/ag-ui/src/lib/interrupt-session.spec.ts | 169 +++++++ libs/ag-ui/src/lib/interrupt-session.ts | 157 +++++++ libs/ag-ui/src/lib/interrupt-session.types.ts | 22 + libs/ag-ui/src/lib/provide-agent.spec.ts | 9 + libs/ag-ui/src/lib/provide-agent.ts | 15 +- libs/ag-ui/src/lib/provide-agent.type-spec.ts | 10 +- .../src/lib/run-state-transaction.spec.ts | 21 + libs/ag-ui/src/lib/run-state-transaction.ts | 17 + .../src/lib/testing/provide-fake-agent.ts | 8 +- .../src/lib/to-agent.conformance.spec.ts | 113 +++++ .../lib/to-agent.interrupt-generation.spec.ts | 38 ++ .../lib/to-agent.interrupt-lifecycle.spec.ts | 206 +++++++++ .../to-agent.interrupt-restoration.spec.ts | 150 ++++++ .../src/lib/to-agent.resume-wire.spec.ts | 20 +- libs/ag-ui/src/lib/to-agent.resume.spec.ts | 34 +- libs/ag-ui/src/lib/to-agent.spec.ts | 51 ++- libs/ag-ui/src/lib/to-agent.ts | 431 ++++++++++++------ libs/ag-ui/src/public-api.ts | 5 +- libs/chat/src/lib/agent/agent.ts | 15 +- .../lib/agent/interrupt-conformance.spec.ts | 82 ++++ libs/chat/testing/interrupt-conformance.ts | 144 ++++++ libs/chat/testing/public-api.ts | 6 + .../src/lib/agent.conformance.spec.ts | 84 ++++ .../internals/stream-manager.bridge.spec.ts | 42 ++ .../lib/internals/stream-manager.bridge.ts | 28 +- 35 files changed, 2588 insertions(+), 212 deletions(-) create mode 100644 libs/ag-ui/src/lib/interrupt-persistence.spec.ts create mode 100644 libs/ag-ui/src/lib/interrupt-persistence.ts create mode 100644 libs/ag-ui/src/lib/interrupt-session.spec.ts create mode 100644 libs/ag-ui/src/lib/interrupt-session.ts create mode 100644 libs/ag-ui/src/lib/interrupt-session.types.ts create mode 100644 libs/ag-ui/src/lib/run-state-transaction.spec.ts create mode 100644 libs/ag-ui/src/lib/run-state-transaction.ts create mode 100644 libs/ag-ui/src/lib/to-agent.interrupt-generation.spec.ts create mode 100644 libs/ag-ui/src/lib/to-agent.interrupt-lifecycle.spec.ts create mode 100644 libs/ag-ui/src/lib/to-agent.interrupt-restoration.spec.ts create mode 100644 libs/chat/src/lib/agent/interrupt-conformance.spec.ts create mode 100644 libs/chat/testing/interrupt-conformance.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b4b011991..0a1117ab9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,20 @@ +## Unreleased + +### Features + +- **ag-ui:** expose complete interrupt sessions, optional atomic persistence and authoritative recovery, and provider lifetime cleanup. + +### Fixes + +- **langgraph:** retain null-payload resume commands and combined message/state input for intentional retry without reusing an aborted signal. +- **ag-ui:** retain exact resume decisions, roll failed state back to committed boundaries, and reject stale callbacks and concurrent recovery actions. + +### Breaking changes + +- **ag-ui:** native batches take precedence over compatibility events in `auto` mode. Select `legacy-command` or `mastra-command` explicitly for backends requiring command transport. +- **ag-ui:** resume requires a pending batch and each native ID exactly once; scalar responses apply only to single-entry batches. New messages, regeneration, and client-tool continuation cannot abandon an unresolved interrupt. +- **ag-ui:** uncertain resumes require authoritative reconciliation before retry. Durable client claims require an application-provided atomic store; backend duplicate-effect protection requires server idempotency. + ## 0.1.0 (2026-09-08) ### 🚀 Features diff --git a/apps/website/content/docs/ag-ui/api/api-docs.json b/apps/website/content/docs/ag-ui/api/api-docs.json index 5251103aa..3e5b8b182 100644 --- a/apps/website/content/docs/ag-ui/api/api-docs.json +++ b/apps/website/content/docs/ag-ui/api/api-docs.json @@ -387,6 +387,18 @@ "description": "Extra HTTP headers sent with every request (e.g. auth tokens).", "optional": true }, + { + "name": "interruptTransport", + "type": "InterruptTransport", + "description": "Explicit compatibility wire profile; auto prefers protocol-native outcomes.", + "optional": true + }, + { + "name": "persistence", + "type": "AgUiInterruptPersistence", + "description": "Optional application-owned durable thread storage and reconciliation.", + "optional": true + }, { "name": "telemetry", "type": "false | AgentRuntimeTelemetrySink", @@ -440,9 +452,15 @@ { "name": "interrupt", "type": "Signal", - "description": "", + "description": "Optional display projection of the pending interrupt. A runtime may expose\na separate full batch and lifecycle surface. A cleared display projection\ndoes not by itself prove that resumed backend work completed.", "optional": true }, + { + "name": "interruptSession", + "type": "Signal", + "description": "Full interrupt batch and its request ownership phase.", + "optional": false + }, { "name": "isLoading", "type": "Signal", @@ -455,6 +473,12 @@ "description": "", "optional": false }, + { + "name": "ready", + "type": "Promise", + "description": "Resolves after persisted thread state is hydrated; actions wait for it.", + "optional": false + }, { "name": "regenerate", "type": "(assistantMessageIndex: number) => Promise", @@ -464,7 +488,7 @@ { "name": "retry", "type": "() => Promise", - "description": "Re-run the last submitted input after a failure. No-op if a run is already\n in flight or there is nothing to retry. Clears `error` and sets loading.", + "description": "Re-run the captured submission after a failure, including a resume command\nwith no message payload. Does not append another user message or reuse an\naborted request signal. Does not restart an in-flight request; no-op when\nnothing is saved.\nAdapters may reject unsafe resume retries until the backend outcome has\nbeen reconciled; a transport failure alone does not prove non-execution.", "optional": false }, { @@ -492,16 +516,43 @@ "optional": false }, { - "name": "submit", - "type": "(input: AgentSubmitInput, opts: AgentSubmitOptions) => Promise", + "name": "toolCalls", + "type": "Signal", "description": "", "optional": false + } + ], + "methods": [ + { + "name": "dispose", + "signature": "dispose(): void", + "description": "Unsubscribe and stop local work. Does not cancel backend checkpoints.", + "params": [] }, { - "name": "toolCalls", - "type": "Signal", + "name": "reconcileInterrupt", + "signature": "reconcileInterrupt(): Promise", + "description": "Recover an uncertain attempt using the configured authoritative reconciler.", + "params": [] + }, + { + "name": "submit", + "signature": "submit(input: AgentSubmitInput, opts: AgUiSubmitOptions): Promise", "description": "", - "optional": false + "params": [ + { + "name": "input", + "type": "AgentSubmitInput", + "description": "", + "optional": false + }, + { + "name": "opts", + "type": "AgUiSubmitOptions", + "description": "", + "optional": true + } + ] } ], "examples": [] @@ -538,6 +589,102 @@ ], "examples": [] }, + { + "name": "AgUiInterruptPersistence", + "kind": "interface", + "description": "", + "properties": [ + { + "name": "namespace", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "reconcile", + "type": "(record: AgUiThreadRecord) => Promise", + "description": "", + "optional": true + }, + { + "name": "store", + "type": "object", + "description": "", + "optional": false + } + ], + "examples": [] + }, + { + "name": "AgUiSubmitOptions", + "kind": "interface", + "description": "Adapter-specific submit guards in addition to cancellation.", + "properties": [ + { + "name": "interruptGeneration", + "type": "number", + "description": "Generation captured when rendering the interrupt decision; rejects stale controls.", + "optional": true + }, + { + "name": "signal", + "type": "AbortSignal", + "description": "", + "optional": true + } + ], + "examples": [] + }, + { + "name": "AgUiThreadRecord", + "kind": "interface", + "description": "", + "properties": [ + { + "name": "committed", + "type": "ThreadSnapshot", + "description": "", + "optional": false + }, + { + "name": "namespace", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "resumeInput", + "type": "ThreadSnapshot", + "description": "", + "optional": true + }, + { + "name": "revision", + "type": "number", + "description": "", + "optional": false + }, + { + "name": "session", + "type": "InterruptSessionSnapshot", + "description": "", + "optional": false + }, + { + "name": "threadId", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "version", + "type": "1", + "description": "", + "optional": false + } + ], + "examples": [] + }, { "name": "CustomStreamEvent", "kind": "interface", @@ -558,6 +705,108 @@ ], "examples": [] }, + { + "name": "InterruptSessionSnapshot", + "kind": "interface", + "description": "", + "properties": [ + { + "name": "attempt", + "type": "ResumeAttempt", + "description": "", + "optional": true + }, + { + "name": "generation", + "type": "number", + "description": "", + "optional": false + }, + { + "name": "interrupts", + "type": "object[]", + "description": "", + "optional": false + }, + { + "name": "legacy", + "type": "AgentInterrupt", + "description": "", + "optional": true + }, + { + "name": "phase", + "type": "InterruptSessionPhase", + "description": "", + "optional": false + }, + { + "name": "runId", + "type": "string", + "description": "", + "optional": true + } + ], + "examples": [] + }, + { + "name": "ResumeAttempt", + "kind": "interface", + "description": "", + "properties": [ + { + "name": "generation", + "type": "number", + "description": "", + "optional": false + }, + { + "name": "id", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "input", + "type": "AgentSubmitInput", + "description": "", + "optional": false + }, + { + "name": "parameters", + "type": "object", + "description": "", + "optional": false + }, + { + "name": "runId", + "type": "string", + "description": "", + "optional": false + } + ], + "examples": [] + }, + { + "name": "ThreadSnapshot", + "kind": "interface", + "description": "Serializable protocol state at a confirmed run boundary.", + "properties": [ + { + "name": "messages", + "type": "object | object | object | object | object | object | object[]", + "description": "", + "optional": false + }, + { + "name": "state", + "type": "Record", + "description": "", + "optional": false + } + ], + "examples": [] + }, { "name": "ToAgentOptions", "kind": "interface", @@ -569,6 +818,18 @@ "description": "A2UI client capabilities (catalog negotiation) to advertise to the agent.\nWhen set, they are seeded once into the AG-UI shared state under the\n`a2ui_client_capabilities` key, so every RunAgentInput.state carries them.\nUse `@threadplane/chat`'s `a2uiClientCapabilities()` for the renderer's\nstandard value.", "optional": true }, + { + "name": "interruptTransport", + "type": "InterruptTransport", + "description": "Native outcomes take precedence in auto mode; select a legacy profile explicitly when required.", + "optional": true + }, + { + "name": "persistence", + "type": "AgUiInterruptPersistence", + "description": "Application-owned durable storage. Requires a stable source threadId and scoped namespace.", + "optional": true + }, { "name": "telemetry", "type": "false | AgentRuntimeTelemetrySink", @@ -585,6 +846,20 @@ "signature": "readonly { events: readonly BaseEvent[]; when: \"initial\" | { toolMessageFor: string } }[]", "examples": [] }, + { + "name": "InterruptSessionPhase", + "kind": "type", + "description": "", + "signature": "\"none\" | \"collecting\" | \"pending\" | \"claimed\" | \"resuming\" | \"acknowledged\" | \"uncertain\" | \"recovery-required\"", + "examples": [] + }, + { + "name": "InterruptTransport", + "kind": "type", + "description": "", + "signature": "\"auto\" | \"protocol\" | \"legacy-command\" | \"mastra-command\"", + "examples": [] + }, { "name": "bridgeCitationsState", "kind": "function", @@ -678,7 +953,7 @@ { "name": "toAgent", "kind": "function", - "description": "Wraps an AG-UI AbstractAgent into the runtime-neutral Agent contract.\n\nThe adapter subscribes to source.subscribe({ onEvent }) and reduces every\nevent into the produced Agent's signals. submit() optimistically appends the\nuser message to both our signals and the source agent's internal message\nlist, then calls source.runAgent(). stop() calls source.abortRun().\n\nSubscription cleanup: the returned Agent does NOT manage its own lifetime.\nCallers using DI should rely on the provider's destroy hook; direct callers\nof toAgent() should treat the returned object's lifecycle as tied to the\nagent instance they constructed. The subscriber registered via\nsource.subscribe() will fire for the lifetime of source.", + "description": "Wraps an AG-UI AbstractAgent into the runtime-neutral Agent contract.\n\nThe adapter subscribes to source.subscribe({ onEvent }) and reduces every\nevent into the produced Agent's signals. submit() optimistically appends the\nuser message to both our signals and the source agent's internal message\nlist, then calls source.runAgent(). stop() calls source.abortRun().\n\nSubscription cleanup: providers dispose the adapter with their injector.\nDirect callers must call dispose() when they no longer need the adapter.", "signature": "toAgent(source: AbstractAgent<>, options: ToAgentOptions): AgUiAgent<>", "params": [ { diff --git a/apps/website/content/docs/ag-ui/guides/interrupts.mdx b/apps/website/content/docs/ag-ui/guides/interrupts.mdx index 9e42ebef8..96c383ebb 100644 --- a/apps/website/content/docs/ag-ui/guides/interrupts.mdx +++ b/apps/website/content/docs/ag-ui/guides/interrupts.mdx @@ -116,7 +116,7 @@ The example never touches the protocol, but it is worth knowing what crosses it. } ``` -Two things to note. The `ag-ui-langgraph` package serializes the payload, so `value` arrives as a JSON string rather than an object; the adapter parses a string value before it stores the interrupt, so consumers read the parsed payload rather than a JSON string. And the pause is what the reducer records: it sets the `interrupt()` signal, ends the run as paused, and returns the agent to its idle status. +The `ag-ui-langgraph` package serializes the payload, so `value` arrives as a JSON string rather than an object. The adapter parses it before exposing the interrupt. This compatibility event begins collecting a pause; the batch becomes ready to answer at a terminal event or clean transport close. State and message changes remain provisional until that boundary. Resuming travels the other way. For a payload shaped like this one — an application object with no runtime-specific identifiers — `submit({ resume })` sends `forwardedProps.command.resume`, the convention `ag-ui-langgraph` reads to resume the checkpoint. @@ -124,7 +124,55 @@ Resuming travels the other way. For a payload shaped like this one — an applic In the graph node, `interrupt({...})` returns the resume value directly. The wrapper reads `forwarded_props.command.resume` and applies it before the graph continues. -Other runtimes signal a pause differently, and the adapter follows them: a `RUN_FINISHED` event carrying an interrupt outcome sets the same signal, and a resume for one of those goes out as the protocol's top-level `resume` array instead. The [event mapping reference](/docs/ag-ui/reference/event-mapping) covers the event surface in full. +Other runtimes signal a pause differently: a `RUN_FINISHED` event carrying a native interrupt outcome records the full batch, and its response goes out as the protocol's top-level `resume` array. Native outcomes take precedence over `CUSTOM on_interrupt` in the default `auto` mode, whichever event arrives first. The [event mapping reference](/docs/ag-ui/reference/event-mapping) covers the event surface in full. + +## Pending batches and transport selection + +`interrupt()` is the runtime-neutral display projection. For a native batch its value is `{ interrupts, runId }`; use `interruptSession()` for the full batch and its lifecycle. The session includes `phase`, `generation`, `interrupts`, and, when present, the compatibility payload, originating `runId`, and saved resume `attempt`. + +The phases are `none`, `collecting`, `pending`, `claimed`, `resuming`, `acknowledged`, `uncertain`, and `recovery-required`. Render approval controls when the phase is `pending`. Submitting a decision claims that batch before dispatch, so a second submission cannot answer it concurrently. `RUN_STARTED` acknowledges the claim and clears the visible pending interrupt; it does not prove the resumed work completed. + +For one native interrupt, `submit({ resume: { approved: true } })` wraps the value as a resolved response to that interrupt. For several, supply one response per pending ID: + +```typescript +await agent.submit({ + resume: [ + { interruptId: 'approval-1', status: 'resolved', payload: { approved: true } }, + { interruptId: 'approval-2', status: 'cancelled' }, + ], +}); +``` + +Use IDs from `interruptSession().interrupts`, not the illustrative IDs above. Every pending ID must appear exactly once; missing, duplicate, unknown, conflicting, and expired IDs are rejected. A cancelled entry cannot carry a payload. A single unwrapped value is accepted only for a single interrupt. Resume requests without a pending batch are rejected, and ordinary input cannot abandon a pending approval. + +To reject a decision from an old approval control, capture `const generation = agent.interruptSession().generation` when rendering it and pass `agent.submit({ resume: answer }, { interruptGeneration: generation })`. The adapter checks this optional generation before claiming the batch or changing messages and state. Read a fresh generation when rendering the next batch, even if the runtime reuses interrupt IDs. + +Set `interruptTransport` in `provideAgent()` or `toAgent()` options when the backend requires a specific format: + +| Value | Resume format | +| --- | --- | +| `auto` (default) | Native protocol when a native batch exists; otherwise Mastra command for a compatibility payload with `toolCallId`, or the legacy command. | +| `protocol` | Top-level `resume` entries correlated with native interrupt IDs. | +| `legacy-command` | `forwardedProps.command.resume`. | +| `mastra-command` | `forwardedProps.command.resume` plus `command.interruptEvent` containing `toolCallId` and the available `runId`. Requires the compatibility payload's `toolCallId`. | + +## Failures, retry, and restoration + +A failed resume restores the committed protocol messages and state used by subsequent requests. Failed provisional messages may remain visible in the local transcript. Authoritative native snapshots define the committed boundary. A compatibility pause commits only at a terminal event or clean transport close. This client rollback cannot undo work already performed on the server. + +When a transport error explicitly carries `requestNotDispatched: true`, the batch returns to `pending` and `retry()` resends the exact saved decision. Submitting a replacement decision is rejected. Set that marker only when the transport can prove the request was not dispatched. A lost connection, abort after dispatch, or failure after acknowledgement can leave the result uncertain; `retry()` then requires authoritative reconciliation and does not resend automatically. + +Restoration is opt-in through `persistence` in the provider or adapter options. The application supplies a stable `threadId`, a scoped `namespace`, and a store with `load(key)` and atomic `compareAndSwap(key, expectedRevision, next)` methods. A missing record uses `expectedRevision: null`; a revision conflict blocks further writes. The record retains committed messages and state, the interrupt session, and the saved resume attempt. No browser storage backend is bundled. + +Atomic storage claims coordinate clients sharing that store. Preventing duplicate backend side effects also requires server-side idempotency; a retained attempt ID alone cannot establish that guarantee. + +Await `agent.ready` before rendering restored approval controls; actions also wait for hydration. An interrupted in-flight claim restores as uncertain, and an acknowledged attempt requires recovery. Configure `persistence.reconcile(record)` to obtain an authoritative backend outcome and call `agent.reconcileInterrupt()` to apply it. The callback returns `unknown`, or `pending`, `acknowledged`, or `completed` with authoritative `committed` and `session` snapshots. An unknown outcome keeps recovery blocked. The adapter does not provide a backend reconciliation endpoint, and stored client state cannot recreate a checkpoint lost by a restarted `MemorySaver` process. + +Providers dispose the adapter with their injector. If you create it with `toAgent()` directly, call `agent.dispose()` when finished. Disposal unsubscribes and stops local work; it does not delete or cancel backend checkpoints. + +### Migration note + +Mixed native and compatibility events now select native transport by default regardless of arrival order. Backends requiring a command format must select the matching explicit profile. Replace bare resume calls with a restored or observed pending batch, answer every native ID, and use `retry()` for a retained decision after a proven pre-dispatch failure. Recovery from an uncertain attempt requires application-provided authoritative reconciliation. ## Cross-adapter parity diff --git a/apps/website/content/docs/ag-ui/reference/event-mapping.mdx b/apps/website/content/docs/ag-ui/reference/event-mapping.mdx index b93961682..479c91289 100644 --- a/apps/website/content/docs/ag-ui/reference/event-mapping.mdx +++ b/apps/website/content/docs/ag-ui/reference/event-mapping.mdx @@ -69,15 +69,15 @@ One prompt against this graph produces the following sequence. The order is the ## Event reference -The adapter exposes `messages`, `status`, `isLoading`, `error`, `toolCalls`, `state`, `interrupt`, `subagents`, and `customEvents` as Signals, `events$` as an Observable, and `clientTools` as a capability, alongside the `submit`, `retry`, `stop`, and `regenerate` methods. The tables below name the surface each event writes to. +The adapter exposes `messages`, `status`, `isLoading`, `error`, `toolCalls`, `state`, `interrupt`, `interruptSession`, `subagents`, and `customEvents` as Signals, `events$` as an Observable, and `clientTools` as a capability. Alongside `submit`, `retry`, `stop`, and `regenerate`, it provides the hydration Promise `ready`, `reconcileInterrupt()`, and `dispose()`. The tables below name the surface each event writes to. ### Run lifecycle | AG-UI event | Surface | Behavior | | --- | --- | --- | -| `RUN_STARTED` | `status`, `isLoading`, `error`, `interrupt`, `customEvents`, `subagents` | Sets `status` to `running` and `isLoading` to `true`; clears the error, the pending interrupt, the accumulated custom events, the subagent map, and any unfinished tool-argument buffer. Ignored when the event's run id does not match the run already bound to this delivery. | -| `RUN_FINISHED` (no outcome, or a success outcome) | `status`, `isLoading`, `messages` | Completes the run, sets `status` to `idle` and `isLoading` to `false`, and settles the run's messages. Ignored on the same run-id gate as `RUN_STARTED`. | -| `RUN_FINISHED` (outcome `{ type: 'interrupt' }`) | `interrupt`, `status`, `isLoading` | Sets `interrupt` to `{ id, value: { interrupts, runId }, resumable: true }`, ends the run as paused, and returns the agent to `idle`. A `CUSTOM` `on_interrupt` that already paused this run keeps its own value. | +| `RUN_STARTED` | `status`, `isLoading`, `error`, `interrupt`, `interruptSession`, `customEvents`, `subagents` | Sets `status` to `running` and `isLoading` to `true`; clears the error, visible interrupt, custom events, subagents, and unfinished tool-argument buffer. A matching resume becomes `acknowledged`; the claim is retained until a terminal outcome, rather than treated as completed. Ignored when the run id does not match this delivery. | +| `RUN_FINISHED` (no outcome, or a success outcome) | `status`, `isLoading`, `messages`, `interruptSession` | Settles the run and returns to `idle`. A collected compatibility interrupt becomes pending at this boundary; otherwise the run completes. Ignored on the same run-id gate as `RUN_STARTED`. | +| `RUN_FINISHED` (outcome `{ type: 'interrupt' }`) | `interrupt`, `interruptSession`, `status`, `isLoading` | Records the full native batch as pending, projects `{ id, value: { interrupts, runId }, resumable: true }`, and returns to `idle`. In default `auto` mode the native batch takes precedence over compatibility events in either arrival order. | | `RUN_ERROR` | `status`, `isLoading`, `error` | Ends the run as failed, sets `status` to `error`, stops loading, and stores the event's `message` as an error (the raw event when no message is present). Ignored on the same run-id gate as `RUN_STARTED`. | | `STEP_STARTED`, `STEP_FINISHED` | none | Not reduced. Node boundaries pass through untouched. | @@ -150,7 +150,7 @@ The citations bridge reads `state.citations`, an object keyed by message id, and | AG-UI event | Surface | Behavior | | --- | --- | --- | -| `CUSTOM` named `on_interrupt` | `interrupt`, `status`, `isLoading` | Sets `interrupt` to `{ id, value, resumable: true }`, ends the run as paused, and returns the agent to `idle`. A string `value` is JSON-parsed first. | +| `CUSTOM` named `on_interrupt` | `interrupt`, `interruptSession`, `status`, `isLoading` | Collects a compatibility interrupt, parsing a string value as JSON. The batch becomes pending at a terminal event or clean transport close. A native batch retains precedence in `auto` mode. | | `CUSTOM` named `state_update` with an object value | `customEvents`, `events$` | Appends `{ name, data }` to `customEvents` and emits `{ type: 'state_update', data }` on `events$`. | | `CUSTOM` (every other name) | `customEvents`, `events$` | Appends `{ name, data }` to `customEvents` and emits `{ type: 'custom', name, data }` on `events$`. | @@ -176,7 +176,11 @@ Text and tool events may also carry a `subagentRunId`. An attributed content eve ## Submit, stop, and failures -`agent.submit({ message })` builds a local user message, appends it to `messages` and to the source agent's own list, and starts the run. When `message` is omitted no user message is appended and the run still starts. `submit({ resume })` is a separate path: it clears the pending interrupt and replays the run with the resume payload, shaped by how that interrupt arrived. `agent.retry()` re-runs the last input without appending the message again, and `agent.regenerate(index)` truncates the transcript back to the preceding user message before re-running. +`agent.submit({ message })` builds a local user message, appends it to `messages` and the source agent's list, and starts the run. Omitting `message` starts a run without appending a user message, provided no interrupt blocks new input. `submit({ resume })` requires a pending batch and claims it before dispatch. Native responses must cover every pending ID exactly once; a single unwrapped value is accepted only for a single interrupt. Ordinary input and regeneration cannot abandon a pending approval. + +`agent.retry()` replays captured input without appending the user message again. For a resume, an error carrying `requestNotDispatched: true` retains the pending batch and exact decision for retry. Uncertain delivery or failure after acknowledgement requires `reconcileInterrupt()` with an application-provided authoritative reconciler before retrying. A failed resume restores committed protocol messages and state; provisional stream changes do not become the next request's baseline, though failed messages may remain visible in the local transcript. Native snapshots are authoritative, while compatibility pause state commits at a terminal event or clean close. + +`interruptTransport` selects `auto`, `protocol`, `legacy-command`, or `mastra-command`. In `auto`, native outcomes use top-level `resume`; compatibility payloads use `forwardedProps.command.resume`, with `command.interruptEvent` for Mastra payloads carrying `toolCallId`. Select a command profile explicitly if the backend requires it even when native events are present. `agent.stop()` ends the current run locally as aborted, clears the error, and calls `source.abortRun()`. Whether the backend stops producing depends on the AG-UI source implementation. diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index 60d63bddf..724594c3f 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -5993,7 +5993,7 @@ { "name": "interrupt", "type": "Signal", - "description": "", + "description": "Optional display projection of the pending interrupt. A runtime may expose\na separate full batch and lifecycle surface. A cleared display projection\ndoes not by itself prove that resumed backend work completed.", "optional": true }, { @@ -6017,7 +6017,7 @@ { "name": "retry", "type": "() => Promise", - "description": "Re-run the last submitted input after a failure. No-op if a run is already\n in flight or there is nothing to retry. Clears `error` and sets loading.", + "description": "Re-run the captured submission after a failure, including a resume command\nwith no message payload. Does not append another user message or reuse an\naborted request signal. Does not restart an in-flight request; no-op when\nnothing is saved.\nAdapters may reject unsafe resume retries until the backend outcome has\nbeen reconciled; a transport failure alone does not prove non-execution.", "optional": false }, { @@ -6313,7 +6313,7 @@ { "name": "interrupt", "type": "Signal", - "description": "", + "description": "Optional display projection of the pending interrupt. A runtime may expose\na separate full batch and lifecycle surface. A cleared display projection\ndoes not by itself prove that resumed backend work completed.", "optional": true }, { @@ -6343,7 +6343,7 @@ { "name": "retry", "type": "() => Promise", - "description": "Re-run the last submitted input after a failure. No-op if a run is already\n in flight or there is nothing to retry. Clears `error` and sets loading.", + "description": "Re-run the captured submission after a failure, including a resume command\nwith no message payload. Does not append another user message or reuse an\naborted request signal. Does not restart an in-flight request; no-op when\nnothing is saved.\nAdapters may reject unsafe resume retries until the backend outcome has\nbeen reconciled; a transport failure alone does not prove non-execution.", "optional": false }, { @@ -7493,7 +7493,7 @@ { "name": "interrupt", "type": "WritableSignal", - "description": "", + "description": "Optional display projection of the pending interrupt. A runtime may expose\na separate full batch and lifecycle surface. A cleared display projection\ndoes not by itself prove that resumed backend work completed.", "optional": true }, { @@ -7523,7 +7523,7 @@ { "name": "retry", "type": "() => Promise", - "description": "Re-run the last submitted input after a failure. No-op if a run is already\n in flight or there is nothing to retry. Clears `error` and sets loading.", + "description": "Re-run the captured submission after a failure, including a resume command\nwith no message payload. Does not append another user message or reuse an\naborted request signal. Does not restart an in-flight request; no-op when\nnothing is saved.\nAdapters may reject unsafe resume retries until the backend outcome has\nbeen reconciled; a transport failure alone does not prove non-execution.", "optional": false }, { @@ -9941,6 +9941,115 @@ ], "examples": [] }, + { + "name": "InterruptConformanceHarness", + "kind": "interface", + "description": "Opt-in harness: pause and failure must be driven through the real adapter.", + "properties": [ + { + "name": "agent", + "type": "Agent<>", + "description": "", + "optional": false + }, + { + "name": "requests", + "type": "InterruptConformanceRequest[]", + "description": "", + "optional": false + }, + { + "name": "restore", + "type": "() => Promise", + "description": "Optional backend history/store restoration; never synthesizes a local pause.", + "optional": true + }, + { + "name": "resume", + "type": "unknown", + "description": "Native decision addressing every interrupt in the fixture batch.", + "optional": false + } + ], + "methods": [ + { + "name": "backendCancellationCount", + "signature": "backendCancellationCount(): number", + "description": "", + "params": [] + }, + { + "name": "cleanup", + "signature": "cleanup(): void | Promise", + "description": "", + "params": [] + }, + { + "name": "deliverLateEvents", + "signature": "deliverLateEvents(): Promise", + "description": "Release captured native callbacks/events, even after cleanup.", + "params": [] + }, + { + "name": "failNextDispatch", + "signature": "failNextDispatch(): void", + "description": "", + "params": [] + }, + { + "name": "pause", + "signature": "pause(): Promise", + "description": "", + "params": [] + }, + { + "name": "pendingBatch", + "signature": "pendingBatch(): object[]", + "description": "", + "params": [] + }, + { + "name": "startLateDelivery", + "signature": "startLateDelivery(): Promise", + "description": "Start a native run and hold its delivery until deliverLateEvents is called.", + "params": [] + } + ], + "examples": [] + }, + { + "name": "InterruptConformanceRequest", + "kind": "interface", + "description": "Semantic view of a native request; adapters retain responsibility for wire assertions.", + "properties": [ + { + "name": "messages", + "type": "string[]", + "description": "", + "optional": false + }, + { + "name": "resume", + "type": "unknown", + "description": "", + "optional": false + }, + { + "name": "state", + "type": "Record", + "description": "", + "optional": false + } + ], + "examples": [] + }, + { + "name": "INTERRUPT_CONFORMANCE_BATCH", + "kind": "const", + "description": "", + "signature": "object[]", + "examples": [] + }, { "name": "REASONING_FIXTURE_EVENTS", "kind": "const", @@ -10037,5 +10146,36 @@ "description": "" }, "examples": [] + }, + { + "name": "runInterruptConformance", + "kind": "function", + "description": "Separate from base conformance so agents without interrupts remain supported.", + "signature": "runInterruptConformance(label: string, factory: () => InterruptConformanceHarness, options: object): void", + "params": [ + { + "name": "label", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "factory", + "type": "() => InterruptConformanceHarness", + "description": "", + "optional": false + }, + { + "name": "options", + "type": "object", + "description": "", + "optional": true + } + ], + "returns": { + "type": "void", + "description": "" + }, + "examples": [] } ] \ No newline at end of file diff --git a/apps/website/content/docs/langgraph/api/api-docs.json b/apps/website/content/docs/langgraph/api/api-docs.json index 7732d3c42..ead58a8d8 100644 --- a/apps/website/content/docs/langgraph/api/api-docs.json +++ b/apps/website/content/docs/langgraph/api/api-docs.json @@ -1615,7 +1615,7 @@ { "name": "retry", "type": "() => Promise", - "description": "Re-run the last submitted input after a failure. No-op if a run is already\n in flight or there is nothing to retry. Clears `error` and sets loading.", + "description": "Re-run the captured submission after a failure, including a resume command\nwith no message payload. Does not append another user message or reuse an\naborted request signal. Does not restart an in-flight request; no-op when\nnothing is saved.\nAdapters may reject unsafe resume retries until the backend outcome has\nbeen reconciled; a transport failure alone does not prove non-execution.", "optional": false }, { @@ -2049,7 +2049,7 @@ { "name": "retry", "type": "() => Promise", - "description": "Re-run the last submitted input after a failure. No-op if a run is already\n in flight or there is nothing to retry. Clears `error` and sets loading.", + "description": "Re-run the captured submission after a failure, including a resume command\nwith no message payload. Does not append another user message or reuse an\naborted request signal. Does not restart an in-flight request; no-op when\nnothing is saved.\nAdapters may reject unsafe resume retries until the backend outcome has\nbeen reconciled; a transport failure alone does not prove non-execution.", "optional": false }, { diff --git a/apps/website/content/docs/langgraph/guides/interrupts.mdx b/apps/website/content/docs/langgraph/guides/interrupts.mdx index 939904fd5..533414c25 100644 --- a/apps/website/content/docs/langgraph/guides/interrupts.mdx +++ b/apps/website/content/docs/langgraph/guides/interrupts.mdx @@ -125,6 +125,16 @@ LangGraph re-runs the interrupting node, `interrupt()` returns the resume value, +## Retrying a resume and restoring a thread + +`retry()` retains command-only submissions, including a resume with no message payload, and replays the captured command and update values. It does not append another user message. The values are captured when submitted, so later edits to the caller's object cannot change the retry. A retry also drops the previous request's abort signal, allowing a fresh request after that signal was aborted. + +Before retrying a resume whose server outcome is unknown, inspect the thread's current checkpoint. A transport failure does not prove the server rejected the decision. Checkpoint hydration restores pending interrupts when you reconnect to the same thread; retaining the thread ID and the server checkpoint is required. This adapter's retry support does not add a client claim ledger, compare-and-swap storage, or transactional rollback of streamed client state. + +### Migration note + +Command-only resumes and updates now remain available to `retry()` even when the submitted message payload was null. Code that previously resubmitted the command manually can use `retry()` after confirming it is safe to repeat. Restore an existing thread through its checkpoint before offering an approval again, and keep side effects above `interrupt()` safe to re-execute. + ## Approving more than once The example pauses once per run. A workflow that needs a sign-off at each stage of a plan uses the same primitive in a loop: the approving node interrupts on the current step, the executing node advances the counter, and a conditional edge sends the run back for the next approval until the plan is exhausted. diff --git a/libs/ag-ui/src/lib/client-tools.ts b/libs/ag-ui/src/lib/client-tools.ts index 03c1e3fc7..89879b5fd 100644 --- a/libs/ag-ui/src/lib/client-tools.ts +++ b/libs/ag-ui/src/lib/client-tools.ts @@ -58,6 +58,7 @@ export function createClientToolsCapability( source: ClientToolsSource, store: ReducerStore, continueRun: ContinueClientToolRun, + beforeResolve?: () => void, ): ClientToolsCapability & { catalogAsAgUiTools(): Tool[] } { const catalog = signal([]); const resolvedIds = signal>(new Set()); @@ -135,6 +136,7 @@ export function createClientToolsCapability( }, resolve(id: string, result: ClientToolResult): void { + beforeResolve?.(); settleResult(id, result); void continueRun(); }, diff --git a/libs/ag-ui/src/lib/interrupt-persistence.spec.ts b/libs/ag-ui/src/lib/interrupt-persistence.spec.ts new file mode 100644 index 000000000..cc2bfc730 --- /dev/null +++ b/libs/ag-ui/src/lib/interrupt-persistence.spec.ts @@ -0,0 +1,180 @@ +import { describe, expect, it, vi } from 'vitest'; +import { InterruptPersistence, type AgUiInterruptPersistence, type AgUiThreadRecord } from './interrupt-persistence'; +import { InterruptSession } from './interrupt-session'; + +function memory() { + const records = new Map(); + const store: AgUiInterruptPersistence['store'] = { + async load(key) { return structuredClone(records.get(key) ?? null); }, + async compareAndSwap(key, expected, next) { + if ((records.get(key)?.revision ?? null) !== expected) return false; + records.set(key, structuredClone(next)); return true; + }, + }; + return { records, config: { namespace: 'account', store } }; +} +function data(phase: 'pending' | 'claimed' | 'resuming' | 'acknowledged' = 'pending') { + const session = new InterruptSession(); + session.observeNative([{ id: 'one', reason: 'approval' }, { id: 'two', reason: 'approval' }], 'original'); + session.ready(); + if (phase !== 'pending') session.claim({ resume: [{ id: 'one', payload: true }, { id: 'two', payload: false }] }, 'attempt', 'resume-run'); + if (phase === 'resuming' || phase === 'acknowledged') session.dispatched('attempt'); + if (phase === 'acknowledged') session.acknowledge('attempt'); + return { committed: { messages: [{ id: 'm', role: 'user' as const, content: 'hello' }], state: { count: 1 } }, session: session.snapshot }; +} + +describe('InterruptPersistence', () => { + it('requires nonempty scope and uses collision-free keys', async () => { + const { config, records } = memory(); + expect(() => new InterruptPersistence({ ...config, namespace: ' ' }, 't')).toThrow(); + expect(() => new InterruptPersistence(config, '')).toThrow(); + await new InterruptPersistence({ ...config, namespace: 'a:b' }, 'c').save(data()); + await new InterruptPersistence({ ...config, namespace: 'a' }, 'b:c').save(data()); + expect([...records.keys()]).toEqual(['["a:b","c"]', '["a","b:c"]']); + }); + + it('restores committed state and the complete pending batch across restart with defensive copies', async () => { + const { config, records } = memory(); + const input = data(); + const first = new InterruptPersistence(config, 't'); + const saving = first.save(input); + input.committed.state.count = 9; + await saving; + const restarted = new InterruptPersistence(config, 't'); + const restored = await restarted.load(); + expect(restored).toMatchObject({ version: 1, namespace: 'account', threadId: 't', revision: 0, ...data() }); + if (!restored) throw new Error('Expected a persisted record'); + restored.session.interrupts.length = 0; + expect((await restarted.load())?.session.interrupts).toHaveLength(2); + expect(records.size).toBe(1); + }); + + it('rejects competing owners without retrying CAS', async () => { + const { config } = memory(); + const a = new InterruptPersistence(config, 't'); const b = new InterruptPersistence(config, 't'); + await Promise.all([a.load(), b.load()]); + const results = await Promise.allSettled([a.save(data()), b.save(data())]); + expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(1); + await expect(b.save(data())).rejects.toThrow(/conflict/i); + }); + + it.each(['claimed', 'resuming', 'acknowledged'] as const)('makes a crashed %s attempt require recovery', async phase => { + const { config } = memory(); + await new InterruptPersistence(config, 't').save({ ...data(phase), resumeInput: data().committed }); + const restored = await new InterruptPersistence(config, 't').load(); + expect(restored?.session.phase).toBe(phase === 'acknowledged' ? 'recovery-required' : 'uncertain'); + expect(restored?.session.attempt?.id).toBe('attempt'); + expect(restored?.resumeInput).toEqual(data().committed); + }); + + it('allows explicit reload after conflict without silently retrying a losing save', async () => { + const { config } = memory(); + const a = new InterruptPersistence(config, 't'); const b = new InterruptPersistence(config, 't'); + await Promise.all([a.load(), b.load()]); + await a.save(data()); + await expect(b.save(data())).rejects.toThrow(/conflict/i); + expect((await b.load())?.revision).toBe(0); + await b.save(data()); + expect((await a.load())?.revision).toBe(1); + }); + + it('reconciles against the latest record after losing a compare-and-swap', async () => { + const { config } = memory(); + const a = new InterruptPersistence(config, 't'); + const b = new InterruptPersistence({ ...config, reconcile: async record => { + expect(record.revision).toBe(1); + expect(record.session.phase).toBe('uncertain'); + return { status: 'pending', ...data() }; + } }, 't'); + await Promise.all([a.load(), b.load()]); + await a.save(data()); await a.save(data('resuming')); + await expect(b.save(data())).rejects.toThrow(/conflict/i); + expect(await b.reconcile()).toMatchObject({ revision: 2, session: { phase: 'pending' } }); + }); + + it.each([ + { version: 2 }, { namespace: 'other' }, { threadId: 'other' }, { revision: -1 }, + { revision: 1.1 }, { committed: { state: [], messages: [] } }, + { committed: { state: { value: Infinity }, messages: [] } }, + { committed: { state: {}, messages: [{ id: 'm', role: 'invalid' }] } }, + { session: { ...data().session, generation: -1 } }, + { session: { ...data().session, phase: 'invalid' } }, + { session: { ...data().session, interrupts: [{ id: '', reason: '' }] } }, + { session: { ...data().session, interrupts: [{ id: 'one', reason: '' }, { id: 'one', reason: '' }] } }, + { session: { ...data().session, interrupts: [] } }, + { session: { ...data().session, phase: 'claimed' } }, + { session: { ...data('claimed').session, attempt: { ...data('claimed').session.attempt, generation: 9 } } }, + { session: { ...data('claimed').session, attempt: { ...data('claimed').session.attempt, parameters: { resume: [{ interruptId: 'wrong', status: 'resolved' }] } } } }, + ])('rejects corrupt or mismatched records %#', async patch => { + const { config, records } = memory(); + records.set('["account","t"]', { version: 1, namespace: 'account', threadId: 't', revision: 0, ...data(), ...patch } as AgUiThreadRecord); + await expect(new InterruptPersistence(config, 't').load()).rejects.toThrow(); + }); + + it('leaves the store untouched when recovery is unknown or missing', async () => { + const { config, records } = memory(); + await new InterruptPersistence(config, 't').save(data('resuming')); + const before = structuredClone([...records]); + await expect(new InterruptPersistence(config, 't').reconcile()).rejects.toThrow('Interrupt recovery requires'); + const unknown = new InterruptPersistence({ ...config, reconcile: async () => ({ status: 'unknown' }) }, 't'); + await expect(unknown.reconcile()).rejects.toThrow('Interrupt recovery requires'); + expect([...records]).toEqual(before); + }); + + it.each(['pending', 'acknowledged', 'completed'] as const)('persists authoritative %s recovery', async status => { + const { config } = memory(); + await new InterruptPersistence(config, 't').save(data('resuming')); + const authoritative = status === 'completed' ? { ...data(), session: { phase: 'none' as const, generation: 1, interrupts: [] } } : data(status); + const persistence = new InterruptPersistence({ ...config, reconcile: async record => { + expect(record.session.phase).toBe('uncertain'); + return { status, ...authoritative }; + } }, 't'); + expect(await persistence.reconcile()).toMatchObject({ ...authoritative, revision: 1 }); + expect(await config.store.load('["account","t"]')).toMatchObject({ ...authoritative, revision: 1 }); + }); + + it('persists a new paused batch when completion reinterrupts', async () => { + const { config } = memory(); + await new InterruptPersistence(config, 't').save(data('resuming')); + const authoritative = { ...data(), session: { ...data().session, generation: 2 } }; + const persistence = new InterruptPersistence({ ...config, reconcile: async () => ({ status: 'completed', ...authoritative }) }, 't'); + expect((await persistence.reconcile())?.session).toEqual(authoritative.session); + }); + + it('rejects completion that retains the old paused batch or attempt', async () => { + const { config } = memory(); + await new InterruptPersistence(config, 't').save(data('resuming')); + const persistence = new InterruptPersistence({ ...config, reconcile: async () => ({ status: 'completed', ...data() }) }, 't'); + await expect(persistence.reconcile()).rejects.toThrow(); + }); + + it('retains exact replay input when authoritative pending recovery retains the attempt', async () => { + const { config } = memory(); + const original = { ...data('resuming'), resumeInput: { messages: [], state: { optimistic: true } } }; + await new InterruptPersistence(config, 't').save(original); + const pending = { ...original.session, phase: 'pending' as const }; + const persistence = new InterruptPersistence({ ...config, reconcile: async () => ({ status: 'pending', committed: original.committed, session: pending }) }, 't'); + expect((await persistence.reconcile())?.resumeInput).toEqual(original.resumeInput); + }); + + it.each(['pending', 'acknowledged', 'completed'] as const)('rejects inconsistent authoritative %s sessions', async status => { + const { config, records } = memory(); + await new InterruptPersistence(config, 't').save(data('resuming')); + const before = structuredClone([...records]); + const session = status === 'acknowledged' ? data().session : data('resuming').session; + const persistence = new InterruptPersistence({ ...config, reconcile: async () => ({ status, ...data(), session }) }, 't'); + await expect(persistence.reconcile()).rejects.toThrow(); + expect([...records]).toEqual(before); + }); + + it('serializes saves and continues after a reported storage failure', async () => { + const { config } = memory(); + const compareAndSwap = vi.spyOn(config.store, 'compareAndSwap'); + compareAndSwap.mockRejectedValueOnce(new Error('disk unavailable')); + const persistence = new InterruptPersistence(config, 't'); + await expect(persistence.save(data())).rejects.toThrow('disk unavailable'); + await Promise.all([persistence.save(data()), persistence.save(data())]); + expect(compareAndSwap.mock.calls.map(call => call[1])).toEqual([null, null, 0]); + expect((await persistence.load())?.revision).toBe(1); + }); +}); diff --git a/libs/ag-ui/src/lib/interrupt-persistence.ts b/libs/ag-ui/src/lib/interrupt-persistence.ts new file mode 100644 index 000000000..11ec6b44d --- /dev/null +++ b/libs/ag-ui/src/lib/interrupt-persistence.ts @@ -0,0 +1,199 @@ +import { InterruptSchema, MessageSchema, ResumeEntrySchema } from '@ag-ui/core'; +import type { InterruptSessionSnapshot } from './interrupt-session.types'; +import type { ThreadSnapshot } from './run-state-transaction'; + +export interface AgUiThreadRecord { + version: 1; + namespace: string; + threadId: string; + revision: number; + committed: ThreadSnapshot; + session: InterruptSessionSnapshot; + resumeInput?: ThreadSnapshot; +} +export interface AgUiInterruptPersistence { + namespace: string; + store: { + load(key: string): Promise; + compareAndSwap(key: string, expectedRevision: number | null, next: AgUiThreadRecord): Promise; + }; + reconcile?: (record: AgUiThreadRecord) => Promise< + { status: 'unknown' } | + { status: 'pending' | 'acknowledged' | 'completed'; committed: ThreadSnapshot; session: InterruptSessionSnapshot } + >; +} + +export class InterruptPersistence { + private readonly key: string; + private readonly namespace: string; + private record: AgUiThreadRecord | null = null; + private loaded = false; + private conflicted = false; + private queue: Promise = Promise.resolve(); + + constructor(private readonly config: AgUiInterruptPersistence, private readonly threadId: string) { + nonempty(config.namespace); nonempty(threadId); + this.namespace = config.namespace; + this.key = JSON.stringify([this.namespace, threadId]); + } + + load(): Promise { + return this.serialize(async () => { + await this.reload(); + return structuredClone(this.record); + }); + } + + save(data: Pick): Promise { + // Capture at invocation, before another queued operation can yield to the caller. + let copy: typeof data; + try { copy = structuredClone(data); } catch (error) { return Promise.reject(error); } + return this.serialize(async () => { + await this.ensureLoaded(); + await this.write(copy); + }); + } + + reconcile(): Promise { + return this.serialize(async () => { + await this.reload(); + if (!this.record) return null; + if (!this.config.reconcile) throw new Error('Interrupt recovery requires authoritative reconciliation'); + const result = await this.config.reconcile(structuredClone(this.record)); + if (result.status === 'unknown') throw new Error('Interrupt recovery requires an authoritative outcome'); + const copy = structuredClone(result); + validateSession(copy.session); + const phase = copy.session.phase; + if ((copy.status === 'pending' && phase !== 'pending') || + (copy.status === 'acknowledged' && phase !== 'acknowledged') || + (copy.status === 'completed' && phase !== 'none' && phase !== 'pending')) { + throw new Error('Invalid authoritative interrupt recovery phase'); + } + if (!['pending', 'acknowledged', 'completed'].includes(copy.status)) throw new Error('Invalid interrupt recovery status'); + if (copy.status === 'completed' && phase === 'pending' && + (copy.session.generation <= this.record.session.generation || copy.session.attempt !== undefined)) { + throw new Error('Completed recovery requires a newly paused interrupt batch'); + } + const previousAttempt = this.record.session.attempt; + const attempt = copy.session.attempt; + const retainedAttempt = attempt && previousAttempt && attempt.id === previousAttempt.id && + attempt.runId === previousAttempt.runId && attempt.generation === previousAttempt.generation; + await this.write({ committed: copy.committed, session: copy.session, + ...(retainedAttempt && this.record.resumeInput ? { resumeInput: this.record.resumeInput } : {}) }); + return structuredClone(this.record); + }); + } + + private serialize(operation: () => Promise): Promise { + const result = this.queue.then(operation); + // Keep the queue usable and attach a rejection handler even if a caller aborts. + this.queue = result.then(() => undefined, () => undefined); + return result; + } + + private async ensureLoaded(): Promise { + if (this.conflicted) throw new Error('Interrupt persistence conflict: another writer owns this thread'); + if (this.loaded) return; + await this.reload(); + } + + private async reload(): Promise { + const value = await this.config.store.load(this.key); + if (value !== null) { + this.validate(value); + const copy = structuredClone(value); + if (copy.session.phase === 'claimed' || copy.session.phase === 'resuming') copy.session.phase = 'uncertain'; + if (copy.session.phase === 'acknowledged') copy.session.phase = 'recovery-required'; + this.record = copy; + } else this.record = null; + this.loaded = true; + this.conflicted = false; + } + + private async write(data: Pick): Promise { + const expected = this.record?.revision ?? null; + const next: AgUiThreadRecord = { ...structuredClone(data), version: 1, namespace: this.namespace, + threadId: this.threadId, revision: expected === null ? 0 : expected + 1 }; + this.validate(next); + if (!await this.config.store.compareAndSwap(this.key, expected, structuredClone(next))) { + this.conflicted = true; + throw new Error('Interrupt persistence conflict: another writer owns this thread'); + } + this.record = next; + } + + private validate(value: AgUiThreadRecord): void { + if (!isRecord(value) || value.version !== 1 || value.namespace !== this.namespace || + value.threadId !== this.threadId || !Number.isSafeInteger(value.revision) || value.revision < 0) { + throw new Error('Invalid interrupt persistence record identity or revision'); + } + validateSnapshot(value.committed); + if (value.resumeInput !== undefined) validateSnapshot(value.resumeInput); + validateSession(value.session); + } +} + +function validateSnapshot(value: ThreadSnapshot): void { + if (!isRecord(value) || !isRecord(value.state) || !Array.isArray(value.messages)) throw new Error('Invalid persisted thread snapshot'); + json(value.state); + for (const message of value.messages) MessageSchema.parse(message); +} + +function validateSession(session: InterruptSessionSnapshot): void { + if (!isRecord(session) || !Number.isSafeInteger(session.generation) || session.generation < 0 || + !['none', 'collecting', 'pending', 'claimed', 'resuming', 'acknowledged', 'uncertain', 'recovery-required'].includes(session.phase) || + !Array.isArray(session.interrupts)) throw new Error('Invalid persisted interrupt session'); + if (session.runId !== undefined) nonempty(session.runId); + const ids = new Set(); + for (const entry of session.interrupts) { + InterruptSchema.parse(entry); nonempty(entry.id); + if (ids.has(entry.id)) throw new Error('Duplicate persisted interrupt id'); + ids.add(entry.id); + } + if (session.legacy !== undefined) { + if (!isRecord(session.legacy) || typeof session.legacy.resumable !== 'boolean') throw new Error('Invalid persisted legacy interrupt'); + nonempty(session.legacy.id); + } + const hasBatch = ids.size > 0 || session.legacy !== undefined; + if ((session.phase === 'none' && (hasBatch || session.attempt !== undefined)) || + (session.phase !== 'none' && (!hasBatch || session.generation === 0))) throw new Error('Invalid persisted interrupt batch'); + if (['claimed', 'resuming', 'acknowledged', 'uncertain', 'recovery-required'].includes(session.phase) && !session.attempt) { + throw new Error('Persisted interrupt phase requires a correlated attempt'); + } + const attempt = session.attempt; + if (!attempt) return; + if (session.phase === 'collecting' || !isRecord(attempt) || attempt.generation !== session.generation || + !isRecord(attempt.input) || attempt.input['resume'] === undefined || !isRecord(attempt.parameters)) throw new Error('Invalid persisted resume attempt'); + nonempty(attempt.id); nonempty(attempt.runId); + if (attempt.parameters.resume !== undefined) { + const entries = attempt.parameters.resume; + if (!Array.isArray(entries) || entries.length !== ids.size || ids.size === 0) throw new Error('Invalid persisted resume batch'); + const responses = new Set(); + for (const entry of entries) { + ResumeEntrySchema.parse(entry); + if (!ids.has(entry.interruptId) || responses.has(entry.interruptId) || + (entry.status === 'cancelled' && entry.payload !== undefined)) throw new Error('Invalid persisted resume correlation'); + responses.add(entry.interruptId); + } + } else { + const props = attempt.parameters.forwardedProps; + if (!isRecord(props) || !isRecord(props['command']) || props['command']['resume'] === undefined) throw new Error('Invalid persisted resume command'); + } +} + +function nonempty(value: unknown): asserts value is string { + if (typeof value !== 'string' || value.trim().length === 0) throw new Error('Interrupt persistence identifiers must be nonempty'); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function json(value: unknown, seen = new Set()): void { + if (value === null || typeof value === 'string' || typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value))) return; + if (typeof value !== 'object' || value === null || seen.has(value) || + (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)) throw new Error('Persisted state must be JSON'); + seen.add(value); + for (const entry of Object.values(value)) json(entry, seen); + seen.delete(value); +} diff --git a/libs/ag-ui/src/lib/interrupt-session.spec.ts b/libs/ag-ui/src/lib/interrupt-session.spec.ts new file mode 100644 index 000000000..976c71b29 --- /dev/null +++ b/libs/ag-ui/src/lib/interrupt-session.spec.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest'; +import { InterruptSession } from './interrupt-session'; + +const batch = [{ id: 'one', reason: 'approval', value: { question: 'Proceed?' } }, { id: 'two', reason: 'input' }]; +function pending(entries: unknown[] = batch) { + const session = new InterruptSession(); + session.observeNative(entries, 'original'); + session.ready(); + return session; +} +const input = { resume: [{ id: 'one', payload: true }, { interruptId: 'two', status: 'cancelled' }] }; + +describe('InterruptSession', () => { + it('starts a new batch for the resume run when the original run id was unknown', () => { + const s = new InterruptSession(); + s.observeNative([{ id: 'old', reason: '' }]); + s.ready(); + s.claim({ resume: true }, 'attempt', 'resume-run'); + s.dispatched('attempt'); + s.observeNative([{ id: 'new', reason: '' }], 'resume-run'); + s.complete('attempt'); + expect(s.snapshot.generation).toBe(2); + expect(s.snapshot.phase).toBe('collecting'); + expect(s.snapshot.interrupts.map(entry => entry.id)).toEqual(['new']); + }); + it('rejects an empty native outcome', () => { + expect(() => new InterruptSession().observeNative([], 'original')).toThrow(); + }); + + it('does not mutate a claimed batch on late same-run observations', () => { + const s = pending(); + s.claim(input, 'a', 'next'); + const before = s.snapshot; + s.observeNative([{ id: 'extra', reason: '' }], 'original'); + s.observeLegacy({ toolCallId: 'late' }, 'original'); + expect(s.snapshot).toEqual(before); + }); + it.each([true, false])('aggregates native and legacy observations in either order (%s)', (nativeFirst) => { + const s = new InterruptSession(); + const native = () => s.observeNative(batch, 'original'); + const legacy = () => s.observeLegacy({ toolCallId: 'tool', value: 1 }, 'original'); + if (nativeFirst) { native(); legacy(); } else { legacy(); native(); } + expect(s.snapshot.interrupts).toEqual(batch); + expect(s.snapshot.phase).toBe('collecting'); + expect(() => s.claim(input, 'a', 'next')).toThrow(); + s.ready(); + expect(s.claim(input, 'a', 'next').parameters.resume).toEqual([ + { interruptId: 'one', status: 'resolved', payload: true }, { interruptId: 'two', status: 'cancelled' }, + ]); + expect(() => s.claim(input, 'b', 'next')).toThrow(); + }); + + it.each([ + true, [], [{ id: 'one' }], [{ id: 'one' }, { id: 'one' }], + [{ id: 'one' }, { id: 'other' }], + [{ id: 'one', status: 'bogus' }, { id: 'two' }], + [{ id: 'one', status: 'cancelled', payload: true }, { id: 'two' }], + ].map(resume => ({ resume })))('rejects invalid or incomplete batch responses $resume', ({ resume }) => { + const s = pending(); + expect(() => s.claim({ resume }, 'a', 'next')).toThrow(); + expect(s.snapshot.phase).toBe('pending'); + }); + + it.each(['2000-01-01T00:00:00Z', 'not-a-date'])('rejects expired/invalid expiry %s', (expiresAt) => { + const s = pending([{ id: 'one', reason: '', expiresAt }]); + expect(() => s.claim({ resume: true }, 'a', 'next')).toThrow(); + }); + + it('allows a scalar only for one native interrupt and forbids a bare resume', () => { + expect(() => new InterruptSession().claim({ resume: true }, 'a', 'next')).toThrow(); + expect(pending([batch[0]]).claim({ resume: true }, 'a', 'next').parameters.resume) + .toEqual([{ interruptId: 'one', status: 'resolved', payload: true }]); + }); + + it('copies inbound fields, snapshots, and claimed input deeply', () => { + const entries = structuredClone(batch); + const s = pending(entries); + entries[0].value!.question = 'changed'; + s.snapshot.interrupts.splice(0); + expect(s.snapshot.interrupts).toEqual(batch); + const submit = { resume: { answer: true }, state: { nested: { x: 1 } } }; + const single = pending([batch[0]]); + const attempt = single.claim(submit, 'a', 'next'); + submit.resume.answer = false; + submit.state.nested.x = 2; + expect(attempt.input).toEqual({ resume: { answer: true }, state: { nested: { x: 1 } } }); + expect(Object.isFrozen(attempt.input.state)).toBe(true); + }); + + it.each(['legacy-command', 'mastra-command'] as const)('uses explicit %s even with native data', (profile) => { + const s = new InterruptSession(profile); + s.observeLegacy({ toolCallId: 'tool', runId: 'original' }, 'original'); + s.observeNative(batch, 'original'); + s.ready(); + const command = { resume: true, ...(profile === 'mastra-command' ? { interruptEvent: { toolCallId: 'tool', runId: 'original' } } : {}) }; + expect(s.claim({ resume: true }, 'a', 'next').parameters).toEqual({ forwardedProps: { command } }); + }); + + it('retries the same retained attempt only when dispatch is known not to have happened', () => { + const s = pending(); + const first = s.claim(input, 'a', 'next'); + s.fail('a', true); + expect(s.snapshot.phase).toBe('pending'); + expect(s.retry()).toEqual(first); + expect(s.snapshot.phase).toBe('claimed'); + expect(() => s.retry()).toThrow(); + s.dispatched('a'); + s.fail('a', false); + expect(s.snapshot.phase).toBe('uncertain'); + expect(() => s.retry()).toThrow(); + }); + + it('requires recovery after an acknowledged resume fails', () => { + const s = pending(); + s.claim(input, 'a', 'next'); + s.dispatched('a'); + s.acknowledge('a'); + s.fail('a', true); + expect(s.snapshot.phase).toBe('recovery-required'); + expect(() => s.retry()).toThrow(); + }); + + it('accepts positive proof of nondispatch after dispatch intent', () => { + const s = pending(); + const attempt = s.claim(input, 'a', 'next'); + s.dispatched('a'); + s.fail('a', true); + expect(s.snapshot.phase).toBe('pending'); + expect(s.retry()).toEqual(attempt); + }); + + it('requires retry to preserve the retained decision after known nondispatch', () => { + const s = pending(); + const original = s.claim(input, 'a', 'next'); + s.fail('a', true); + expect(() => s.claim({ resume: [{ id: 'one', payload: false }, { id: 'two' }] }, 'b', 'different')).toThrow(); + expect(s.snapshot.phase).toBe('pending'); + expect(s.retry()).toEqual(original); + }); + + it('correlates transitions and preserves reinterruptions against old terminal callbacks', () => { + const s = pending(); + s.claim(input, 'a', 'next'); + s.acknowledge('wrong'); + expect(s.snapshot.phase).toBe('claimed'); + s.dispatched('a'); + s.observeNative([{ id: 'three', reason: '' }], 'next'); + s.observeLegacy({ toolCallId: 'new-tool' }, 'next'); + s.complete('a'); + s.fail('a', false); + s.ready(); + expect(s.snapshot.phase).toBe('pending'); + expect(s.snapshot.generation).toBe(2); + expect(s.snapshot.interrupts.map(i => i.id)).toEqual(['three']); + }); + + it('restores a defensive snapshot and clears a matching completed attempt', () => { + const s = pending(); + s.claim(input, 'a', 'next'); + s.fail('a', true); + const snapshot = s.snapshot; + const restored = new InterruptSession(); + restored.restore(snapshot); + snapshot.interrupts.length = 0; + expect(restored.retry().id).toBe('a'); + restored.complete('a'); + expect(restored.snapshot.phase).toBe('none'); + }); +}); diff --git a/libs/ag-ui/src/lib/interrupt-session.ts b/libs/ag-ui/src/lib/interrupt-session.ts new file mode 100644 index 000000000..19fa2e659 --- /dev/null +++ b/libs/ag-ui/src/lib/interrupt-session.ts @@ -0,0 +1,157 @@ +import { InterruptSchema, ResumeEntrySchema, type Interrupt, type ResumeEntry } from '@ag-ui/core'; +import type { AgentSubmitInput } from '@threadplane/chat'; +import type { InterruptSessionSnapshot, InterruptTransport, ResumeAttempt } from './interrupt-session.types'; + +/** Owns a correlated interrupt batch independently of transport/UI lifecycles. */ +export class InterruptSession { + private state: InterruptSessionSnapshot = { phase: 'none', generation: 0, interrupts: [] }; + + constructor(private readonly profile: InterruptTransport = 'auto') {} + + get snapshot(): InterruptSessionSnapshot { return structuredClone(this.state); } + + observeNative(entries: unknown[], runId?: string): void { + if (entries.length === 0) throw new Error('Native interrupt outcome must contain interrupts'); + if (this.isLateObservation(runId)) return; + const parsed = entries.map(entry => InterruptSchema.passthrough().parse( + isRecord(entry) && entry['reason'] === undefined ? { ...entry, reason: '' } : entry, + )); + if (new Set(parsed.map(entry => entry.id)).size !== parsed.length) throw new Error('Duplicate interrupt id'); + this.beginObservation(runId); + const merged = new Map(this.state.interrupts.map(entry => [entry.id, entry])); + for (const entry of parsed) merged.set(entry.id, structuredClone(entry)); + this.state.interrupts = [...merged.values()]; + } + + observeLegacy(value: unknown, runId?: string): void { + if (this.isLateObservation(runId)) return; + this.beginObservation(runId); + this.state.legacy = { + id: this.state.legacy?.id ?? `interrupt-${this.state.generation}`, + value: structuredClone(value), + resumable: true, + }; + } + + ready(): void { if (this.state.phase === 'collecting') this.state.phase = 'pending'; } + + claim(input: AgentSubmitInput, attemptId: string, runId: string): ResumeAttempt { + if (this.state.phase !== 'pending') throw new Error('No pending interrupt available to resume'); + if (this.state.attempt) throw new Error('Use retry to resend the retained interrupt decision'); + if (input.resume === undefined) throw new Error('A resume response is required'); + this.validateExpiry(); + const copy = structuredClone(input); + const parameters = this.parameters(copy.resume); + const attempt = freezeDeep({ id: attemptId, runId, input: copy, parameters, generation: this.state.generation }); + this.state.attempt = attempt; + this.state.phase = 'claimed'; + return attempt; + } + + dispatched(id: string): void { + if (this.matches(id) && this.state.phase === 'claimed') this.state.phase = 'resuming'; + } + + acknowledge(id: string): void { + if (this.matches(id) && (this.state.phase === 'claimed' || this.state.phase === 'resuming')) this.state.phase = 'acknowledged'; + } + + fail(id: string, knownNotDispatched: boolean): void { + if (!this.matches(id)) return; + if (this.state.phase === 'acknowledged' || this.state.phase === 'recovery-required') { + this.state.phase = 'recovery-required'; + } else if (knownNotDispatched && (this.state.phase === 'claimed' || this.state.phase === 'resuming')) { + this.state.phase = 'pending'; + } else if (this.state.phase !== 'pending') { + this.state.phase = 'uncertain'; + } + } + + complete(id: string): void { + if (this.matches(id)) this.state = { phase: 'none', generation: this.state.generation, interrupts: [] }; + } + + restore(snapshot: InterruptSessionSnapshot): void { + this.state = structuredClone(snapshot); + if (this.state.attempt) freezeDeep(this.state.attempt); + } + + retry(): ResumeAttempt { + if (this.state.phase === 'uncertain' || this.state.phase === 'recovery-required') throw new Error('Interrupt recovery requires reconciliation before retry'); + if (this.state.phase !== 'pending' || !this.state.attempt) throw new Error('Resume cannot be retried safely'); + this.validateExpiry(); + this.state.phase = 'claimed'; + return this.state.attempt; + } + + private isLateObservation(runId?: string): boolean { + return this.state.attempt !== undefined && (runId === undefined || runId === this.state.runId); + } + + private beginObservation(runId?: string): void { + const isResumeRun = runId !== undefined && this.state.attempt?.runId === runId; + if (this.state.phase === 'none' || isResumeRun || (runId !== undefined && this.state.runId !== undefined && runId !== this.state.runId)) { + this.state = { phase: 'collecting', generation: this.state.generation + 1, interrupts: [], runId }; + } else if (this.state.runId === undefined) { + this.state.runId = runId; + } + } + + private matches(id: string): boolean { + return this.state.attempt?.id === id && this.state.attempt.generation === this.state.generation; + } + + private validateExpiry(): void { + for (const entry of this.state.interrupts) { + if (entry.expiresAt !== undefined && (!Number.isFinite(Date.parse(entry.expiresAt)) || Date.parse(entry.expiresAt) <= Date.now())) { + throw new Error(`Interrupt ${entry.id} has expired or has an invalid expiry`); + } + } + } + + private parameters(resume: unknown): ResumeAttempt['parameters'] { + const value = this.state.legacy?.value; + const native = this.profile === 'protocol' || (this.profile === 'auto' && this.state.interrupts.length > 0); + if (native) return { resume: nativeResponses(resume, this.state.interrupts) }; + const mastra = this.profile === 'mastra-command' || (this.profile === 'auto' && isRecord(value) && typeof value['toolCallId'] === 'string'); + if (mastra) { + if (!isRecord(value) || typeof value['toolCallId'] !== 'string') throw new Error('Mastra interrupt requires a toolCallId'); + const runId = typeof value['runId'] === 'string' ? value['runId'] : this.state.runId; + return { forwardedProps: { command: { resume, interruptEvent: { toolCallId: value['toolCallId'], ...(runId ? { runId } : {}) } } } }; + } + return { forwardedProps: { command: { resume } } }; + } +} + +function nativeResponses(resume: unknown, interrupts: Interrupt[]): ResumeEntry[] { + if (interrupts.length === 0) throw new Error('No native interrupt available'); + const structured = Array.isArray(resume) && (resume.length === 0 || resume.some(entry => isRecord(entry) && ('id' in entry || 'interruptId' in entry))); + let entries: ResumeEntry[]; + if (structured) { + entries = (resume as unknown[]).map(entry => { + if (!isRecord(entry)) throw new Error('Invalid resume entry'); + if (entry['id'] !== undefined && entry['interruptId'] !== undefined && entry['id'] !== entry['interruptId']) throw new Error('Conflicting interrupt ids'); + const parsed = ResumeEntrySchema.parse({ ...entry, interruptId: entry['interruptId'] ?? entry['id'], status: entry['status'] === undefined ? 'resolved' : entry['status'] }); + if (parsed.status === 'cancelled' && parsed.payload !== undefined) throw new Error('Cancelled responses cannot carry payload'); + return parsed; + }); + } else { + if (interrupts.length !== 1) throw new Error('A response is required for every interrupt'); + entries = [{ interruptId: interrupts[0].id, status: 'resolved', payload: resume }]; + } + const ids = new Set(entries.map(entry => entry.interruptId)); + if (entries.length !== interrupts.length || ids.size !== entries.length || interrupts.some(entry => !ids.has(entry.id))) throw new Error('Resume must cover each interrupt exactly once'); + return entries; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function freezeDeep(value: T): T { + if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) { + Object.freeze(value); + for (const item of Object.values(value)) freezeDeep(item); + } + return value; +} diff --git a/libs/ag-ui/src/lib/interrupt-session.types.ts b/libs/ag-ui/src/lib/interrupt-session.types.ts new file mode 100644 index 000000000..b69e36cf8 --- /dev/null +++ b/libs/ag-ui/src/lib/interrupt-session.types.ts @@ -0,0 +1,22 @@ +import type { Interrupt, ResumeEntry } from '@ag-ui/core'; +import type { AgentInterrupt, AgentSubmitInput } from '@threadplane/chat'; + +export type InterruptTransport = 'auto' | 'protocol' | 'legacy-command' | 'mastra-command'; +export type InterruptSessionPhase = 'none' | 'collecting' | 'pending' | 'claimed' | 'resuming' | 'acknowledged' | 'uncertain' | 'recovery-required'; + +export interface ResumeAttempt { + id: string; + runId: string; + input: AgentSubmitInput; + parameters: { resume?: ResumeEntry[]; forwardedProps?: Record }; + generation: number; +} + +export interface InterruptSessionSnapshot { + phase: InterruptSessionPhase; + generation: number; + interrupts: Interrupt[]; + legacy?: AgentInterrupt; + runId?: string; + attempt?: ResumeAttempt; +} diff --git a/libs/ag-ui/src/lib/provide-agent.spec.ts b/libs/ag-ui/src/lib/provide-agent.spec.ts index 99c657072..ffcf0569b 100644 --- a/libs/ag-ui/src/lib/provide-agent.spec.ts +++ b/libs/ag-ui/src/lib/provide-agent.spec.ts @@ -64,6 +64,15 @@ class StubAgent { } describe('provideAgent', () => { + it('disposes the adapter when its injector is destroyed', async () => { + TestBed.configureTestingModule({ providers: [provideAgent({ url: 'http://test.invalid', telemetry: false })] }); + const ref = TestBed.runInInjectionContext(() => injectAgent()); + const dispose = vi.spyOn(ref, 'dispose'); + TestBed.resetTestingModule(); + expect(dispose).toHaveBeenCalledOnce(); + await expect(ref.submit({ message: 'too late' })).rejects.toThrow(/disposed/); + }); + it('rejects a hostile signal before invoking fetch without reporting', async () => { const reportOperationFailure = vi.fn(); const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); diff --git a/libs/ag-ui/src/lib/provide-agent.ts b/libs/ag-ui/src/lib/provide-agent.ts index 03cba457d..b1fc0dddd 100644 --- a/libs/ag-ui/src/lib/provide-agent.ts +++ b/libs/ag-ui/src/lib/provide-agent.ts @@ -1,7 +1,7 @@ -import { InjectionToken, inject, isDevMode, type Provider } from '@angular/core'; +import { DestroyRef, InjectionToken, inject, isDevMode, type Provider } from '@angular/core'; import { HttpAgent } from '@ag-ui/client'; import type { AgentRef, AgentRuntimeTelemetrySink } from '@threadplane/chat'; -import { toAgent, ɵtoAgentWithProtectedErrors, type AgUiAgent } from './to-agent'; +import { toAgent, ɵtoAgentWithProtectedErrors, type AgUiAgent, type ToAgentOptions } from './to-agent'; import { createRuntimeProtectedFetch, ɵAG_UI_RUNTIME_OPERATION_REPORTER, @@ -13,6 +13,10 @@ import { * telemetry sink. */ export interface AgentConfig { + /** Explicit compatibility wire profile; auto prefers protocol-native outcomes. */ + interruptTransport?: ToAgentOptions['interruptTransport']; + /** Optional application-owned durable thread storage and reconciliation. */ + persistence?: ToAgentOptions['persistence']; /** Endpoint URL of the AG-UI HTTP agent (e.g. `'http://localhost:8000/agent'`). Required. */ url: string; /** Agent identifier, when the endpoint serves more than one agent. */ @@ -52,10 +56,13 @@ function buildAgUiAgent(configOrFactory: AgentConfig | (() => AgentConfig)): AgU ? { fetch: createRuntimeProtectedFetch(reportOperationFailure) } : {}), }); - const options = { telemetry: config.telemetry }; - return reportOperationFailure === null + if (config.persistence && !config.threadId) throw new Error('Interrupt persistence requires a stable configured threadId'); + const options = { telemetry: config.telemetry, interruptTransport: config.interruptTransport, persistence: config.persistence }; + const adapter = reportOperationFailure === null ? toAgent(source, options) : ɵtoAgentWithProtectedErrors(source, options); + inject(DestroyRef).onDestroy(() => adapter.dispose()); + return adapter; } function isAgentRef(x: unknown): x is AgentRef { diff --git a/libs/ag-ui/src/lib/provide-agent.type-spec.ts b/libs/ag-ui/src/lib/provide-agent.type-spec.ts index 4f2d879c3..c78c0fb4c 100644 --- a/libs/ag-ui/src/lib/provide-agent.type-spec.ts +++ b/libs/ag-ui/src/lib/provide-agent.type-spec.ts @@ -1,7 +1,8 @@ -import { createAgentRef } from '@threadplane/chat'; +import { createAgentRef, type Agent } from '@threadplane/chat'; import type { Equal, Expect } from '../testing/type-assert'; import { injectAgent } from './provide-agent'; import type { AgUiAgent } from './to-agent'; +import type { AgUiSubmitOptions } from '../public-api'; interface TripState { day: number; places: string[]; } const TRIP = createAgentRef('trip'); @@ -13,3 +14,10 @@ type _isAgUi = Expect>>; const plain = ctx(() => injectAgent()); type _plainState = Expect, Record>>; + +const resumeOptions: AgUiSubmitOptions = { interruptGeneration: 2, signal: new AbortController().signal }; +typed.submit({ resume: true }, resumeOptions); +const neutral: Agent = typed; +neutral.submit({ resume: true }, { signal: new AbortController().signal }); +// @ts-expect-error The adapter generation is numeric. +typed.submit({ resume: true }, { interruptGeneration: '2' }); diff --git a/libs/ag-ui/src/lib/run-state-transaction.spec.ts b/libs/ag-ui/src/lib/run-state-transaction.spec.ts new file mode 100644 index 000000000..8aa284ac1 --- /dev/null +++ b/libs/ag-ui/src/lib/run-state-transaction.spec.ts @@ -0,0 +1,21 @@ +import { it, expect } from 'vitest'; +import { RunStateTransaction } from './run-state-transaction'; + +it('keeps a paused committed snapshot separate from an optimistic resume draft', () => { + const transaction = new RunStateTransaction({ state: { approved: false }, messages: [] }); + transaction.commit({ state: { approved: false, paused: true }, messages: [] }); + const draft = transaction.begin(); + draft.state['approved'] = true; + expect(transaction.committed.state).toEqual({ approved: false, paused: true }); + expect(transaction.rollback()).toEqual(transaction.committed); +}); + +it('commits replacement snapshots without retaining removed keys or mutable references', () => { + const transaction = new RunStateTransaction({ state: { old: 1 }, messages: [] }); + const next = { state: { new: { value: 2 } }, messages: [] }; + transaction.commit(next); + next.state.new.value = 9; + const copy = transaction.committed; + copy.state['extra'] = true; + expect(transaction.committed.state).toEqual({ new: { value: 2 } }); +}); diff --git a/libs/ag-ui/src/lib/run-state-transaction.ts b/libs/ag-ui/src/lib/run-state-transaction.ts new file mode 100644 index 000000000..67afe8b4c --- /dev/null +++ b/libs/ag-ui/src/lib/run-state-transaction.ts @@ -0,0 +1,17 @@ +import type { Message } from '@ag-ui/core'; + +/** Serializable protocol state at a confirmed run boundary. */ +export interface ThreadSnapshot { + state: Record; + messages: Message[]; +} + +/** Keeps failed/optimistic run input separate from the last confirmed boundary. */ +export class RunStateTransaction { + private boundary: ThreadSnapshot; + constructor(initial: ThreadSnapshot) { this.boundary = structuredClone(initial); } + get committed(): ThreadSnapshot { return structuredClone(this.boundary); } + begin(): ThreadSnapshot { return this.committed; } + commit(snapshot: ThreadSnapshot): void { this.boundary = structuredClone(snapshot); } + rollback(): ThreadSnapshot { return this.committed; } +} diff --git a/libs/ag-ui/src/lib/testing/provide-fake-agent.ts b/libs/ag-ui/src/lib/testing/provide-fake-agent.ts index 492f8f701..1f9e7432c 100644 --- a/libs/ag-ui/src/lib/testing/provide-fake-agent.ts +++ b/libs/ag-ui/src/lib/testing/provide-fake-agent.ts @@ -1,5 +1,5 @@ // libs/ag-ui/src/lib/testing/provide-fake-agent.ts -import { type Provider } from '@angular/core'; +import { DestroyRef, inject, type Provider } from '@angular/core'; import type { FakeAgentConfig } from '@threadplane/chat/testing'; import { AGENT } from '../provide-agent'; import { toAgent } from '../to-agent'; @@ -53,7 +53,11 @@ export function provideFakeAgent(config: AgUiFakeAgentConfig = {}): Provider[] { return [ { provide: AGENT, - useFactory: () => toAgent(new FakeAgent(config)), + useFactory: () => { + const adapter = toAgent(new FakeAgent(config)); + inject(DestroyRef).onDestroy(() => adapter.dispose()); + return adapter; + }, }, ]; } diff --git a/libs/ag-ui/src/lib/to-agent.conformance.spec.ts b/libs/ag-ui/src/lib/to-agent.conformance.spec.ts index 855e4fbdb..ae4fbbb1d 100644 --- a/libs/ag-ui/src/lib/to-agent.conformance.spec.ts +++ b/libs/ag-ui/src/lib/to-agent.conformance.spec.ts @@ -3,6 +3,12 @@ import type { AbstractAgent, BaseEvent } from '@ag-ui/client'; import type { RunAgentInput } from '@ag-ui/core'; import { runAgentConformance } from '@threadplane/chat/testing'; import { toAgent } from './to-agent'; +import type { AgUiInterruptPersistence, AgUiThreadRecord } from './interrupt-persistence'; +import { + INTERRUPT_CONFORMANCE_BATCH, + runInterruptConformance, + type InterruptConformanceRequest, +} from '@threadplane/chat/testing'; /** * Minimal stub that satisfies the AbstractAgent shape for conformance testing. @@ -43,6 +49,113 @@ runAgentConformance('toAgent (AG-UI adapter)', () => { return toAgent(new StubAgent() as unknown as AbstractAgent); }); +runInterruptConformance('toAgent (AG-UI adapter)', () => { + const requests: InterruptConformanceRequest[] = []; + let pause = false; + let fail = false; + let runNumber = 0; + let lateGate: Promise | undefined; + let releaseLate: (() => void) | undefined; + let startedLate: (() => void) | undefined; + let lateRun: Promise | undefined; + const subscribers: Array<{ + onRunInitialized?: (p: { input: { runId: string } }) => void; + onEvent?: (p: { event: BaseEvent; input: { runId: string } }) => void; + }> = []; + const source = { + threadId: 'conformance-thread', + state: {} as Record, + messages: [] as Array<{ content: string }>, + subscribe(sub: typeof subscribers[number]) { + subscribers.push(sub); + return { unsubscribe: () => { subscribers.splice(subscribers.indexOf(sub), 1); } }; + }, + addMessage(message: { content: string }) { this.messages.push(message); }, + abortRun() { /* Local transport abort does not cancel a backend checkpoint. */ }, + async runAgent(parameters?: { resume?: Array<{ payload: unknown }>; forwardedProps?: { command?: { resume?: unknown } } }) { + requests.push({ + resume: parameters?.resume ?? parameters?.forwardedProps?.command?.resume, + state: { ...this.state }, + messages: this.messages.map(message => message.content), + }); + if (fail) { + fail = false; + throw Object.assign(new Error('Known failure before dispatch'), { requestNotDispatched: true }); + } + const runId = `run-${++runNumber}`; + for (const sub of subscribers) sub.onRunInitialized?.({ input: { runId } }); + // Capture callbacks before unsubscribe to model queued SDK delivery. + const runSubscribers = [...subscribers]; + const emit = (event: unknown) => { + for (const sub of runSubscribers) sub.onEvent?.({ event: event as BaseEvent, input: { runId } }); + }; + emit({ type: 'RUN_STARTED', runId }); + if (lateGate) { + startedLate?.(); + await lateGate; + emit({ type: 'STATE_SNAPSHOT', snapshot: { late: true } }); + emit({ type: 'MESSAGES_SNAPSHOT', messages: [{ id: 'late-message', role: 'assistant', content: 'Late response' }] }); + emit({ type: 'RUN_FINISHED', runId, outcome: { type: 'interrupt', interrupts: [ + { id: 'late-interrupt', reason: 'confirmation', value: { question: 'Too late?' } }, + ] } }); + return { result: undefined, newMessages: [] }; + } + emit({ type: 'RUN_FINISHED', runId, ...(pause ? { + outcome: { type: 'interrupt', interrupts: INTERRUPT_CONFORMANCE_BATCH.map(entry => ({ ...entry, reason: 'confirmation' })) }, + } : {}) }); + pause = false; + return { result: undefined, newMessages: [] }; + }, + }; + let ref = toAgent(source as unknown as AbstractAgent); + return { + get agent() { return ref; }, + resume: INTERRUPT_CONFORMANCE_BATCH.map(entry => ({ + interruptId: entry.id, status: 'resolved', payload: { approved: true }, + })), + requests, + pause: async () => { pause = true; await ref.submit({}); }, + pendingBatch: () => ref.interruptSession().interrupts.map(entry => ({ id: entry.id, value: (entry as unknown as { value: unknown }).value })), + failNextDispatch: () => { fail = true; }, + backendCancellationCount: () => 0, + startLateDelivery: async () => { + lateGate = new Promise(resolve => { releaseLate = resolve; }); + const started = new Promise(resolve => { startedLate = resolve; }); + lateRun = ref.submit({}); + await started; + }, + deliverLateEvents: async () => { releaseLate?.(); await lateRun; }, + restore: async () => { + const records = new Map(); + const persistence: AgUiInterruptPersistence = { + namespace: 'conformance', + store: { + async load(key) { return structuredClone(records.get(key) ?? null); }, + async compareAndSwap(key, revision, record) { + if ((records.get(key)?.revision ?? null) !== revision) return false; + records.set(key, structuredClone(record)); + return true; + }, + }, + }; + ref.dispose(); + ref = toAgent(source as unknown as AbstractAgent, { persistence }); + await ref.ready; + pause = true; + await ref.submit({}); + expect(records.size).toBe(1); + ref.dispose(); + const before = requests.length; + const replacement = { ...source, state: {}, messages: [], pendingInterrupts: [] }; + ref = toAgent(replacement as unknown as AbstractAgent, { persistence }); + await ref.ready; + expect(requests).toHaveLength(before); + expect(replacement.pendingInterrupts).toHaveLength(INTERRUPT_CONFORMANCE_BATCH.length); + }, + cleanup: () => ref.dispose(), + }; +}, { restoration: true }); + import { REASONING_FIXTURE_EVENTS, REASONING_FIXTURE_MESSAGE_ID, diff --git a/libs/ag-ui/src/lib/to-agent.interrupt-generation.spec.ts b/libs/ag-ui/src/lib/to-agent.interrupt-generation.spec.ts new file mode 100644 index 000000000..287625653 --- /dev/null +++ b/libs/ag-ui/src/lib/to-agent.interrupt-generation.spec.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { AbstractAgent, AgentSubscriber, BaseEvent } from '@ag-ui/client'; +import type { Message } from '@ag-ui/core'; +import { toAgent } from './to-agent'; + +describe('interrupt UI generation', () => { + it('rejects a stale decision before mutating input, claiming, or dispatching', async () => { + const subscribers: AgentSubscriber[] = []; + const source = { + threadId: 'thread', state: {}, messages: [] as Message[], pendingInterrupts: [], + subscribe(sub: AgentSubscriber) { subscribers.push(sub); return { unsubscribe() { subscribers.splice(subscribers.indexOf(sub), 1); } }; }, + addMessage(message: Message) { source.messages.push(message); }, + setMessages(messages: Message[]) { source.messages = messages; }, + abortRun: vi.fn(), + runAgent: vi.fn(async (params?: { runId?: string }) => { + const input = { threadId: 'thread', runId: params?.runId ?? 'initial' }; + for (const event of [ + { type: 'RUN_STARTED', ...input }, + { type: 'RUN_FINISHED', ...input, outcome: { type: 'interrupt', interrupts: [{ id: 'same-id', reason: 'approve' }] } }, + ]) for (const sub of subscribers) sub.onEvent?.({ event: event as BaseEvent, input } as never); + }), + }; + const agent = toAgent(source as unknown as AbstractAgent, { telemetry: false }); + await agent.submit({}); + const initialGeneration = agent.interruptSession().generation; + await agent.submit({ resume: true }, { interruptGeneration: initialGeneration }); + const before = agent.interruptSession(); + expect(before.generation).toBe(initialGeneration + 1); + await expect(agent.submit( + { resume: false, state: { stale: true }, message: 'old answer' }, + { interruptGeneration: initialGeneration }, + )).rejects.toThrow(/generation|stale/i); + expect(agent.interruptSession()).toEqual(before); + expect(source.messages).toEqual([]); + expect(source.state).toEqual({}); + expect(source.runAgent).toHaveBeenCalledTimes(2); + }); +}); diff --git a/libs/ag-ui/src/lib/to-agent.interrupt-lifecycle.spec.ts b/libs/ag-ui/src/lib/to-agent.interrupt-lifecycle.spec.ts new file mode 100644 index 000000000..9593474c2 --- /dev/null +++ b/libs/ag-ui/src/lib/to-agent.interrupt-lifecycle.spec.ts @@ -0,0 +1,206 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { AbstractAgent, AgentSubscriber, BaseEvent } from '@ag-ui/client'; +import type { Message } from '@ag-ui/core'; +import { toAgent } from './to-agent'; + +function harness() { + const subscribers: AgentSubscriber[] = []; + let input = { threadId: 'thread-1', runId: 'pause-run' }; + const source = { + threadId: 'thread-1', state: {} as Record, messages: [] as Message[], + pendingInterrupts: [], + subscribe(sub: AgentSubscriber) { subscribers.push(sub); return { unsubscribe() { subscribers.splice(subscribers.indexOf(sub), 1); } }; }, + addMessage(message: Message) { source.messages.push(message); }, + setMessages(messages: Message[]) { source.messages = messages; }, + abortRun: vi.fn(), + runAgent: vi.fn(async (params?: { runId?: string }) => { + input = { ...input, runId: params?.runId ?? 'pause-run' }; + emit({ type: 'RUN_STARTED', ...input }); + emit({ type: 'RUN_FINISHED', ...input, outcome: { type: 'interrupt', interrupts: [{ id: 'i1', reason: 'confirmation' }] } }); + }), + }; + function emit(event: unknown) { + if ((event as { runId?: string }).runId) input = { ...input, runId: (event as { runId: string }).runId }; + for (const sub of [...subscribers]) sub.onEvent?.({ event: event as BaseEvent, input } as never); + } + const agent = toAgent(source as unknown as AbstractAgent, { telemetry: false }); + return { source, agent, emit }; +} + +describe('interrupt request ownership', () => { + it('does not bind a resume to another run before acknowledgement', async () => { + const { agent, source, emit } = harness(); + await agent.submit({}); + source.runAgent.mockImplementationOnce(async params => { + emit({ type: 'RUN_STARTED', runId: 'unrelated-run' }); + emit({ type: 'STATE_SNAPSHOT', snapshot: { unrelated: true } }); + expect(agent.interruptSession().phase).toBe('resuming'); + expect(agent.state()).toEqual({}); + emit({ type: 'RUN_STARTED', runId: params?.runId }); + expect(agent.interruptSession().phase).toBe('acknowledged'); + emit({ type: 'RUN_FINISHED', runId: params?.runId }); + }); + await agent.submit({ resume: true }); + expect(agent.error()).toBeUndefined(); + expect(agent.interruptSession().phase).toBe('none'); + }); + + it('does not reuse failed ordinary draft state and retries its original input', async () => { + const { agent, source, emit } = harness(); + source.runAgent.mockImplementationOnce(async () => { + emit({ type: 'RUN_STARTED', runId: 'first-run' }); + emit({ type: 'STATE_SNAPSHOT', snapshot: { partial: true } }); + throw new Error('failed'); + }); + await agent.submit({ message: 'hello', state: { requested: true } }); + expect(agent.state()).toEqual({}); + expect(source.state).toEqual({}); + source.runAgent.mockImplementationOnce(async () => { + expect(source.state).toEqual({ requested: true }); + expect(source.messages.map(message => message.content)).toEqual(['hello']); + emit({ type: 'RUN_STARTED', runId: 'retry-run' }); + emit({ type: 'RUN_FINISHED', runId: 'retry-run' }); + }); + await agent.retry(); + expect(agent.messages().filter(message => message.role === 'user')).toHaveLength(1); + expect(agent.error()).toBeUndefined(); + }); + + it('keeps a provisional compatibility pause unclaimable when the stream fails', async () => { + const { agent, source, emit } = harness(); + source.runAgent.mockImplementationOnce(async () => { + emit({ type: 'RUN_STARTED', runId: 'pause-run' }); + emit({ type: 'CUSTOM', name: 'on_interrupt', value: { question: 'Approve?' } }); + throw new Error('stream failed before terminal pause'); + }); + await agent.submit({}); + expect(agent.error()).toBeDefined(); + expect(agent.interruptSession().phase).toBe('collecting'); + await expect(agent.submit({ resume: true })).rejects.toThrow(/pending/); + }); + + it('rejects malformed native terminal batches without committing state', async () => { + const { agent, source, emit } = harness(); + source.runAgent.mockImplementationOnce(async () => { + emit({ type: 'RUN_STARTED', runId: 'pause-run' }); + emit({ type: 'RUN_FINISHED', runId: 'pause-run', outcome: { type: 'interrupt', interrupts: [] } }); + }); + await agent.submit({}); + expect(agent.error()).toBeDefined(); + expect(agent.interruptSession().phase).toBe('none'); + }); + + it('rolls back and settles an active resume when its caller aborts or disposes', async () => { + for (const action of ['abort', 'dispose'] as const) { + const { agent, source, emit } = harness(); + await agent.submit({}); + let finish!: () => void; + source.runAgent.mockImplementationOnce(async params => { + emit({ type: 'RUN_STARTED', runId: params?.runId }); + emit({ type: 'STATE_SNAPSHOT', snapshot: { partial: true } }); + await new Promise(resolve => { finish = resolve; }); + }); + const controller = new AbortController(); + const request = agent.submit({ resume: true, state: { patched: true } }, { signal: controller.signal }); + if (action === 'abort') controller.abort(); else agent.dispose(); + expect(agent.state()).toEqual({}); + expect(agent.isLoading()).toBe(false); + expect(agent.interruptSession().phase).toBe('recovery-required'); + emit({ type: 'STATE_SNAPSHOT', snapshot: { late: true } }); + expect(agent.state()).toEqual({}); + finish(); await request; + } + }); + + it('blocks client-tool continuation before it mutates outgoing messages during a pause', async () => { + const { agent, source } = harness(); + await agent.submit({}); + expect(() => agent.clientTools.resolve('tool-1', { ok: true, value: 'done' })).toThrow(/interrupt/i); + expect(source.messages).toEqual([]); + expect(source.runAgent).toHaveBeenCalledTimes(1); + }); + + it('rolls failed resume state back to the paused boundary and ignores late state', async () => { + const { agent, source, emit } = harness(); + source.runAgent.mockImplementationOnce(async () => { + emit({ type: 'RUN_STARTED', runId: 'pause-run' }); + emit({ type: 'STATE_SNAPSHOT', snapshot: { amount: 12 } }); + emit({ type: 'RUN_FINISHED', runId: 'pause-run', outcome: { type: 'interrupt', interrupts: [{ id: 'i1', reason: 'confirmation' }] } }); + emit({ type: 'STATE_SNAPSHOT', snapshot: { amount: 999 } }); + }); + await agent.submit({}); + expect(agent.state()).toEqual({ amount: 12 }); + source.runAgent.mockImplementationOnce(async params => { + emit({ type: 'RUN_STARTED', runId: params?.runId }); + emit({ type: 'STATE_SNAPSHOT', snapshot: { partial: true } }); + throw new Error('transport lost'); + }); + await agent.submit({ resume: true, state: { amount: 24 } }); + expect(agent.state()).toEqual({ amount: 12 }); + expect(source.state).toEqual({ amount: 12 }); + expect(agent.interruptSession().phase).toBe('recovery-required'); + }); + + it('disposes subscriptions and rejects later submissions', async () => { + const { agent, source, emit } = harness(); + await agent.submit({}); + agent.dispose(); + emit({ type: 'STATE_SNAPSHOT', snapshot: { stale: true } }); + expect(agent.state()).toEqual({}); + await expect(agent.submit({ message: 'after dispose' })).rejects.toThrow(/disposed/); + expect(source.runAgent).toHaveBeenCalledTimes(1); + }); + + it('rejects unrelated input without discarding the pause', async () => { + const { agent, source } = harness(); + await agent.submit({}); + await expect(agent.submit({ message: 'ignore the pause' })).rejects.toThrow(/interrupt/i); + expect(source.runAgent).toHaveBeenCalledTimes(1); + expect(agent.interrupt()).toBeDefined(); + expect(source.messages).toEqual([]); + }); + + it('rejects an ordinary retry before changing a fresh paused snapshot', async () => { + const { agent, source, emit } = harness(); + source.runAgent.mockImplementationOnce(async () => { + emit({ type: 'RUN_STARTED', runId: 'pause-run' }); + emit({ type: 'STATE_SNAPSHOT', snapshot: { committed: true } }); + emit({ type: 'RUN_FINISHED', runId: 'pause-run', outcome: { type: 'interrupt', interrupts: [{ id: 'i1', reason: '' }] } }); + }); + await agent.submit({ state: { initial: true } }); + await expect(agent.retry()).rejects.toThrow(/interrupt/); + expect(agent.state()).toEqual({ committed: true }); + }); + + it('retains the decision on known pre-dispatch failure and retries it', async () => { + const { agent, source } = harness(); + await agent.submit({}); + source.runAgent.mockRejectedValueOnce(Object.assign(new Error('not sent'), { requestNotDispatched: true })); + await agent.submit({ resume: true }); + expect(agent.interrupt()).toBeDefined(); + expect(agent.error()).toBeDefined(); + await agent.retry(); + expect(source.runAgent.mock.calls[2][0]).toEqual(source.runAgent.mock.calls[1][0]); + }); + + it('claims synchronously so a second decision cannot start another request', async () => { + const { agent, source } = harness(); + await agent.submit({}); + let finish!: () => void; + source.runAgent.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + const first = agent.submit({ resume: true }); + await expect(agent.submit({ resume: false })).rejects.toThrow(/interrupt|progress|claim/i); + finish(); + await first; + expect(source.runAgent).toHaveBeenCalledTimes(2); + }); + + it('does not silently retry an ambiguous dispatched decision', async () => { + const { agent, source } = harness(); + await agent.submit({}); + source.runAgent.mockRejectedValueOnce(new Error('connection lost after send')); + await agent.submit({ resume: true }); + await expect(agent.retry()).rejects.toThrow(/reconcil|uncertain|recover/i); + expect(source.runAgent).toHaveBeenCalledTimes(2); + }); +}); diff --git a/libs/ag-ui/src/lib/to-agent.interrupt-restoration.spec.ts b/libs/ag-ui/src/lib/to-agent.interrupt-restoration.spec.ts new file mode 100644 index 000000000..4b44cbc53 --- /dev/null +++ b/libs/ag-ui/src/lib/to-agent.interrupt-restoration.spec.ts @@ -0,0 +1,150 @@ +import { it, expect, vi } from 'vitest'; +import { HttpAgent } from '@ag-ui/client'; +import { toAgent } from './to-agent'; +import type { AgUiInterruptPersistence, AgUiThreadRecord } from './interrupt-persistence'; + +function memory() { + const records = new Map(); + const config: AgUiInterruptPersistence = { + namespace: 'account/agent', + store: { + async load(key) { return structuredClone(records.get(key) ?? null); }, + async compareAndSwap(key, revision, record) { + if ((records.get(key)?.revision ?? null) !== revision) return false; + records.set(key, structuredClone(record)); return true; + }, + }, + }; + return { config, records }; +} + +function source() { + const fetch = vi.fn(async (_url: unknown, init?: RequestInit) => { + const input = JSON.parse(String(init?.body)); + const events = [ + { type: 'RUN_STARTED', runId: input.runId, threadId: input.threadId }, + { type: 'STATE_SNAPSHOT', snapshot: { amount: 12 } }, + { type: 'MESSAGES_SNAPSHOT', messages: [{ id: 'u1', role: 'user', content: 'Approve this' }] }, + { type: 'RUN_FINISHED', runId: input.runId, threadId: input.threadId, outcome: { type: 'interrupt', interrupts: [{ id: 'i1', reason: 'confirmation' }, { id: 'i2', reason: 'confirmation' }] } }, + ]; + return new Response(events.map(event => `data: ${JSON.stringify(event)}\n\n`).join(''), { headers: { 'content-type': 'text/event-stream' } }); + }); + return { source: new HttpAgent({ url: 'http://test.invalid', threadId: 't1', fetch }), fetch }; +} + +it('restores committed messages, state and a complete batch into a new adapter', async () => { + const { config } = memory(); + const original = source(); + const first = toAgent(original.source, { persistence: config, telemetry: false }); + await first.ready; + await first.submit({ message: 'Approve this' }); + first.dispose(); + const replacement = source(); + const second = toAgent(replacement.source, { persistence: config, telemetry: false }); + await second.ready; + expect(second.state()).toEqual({ amount: 12 }); + expect(second.messages().map(message => message.content)).toEqual(['Approve this']); + expect(second.interruptSession().interrupts.map(entry => entry.id)).toEqual(['i1', 'i2']); + expect(replacement.source.pendingInterrupts).toHaveLength(2); + expect(replacement.fetch).not.toHaveBeenCalled(); + second.dispose(); +}); + +it('restores tool identity and accepts a resumed result followed by a new interrupt batch', async () => { + const { config } = memory(); + const initial = source(); + initial.fetch.mockImplementationOnce(async (_url, init) => { + const input = JSON.parse(String(init?.body)); + const events = [ + { type: 'RUN_STARTED', runId: input.runId, threadId: input.threadId }, + { type: 'MESSAGES_SNAPSHOT', messages: [{ id: 'assistant-1', role: 'assistant', content: '', toolCalls: [{ id: 'tool-1', type: 'function', function: { name: 'approve', arguments: '{}' } }] }] }, + { type: 'RUN_FINISHED', runId: input.runId, threadId: input.threadId, outcome: { type: 'interrupt', interrupts: [{ id: 'i1', reason: 'confirmation' }] } }, + ]; + return new Response(events.map(event => `data: ${JSON.stringify(event)}\n\n`).join(''), { headers: { 'content-type': 'text/event-stream' } }); + }); + const first = toAgent(initial.source, { persistence: config, telemetry: false }); + await first.ready; await first.submit({}); first.dispose(); + const replacement = source(); + replacement.fetch.mockImplementationOnce(async (_url, init) => { + const input = JSON.parse(String(init?.body)); + const events = [ + { type: 'RUN_STARTED', runId: input.runId, threadId: input.threadId }, + { type: 'TOOL_CALL_RESULT', toolCallId: 'tool-1', messageId: 'result-1', role: 'tool', content: '{"approved":true}' }, + { type: 'STATE_SNAPSHOT', snapshot: { approved: true } }, + { type: 'RUN_FINISHED', runId: input.runId, threadId: input.threadId, outcome: { type: 'interrupt', interrupts: [{ id: 'next', reason: 'confirmation' }] } }, + ]; + return new Response(events.map(event => `data: ${JSON.stringify(event)}\n\n`).join(''), { headers: { 'content-type': 'text/event-stream' } }); + }); + const second = toAgent(replacement.source, { persistence: config, telemetry: false }); + await second.ready; + const generation = second.interruptSession().generation; + expect(second.toolCalls()?.map(tool => tool.id)).toEqual(['tool-1']); + await second.submit({ resume: true }); + expect(second.error()).toBeUndefined(); + expect(second.toolCalls()?.find(tool => tool.id === 'tool-1')?.result).toEqual({ approved: true }); + expect(second.interruptSession().generation).toBe(generation + 1); + expect(second.interruptSession().interrupts.map(entry => entry.id)).toEqual(['next']); + expect(second.state()).toEqual({ approved: true }); + second.dispose(); +}); + +it('prevents a second restored instance from dispatching a conflicting claim', async () => { + const { config } = memory(); + const first = toAgent(source().source, { persistence: config, telemetry: false }); + await first.ready; await first.submit({}); + const otherSource = source(); + const second = toAgent(otherSource.source, { persistence: config, telemetry: false }); + await second.ready; + const responses = ['i1', 'i2'].map(interruptId => ({ interruptId, status: 'resolved', payload: true })); + await first.submit({ resume: responses }); + await second.submit({ resume: responses }); + expect(otherSource.fetch).not.toHaveBeenCalled(); + expect(second.error()).toBeDefined(); + first.dispose(); second.dispose(); +}); + +it('blocks decisions while authoritative reconciliation is in progress', async () => { + const { config } = memory(); + let release!: () => void; + config.reconcile = async record => { + await new Promise(resolve => { release = resolve; }); + return { status: 'pending', committed: record.committed, session: record.session }; + }; + const original = source(); + const agent = toAgent(original.source, { persistence: config, telemetry: false }); + await agent.ready; await agent.submit({}); + const recovery = agent.reconcileInterrupt(); + await vi.waitFor(() => expect(release).toBeTypeOf('function')); + const resume = ['i1', 'i2'].map(interruptId => ({ interruptId, status: 'resolved', payload: true })); + await expect(agent.submit({ resume })).rejects.toThrow(/reconcil|recovery/); + expect(original.fetch).toHaveBeenCalledTimes(1); + release(); await recovery; + agent.dispose(); +}); + +it('requires reconciliation after a lost acknowledgement and replays the exact saved decision', async () => { + const { config } = memory(); + config.reconcile = async record => ({ status: 'pending', committed: record.committed, session: { ...record.session, phase: 'pending' } }); + const original = source(); + const first = toAgent(original.source, { persistence: config, telemetry: false }); + await first.ready; await first.submit({}); + original.fetch.mockRejectedValueOnce(new Error('lost acknowledgement')); + const resume = ['i1', 'i2'].map(interruptId => ({ interruptId, status: 'resolved', payload: { approved: true } })); + await first.submit({ resume, state: { reviewer: 'Ada' } }); + const failedRequest = JSON.parse(String(original.fetch.mock.calls[1][1]?.body)); + first.dispose(); + const replacement = source(); + const second = toAgent(replacement.source, { persistence: config, telemetry: false }); + await second.ready; + expect(second.interruptSession().phase).toBe('uncertain'); + await expect(second.retry()).rejects.toThrow(/reconcil/); + expect(replacement.fetch).not.toHaveBeenCalled(); + await second.reconcileInterrupt(); + await second.retry(); + const replay = JSON.parse(String(replacement.fetch.mock.calls[0][1]?.body)); + expect(replay.resume).toEqual(failedRequest.resume); + expect(replay.state).toEqual(failedRequest.state); + expect(replay.messages).toEqual(failedRequest.messages); + expect(replay.runId).toBe(failedRequest.runId); + second.dispose(); +}); diff --git a/libs/ag-ui/src/lib/to-agent.resume-wire.spec.ts b/libs/ag-ui/src/lib/to-agent.resume-wire.spec.ts index bdc742706..acf25b2ac 100644 --- a/libs/ag-ui/src/lib/to-agent.resume-wire.spec.ts +++ b/libs/ag-ui/src/lib/to-agent.resume-wire.spec.ts @@ -90,6 +90,7 @@ interface WireHarness { function wireHarness( threadId: string, responses: Array<(request: Record) => Response>, + options: Parameters[1] = {}, ): WireHarness { const bodies: Array> = []; let call = 0; @@ -105,7 +106,7 @@ function wireHarness( threadId, fetch: fetchMock as unknown as ConstructorParameters[0]['fetch'], }); - return { agent: toAgent(source), source, bodies: () => bodies }; + return { agent: toAgent(source, options), source, bodies: () => bodies }; } describe('AWS Strands resume over the wire (0.0.59 top-level resume array)', () => { @@ -144,7 +145,7 @@ describe('AWS Strands resume over the wire (0.0.59 top-level resume array)', () expect(messages[0]).toEqual((measuredMessages as unknown[])[0]); }); - it('lets a plain submit abandon the interrupt without tripping the 0.0.59 pending-interrupt gate', async () => { + it('rejects a plain submit while retaining the pending interrupt ledger', async () => { const { agent, source, bodies } = wireHarness('th-int-555800', [ (request) => sseResponseFromFixture('strands-interrupt.sse', request), (request) => syntheticSuccessResponse(request), @@ -153,13 +154,14 @@ describe('AWS Strands resume over the wire (0.0.59 top-level resume array)', () await agent.submit({ message: 'Schedule a meeting with Dana about the Q3 roadmap.' }); expect(source.pendingInterrupts).toHaveLength(1); - // Pre-0.0.59 semantics: a plain message after an interrupt just runs. - // Without the adapter clearing the ledger, 0.0.59's onInitialize throws - // AGUIError before any request is sent. - await agent.submit({ message: 'Never mind, cancel that.' }); + const pendingInterrupts = [...source.pendingInterrupts]; + const messages = agent.messages(); + await expect(agent.submit({ message: 'Never mind, cancel that.' })).rejects.toThrow('Resolve the pending interrupt'); expect(agent.error()).toBeUndefined(); - expect(bodies()).toHaveLength(2); - expect(bodies()[1]['resume']).toBeUndefined(); + expect(bodies()).toHaveLength(1); + expect(source.pendingInterrupts).toEqual(pendingInterrupts); + expect(agent.messages()).toEqual(messages); + expect(agent.interrupt!()).toBeDefined(); }); }); @@ -209,7 +211,7 @@ describe('Mastra resume over the wire (forwardedProps shape preserved)', () => { const { agent, source, bodies } = wireHarness('thread-hitl-1', [ (request) => sseResponseFromFixture('mastra-reinterrupt.sse', request), (request) => syntheticSuccessResponse(request), - ]); + ], { interruptTransport: 'mastra-command' }); await agent.submit({ message: 'Schedule a meeting with Dana about the Q4 roadmap.' }); expect(agent.interrupt!()).toBeDefined(); diff --git a/libs/ag-ui/src/lib/to-agent.resume.spec.ts b/libs/ag-ui/src/lib/to-agent.resume.spec.ts index 5b8cf9bf1..af6a9f50c 100644 --- a/libs/ag-ui/src/lib/to-agent.resume.spec.ts +++ b/libs/ag-ui/src/lib/to-agent.resume.spec.ts @@ -83,13 +83,11 @@ function lastRunAgentArg(stub: StubAgent): { } describe('submit({ resume }) — LangGraph wire shape is unchanged', () => { - it('sends exactly { command: { resume } } when no interrupt is pending', async () => { + it('rejects resume when no interrupt is pending', async () => { const stub = new StubAgent(); const agent = toAgent(stub as unknown as AbstractAgent); - await agent.submit({ resume: { approved: true } }); - expect(stub.runAgent).toHaveBeenCalledWith({ - forwardedProps: { command: { resume: { approved: true } } }, - }); + await expect(agent.submit({ resume: { approved: true } })).rejects.toThrow('No pending interrupt'); + expect(stub.runAgent).not.toHaveBeenCalled(); }); it('sends exactly { command: { resume } } for an on_interrupt payload without identifying fields', async () => { @@ -102,11 +100,18 @@ describe('submit({ resume }) — LangGraph wire shape is unchanged', () => { } as unknown as BaseEvent); expect(agent.interrupt!()).toBeDefined(); + stub.runAgent.mockImplementationOnce(async () => { + const runId = (stub.runAgent.mock.calls.at(-1) as unknown as [{ runId: string }])[0].runId; + stub.emit({ type: 'RUN_STARTED', runId } as BaseEvent); + stub.emit({ type: 'RUN_FINISHED', runId } as BaseEvent); + return { result: undefined, newMessages: [] }; + }); await agent.submit({ resume: { approved: true } }); - expect(lastRunAgentArg(stub)).toEqual({ + expect(lastRunAgentArg(stub)).toEqual(expect.objectContaining({ forwardedProps: { command: { resume: { approved: true } } }, - }); + })); + expect(lastRunAgentArg(stub).resume).toBeUndefined(); expect(agent.interrupt!()).toBeUndefined(); }); }); @@ -121,13 +126,14 @@ describe('submit({ resume }) — Mastra transcript round-trip', () => { // plus command.interruptEvent{toolCallId,runId}. it('reproduces the measured command.resume + command.interruptEvent shape', async () => { const stub = new StubAgent(); - const agent = toAgent(stub as unknown as AbstractAgent); + const agent = toAgent(stub as unknown as AbstractAgent, { interruptTransport: 'mastra-command' }); await replayInterruptRun(stub, agent, 'mastra-reinterrupt.sse', 'run-hitl-2'); await agent.submit({ resume: { chosen_time: '2026-09-01T10:00' } }); const measured = readCapturedRequest('mastra-resume-correct.request.json'); expect(lastRunAgentArg(stub).forwardedProps).toEqual(measured['forwardedProps']); + expect(lastRunAgentArg(stub).resume).toBeUndefined(); expect(lastRunAgentArg(stub).forwardedProps).toEqual({ command: { resume: { chosen_time: '2026-09-01T10:00' }, @@ -215,7 +221,8 @@ describe('submit({ resume }) — AWS Strands interrupt outcome', () => { }]; await agent.submit({ resume: structured }); - expect(lastRunAgentArg(stub)).toEqual({ resume: structured }); + expect(lastRunAgentArg(stub).resume).toEqual(structured); + expect(lastRunAgentArg(stub).forwardedProps).toBeUndefined(); }); // SYNTHETIC: entries authored with the pre-standard `id` key are renamed to @@ -226,16 +233,17 @@ describe('submit({ resume }) — AWS Strands interrupt outcome', () => { await replayInterruptRun(stub, agent, 'strands-interrupt.sse', 'run-1'); await agent.submit({ - resume: [{ id: 'interrupt-1', payload: { ok: true }, metadata: { via: 'test' } }], + resume: [{ id: 'v1:tool_call:call_A9ckGX1LrvO82OhqZinzDsom:340a4daa-b874-5aad-8309-a63b92d507dd', payload: { ok: true }, metadata: { via: 'test' } }], }); - expect(lastRunAgentArg(stub)).toEqual({ + expect(lastRunAgentArg(stub)).toEqual(expect.objectContaining({ resume: [{ - interruptId: 'interrupt-1', + interruptId: 'v1:tool_call:call_A9ckGX1LrvO82OhqZinzDsom:340a4daa-b874-5aad-8309-a63b92d507dd', status: 'resolved', payload: { ok: true }, metadata: { via: 'test' }, }], - }); + })); + expect(lastRunAgentArg(stub).forwardedProps).toBeUndefined(); }); }); diff --git a/libs/ag-ui/src/lib/to-agent.spec.ts b/libs/ag-ui/src/lib/to-agent.spec.ts index 88ba5432c..7f3a1ff68 100644 --- a/libs/ag-ui/src/lib/to-agent.spec.ts +++ b/libs/ag-ui/src/lib/to-agent.spec.ts @@ -79,11 +79,8 @@ describe('automatic development evidence', () => { developmentEvidence.events = []; const stub = new StubAgent(); const agent = toAgent(stub as unknown as AbstractAgent); - stub.runAgent.mockImplementationOnce(async () => { - stub.emit({ type: 'RUN_FINISHED' } as BaseEvent); - return { result: undefined, newMessages: [] }; - }); - await agent.submit({ resume: true }); + await expect(agent.submit({ resume: true })).rejects.toThrow('No pending interrupt'); + expect(stub.runAgent).not.toHaveBeenCalled(); expect(developmentEvidence.events).not.toContain('interrupt.handled'); stub.runAgent.mockImplementationOnce(async () => { stub.emit({ type: 'RUN_STARTED', runId: 'paused' } as BaseEvent); @@ -93,8 +90,9 @@ describe('automatic development evidence', () => { await agent.submit({}); developmentEvidence.events = []; stub.runAgent.mockImplementationOnce(async () => { - stub.emit({ type: 'RUN_STARTED', runId: 'resumed' } as BaseEvent); - stub.emit({ type: 'RUN_FINISHED', runId: 'resumed' } as BaseEvent); + const runId = (stub.runAgent.mock.calls.at(-1) as unknown as [{ runId: string }])[0].runId; + stub.emit({ type: 'RUN_STARTED', runId } as BaseEvent); + stub.emit({ type: 'RUN_FINISHED', runId } as BaseEvent); return { result: undefined, newMessages: [] }; }); await agent.submit({ resume: true }); @@ -478,9 +476,17 @@ describe('toAgent', () => { it('submit({ resume }) calls runAgent with forwardedProps.command.resume and appends no message', async () => { const stub = new StubAgent(); const a = toAgent(stub as unknown as AbstractAgent); + stub.emit({ type: 'CUSTOM', name: 'on_interrupt', value: { kind: 'approval' } } as unknown as BaseEvent); + stub.runAgent.mockImplementationOnce(async () => { + const runId = (stub.runAgent.mock.calls.at(-1) as unknown as [{ runId: string }])[0].runId; + stub.emit({ type: 'RUN_STARTED', runId } as BaseEvent); + stub.emit({ type: 'RUN_FINISHED', runId } as BaseEvent); + return { result: undefined, newMessages: [] }; + }); const before = a.messages().length; await a.submit({ resume: { approved: true } }); - expect(stub.runAgent).toHaveBeenCalledWith({ forwardedProps: { command: { resume: { approved: true } } } }); + expect(stub.runAgent).toHaveBeenCalledWith(expect.objectContaining({ forwardedProps: { command: { resume: { approved: true } } } })); + expect((stub.runAgent.mock.calls[0] as unknown as [{ resume?: unknown }])[0].resume).toBeUndefined(); expect(a.messages().length).toBe(before); expect(a.interrupt!()).toBeUndefined(); }); @@ -518,6 +524,13 @@ describe('toAgent', () => { // Arrange an active interrupt (mirrors existing interrupt tests) stub.emit({ type: 'CUSTOM', name: 'on_interrupt', value: { kind: 'approval' } } as unknown as BaseEvent); expect(a.interrupt!()).toBeDefined(); + stub.runAgent.mockImplementationOnce(async () => { + expect(stub.state).toMatchObject({ reasoning_effort: 'high' }); + const runId = (stub.runAgent.mock.calls.at(-1) as unknown as [{ runId: string }])[0].runId; + stub.emit({ type: 'RUN_STARTED', runId } as BaseEvent); + stub.emit({ type: 'RUN_FINISHED', runId } as BaseEvent); + return { result: undefined, newMessages: [] }; + }); await a.submit({ resume: 'approved', state: { reasoning_effort: 'high' } }); expect(stub.state).toMatchObject({ reasoning_effort: 'high' }); }); @@ -596,6 +609,13 @@ describe('toAgent', () => { if (operation === 'retry') { await agent.submit({ message: 'seed retry' }); + } else if (operation === 'resume') { + source.runAgent.mockImplementationOnce(async () => { + source.emit({ type: 'RUN_STARTED', runId: 'seed-pause' } as BaseEvent); + source.emit({ type: 'RUN_FINISHED', runId: 'seed-pause', outcome: { type: 'interrupt', interrupts: [{ id: 'approval', value: {} }] } } as BaseEvent); + return { result: undefined, newMessages: [] }; + }); + await agent.submit({ message: 'seed resume' }); } else if (operation === 'regenerate') { const seedRun = deferNextRun(source); const seedPending = agent.submit({ message: 'seed regenerate' }); @@ -615,7 +635,7 @@ describe('toAgent', () => { : operation === 'resume' ? agent.submit({ resume: { approved: true } }) : agent.regenerate(1); - const runId = `close-${operation}`; + const runId = operation === 'resume' ? (source.runAgent.mock.calls.at(-1) as unknown as [{ runId: string }])[0].runId : `close-${operation}`; const messageId = `ai-${operation}`; source.emit({ type: 'RUN_STARTED' } as BaseEvent, runId); source.emit({ type: 'TEXT_MESSAGE_START', messageId, role: 'assistant' } as never, runId); @@ -804,7 +824,7 @@ describe('toAgent', () => { source.emit({ type: 'RUN_STARTED', runId: 'run-1' } as BaseEvent); source.emit({ type: 'TEXT_MESSAGE_START', messageId: 'ai-reused', role: 'assistant' } as never); source.emit({ type: 'TEXT_MESSAGE_CONTENT', messageId: 'ai-reused', delta: 'first' } as never); - source.emit({ type: 'RUN_FINISHED', runId: 'run-1' } as BaseEvent); + source.emit({ type: 'RUN_FINISHED', runId: 'run-1', ...(operation === 'resume' ? { outcome: { type: 'interrupt', interrupts: [{ id: 'approval', value: {} }] } } : {}) } as BaseEvent); firstRun.resolve(); await firstPending; const firstGeneration = agent.messages().find(message => message.id === 'ai-reused')!.delivery.generation; @@ -815,7 +835,7 @@ describe('toAgent', () => { : operation === 'regenerate' ? agent.regenerate(1) : agent.submit({ resume: { approved: true } }); - source.emit({ type: 'RUN_STARTED', runId: 'run-2' } as BaseEvent); + source.emit({ type: 'RUN_STARTED', runId: operation === 'resume' ? (source.runAgent.mock.calls.at(-1) as unknown as [{ runId: string }])[0].runId : 'run-2' } as BaseEvent); source.emit({ type: 'TEXT_MESSAGE_START', messageId: 'ai-reused', role: 'assistant' } as never); const nextDelivery = agent.messages().find(message => message.id === 'ai-reused')!.delivery; @@ -837,7 +857,7 @@ describe('toAgent', () => { source.emit({ type: 'RUN_STARTED' } as BaseEvent, 'seed-snapshot'); source.emit({ type: 'TEXT_MESSAGE_START', messageId: 'reused-ai', role: 'assistant' } as never, 'seed-snapshot'); source.emit({ type: 'TEXT_MESSAGE_CONTENT', messageId: 'reused-ai', delta: 'prior' } as never, 'seed-snapshot'); - source.emit({ type: 'RUN_FINISHED' } as BaseEvent, 'seed-snapshot'); + source.emit({ type: 'RUN_FINISHED', ...(operation === 'resume' ? { outcome: { type: 'interrupt', interrupts: [{ id: 'approval', value: {} }] } } : {}) } as BaseEvent, 'seed-snapshot'); seedRun.resolve(); await seedPending; const priorGeneration = agent.messages().find(message => message.id === 'reused-ai')!.delivery.generation; @@ -846,19 +866,20 @@ describe('toAgent', () => { const pending = operation === 'retry' ? agent.retry() : agent.submit({ resume: { approved: true } }); - source.emit({ type: 'RUN_STARTED' } as BaseEvent, `snapshot-${operation}`); + const runId = operation === 'resume' ? (source.runAgent.mock.calls.at(-1) as unknown as [{ runId: string }])[0].runId : `snapshot-${operation}`; + source.emit({ type: 'RUN_STARTED' } as BaseEvent, runId); source.emit({ type: 'MESSAGES_SNAPSHOT', messages: [ { id: userId, role: 'user', content: 'hello' }, { id: 'reused-ai', role: 'assistant', content: 'replacement' }, ], - } as never, `snapshot-${operation}`); + } as never, runId); const replacement = agent.messages().find(message => message.id === 'reused-ai')!.delivery; expect(replacement.phase).toBe('streaming'); expect(replacement.generation).not.toBe(priorGeneration); - source.emit({ type: 'RUN_FINISHED' } as BaseEvent, `snapshot-${operation}`); + source.emit({ type: 'RUN_FINISHED' } as BaseEvent, runId); nextRun.resolve(); await pending; expect(agent.messages().find(message => message.id === 'reused-ai')?.delivery) diff --git a/libs/ag-ui/src/lib/to-agent.ts b/libs/ag-ui/src/lib/to-agent.ts index c4dca3210..3e83516de 100644 --- a/libs/ag-ui/src/lib/to-agent.ts +++ b/libs/ag-ui/src/lib/to-agent.ts @@ -6,7 +6,6 @@ import { createDevelopmentRuntime, registerDevelopmentRuntimePolicy } from '@thr import { THREADPLANE_PACKAGE_VERSION as packageVersion } from './package-version'; import { Subject } from 'rxjs'; import type { AbstractAgent } from '@ag-ui/client'; -import type { ResumeEntry } from '@ag-ui/core'; import { completeDelivery, staticDelivery, @@ -35,8 +34,16 @@ import { type ActivityEntry, } from './reducer'; import { createClientToolsCapability } from './client-tools'; +import { InterruptSession } from './interrupt-session'; +import type { InterruptSessionSnapshot, InterruptTransport, ResumeAttempt } from './interrupt-session.types'; +import { RunStateTransaction } from './run-state-transaction'; +import { InterruptPersistence, type AgUiInterruptPersistence, type AgUiThreadRecord } from './interrupt-persistence'; export interface ToAgentOptions { + /** Application-owned durable storage. Requires a stable source threadId and scoped namespace. */ + persistence?: AgUiInterruptPersistence; + /** Native outcomes take precedence in auto mode; select a legacy profile explicitly when required. */ + interruptTransport?: InterruptTransport; /** * Omit to enable automatic development-only collection. Set `false` to disable. * An app-owned sink replaces the automatic destination and receives the @@ -81,6 +88,12 @@ function agentRuntimeTelemetryErrorClass(error: unknown): string { return 'UnknownError'; } +/** Adapter-specific submit guards in addition to cancellation. */ +export interface AgUiSubmitOptions extends AgentSubmitOptions { + /** Generation captured when rendering the interrupt decision; rejects stale controls. */ + interruptGeneration?: number; +} + /** * The neutral Agent contract, widened with the AG-UI adapter's * `customEvents` signal (the chat composition feature-detects it to enable @@ -90,6 +103,15 @@ function agentRuntimeTelemetryErrorClass(error: unknown): string { * overlap. */ export interface AgUiAgent> extends Agent { + submit(input: AgentSubmitInput, opts?: AgUiSubmitOptions): Promise; + /** Resolves after persisted thread state is hydrated; actions wait for it. */ + ready: Promise; + /** Recover an uncertain attempt using the configured authoritative reconciler. */ + reconcileInterrupt(): Promise; + /** Full interrupt batch and its request ownership phase. */ + interruptSession: Signal; + /** Unsubscribe and stop local work. Does not cancel backend checkpoints. */ + dispose(): void; customEvents: Signal; clientTools: ClientToolsCapability; /** Subagent activities (activityType==='subagent') projected to the neutral @@ -106,11 +128,8 @@ export interface AgUiAgent> extends Agent; messages: typeof source.messages; localMessages?: Message[] } | undefined; + const persistence = options.persistence ? new InterruptPersistence(options.persistence, source.threadId) : undefined; + let hydrated = !persistence; + let reconciling = false; + let persistenceFault: unknown; + let persistenceWrites: Promise = Promise.resolve(); + function storageError(error: unknown): void { + persistenceFault = error; + if (!disposed) { + store.error.set(options.protectOperationErrors ? protectedAgentError() : projectAgentError(error)); + store.status.set('error'); store.isLoading.set(false); + } + } + function persistCurrent(): Promise { + if (!persistence) return Promise.resolve(); + const data = { + committed: transaction.committed, session: interrupts.snapshot, + ...(resumeInput && interrupts.snapshot.attempt ? { resumeInput: { state: resumeInput.state, messages: resumeInput.messages } } : {}), + }; + const write = persistenceWrites.then(() => persistence.save(data)); + persistenceWrites = write; + void write.catch(storageError); + return write; + } + function hydrate(record: AgUiThreadRecord): void { + if (disposed) return; + transaction.commit(record.committed); + source.state = structuredClone(record.committed.state); + source.messages = structuredClone(record.committed.messages); + store.deliveryRun = null; + reduceEvent({ type: 'MESSAGES_SNAPSHOT', messages: record.committed.messages } as never, store); + store.state.set(structuredClone(record.committed.state)); + interrupts.restore(record.session); + source.pendingInterrupts = structuredClone(record.session.interrupts); + resumeInput = record.resumeInput ? structuredClone(record.resumeInput) : undefined; + publishInterrupt(); + } + const ready = persistence + ? persistence.load().then(record => { if (record) hydrate(record); hydrated = true; }).catch(error => { storageError(error); throw error; }) + : Promise.resolve(); + void ready.catch(() => undefined); + function publishInterrupt(): void { + const snapshot = interrupts.snapshot; + interruptSession.set(snapshot); + if (snapshot.phase === 'none' || snapshot.phase === 'acknowledged') { + store.interrupt.set(undefined); + } else if (snapshot.interrupts.length && options.interruptTransport !== 'legacy-command' && options.interruptTransport !== 'mastra-command') { + store.interrupt.set({ + id: snapshot.interrupts[0].id, resumable: true, + value: { interrupts: snapshot.interrupts, ...(snapshot.runId ? { runId: snapshot.runId } : {}) }, + }); + } else { + store.interrupt.set(snapshot.legacy); + } + } + function assertAvailable(): void { + if (disposed) throw new Error('Agent has been disposed'); + if (reconciling) throw new Error('Interrupt reconciliation is in progress'); + if (!hydrated) throw new Error('Wait for agent.ready before starting a request'); + if (persistenceFault) throw new Error('Interrupt storage recovery requires reconciliation'); + } + function assertNoInterrupt(): void { + assertAvailable(); + if (interrupts.snapshot.phase !== 'none') throw new Error('Resolve the pending interrupt before starting another request'); + } + function rollbackState(): void { + const committed = transaction.rollback(); + source.state = committed.state; + source.messages = committed.messages; + store.state.set(committed.state); + } + function commitState(): void { + transaction.commit({ state: store.state(), messages: source.messages ?? [] }); + } const telemetryProperties = { transport: 'ag-ui' as const, surface: 'to_agent' }; const developmentRuntime = createDevelopmentRuntime({ integration: 'ag-ui', packageName: '@threadplane/ag-ui', packageVersion, @@ -172,6 +269,8 @@ function createAgentAdapter( startedAt: number; telemetrySettled: boolean; resumedInterrupt: boolean; + resumeAttempt?: ResumeAttempt; + terminalReceived?: boolean; } let activeRun: AdapterRun | null = null; const runsByProtocolId = new Map(); @@ -179,6 +278,7 @@ function createAgentAdapter( // Tracks the last AgentSubmitInput so retry() can re-run it without // duplicating the user message. Set at the top of submit()'s message path. let lastInput: AgentSubmitInput | undefined; + let lastRunInput: typeof resumeInput; function resolveCallbackRun(protocolRunId: string | undefined): AdapterRun | null { if (!protocolRunId) return activeRun; @@ -269,18 +369,31 @@ function createAgentAdapter( } function failRun(run: AdapterRun, error: unknown): void { - if (run.outcome !== undefined) return; + if (disposed || (run.outcome !== undefined && !(run.outcome === 'paused' && !run.terminalReceived && interrupts.snapshot.phase === 'collecting'))) return; + run.terminalReceived = true; finalizeDeliveryRun(store, run, 'error'); if (activeRun === run) { + rollbackState(); + if (run.resumeAttempt) { + interrupts.fail(run.resumeAttempt.id, isRecord(error) && error['requestNotDispatched'] === true); + publishInterrupt(); + } store.status.set('error'); store.isLoading.set(false); store.error.set(options.protectOperationErrors ? protectedAgentError() : projectAgentError(error)); + void persistCurrent().catch(() => undefined); } failRunTelemetry(options.protectOperationErrors ? undefined : error, run); } function settleTransportClose(run: AdapterRun): void { if (run.outcome === undefined) { + if (run.resumeAttempt) { + rollbackState(); + interrupts.fail(run.resumeAttempt.id, false); + publishInterrupt(); + void persistCurrent().catch(() => undefined); + } finalizeDeliveryRun(store, run, run.ownedMessageIds.size > 0 ? 'interrupted' : 'success'); if (activeRun === run) { store.status.set('idle'); @@ -291,6 +404,21 @@ function createAgentAdapter( finishRunTelemetry(run); } + function abortRun(run: AdapterRun): void { + if (run.outcome !== undefined) return; + rollbackState(); + if (run.resumeAttempt) { + interrupts.fail(run.resumeAttempt.id, false); + publishInterrupt(); + void persistCurrent().catch(() => undefined); + } + finalizeDeliveryRun(store, run, 'aborted'); + store.status.set('idle'); + store.isLoading.set(false); + store.error.set(undefined); + finishRunTelemetry(run); + } + type RunParameters = Parameters[0]; async function executeRun( @@ -298,32 +426,58 @@ function createAgentAdapter( parameters?: RunParameters, allowBaselineTail = false, resumedInterrupt = false, + resumeAttempt?: ResumeAttempt, + signal?: AbortSignal, ): Promise { + assertAvailable(); + if (!resumeAttempt) assertNoInterrupt(); const run = beginRun(requestType, allowBaselineTail, resumedInterrupt); + if (!resumeAttempt) lastRunInput = structuredClone({ state: source.state ?? {}, messages: source.messages ?? [], localMessages: store.messages() }); + run.resumeAttempt = resumeAttempt; + if (resumeAttempt) { + run.protocolRunId = resumeAttempt.runId; + runsByProtocolId.set(resumeAttempt.runId, run); + } const tools = clientToolsCap.catalogAsAgUiTools(); const runParameters = parameters === undefined && tools.length === 0 ? undefined : { ...parameters, ...(tools.length > 0 ? { tools } : {}) }; - // @ag-ui/client@0.0.59 records RUN_FINISHED interrupt outcomes on - // source.pendingInterrupts and REFUSES the next runAgent() unless a - // `resume` entry addresses every pending id (AGUIError thrown in - // onInitialize). The adapter's own interrupt signal governs resume flow: - // when this run resolves the interrupt through forwardedProps (Mastra, - // LangGraph — their measured wire shapes carry no top-level resume) or is - // a plain submit that abandons the interrupt (the pre-0.0.59 semantics), - // clear the client-side ledger so the run is sent exactly as before. - if (runParameters?.resume === undefined) { + // Compatibility profiles address the same canonical claim through their + // native command. Ordinary input never clears a pending client ledger. + if (resumeAttempt && runParameters?.resume === undefined) { const pending = (source as { pendingInterrupts?: unknown }).pendingInterrupts; if (Array.isArray(pending) && pending.length > 0) { (source as { pendingInterrupts: unknown[] }).pendingInterrupts = []; } } + const abort = () => { + if (activeRun !== run || run.outcome !== undefined) return; + abortRun(run); + source.abortRun(); + }; try { + if (resumeAttempt && persistence) await persistCurrent(); + if (signal?.aborted) { + if (resumeAttempt) { interrupts.fail(resumeAttempt.id, true); publishInterrupt(); } + throw Object.assign(new Error('Request aborted before dispatch'), { requestNotDispatched: true }); + } + signal?.addEventListener('abort', abort, { once: true }); + if (resumeAttempt) { + interrupts.dispatched(resumeAttempt.id); publishInterrupt(); + if (persistence) await persistCurrent(); + } + if (disposed || activeRun !== run || run.outcome !== undefined) return; await source.runAgent(runParameters); + if (disposed || activeRun !== run) return; + if (interrupts.snapshot.phase === 'collecting' && !run.terminalReceived) { interrupts.ready(); publishInterrupt(); commitState(); void persistCurrent().catch(() => undefined); } settleTransportClose(run); + await persistenceWrites; } catch (err) { if (run.outcome === 'aborted' && safeIsAbortError(err)) return; failRun(run, err); + } finally { + signal?.removeEventListener('abort', abort); + await persistenceWrites.catch(() => undefined); } } @@ -331,27 +485,37 @@ function createAgentAdapter( source, store, () => executeRun('client-tool-continuation', undefined, true), + assertNoInterrupt, ); // Tap all events from the source agent via the AgentSubscriber API. // This subscription lives for the lifetime of `source`. - source.subscribe({ + const subscription = source.subscribe({ onRunInitialized({ input }) { + if (disposed) return; resolveCallbackRun(input.runId); }, - onEvent({ event, input }) { + onEvent({ event, input }): void | { stopPropagation: boolean } { + if (disposed) return { stopPropagation: true }; const callbackRunId = input?.runId ?? (event as { runId?: string }).runId; const run = resolveCallbackRun(callbackRunId); if (!run) { - if (!callbackRunId) reduceEvent(event, store); + if (!callbackRunId) { + reduceEvent(event, store); + if (event.type === 'CUSTOM' && (event as { name?: string }).name === 'on_interrupt') { + interrupts.observeLegacy(store.interrupt()?.value); + interrupts.ready(); publishInterrupt(); + } + } return; } if (run !== activeRun) { if (event.type === 'RUN_FINISHED') finalizeDeliveryRun(store, run, 'success'); else if (event.type === 'RUN_ERROR') finalizeDeliveryRun(store, run, 'error'); - return; + return { stopPropagation: true }; } - if (event.type === 'RUN_ERROR' && run.outcome === 'aborted') return; + if (run.outcome === 'aborted' || run.outcome === 'error' || run.outcome === 'interrupted') return { stopPropagation: true }; + if (run.terminalReceived && (run.outcome !== 'paused' || (event.type !== 'CUSTOM' && event.type !== 'RUN_FINISHED'))) return { stopPropagation: true }; const hasDevelopmentEvidence = event.type !== 'RUN_FINISHED' || hasValidFinishedOutcome(event); if (run.outcome === undefined && hasDevelopmentEvidence && supportedDevelopmentEventTypes.has(event.type)) { developmentRuntime.milestone('transport.connected'); @@ -360,19 +524,56 @@ function createAgentAdapter( failRun(run, undefined); return; } + if (event.type === 'RUN_FINISHED') { + const outcome = (event as unknown as { outcome?: { type: string; interrupts?: unknown[] } }).outcome; + try { + if (!hasValidFinishedOutcome(event)) throw new Error('Invalid run outcome'); + if (outcome?.type === 'interrupt') interrupts.observeNative(outcome.interrupts ?? [], run.protocolRunId); + } catch (error) { + failRun(run, error); + return { stopPropagation: true }; + } + } const wasPending = run.outcome === undefined; reduceEvent(event, store); + if (event.type === 'RUN_STARTED' && run.resumeAttempt && run.outcome === undefined + && (!(event as { runId?: string }).runId || (event as { runId?: string }).runId === run.protocolRunId)) { + interrupts.acknowledge(run.resumeAttempt.id); publishInterrupt(); + void persistCurrent().catch(() => undefined); + } + if (event.type === 'CUSTOM' && (event as { name?: string }).name === 'on_interrupt') { + const value = (event as unknown as { value: unknown }).value; + let parsed = value; + if (typeof value === 'string') { try { parsed = JSON.parse(value); } catch { /* Keep opaque compatibility values. */ } } + interrupts.observeLegacy(parsed, run.protocolRunId); + publishInterrupt(); + } + if (event.type === 'RUN_FINISHED') { + run.terminalReceived = true; + if (interrupts.snapshot.phase === 'collecting') interrupts.ready(); + else if (run.resumeAttempt && run.outcome === 'success') interrupts.complete(run.resumeAttempt.id); + publishInterrupt(); + commitState(); + void persistCurrent().catch(() => undefined); + } + if (event.type === 'RUN_ERROR') { + run.terminalReceived = true; + if (run.resumeAttempt) { interrupts.fail(run.resumeAttempt.id, false); publishInterrupt(); } + rollbackState(); + void persistCurrent().catch(() => undefined); + } if (run && event.type === 'RUN_FINISHED' && run.outcome === 'success') { if (wasPending && hasDevelopmentEvidence && !store.interrupt() && !store.error()) { developmentRuntime.milestone('runtime.first_stream_completed', Date.now() - run.startedAt); if (run.resumedInterrupt) developmentRuntime.milestone('interrupt.handled'); } finishRunTelemetry(run); - } else if (run && event.type === 'RUN_ERROR' && run.outcome === 'error') { + } else if (event.type === 'RUN_ERROR') { failRunTelemetry((event as { message?: unknown }).message ?? event, run); } }, onRunFailed({ error, input }) { + if (disposed) return; const run = resolveCallbackRun(input?.runId); if (run) { if (run.outcome === 'aborted' && safeIsAbortError(error)) { @@ -438,6 +639,34 @@ function createAgentAdapter( } return registerDevelopmentRuntimePolicy({ + ready, + reconcileInterrupt: async () => { + if (disposed) throw new Error('Agent has been disposed'); + if (reconciling) throw new Error('Interrupt reconciliation is in progress'); + if (activeRun && activeRun.outcome === undefined) throw new Error('Stop the active request before reconciliation'); + if (!persistence) throw new Error('Interrupt recovery requires a persistence reconciler'); + reconciling = true; + try { + await persistenceWrites.catch(() => undefined); + const record = await persistence.reconcile(); + if (disposed) return; + if (record) hydrate(record); + persistenceFault = undefined; persistenceWrites = Promise.resolve(); + hydrated = true; store.error.set(undefined); store.status.set('idle'); + } finally { + reconciling = false; + } + }, + interruptSession: interruptSession.asReadonly(), + dispose: () => { + if (disposed) return; + disposed = true; + if (activeRun) abortRun(activeRun); + subscription.unsubscribe(); + source.abortRun(); + developmentRuntime.dispose(); + store.events$.complete(); + }, messages: store.messages, status: store.status, isLoading: store.isLoading, @@ -462,24 +691,32 @@ function createAgentAdapter( }), clientTools: clientToolsCap, - submit: async (input: AgentSubmitInput, _opts?: AgentSubmitOptions) => { + submit: async (input: AgentSubmitInput, opts?: AgUiSubmitOptions) => { + if (!hydrated) await ready; + assertAvailable(); if (input.resume !== undefined) { - // Resume path: clear the pending interrupt and replay the run with the - // resume payload. The wire mechanism (protocol-standard top-level - // `resume` array vs forwardedProps) is derived from how the inbound - // interrupt arrived — see buildResumeRunParameters. + if (opts?.interruptGeneration !== undefined && opts.interruptGeneration !== interrupts.snapshot.generation) { + throw new Error('Stale interrupt generation: refresh the decision before submitting'); + } + const attempt = interrupts.claim(input, randomId(), randomId()); + publishInterrupt(); applyStatePatch(input.state); - const pendingInterrupt = store.interrupt(); - store.interrupt.set(undefined); + const userMsg = buildUserMessage(input); + if (userMsg) { + store.messages.update(prev => [...prev, userMsg]); + source.addMessage(userMsg as Parameters[0]); + } + resumeInput = structuredClone({ state: source.state ?? {}, messages: source.messages ?? [], localMessages: store.messages() }); await executeRun( 'resume', - buildResumeRunParameters(input.resume, pendingInterrupt), + { ...attempt.parameters, runId: attempt.runId }, true, - pendingInterrupt !== undefined, + true, attempt, opts?.signal, ); return; } + assertNoInterrupt(); applyStatePatch(input.state); // Optimistic append of user message to our signals and to the source @@ -495,13 +732,38 @@ function createAgentAdapter( // user message (the message is already in the list by this point). lastInput = input; - await executeRun('submit'); + await executeRun('submit', undefined, false, false, undefined, opts?.signal); }, retry: async () => { + if (!hydrated) await ready; + assertAvailable(); + if (interrupts.snapshot.attempt) { + const attempt = interrupts.retry(); + publishInterrupt(); + if (resumeInput) { + const restored = structuredClone(resumeInput); + source.state = restored.state; source.messages = restored.messages; + store.state.set(restored.state); + if (restored.localMessages) store.messages.set(restored.localMessages); + else { + store.deliveryRun = null; + reduceEvent({ type: 'MESSAGES_SNAPSHOT', messages: restored.messages } as never, store); + } + } + store.error.set(undefined); + await executeRun('resume', { ...attempt.parameters, runId: attempt.runId }, true, true, attempt); + return; + } if (store.isLoading()) return; // no-op while a run is in flight if (lastInput === undefined) return; // nothing to retry + assertNoInterrupt(); store.error.set(undefined); + if (lastRunInput) { + const restored = structuredClone(lastRunInput); + source.state = restored.state; source.messages = restored.messages; + store.state.set(restored.state); store.messages.set(restored.localMessages ?? []); + } // Re-run the same message list against the source without appending a // duplicate user message — the message is already in store.messages and // source's internal list from the original submit(). @@ -510,17 +772,14 @@ function createAgentAdapter( stop: async () => { const run = activeRun; - if (run && run.outcome === undefined) { - finalizeDeliveryRun(store, run, 'aborted'); - store.status.set('idle'); - store.isLoading.set(false); - store.error.set(undefined); - finishRunTelemetry(run); - } + if (run) abortRun(run); source.abortRun(); + await persistenceWrites.catch(() => undefined); }, regenerate: async (assistantMessageIndex: number): Promise => { + if (!hydrated) await ready; + assertNoInterrupt(); if (store.isLoading()) { throw new Error('Cannot regenerate while agent is loading another response'); } @@ -601,100 +860,6 @@ function safeIsAbortError(error: unknown): boolean { } } -/** - * Build the outgoing run parameters for a `submit({ resume })`. - * - * The wire mechanism is keyed on how the pending interrupt arrived, so each - * runtime finds the resume payload where it reads it (all measured against - * the 2026-08-31 runtime-portability spike captures): - * - * - LangGraph (CUSTOM on_interrupt with an opaque payload — no identifying - * fields): exactly `{ forwardedProps: { command: { resume } } }`, - * byte-for-byte the historical shape. Backward compatibility here is - * non-negotiable. - * - Mastra (CUSTOM on_interrupt payload carrying `toolCallId` + `runId`): - * `{ forwardedProps: { command: { resume, interruptEvent: { toolCallId, - * runId } } } }` — the measured working request (fixtures/ - * runtime-transcripts/mastra-resume-correct.request.json), byte-for-byte - * unchanged. - * - Protocol-standard RUN_FINISHED interrupt outcome (AWS Strands, Microsoft - * Agent Framework — stored by the reducer as `{ interrupts: [...], runId }`): - * the protocol-standard TOP-LEVEL `resume` array, one - * `{ interruptId, status: 'resolved', payload }` entry per pending - * interrupt. RunAgentInputSchema@0.0.59 carries the field and - * prepareRunAgentInput serializes it (0.0.52 dropped it at assembly, which - * made Strands resume unsendable — the reason for the 0.0.59 upgrade). - * Strands reads exactly this shape (fixtures/runtime-transcripts/ - * strands-resume.request.json, measured working); the Microsoft bridge - * reads the top-level field FIRST (`_extract_resume_payload` in - * agent_framework_ag_ui checks `input.resume` before - * forwardedProps.command.resume) and accepts `interruptId` entries. A - * caller that already provides entry-shaped `resume` values has them - * normalized to `ResumeEntry` (`id` → `interruptId`) and sent top-level. - */ -function buildResumeRunParameters( - resume: unknown, - interrupt: AgentInterrupt | undefined, -): { resume: ResumeEntry[] } | { forwardedProps: Record } { - const value = interrupt?.value; - if (isRecord(value)) { - if (typeof value['toolCallId'] === 'string') { - return { - forwardedProps: { - command: { - resume, - interruptEvent: { - toolCallId: value['toolCallId'], - ...(typeof value['runId'] === 'string' ? { runId: value['runId'] } : {}), - }, - }, - }, - }; - } - if (Array.isArray(value['interrupts'])) { - if (isStructuredResume(resume)) { - return { resume: (resume as Record[]).map(toResumeEntry) }; - } - const entries = (value['interrupts'] as unknown[]) - .filter(isRecord) - .filter((entry) => typeof entry['id'] === 'string'); - if (entries.length > 0) { - return { - resume: entries.map((entry) => ({ - interruptId: entry['id'] as string, - status: 'resolved' as const, - payload: resume, - })), - }; - } - } - } - return { forwardedProps: { command: { resume } } }; -} - -/** True when the caller already provided per-interrupt entries - * (`[{ id | interruptId, ... }]`). */ -function isStructuredResume(resume: unknown): boolean { - return Array.isArray(resume) - && resume.length > 0 - && resume.every((entry) => - isRecord(entry) - && (typeof entry['id'] === 'string' || typeof entry['interruptId'] === 'string'), - ); -} - -/** Normalize a caller-authored entry to the protocol's ResumeEntry shape: - * `id` becomes `interruptId`, status defaults to 'resolved', every other - * field (payload, metadata, …) rides along unchanged. */ -function toResumeEntry(entry: Record): ResumeEntry { - const { id, interruptId, status, ...rest } = entry; - return { - interruptId: (typeof interruptId === 'string' ? interruptId : id) as string, - status: status === 'cancelled' ? 'cancelled' : 'resolved', - ...rest, - } as ResumeEntry; -} - function isRecord(v: unknown): v is Record { return typeof v === 'object' && v !== null && !Array.isArray(v); } diff --git a/libs/ag-ui/src/public-api.ts b/libs/ag-ui/src/public-api.ts index f6a2c3206..f5cb78e98 100644 --- a/libs/ag-ui/src/public-api.ts +++ b/libs/ag-ui/src/public-api.ts @@ -1,5 +1,8 @@ export { toAgent } from './lib/to-agent'; -export type { ToAgentOptions, AgUiAgent } from './lib/to-agent'; +export type { ToAgentOptions, AgUiAgent, AgUiSubmitOptions } from './lib/to-agent'; +export type { InterruptSessionSnapshot, InterruptSessionPhase, InterruptTransport, ResumeAttempt } from './lib/interrupt-session.types'; +export type { AgUiInterruptPersistence, AgUiThreadRecord } from './lib/interrupt-persistence'; +export type { ThreadSnapshot } from './lib/run-state-transaction'; export type { CustomStreamEvent } from './lib/reducer'; export { provideAgent, injectAgent } from './lib/provide-agent'; export type { AgentConfig } from './lib/provide-agent'; diff --git a/libs/chat/src/lib/agent/agent.ts b/libs/chat/src/lib/agent/agent.ts index afd9bb4e2..ec3fd2ae1 100644 --- a/libs/chat/src/lib/agent/agent.ts +++ b/libs/chat/src/lib/agent/agent.ts @@ -36,8 +36,14 @@ export interface Agent { submit: (input: AgentSubmitInput, opts?: AgentSubmitOptions) => Promise; stop: () => Promise; - /** Re-run the last submitted input after a failure. No-op if a run is already - * in flight or there is nothing to retry. Clears `error` and sets loading. */ + /** + * Re-run the captured submission after a failure, including a resume command + * with no message payload. Does not append another user message or reuse an + * aborted request signal. Does not restart an in-flight request; no-op when + * nothing is saved. + * Adapters may reject unsafe resume retries until the backend outcome has + * been reconciled; a transport failure alone does not prove non-execution. + */ retry: () => Promise; /** @@ -52,6 +58,11 @@ export interface Agent { regenerate: (assistantMessageIndex: number) => Promise; // Extended (optional; absent when runtime does not support) + /** + * Optional display projection of the pending interrupt. A runtime may expose + * a separate full batch and lifecycle surface. A cleared display projection + * does not by itself prove that resumed backend work completed. + */ interrupt?: Signal; subagents?: Signal>; /** Optional: client-declared, client-executed tools (see ClientToolsCapability). */ diff --git a/libs/chat/src/lib/agent/interrupt-conformance.spec.ts b/libs/chat/src/lib/agent/interrupt-conformance.spec.ts new file mode 100644 index 000000000..a1833652f --- /dev/null +++ b/libs/chat/src/lib/agent/interrupt-conformance.spec.ts @@ -0,0 +1,82 @@ +import { expect, test, vi } from 'vitest'; +import { signal } from '@angular/core'; +import { EMPTY } from 'rxjs'; +import type { AgentError } from './agent-error'; +import type { AgentInterrupt } from './agent-interrupt'; +import type { AgentSubmitInput } from './agent-submit'; +import { + INTERRUPT_CONFORMANCE_BATCH, + interruptConformanceScenarios, + type InterruptConformanceHarness, +} from '../../../testing/interrupt-conformance'; + +let harness: InterruptConformanceHarness; +const registrations = interruptConformanceScenarios(() => harness); + +function referenceHarness(bug?: 'lost-resume' | 'lost-message' | 'duplicate-message' | 'lost-batch' | 'stale-callback') { + const requests: InterruptConformanceHarness['requests'] = []; + const interrupt = signal(undefined); + const error = signal(undefined); + let last: AgentSubmitInput = {}; + let fail = false; + const dispatch = async (input: AgentSubmitInput) => { + requests.push({ + resume: input.resume, + state: input.state ?? {}, + messages: input.message && bug !== 'lost-message' ? [input.message as string] : [], + }); + error.set(fail ? new Error('Known failure') as AgentError : undefined); + fail = false; + }; + let disposed = false; + const state = signal>({}); + const cleanup = vi.fn(() => { disposed = true; }); + return { + resume: { 'approval-a': { approved: true }, 'approval-b': { approved: true } }, + agent: { + messages: signal([]), status: signal('idle' as const), isLoading: signal(false), + error, toolCalls: signal([]), state, interrupt, events$: EMPTY, + submit: async (input: AgentSubmitInput) => { last = input; await dispatch(input); }, + retry: async () => { + await dispatch(bug === 'lost-resume' ? { ...last, resume: undefined } : last); + if (bug === 'duplicate-message') requests.at(-1)!.messages.push(last.message as string); + }, + stop: async () => undefined, + regenerate: async () => undefined, + }, + requests, + pause: async () => interrupt.set({ id: 'approval-a', value: {}, resumable: true }), + pendingBatch: () => bug === 'lost-batch' ? INTERRUPT_CONFORMANCE_BATCH.slice(0, 1) : INTERRUPT_CONFORMANCE_BATCH, + failNextDispatch: () => { fail = true; }, + backendCancellationCount: () => 0, + startLateDelivery: async () => undefined, + deliverLateEvents: async () => { + if (!disposed || bug === 'stale-callback') state.set({ late: true }); + }, + cleanup, + } satisfies InterruptConformanceHarness; +} + +test.each([...registrations.keys()])('accepts a conforming harness: %s', async label => { + harness = referenceHarness(); + await registrations.get(label)!(); + expect(harness.cleanup).toHaveBeenCalledTimes(label === 'ignores captured delivery after disposal' ? 2 : 1); +}); + +test.each([ + ['lost-resume', 'intentionally retries a failed resume-only dispatch unchanged'], + ['lost-message', 'forwards resume, state and message together'], + ['duplicate-message', 'intentionally retries a failed combined dispatch unchanged'], + ['lost-batch', 'exposes the complete paused batch while remaining idle'], + ['stale-callback', 'ignores captured delivery after disposal'], +] as const)('detects %s and still cleans up the harness', async (bug, label) => { + harness = referenceHarness(bug); + await expect(registrations.get(label)!()).rejects.toThrow(); + expect(harness.cleanup).toHaveBeenCalledTimes(bug === 'stale-callback' ? 2 : 1); +}); + +test('restoration is only registered when explicitly enabled', () => { + expect(registrations.has('restores the complete pause from backend history')).toBe(false); + const restoring = interruptConformanceScenarios(() => harness, { restoration: true }); + expect(restoring.has('restores the complete pause from backend history')).toBe(true); +}); diff --git a/libs/chat/testing/interrupt-conformance.ts b/libs/chat/testing/interrupt-conformance.ts new file mode 100644 index 000000000..5ae7aba4e --- /dev/null +++ b/libs/chat/testing/interrupt-conformance.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest'; +import type { Agent, AgentSubmitInput } from '@threadplane/chat'; + +export const INTERRUPT_CONFORMANCE_BATCH = [ + { id: 'approval-a', value: { question: 'Approve A?' } }, + { id: 'approval-b', value: { question: 'Approve B?' } }, +]; + +/** Semantic view of a native request; adapters retain responsibility for wire assertions. */ +export interface InterruptConformanceRequest { + resume: unknown; + state: Record; + messages: string[]; +} + +/** Opt-in harness: pause and failure must be driven through the real adapter. */ +export interface InterruptConformanceHarness { + agent: Agent; + /** Native decision addressing every interrupt in the fixture batch. */ + resume: unknown; + pause(): Promise; + pendingBatch(): Array<{ id: string; value: unknown }>; + failNextDispatch(): void; + requests: InterruptConformanceRequest[]; + backendCancellationCount(): number; + /** Start a native run and hold its delivery until deliverLateEvents is called. */ + startLateDelivery(): Promise; + /** Release captured native callbacks/events, even after cleanup. */ + deliverLateEvents(): Promise; + cleanup(): void | Promise; + /** Optional backend history/store restoration; never synthesizes a local pause. */ + restore?: () => Promise; +} + +/** Separate from base conformance so agents without interrupts remain supported. */ +export function runInterruptConformance( + label: string, + factory: () => InterruptConformanceHarness, + options: { restoration?: boolean } = {}, +): void { + describe(`${label} — interrupt conformance`, () => { + for (const [name, scenario] of interruptConformanceScenarios(factory, options)) { + it(name, scenario); + } + }); +} + +/** @internal Exposed here to verify the suite rejects nonconforming harnesses. */ +export function interruptConformanceScenarios( + factory: () => InterruptConformanceHarness, + options: { restoration?: boolean } = {}, +): Map Promise> { + const scenarios = new Map Promise>(); + const register = (name: string, scenario: () => Promise) => scenarios.set(name, scenario); + const withHarness = async (test: (h: InterruptConformanceHarness) => Promise) => { + const h = factory(); + try { await test(h); } finally { await h.cleanup(); } + }; + + register('exposes the complete paused batch while remaining idle', () => withHarness(async h => { + await h.pause(); + expect(h.agent.interrupt?.()?.resumable).toBe(true); + expect(h.agent.status()).toBe('idle'); + expect(h.agent.isLoading()).toBe(false); + expect(h.pendingBatch()).toEqual(INTERRUPT_CONFORMANCE_BATCH); + })); + + register('forwards resume, state and message together', () => withHarness(async h => { + await h.pause(); + const input = combinedInput(h.resume); + await h.agent.submit(input); + expect(h.requests.at(-1)).toEqual({ + resume: input.resume, state: input.state, messages: [input.message], + }); + })); + + for (const combined of [false, true]) { + register(`intentionally retries a failed ${combined ? 'combined' : 'resume-only'} dispatch unchanged`, () => withHarness(async h => { + await h.pause(); + const input: AgentSubmitInput = combined ? combinedInput(h.resume) : { resume: h.resume }; + const before = h.requests.length; + h.failNextDispatch(); + await h.agent.submit(input).catch(() => undefined); + expect(h.agent.error()).toBeDefined(); + expect(h.requests).toHaveLength(before + 1); + const failed = h.requests.at(-1); + await h.agent.retry(); + expect(h.requests).toHaveLength(before + 2); + expect(h.requests.at(-1)).toEqual(failed); + expect(h.requests.at(-1)).toEqual({ + resume: input.resume, + state: input.state ?? {}, + messages: combined ? [input.message] : [], + }); + expect(h.agent.error()).toBeUndefined(); + expect(h.agent.isLoading()).toBe(false); + })); + } + + register('stops local work without cancelling the backend pause', () => withHarness(async h => { + await h.pause(); + const before = h.backendCancellationCount(); + await h.agent.stop(); + expect(h.backendCancellationCount()).toBe(before); + expect(h.pendingBatch()).toEqual(INTERRUPT_CONFORMANCE_BATCH); + })); + + register('applies captured delivery while the agent is live', () => withHarness(async h => { + await h.startLateDelivery(); + const before = snapshot(h); + await h.deliverLateEvents(); + expect(snapshot(h)).not.toEqual(before); + })); + + register('ignores captured delivery after disposal', () => withHarness(async h => { + await h.startLateDelivery(); + await h.cleanup(); + const disposed = snapshot(h); + await h.deliverLateEvents(); + expect(snapshot(h)).toEqual(disposed); + })); + + if (options.restoration) { + register('restores the complete pause from backend history', () => withHarness(async h => { + expect(h.restore).toBeTypeOf('function'); + if (!h.restore) throw new Error('Restoration harness is required'); + await h.restore(); + expect(h.pendingBatch()).toEqual(INTERRUPT_CONFORMANCE_BATCH); + expect(h.agent.interrupt?.()?.resumable).toBe(true); + })); + } + return scenarios; +} + +function combinedInput(resume: unknown) { + return { resume, state: { reviewer: 'Ada' }, message: 'Approved, continue' }; +} + +function snapshot(h: InterruptConformanceHarness) { + return structuredClone({ + state: h.agent.state(), messages: h.agent.messages(), + interrupt: h.agent.interrupt?.(), batch: h.pendingBatch(), + }); +} diff --git a/libs/chat/testing/public-api.ts b/libs/chat/testing/public-api.ts index 0908c0d26..b5fd692ac 100644 --- a/libs/chat/testing/public-api.ts +++ b/libs/chat/testing/public-api.ts @@ -1,5 +1,11 @@ export type { FakeAgentConfig } from './fake-agent-config'; export { runAgentConformance } from './agent-conformance'; +export { + runInterruptConformance, + INTERRUPT_CONFORMANCE_BATCH, + type InterruptConformanceHarness, + type InterruptConformanceRequest, +} from './interrupt-conformance'; export { runAgentWithHistoryConformance } from './agent-with-history-conformance'; export { REASONING_FIXTURE_MESSAGE_ID, diff --git a/libs/langgraph/src/lib/agent.conformance.spec.ts b/libs/langgraph/src/lib/agent.conformance.spec.ts index 2ea54fc3c..8c3269e4b 100644 --- a/libs/langgraph/src/lib/agent.conformance.spec.ts +++ b/libs/langgraph/src/lib/agent.conformance.spec.ts @@ -11,6 +11,12 @@ import { runAgentConformance } from '@threadplane/chat/testing'; import { provideAgent } from './agent.provider'; import { injectAgent } from './inject-agent'; import { MockAgentTransport } from './transport/mock-stream.transport'; +import { agent } from './agent.fn'; +import { + INTERRUPT_CONFORMANCE_BATCH, + runInterruptConformance, + type InterruptConformanceRequest, +} from '@threadplane/chat/testing'; runAgentConformance('agent (LangGraph)', () => { TestBed.resetTestingModule(); @@ -25,3 +31,81 @@ runAgentConformance('agent (LangGraph)', () => { }); return TestBed.runInInjectionContext(() => injectAgent()); }); + +runInterruptConformance('agent (LangGraph)', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({}); + const requests: InterruptConformanceRequest[] = []; + const transport = new MockAgentTransport(); + let pause = false; + let fail = false; + let lateGate: Promise | undefined; + let releaseLate: (() => void) | undefined; + let startedLate: (() => void) | undefined; + let lateRun: Promise | undefined; + transport.stream = async function* (_assistant, _thread, payload, _signal, options) { + const command = options?.command; + const update = (command?.update ?? payload ?? {}) as Record; + const { messages, ...state } = update; + requests.push({ + resume: command?.resume, + state, + messages: ((messages ?? []) as Array<{ content: string }>).map(message => message.content), + }); + if (fail) { fail = false; throw new Error('Known failure before dispatch'); } + if (lateGate) { + startedLate?.(); + await lateGate; + // Deliberately ignores the abort signal, modeling an already queued event. + yield { type: 'values', values: { + late: true, + messages: [{ id: 'late-message', type: 'ai', content: 'Late response' }], + __interrupt__: [{ id: 'late-interrupt', value: { question: 'Too late?' } }], + } }; + return; + } + if (pause) { + pause = false; + yield { type: 'values', values: { __interrupt__: INTERRUPT_CONFORMANCE_BATCH } }; + } else { + yield { type: 'values', values: {} }; + } + }; + const ref = TestBed.runInInjectionContext(() => agent({ + apiUrl: '', assistantId: 'test', threadId: 'thread-1', transport, throttle: false, + })); + return { + agent: ref, + resume: { 'approval-a': { approved: true }, 'approval-b': { approved: true } }, + requests, + pause: async () => { pause = true; await ref.submit({}); }, + pendingBatch: () => ref.langGraphInterrupts().map(entry => ({ id: entry.id!, value: entry.value })), + failNextDispatch: () => { fail = true; }, + backendCancellationCount: () => transport.cancelledRuns.length, + startLateDelivery: async () => { + lateGate = new Promise(resolve => { releaseLate = resolve; }); + const started = new Promise(resolve => { startedLate = resolve; }); + lateRun = ref.submit({}); + await started; + }, + deliverLateEvents: async () => { releaseLate?.(); await lateRun; }, + restore: async () => { + transport.history = [{ + values: { messages: [], reviewer: 'Ada' }, + next: ['approval'], + checkpoint: { thread_id: 'restored-thread', checkpoint_ns: '', checkpoint_id: 'paused', checkpoint_map: null }, + metadata: null, + created_at: '2026-09-08T00:00:00.000Z', + parent_checkpoint: null, + tasks: INTERRUPT_CONFORMANCE_BATCH.map((interrupt, index) => ({ + id: `task-${index}`, name: 'approval', interrupts: [interrupt], + })), + }]; + ref.switchThread('restored-thread'); + await expect.poll(() => ref.isThreadLoading()).toBe(false); + expect(transport.historyCalls).toContain('restored-thread'); + expect(requests).toHaveLength(0); + }, + cleanup: () => { TestBed.resetTestingModule(); }, + }; +}, { restoration: true }); diff --git a/libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts b/libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts index b086c5bd9..5b22c2c4d 100644 --- a/libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts +++ b/libs/langgraph/src/lib/internals/stream-manager.bridge.spec.ts @@ -1573,6 +1573,48 @@ describe('createStreamManagerBridge', () => { }); describe('submit outcomes and retry state', () => { + it('retries a failed null-payload resume with its captured command and a fresh signal', async () => { + const requests: Array<{ payload: unknown; command: unknown; aborted: boolean }> = []; + const transport: AgentTransport = { + async *stream(_assistantId, _threadId, payload, signal, options) { + requests.push({ payload, command: structuredClone(options?.command), aborted: signal?.aborted ?? false }); + if (requests.length === 1) throw new Error('connection failed'); + yield { type: 'values', values: { done: true } }; + }, + }; + const destroy$ = new Subject(); + const controller = new AbortController(); + const bridge = createStreamManagerBridge({ + options: { apiUrl: '', assistantId: 'test', transport }, + subjects: makeSubjects(), threadId$: of('thread-1'), destroy$, + }); + const command = { resume: { approved: true }, update: { amount: 12 } }; + expect(await bridge.submit(null, { command, signal: controller.signal })).toBe('error'); + command.resume.approved = false; + command.update.amount = 99; + controller.abort(); + expect(await bridge.resubmitLast()).toBe('success'); + expect(requests).toEqual([ + { payload: null, command: { resume: { approved: true }, update: { amount: 12 } }, aborted: false }, + { payload: null, command: { resume: { approved: true }, update: { amount: 12 } }, aborted: false }, + ]); + destroy$.next(); + }); + + it('does not replay retained input after disposal', async () => { + const transport = new MockAgentTransport(); + const destroy$ = new Subject(); + const bridge = createStreamManagerBridge({ + options: { apiUrl: '', assistantId: 'test', transport }, + subjects: makeSubjects(), threadId$: of('thread-1'), destroy$, + }); + const run = bridge.submit({ messages: [] }); + transport.close(); + await run; + destroy$.next(); + expect(await bridge.resubmitLast()).toBe('not-started'); + }); + it('returns not-started without a retained non-null payload', async () => { let streamCalls = 0; const transport: AgentTransport = { diff --git a/libs/langgraph/src/lib/internals/stream-manager.bridge.ts b/libs/langgraph/src/lib/internals/stream-manager.bridge.ts index b1979846a..39a11844c 100644 --- a/libs/langgraph/src/lib/internals/stream-manager.bridge.ts +++ b/libs/langgraph/src/lib/internals/stream-manager.bridge.ts @@ -172,8 +172,10 @@ export function createStreamManagerBridge(); @@ -441,8 +443,7 @@ export function createStreamManagerBridge { + disposed = true; + lastRequest = undefined; developmentRuntime.dispose(); invalidateQueueDrain(); abortController?.abort(); @@ -718,6 +721,7 @@ export function createStreamManagerBridge { + if (disposed) return 'aborted'; invalidateQueueDrain(); abortController?.abort(); const controller = new AbortController(); @@ -738,8 +742,16 @@ export function createStreamManagerBridge { - if (lastPayload === null) return 'not-started'; - return runStream(lastPayload, lastOptions, 'resubmit'); + if (!lastRequest || disposed) return 'not-started'; + return runStream(lastRequest.payload, lastRequest.options, 'resubmit'); }, getReasoningDurationMs: (id: string): number | undefined => { From bc99636ad6c072b956fbcdba4a78bf4075c260ef Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 8 Sep 2026 13:40:04 -0700 Subject: [PATCH 2/6] fix(chat): preserve composer drafts while interrupt input is blocked --- .../content/docs/ag-ui/api/api-docs.json | 6 +++++ .../content/docs/ag-ui/guides/interrupts.mdx | 2 ++ .../content/docs/chat/api/api-docs.json | 20 ++++++++++++++- .../content/docs/langgraph/api/api-docs.json | 12 +++++++++ .../to-agent.interrupt-restoration.spec.ts | 15 +++++++++++ libs/ag-ui/src/lib/to-agent.ts | 25 ++++++++++--------- libs/chat/src/lib/agent/agent.ts | 2 ++ .../lib/compositions/chat/chat.component.ts | 5 ++-- .../chat-input/chat-input.component.spec.ts | 22 ++++++++++++++++ .../chat-input/chat-input.component.ts | 6 ++--- 10 files changed, 97 insertions(+), 18 deletions(-) diff --git a/apps/website/content/docs/ag-ui/api/api-docs.json b/apps/website/content/docs/ag-ui/api/api-docs.json index 3e5b8b182..0377fb916 100644 --- a/apps/website/content/docs/ag-ui/api/api-docs.json +++ b/apps/website/content/docs/ag-ui/api/api-docs.json @@ -461,6 +461,12 @@ "description": "Full interrupt batch and its request ownership phase.", "optional": false }, + { + "name": "isInputBlocked", + "type": "Signal", + "description": "Optional gate for ordinary composer input while the runtime requires resolution or recovery. Resume actions remain adapter-controlled.", + "optional": true + }, { "name": "isLoading", "type": "Signal", diff --git a/apps/website/content/docs/ag-ui/guides/interrupts.mdx b/apps/website/content/docs/ag-ui/guides/interrupts.mdx index 96c383ebb..7b978f5d0 100644 --- a/apps/website/content/docs/ag-ui/guides/interrupts.mdx +++ b/apps/website/content/docs/ag-ui/guides/interrupts.mdx @@ -168,6 +168,8 @@ Atomic storage claims coordinate clients sharing that store. Preventing duplicat Await `agent.ready` before rendering restored approval controls; actions also wait for hydration. An interrupted in-flight claim restores as uncertain, and an acknowledged attempt requires recovery. Configure `persistence.reconcile(record)` to obtain an authoritative backend outcome and call `agent.reconcileInterrupt()` to apply it. The callback returns `unknown`, or `pending`, `acknowledged`, or `completed` with authoritative `committed` and `session` snapshots. An unknown outcome keeps recovery blocked. The adapter does not provide a backend reconciliation endpoint, and stored client state cannot recreate a checkpoint lost by a restarted `MemorySaver` process. +The optional `isInputBlocked` signal disables ordinary chat input during hydration, reconciliation, and unresolved interrupt phases. The built-in composer preserves drafts while blocked; approval controls still submit through the resume path. + Providers dispose the adapter with their injector. If you create it with `toAgent()` directly, call `agent.dispose()` when finished. Disposal unsubscribes and stops local work; it does not delete or cancel backend checkpoints. ### Migration note diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index 724594c3f..594e5c625 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -5996,6 +5996,12 @@ "description": "Optional display projection of the pending interrupt. A runtime may expose\na separate full batch and lifecycle surface. A cleared display projection\ndoes not by itself prove that resumed backend work completed.", "optional": true }, + { + "name": "isInputBlocked", + "type": "Signal", + "description": "Optional gate for ordinary composer input while the runtime requires resolution or recovery. Resume actions remain adapter-controlled.", + "optional": true + }, { "name": "isLoading", "type": "Signal", @@ -6316,6 +6322,12 @@ "description": "Optional display projection of the pending interrupt. A runtime may expose\na separate full batch and lifecycle surface. A cleared display projection\ndoes not by itself prove that resumed backend work completed.", "optional": true }, + { + "name": "isInputBlocked", + "type": "Signal", + "description": "Optional gate for ordinary composer input while the runtime requires resolution or recovery. Resume actions remain adapter-controlled.", + "optional": true + }, { "name": "isLoading", "type": "Signal", @@ -7496,6 +7508,12 @@ "description": "Optional display projection of the pending interrupt. A runtime may expose\na separate full batch and lifecycle surface. A cleared display projection\ndoes not by itself prove that resumed backend work completed.", "optional": true }, + { + "name": "isInputBlocked", + "type": "Signal", + "description": "Optional gate for ordinary composer input while the runtime requires resolution or recovery. Resume actions remain adapter-controlled.", + "optional": true + }, { "name": "isLoading", "type": "WritableSignal", @@ -9652,7 +9670,7 @@ { "name": "submitMessage", "kind": "function", - "description": "Submits a trimmed message to the agent.\nReturns the trimmed string on success, or `null` if the input was empty.", + "description": "Submits a trimmed message to the agent.\nReturns the trimmed string on dispatch, or `null` for empty or blocked input.", "signature": "submitMessage(agent: Agent<>, text: string): string | null", "params": [ { diff --git a/apps/website/content/docs/langgraph/api/api-docs.json b/apps/website/content/docs/langgraph/api/api-docs.json index ead58a8d8..5cf3afd2e 100644 --- a/apps/website/content/docs/langgraph/api/api-docs.json +++ b/apps/website/content/docs/langgraph/api/api-docs.json @@ -1534,6 +1534,12 @@ "description": "Current human-in-the-loop pause, or `undefined` when the run is not paused.\n\nNarrowed from the neutral `Agent` contract, where `interrupt` is optional\nbecause a runtime without human-in-the-loop support omits it. The LangGraph\nadapter always provides it, so `injectAgent().interrupt()` type-checks\ndirectly under `strictNullChecks` — no `?.()` needed.", "optional": false }, + { + "name": "isInputBlocked", + "type": "Signal", + "description": "Optional gate for ordinary composer input while the runtime requires resolution or recovery. Resume actions remain adapter-controlled.", + "optional": true + }, { "name": "isLoading", "type": "Signal", @@ -1968,6 +1974,12 @@ "description": "Current human-in-the-loop pause, or `undefined` when the run is not paused.\n\nNarrowed from the neutral `Agent` contract, where `interrupt` is optional\nbecause a runtime without human-in-the-loop support omits it. The LangGraph\nadapter always provides it, so `injectAgent().interrupt()` type-checks\ndirectly under `strictNullChecks` — no `?.()` needed.", "optional": false }, + { + "name": "isInputBlocked", + "type": "Signal", + "description": "Optional gate for ordinary composer input while the runtime requires resolution or recovery. Resume actions remain adapter-controlled.", + "optional": true + }, { "name": "isLoading", "type": "WritableSignal", diff --git a/libs/ag-ui/src/lib/to-agent.interrupt-restoration.spec.ts b/libs/ag-ui/src/lib/to-agent.interrupt-restoration.spec.ts index 4b44cbc53..c2dab1763 100644 --- a/libs/ag-ui/src/lib/to-agent.interrupt-restoration.spec.ts +++ b/libs/ag-ui/src/lib/to-agent.interrupt-restoration.spec.ts @@ -32,6 +32,21 @@ function source() { return { source: new HttpAgent({ url: 'http://test.invalid', threadId: 't1', fetch }), fetch }; } +it('blocks the composer until asynchronous hydration completes', async () => { + const { config } = memory(); + let release!: () => void; + config.store.load = async () => { + await new Promise(resolve => { release = resolve; }); + return null; + }; + const agent = toAgent(source().source, { persistence: config, telemetry: false }); + expect(agent.isInputBlocked?.()).toBe(true); + await vi.waitFor(() => expect(release).toBeTypeOf('function')); + release(); await agent.ready; + expect(agent.isInputBlocked?.()).toBe(false); + agent.dispose(); +}); + it('restores committed messages, state and a complete batch into a new adapter', async () => { const { config } = memory(); const original = source(); diff --git a/libs/ag-ui/src/lib/to-agent.ts b/libs/ag-ui/src/lib/to-agent.ts index 3e83516de..f82de093b 100644 --- a/libs/ag-ui/src/lib/to-agent.ts +++ b/libs/ag-ui/src/lib/to-agent.ts @@ -187,8 +187,8 @@ function createAgentAdapter( let disposed = false; let resumeInput: { state: Record; messages: typeof source.messages; localMessages?: Message[] } | undefined; const persistence = options.persistence ? new InterruptPersistence(options.persistence, source.threadId) : undefined; - let hydrated = !persistence; - let reconciling = false; + const hydrated = signal(!persistence); + const reconciling = signal(false); let persistenceFault: unknown; let persistenceWrites: Promise = Promise.resolve(); function storageError(error: unknown): void { @@ -223,7 +223,7 @@ function createAgentAdapter( publishInterrupt(); } const ready = persistence - ? persistence.load().then(record => { if (record) hydrate(record); hydrated = true; }).catch(error => { storageError(error); throw error; }) + ? persistence.load().then(record => { if (record) hydrate(record); hydrated.set(true); }).catch(error => { storageError(error); throw error; }) : Promise.resolve(); void ready.catch(() => undefined); function publishInterrupt(): void { @@ -242,8 +242,8 @@ function createAgentAdapter( } function assertAvailable(): void { if (disposed) throw new Error('Agent has been disposed'); - if (reconciling) throw new Error('Interrupt reconciliation is in progress'); - if (!hydrated) throw new Error('Wait for agent.ready before starting a request'); + if (reconciling()) throw new Error('Interrupt reconciliation is in progress'); + if (!hydrated()) throw new Error('Wait for agent.ready before starting a request'); if (persistenceFault) throw new Error('Interrupt storage recovery requires reconciliation'); } function assertNoInterrupt(): void { @@ -642,22 +642,23 @@ function createAgentAdapter( ready, reconcileInterrupt: async () => { if (disposed) throw new Error('Agent has been disposed'); - if (reconciling) throw new Error('Interrupt reconciliation is in progress'); + if (reconciling()) throw new Error('Interrupt reconciliation is in progress'); if (activeRun && activeRun.outcome === undefined) throw new Error('Stop the active request before reconciliation'); if (!persistence) throw new Error('Interrupt recovery requires a persistence reconciler'); - reconciling = true; + reconciling.set(true); try { await persistenceWrites.catch(() => undefined); const record = await persistence.reconcile(); if (disposed) return; if (record) hydrate(record); persistenceFault = undefined; persistenceWrites = Promise.resolve(); - hydrated = true; store.error.set(undefined); store.status.set('idle'); + hydrated.set(true); store.error.set(undefined); store.status.set('idle'); } finally { - reconciling = false; + reconciling.set(false); } }, interruptSession: interruptSession.asReadonly(), + isInputBlocked: computed(() => !hydrated() || reconciling() || interruptSession().phase !== 'none'), dispose: () => { if (disposed) return; disposed = true; @@ -692,7 +693,7 @@ function createAgentAdapter( clientTools: clientToolsCap, submit: async (input: AgentSubmitInput, opts?: AgUiSubmitOptions) => { - if (!hydrated) await ready; + if (!hydrated()) await ready; assertAvailable(); if (input.resume !== undefined) { if (opts?.interruptGeneration !== undefined && opts.interruptGeneration !== interrupts.snapshot.generation) { @@ -736,7 +737,7 @@ function createAgentAdapter( }, retry: async () => { - if (!hydrated) await ready; + if (!hydrated()) await ready; assertAvailable(); if (interrupts.snapshot.attempt) { const attempt = interrupts.retry(); @@ -778,7 +779,7 @@ function createAgentAdapter( }, regenerate: async (assistantMessageIndex: number): Promise => { - if (!hydrated) await ready; + if (!hydrated()) await ready; assertNoInterrupt(); if (store.isLoading()) { throw new Error('Cannot regenerate while agent is loading another response'); diff --git a/libs/chat/src/lib/agent/agent.ts b/libs/chat/src/lib/agent/agent.ts index ec3fd2ae1..63f02c4e7 100644 --- a/libs/chat/src/lib/agent/agent.ts +++ b/libs/chat/src/lib/agent/agent.ts @@ -28,6 +28,8 @@ export interface Agent { messages: Signal; status: Signal; isLoading: Signal; + /** Optional gate for ordinary composer input while the runtime requires resolution or recovery. Resume actions remain adapter-controlled. */ + isInputBlocked?: Signal; error: Signal; toolCalls: Signal; state: Signal; diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts index aee63817a..6dec0eab4 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -282,7 +282,7 @@ export function isPinned( (sig: unknown, value: T): void { } describe('submitMessage()', () => { + it('does not dispatch ordinary input when the adapter blocks it', () => { + const agent = mockAgent(); + const blocked = { ...agent, isInputBlocked: signal(true) }; + expect(submitMessage(blocked, 'keep my draft')).toBeNull(); + expect(agent.submitCalls).toHaveLength(0); + }); it('calls agent.submit with { message: trimmed text }', async () => { const agent = mockAgent(); @@ -102,6 +108,22 @@ describe('ChatInputComponent', () => { fixture.detectChanges(); }); + it('disables Send and preserves the draft on Enter until the adapter releases input', () => { + const agent = mockAgent(); + const blocked = signal(true); + setSignalInput(fixture.componentInstance.agent, { ...agent, isInputBlocked: blocked }); + fixture.componentInstance.messageText.set('keep my draft'); + fixture.detectChanges(); + const button = fixture.nativeElement.querySelector('button[aria-label="Send message"]') as HTMLButtonElement; + expect(button.disabled).toBe(true); + fixture.componentInstance.onKeydown(new KeyboardEvent('keydown', { key: 'Enter' })); + expect(agent.submitCalls).toHaveLength(0); + expect(fixture.componentInstance.messageText()).toBe('keep my draft'); + blocked.set(false); + fixture.detectChanges(); + expect(button.disabled).toBe(false); + }); + it('renders the pill with full border-radius', () => { const pill = (fixture.nativeElement as HTMLElement).querySelector('.chat-input__pill') as HTMLElement; expect(pill).not.toBeNull(); diff --git a/libs/chat/src/lib/primitives/chat-input/chat-input.component.ts b/libs/chat/src/lib/primitives/chat-input/chat-input.component.ts index 82252ed84..ccace6bd8 100644 --- a/libs/chat/src/lib/primitives/chat-input/chat-input.component.ts +++ b/libs/chat/src/lib/primitives/chat-input/chat-input.component.ts @@ -16,14 +16,14 @@ import { CHAT_INPUT_STYLES } from '../../styles/chat-input.styles'; /** * Submits a trimmed message to the agent. - * Returns the trimmed string on success, or `null` if the input was empty. + * Returns the trimmed string on dispatch, or `null` for empty or blocked input. */ export function submitMessage( agent: Agent, text: string, ): string | null { const trimmed = text.trim(); - if (!trimmed) return null; + if (!trimmed || agent.isInputBlocked?.()) return null; void agent.submit({ message: trimmed }); return trimmed; } @@ -106,7 +106,7 @@ export class ChatInputComponent { /** Submit is allowed only when not loading and there's non-whitespace text. */ readonly canSubmit = computed(() => { - if (this.isLoading()) return false; + if (this.isLoading() || this.agent().isInputBlocked?.()) return false; return this.messageText().trim().length > 0; }); From 5cea5604b0ff31a93a5b83342acd74e243a55936 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 8 Sep 2026 13:53:23 -0700 Subject: [PATCH 3/6] fix(ag-ui): preserve interrupt types in non-strict consumers --- libs/ag-ui/src/lib/interrupt-session.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/libs/ag-ui/src/lib/interrupt-session.ts b/libs/ag-ui/src/lib/interrupt-session.ts index 19fa2e659..2515d9527 100644 --- a/libs/ag-ui/src/lib/interrupt-session.ts +++ b/libs/ag-ui/src/lib/interrupt-session.ts @@ -13,9 +13,13 @@ export class InterruptSession { observeNative(entries: unknown[], runId?: string): void { if (entries.length === 0) throw new Error('Native interrupt outcome must contain interrupts'); if (this.isLateObservation(runId)) return; - const parsed = entries.map(entry => InterruptSchema.passthrough().parse( - isRecord(entry) && entry['reason'] === undefined ? { ...entry, reason: '' } : entry, - )); + const parsed = entries.map(entry => { + const normalized = isRecord(entry) && entry['reason'] === undefined ? { ...entry, reason: '' } : entry; + // Keep the SDK's declared output type across consumer strictness settings. + // Preserve extensions separately instead of re-inferring a passthrough schema. + const validated = InterruptSchema.parse(normalized); + return { ...(isRecord(normalized) ? normalized : {}), ...validated }; + }); if (new Set(parsed.map(entry => entry.id)).size !== parsed.length) throw new Error('Duplicate interrupt id'); this.beginObservation(runId); const merged = new Map(this.state.interrupts.map(entry => [entry.id, entry])); From 800c594346d9955d5ca410f6ff6b8f479c6f7e5c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 8 Sep 2026 14:25:39 -0700 Subject: [PATCH 4/6] fix(ag-ui): require SDK interrupt schema support --- apps/website/content/docs/ag-ui/guides/interrupts.mdx | 2 ++ examples/chat/smoke/template/package.json | 4 ++-- libs/ag-ui/package.json | 4 ++-- package-lock.json | 4 ++-- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/website/content/docs/ag-ui/guides/interrupts.mdx b/apps/website/content/docs/ag-ui/guides/interrupts.mdx index 7b978f5d0..274b46f05 100644 --- a/apps/website/content/docs/ag-ui/guides/interrupts.mdx +++ b/apps/website/content/docs/ag-ui/guides/interrupts.mdx @@ -174,6 +174,8 @@ Providers dispose the adapter with their injector. If you create it with `toAgen ### Migration note +Install `@ag-ui/client` and `@ag-ui/core` at version `0.0.59`. The adapter now declares `^0.0.59` peer dependencies because native interrupt validation uses the protocol schemas provided by that SDK version. + Mixed native and compatibility events now select native transport by default regardless of arrival order. Backends requiring a command format must select the matching explicit profile. Replace bare resume calls with a restored or observed pending batch, answer every native ID, and use `retry()` for a retained decision after a proven pre-dispatch failure. Recovery from an uncertain attempt requires application-provided authoritative reconciliation. ## Cross-adapter parity diff --git a/examples/chat/smoke/template/package.json b/examples/chat/smoke/template/package.json index 64413bd1f..062fe2238 100644 --- a/examples/chat/smoke/template/package.json +++ b/examples/chat/smoke/template/package.json @@ -28,8 +28,8 @@ "@cacheplane/partial-json": "^0.2.0", "@langchain/core": "^1.1.33", "@langchain/langgraph-sdk": "^1.7.4", - "@ag-ui/client": "^0.0.52", - "@ag-ui/core": "^0.0.52", + "@ag-ui/client": "^0.0.59", + "@ag-ui/core": "^0.0.59", "@json-render/core": "^0.16.0", "katex": "^0.17.0", "marked": "^16.0.0", diff --git a/libs/ag-ui/package.json b/libs/ag-ui/package.json index 6c8486405..2abb9d314 100644 --- a/libs/ag-ui/package.json +++ b/libs/ag-ui/package.json @@ -13,8 +13,8 @@ "@threadplane/chat": "0.1.0", "@threadplane/telemetry": "^0.1.0", "@angular/core": "^20.0.0 || ^21.0.0 || ^22.0.0", - "@ag-ui/client": "*", - "@ag-ui/core": "*", + "@ag-ui/client": "^0.0.59", + "@ag-ui/core": "^0.0.59", "rxjs": "~7.8.0" }, "license": "MIT", diff --git a/package-lock.json b/package-lock.json index 921005bcc..0f1a50ec7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -410,8 +410,8 @@ "hasInstallScript": true, "license": "MIT", "peerDependencies": { - "@ag-ui/client": "*", - "@ag-ui/core": "*", + "@ag-ui/client": "^0.0.59", + "@ag-ui/core": "^0.0.59", "@angular/core": "^20.0.0 || ^21.0.0 || ^22.0.0", "@threadplane/chat": "0.1.0", "@threadplane/telemetry": "^0.1.0", From 8bb7539d5678edfc9d08b989a17b0dda8464b429 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 8 Sep 2026 14:44:24 -0700 Subject: [PATCH 5/6] fix(smoke): load compatibility probes outside the initial bundle --- examples/chat/smoke/template/src/main.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/chat/smoke/template/src/main.ts b/examples/chat/smoke/template/src/main.ts index b7371a7ba..8c4f17e6b 100644 --- a/examples/chat/smoke/template/src/main.ts +++ b/examples/chat/smoke/template/src/main.ts @@ -1,9 +1,10 @@ import { bootstrapApplication } from '@angular/platform-browser'; import { appConfig } from './app/app.config'; import { App } from './app/app'; -import { bootstrapCompatibilityProbe } from './compatibility-probe'; Promise.all([ bootstrapApplication(App, appConfig), - bootstrapCompatibilityProbe(), + import('./compatibility-probe').then(({ bootstrapCompatibilityProbe }) => + bootstrapCompatibilityProbe() + ), ]).catch((err) => console.error(err)); From bb3fab906a0dccb6371abda432fac75710509298 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 8 Sep 2026 15:35:21 -0700 Subject: [PATCH 6/6] fix(mastra): migrate approval details to interrupt sessions --- .../docs/runtimes/mastra/how-it-connects.mdx | 6 +++--- .../content/docs/runtimes/mastra/overview.mdx | 2 +- .../runtimes/mastra/angular/src/app/app.config.ts | 1 + .../mastra/angular/src/app/mastra.component.ts | 13 +++++++------ 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/apps/website/content/docs/runtimes/mastra/how-it-connects.mdx b/apps/website/content/docs/runtimes/mastra/how-it-connects.mdx index 42e1eab6d..4eec5993f 100644 --- a/apps/website/content/docs/runtimes/mastra/how-it-connects.mdx +++ b/apps/website/content/docs/runtimes/mastra/how-it-connects.mdx @@ -18,13 +18,13 @@ This page records the AG-UI wire behavior measured for Mastra on 2026-08-31. It The service refuses to boot without `AG_UI_INTERNAL_TOKEN`, rejects any non-health route without a matching header, and maps an error from the underlying observable to a `RUN_ERROR` frame rather than dropping the socket. Any route other than `/ok` that arrives without a matching header returns `401 {"detail":"unauthorized"}`; with a valid token, an unknown path or method returns `404 {"detail":"not found"}` and an unregistered topic returns `404 {"detail":"no such topic: "}`. -In your own application this is still an ordinary `provideAgent({ url: '/agent' })`; the example in this repository passes a factory only because it resolves its endpoint at runtime. The token is injected by the dev proxy in front of the app, never by the browser. +In your own application this is still an ordinary `provideAgent({ url: '/agent', interruptTransport: 'mastra-command' })`; the example in this repository passes a factory only because it resolves its endpoint at runtime. The token is injected by the dev proxy in front of the app, never by the browser. ## Interrupts arrive by both conventions Mastra is the only measured runtime that signals an interrupt twice: it emits a `CUSTOM` event named `on_interrupt` **and** finishes the run with the protocol-standard interrupt outcome. -AWS Strands and Microsoft Agent Framework emit only the outcome. The LangGraph bridge emits only `on_interrupt`. The adapter accepts either, and within a single run the first signal it sees wins, so a runtime that emits both is handled without special-casing. +AWS Strands and Microsoft Agent Framework emit only the outcome. The LangGraph bridge emits only `on_interrupt`. The adapter retains both forms in one session. The native batch drives the display projection, while `interruptSession().legacy?.value` preserves the Mastra suspend details used by the approval card. ## Resume uses interruptEvent @@ -40,7 +40,7 @@ Mastra reads resume data from `forwardedProps.command.interruptEvent`, carrying } ``` -That is a third distinct shape. Strands and Microsoft Agent Framework read a top-level `resume` array; the LangGraph bridge reads `forwardedProps.command.resume`. Application code passes one neutral `submit({ resume })` and the adapter derives the wire shape from how the interrupt arrived. +That is a third distinct shape. Strands and Microsoft Agent Framework read a top-level `resume` array; the LangGraph bridge reads `forwardedProps.command.resume`. Application code passes one neutral `submit({ resume })`. The Mastra provider selects `interruptTransport: 'mastra-command'` explicitly because this backend emits both compatibility and native interrupts, and `auto` otherwise selects the native wire shape. ## Suspend and resume require persistent storage diff --git a/apps/website/content/docs/runtimes/mastra/overview.mdx b/apps/website/content/docs/runtimes/mastra/overview.mdx index 7b76e9f4d..e9ec0a4ee 100644 --- a/apps/website/content/docs/runtimes/mastra/overview.mdx +++ b/apps/website/content/docs/runtimes/mastra/overview.mdx @@ -113,7 +113,7 @@ The class supplies that payload and maps the two actions onto resume calls. -`agent.interrupt()` carries the parsed `CUSTOM on_interrupt` payload as its `value`, and because that payload carries a tool-call id, `submit({ resume })` goes back out as `forwardedProps.command = { resume, interruptEvent: { toolCallId, runId } }` — the shape the Mastra bridge reads to reopen the suspended run. +`agent.interruptSession().legacy?.value` carries the parsed `CUSTOM on_interrupt` payload alongside the native interrupt batch. The provider selects `interruptTransport: 'mastra-command'`, so `submit({ resume })` goes back out as `forwardedProps.command = { resume, interruptEvent: { toolCallId, runId } }` — the shape the Mastra bridge reads to reopen the suspended run. ## What the integration demonstrates diff --git a/cockpit/runtimes/mastra/angular/src/app/app.config.ts b/cockpit/runtimes/mastra/angular/src/app/app.config.ts index 0d04381b6..6596d6782 100644 --- a/cockpit/runtimes/mastra/angular/src/app/app.config.ts +++ b/cockpit/runtimes/mastra/angular/src/app/app.config.ts @@ -11,6 +11,7 @@ export const appConfig: ApplicationConfig = { } return { url: connection.url, + interruptTransport: 'mastra-command', }; }), ], diff --git a/cockpit/runtimes/mastra/angular/src/app/mastra.component.ts b/cockpit/runtimes/mastra/angular/src/app/mastra.component.ts index d43749f19..ac5560c4c 100644 --- a/cockpit/runtimes/mastra/angular/src/app/mastra.component.ts +++ b/cockpit/runtimes/mastra/angular/src/app/mastra.component.ts @@ -50,8 +50,9 @@ interface PackingList { * packing list. * - A suspended tool (`reserve_campsite`) surfaces BOTH interrupt * conventions: CUSTOM `on_interrupt` (payload carrying toolCallId + runId) - * and the protocol-standard RUN_FINISHED interrupt outcome. The reducer's - * first-signal-wins rule keeps the Mastra-shaped payload, so + * and the protocol-standard RUN_FINISHED interrupt outcome. The session + * retains the Mastra payload alongside the native batch. The explicit + * `mastra-command` transport in app.config.ts ensures * `submit({ resume })` goes out as * `forwardedProps.command = { resume, interruptEvent: { toolCallId, runId } }` * — exactly what the Mastra bridge requires to resume the suspended run. @@ -251,13 +252,13 @@ export class MastraComponent { // #region approval-actions /** - * The pending Mastra suspend. The reducer stores the parsed CUSTOM + * The pending Mastra suspend. The session retains the parsed CUSTOM * `on_interrupt` payload: `{ type: 'mastra_suspend', toolCallId, toolName, - * suspendPayload, args, resumeSchema, runId }` (first-signal-wins over the - * RUN_FINISHED outcome that follows it on the wire). + * suspendPayload, args, resumeSchema, runId }` alongside the native + * RUN_FINISHED outcome that follows it on the wire. */ private readonly suspendValue = computed(() => { - return this.agent.interrupt?.()?.value as + return this.agent.interruptSession().legacy?.value as | { toolName?: string; suspendPayload?: { site?: string; nights?: number; total_usd?: number } } | undefined; });