Skip to content

Commit 01e7129

Browse files
bloveclaude
andcommitted
fix(ag-ui): send protocol-standard top-level resume for outcome interrupts
submit({ resume }) now branches on how the pending interrupt arrived: - RUN_FINISHED interrupt outcome (AWS Strands, Microsoft Agent Framework): the protocol-standard TOP-LEVEL resume array — one { interruptId, status: 'resolved', payload } entry per pending interrupt — which @ag-ui/client@0.0.59 now serializes (0.0.52's prepareRunAgentInput dropped it at assembly; Strands reads ONLY this location, so its resume was unsendable before this upgrade). Byte-equal to the measured working Strands request (new fixture strands-resume.request.json, the spike's interrupt_phase2_resume capture). The Microsoft bridge reads the top-level field FIRST (_extract_resume_payload checks input.resume before forwardedProps.command.resume) and accepts interruptId keys, so its measured entries ride the standard location with only the id -> interruptId key rename. Caller-authored entry arrays are normalized to ResumeEntry and sent top-level. - CUSTOM on_interrupt (LangGraph opaque payload, Mastra toolCallId/runId): forwardedProps shapes stay byte-for-byte unchanged, reproduced against the measured captures. 0.0.59 also records RUN_FINISHED interrupt outcomes on source.pendingInterrupts and refuses the next run unless resume entries address every pending id. The adapter clears that ledger whenever the run carries no resume entries — Mastra resumes through forwardedProps despite emitting the outcome (double-signal), and a plain submit that abandons an interrupt keeps its pre-0.0.59 semantics. The reducer keeps its tolerant hand-rolled outcome view rather than adopting the strict InterruptSchema / RunFinishedInterruptOutcomeSchema: a strict parse would drop near-miss entries the contract preserves verbatim (documented inline). New wire-level spec drives the REAL @ag-ui/client HttpAgent with a mocked fetch, replays the captured interrupt transcripts, and asserts the serialized resume request bodies against the measured working requests — full byte-equality for Strands except two documented volatile fields (client-minted runId; messages resent from the interrupt run's MESSAGES_SNAPSHOT where the spike driver resent only the user message, whose byte-equality IS asserted). All Step 1 transcript-replay fixtures pass on 0.0.59 unmodified. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 3a796fa commit 01e7129

5 files changed

Lines changed: 409 additions & 79 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"threadId": "th-int-555800",
3+
"runId": "run-2",
4+
"messages": [
5+
{
6+
"id": "u-b176b0c0",
7+
"role": "user",
8+
"content": "Schedule a meeting with Dana about the Q3 roadmap."
9+
}
10+
],
11+
"tools": [],
12+
"context": [],
13+
"state": {},
14+
"forwardedProps": {},
15+
"resume": [
16+
{
17+
"interruptId": "v1:tool_call:call_A9ckGX1LrvO82OhqZinzDsom:340a4daa-b874-5aad-8309-a63b92d507dd",
18+
"status": "resolved",
19+
"payload": {
20+
"chosen_label": "Tuesday 10:00"
21+
}
22+
}
23+
]
24+
}

libs/ag-ui/src/lib/reducer.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -521,8 +521,14 @@ function randomId(): string {
521521
return Math.random().toString(36).slice(2);
522522
}
523523

524-
/** Loosely-typed RUN_FINISHED outcome (protocol-standard since AG-UI added
525-
* run outcomes; @ag-ui/client@0.0.52 parses it through intact). */
524+
/** Loosely-typed RUN_FINISHED outcome. @ag-ui/core@0.0.59 ships the strict
525+
* RunFinishedInterruptOutcomeSchema / InterruptSchema for this shape, but
526+
* the reducer deliberately keeps this tolerant hand-rolled view: a strict
527+
* parse would silently DROP an interrupt whose entries deviate from the
528+
* schema (extra keys on the strict outcome object, a missing `reason`),
529+
* while the contract here is to preserve every entry verbatim under
530+
* `value.interrupts` for resume to address. Validation strictness would be
531+
* a behavior change, not a simplification. */
526532
interface RunFinishedOutcome {
527533
type?: string;
528534
interrupts?: unknown;
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
// SPDX-License-Identifier: MIT
2+
//
3+
// Wire-level resume tests against the REAL @ag-ui/client HttpAgent (0.0.59).
4+
//
5+
// The stub-based specs in to-agent.resume.spec.ts assert what the adapter
6+
// hands to runAgent(); these specs assert what actually leaves over HTTP —
7+
// prepareRunAgentInput assembly included. That distinction is the whole
8+
// story of this upgrade: on 0.0.52 prepareRunAgentInput dropped a top-level
9+
// `resume` at assembly, so the parameter-level shape never reached the wire.
10+
//
11+
// Each test replays a REAL captured interrupt transcript (fixtures/
12+
// runtime-transcripts/, 2026-08-31 runtime-portability spikes) through the
13+
// real client, submits a resume, and compares the serialized request body
14+
// against the request MEASURED to work for that runtime. Fixture payloads
15+
// are verbatim from the wire — do not edit them.
16+
//
17+
// Byte-equality caveats (each documented at the assertion):
18+
// - `runId` is minted per run by the client (uuid v4); the spike drivers
19+
// used fixed ids. Excluded from equality, asserted to be a string.
20+
// - `messages` are the client's accumulated thread (set by the interrupt
21+
// run's MESSAGES_SNAPSHOT), so the resume request legitimately resends the
22+
// assistant tool-call message too; the spike drivers resent only the user
23+
// message. The user message is asserted byte-equal (its id is
24+
// deterministic — the snapshot assigned it); the array as a whole is not.
25+
// Every other top-level field is compared byte-for-byte.
26+
import { describe, it, expect, vi } from 'vitest';
27+
import { readFileSync } from 'node:fs';
28+
import { join } from 'node:path';
29+
import { HttpAgent } from '@ag-ui/client';
30+
import { toAgent } from './to-agent';
31+
32+
const FIXTURES_DIR = join(__dirname, '../../fixtures/runtime-transcripts');
33+
34+
/** Build a text/event-stream Response from a fixture's `data:` lines
35+
* (non-data lines, such as maf-hitl-resume.sse's `__request__` header line,
36+
* are capture metadata and not part of the SSE stream).
37+
*
38+
* The events' TOP-LEVEL `threadId`/`runId` are re-stamped with the ids of
39+
* the request being answered — exactly what the real servers do (they echo
40+
* the ids the client sent; the spike drivers picked their own run ids,
41+
* while the 0.0.59 client mints a uuid per run). Payloads are otherwise
42+
* verbatim from the capture. */
43+
function sseResponseFromFixture(name: string, request: Record<string, unknown>): Response {
44+
const raw = readFileSync(join(FIXTURES_DIR, name), 'utf8');
45+
const body = raw
46+
.split('\n')
47+
.filter((line) => line.startsWith('data:'))
48+
.map((line) => {
49+
const event = JSON.parse(line.slice('data:'.length)) as Record<string, unknown>;
50+
if ('threadId' in event) event['threadId'] = request['threadId'];
51+
if ('runId' in event) event['runId'] = request['runId'];
52+
return `data: ${JSON.stringify(event)}`;
53+
})
54+
.join('\n\n') + '\n\n';
55+
return new Response(body, {
56+
status: 200,
57+
headers: { 'content-type': 'text/event-stream' },
58+
});
59+
}
60+
61+
/** SYNTHETIC minimal success run for the resume response — these tests are
62+
* about the outgoing resume REQUEST; the response just has to be a valid
63+
* stream so the run settles. */
64+
function syntheticSuccessResponse(request: Record<string, unknown>): Response {
65+
const { threadId, runId } = request;
66+
const body = [
67+
`data: ${JSON.stringify({ type: 'RUN_STARTED', threadId, runId })}`,
68+
`data: ${JSON.stringify({ type: 'RUN_FINISHED', threadId, runId })}`,
69+
].join('\n\n') + '\n\n';
70+
return new Response(body, {
71+
status: 200,
72+
headers: { 'content-type': 'text/event-stream' },
73+
});
74+
}
75+
76+
function readCapturedRequest(name: string): Record<string, unknown> {
77+
const raw = readFileSync(join(FIXTURES_DIR, name), 'utf8');
78+
if (name.endsWith('.request.json')) return JSON.parse(raw) as Record<string, unknown>;
79+
const firstLine = raw.split('\n', 1)[0];
80+
return (JSON.parse(firstLine) as { __request__: Record<string, unknown> }).__request__;
81+
}
82+
83+
interface WireHarness {
84+
agent: ReturnType<typeof toAgent>;
85+
source: HttpAgent;
86+
bodies: () => Array<Record<string, unknown>>;
87+
}
88+
89+
/** Real HttpAgent whose fetch is fed queued fixture responses; every request
90+
* body is recorded for wire-shape assertions. */
91+
function wireHarness(
92+
threadId: string,
93+
responses: Array<(request: Record<string, unknown>) => Response>,
94+
): WireHarness {
95+
const bodies: Array<Record<string, unknown>> = [];
96+
let call = 0;
97+
const fetchMock = vi.fn(async (_url: unknown, init?: { body?: unknown }) => {
98+
const request = JSON.parse(String(init?.body)) as Record<string, unknown>;
99+
bodies.push(request);
100+
const next = responses[call] ?? responses[responses.length - 1];
101+
call += 1;
102+
return next(request);
103+
});
104+
const source = new HttpAgent({
105+
url: 'http://spike.invalid/agent',
106+
threadId,
107+
fetch: fetchMock as unknown as ConstructorParameters<typeof HttpAgent>[0]['fetch'],
108+
});
109+
return { agent: toAgent(source), source, bodies: () => bodies };
110+
}
111+
112+
describe('AWS Strands resume over the wire (0.0.59 top-level resume array)', () => {
113+
it('serializes the measured working request', async () => {
114+
const { agent, source, bodies } = wireHarness('th-int-555800', [
115+
(request) => sseResponseFromFixture('strands-interrupt.sse', request),
116+
(request) => syntheticSuccessResponse(request),
117+
]);
118+
119+
await agent.submit({ message: 'Schedule a meeting with Dana about the Q3 roadmap.' });
120+
expect(agent.interrupt!()).toBeDefined();
121+
// 0.0.59 client-side ledger recorded the RUN_FINISHED interrupt outcome.
122+
expect(source.pendingInterrupts).toHaveLength(1);
123+
124+
await agent.submit({ resume: { chosen_label: 'Tuesday 10:00' } });
125+
expect(agent.error()).toBeUndefined();
126+
127+
const measured = readCapturedRequest('strands-resume.request.json');
128+
expect(bodies()).toHaveLength(2);
129+
const body = bodies()[1];
130+
131+
// Byte-equality on everything except the documented volatile fields.
132+
const { runId: measuredRunId, messages: measuredMessages, ...measuredRest } = measured;
133+
const { runId, messages, ...rest } = body as {
134+
runId: unknown; messages: Array<Record<string, unknown>>;
135+
} & Record<string, unknown>;
136+
expect(rest).toEqual(measuredRest);
137+
// runId: client-minted uuid per run; the spike driver sent 'run-2'.
138+
expect(typeof runId).toBe('string');
139+
expect(runId).not.toBe(measuredRunId);
140+
// messages[0]: the user message, byte-equal — id included, because the
141+
// interrupt run's MESSAGES_SNAPSHOT assigned it deterministically. The
142+
// client also resends the snapshot's assistant tool-call message, which
143+
// the spike driver omitted; Strands keys resume on the top-level resume
144+
// array (measured), not on the resent messages.
145+
expect(messages[0]).toEqual((measuredMessages as unknown[])[0]);
146+
});
147+
148+
it('lets a plain submit abandon the interrupt without tripping the 0.0.59 pending-interrupt gate', async () => {
149+
const { agent, source, bodies } = wireHarness('th-int-555800', [
150+
(request) => sseResponseFromFixture('strands-interrupt.sse', request),
151+
(request) => syntheticSuccessResponse(request),
152+
]);
153+
154+
await agent.submit({ message: 'Schedule a meeting with Dana about the Q3 roadmap.' });
155+
expect(source.pendingInterrupts).toHaveLength(1);
156+
157+
// Pre-0.0.59 semantics: a plain message after an interrupt just runs.
158+
// Without the adapter clearing the ledger, 0.0.59's onInitialize throws
159+
// AGUIError before any request is sent.
160+
await agent.submit({ message: 'Never mind, cancel that.' });
161+
expect(agent.error()).toBeUndefined();
162+
expect(bodies()).toHaveLength(2);
163+
expect(bodies()[1]['resume']).toBeUndefined();
164+
});
165+
});
166+
167+
describe('Microsoft Agent Framework resume over the wire', () => {
168+
it('serializes top-level resume entries addressing the measured pending interrupt', async () => {
169+
const { agent, source, bodies } = wireHarness('thread-06-hitl-interrupt', [
170+
(request) => sseResponseFromFixture('maf-hitl-interrupt.sse', request),
171+
(request) => syntheticSuccessResponse(request),
172+
]);
173+
174+
await agent.submit({ message: 'Plan the task: build a birdhouse.' });
175+
expect(agent.interrupt!()).toBeDefined();
176+
expect(source.pendingInterrupts).toHaveLength(1);
177+
178+
await agent.submit({ resume: { approved: true } });
179+
expect(agent.error()).toBeUndefined();
180+
181+
expect(bodies()).toHaveLength(2);
182+
const body = bodies()[1];
183+
const measured = readCapturedRequest('maf-hitl-resume.sse');
184+
185+
// The measured working request carried the entries under the
186+
// pre-standard forwardedProps.command.resume location keyed `id`. The
187+
// bridge reads the protocol-standard top-level field FIRST
188+
// (_extract_resume_payload checks input.resume before
189+
// forwardedProps.command.resume) and accepts `interruptId`, so the wire
190+
// moves to the standard location; identity, status, and payload are
191+
// asserted against the measured entries.
192+
const measuredEntries = (
193+
measured['forwardedProps'] as { command: { resume: Array<Record<string, unknown>> } }
194+
).command.resume;
195+
expect(body['resume']).toEqual(
196+
measuredEntries.map(({ id, ...restEntry }) => ({ interruptId: id, ...restEntry })),
197+
);
198+
expect(body['forwardedProps']).toEqual({});
199+
expect(body['threadId']).toBe(measured['threadId']);
200+
});
201+
});
202+
203+
describe('Mastra resume over the wire (forwardedProps shape preserved)', () => {
204+
// Mastra emits BOTH the CUSTOM on_interrupt convention (first) and the
205+
// RUN_FINISHED interrupt outcome — so on 0.0.59 the client ledger records
206+
// a pending interrupt even though the measured working resume rides
207+
// forwardedProps.command with NO top-level resume. The adapter must clear
208+
// the ledger and reproduce the 0.0.52-measured request byte-for-byte.
209+
it('reproduces the measured forwardedProps request with no top-level resume', async () => {
210+
const { agent, source, bodies } = wireHarness('thread-hitl-1', [
211+
(request) => sseResponseFromFixture('mastra-reinterrupt.sse', request),
212+
(request) => syntheticSuccessResponse(request),
213+
]);
214+
215+
await agent.submit({ message: 'Schedule a meeting with Dana about the Q4 roadmap.' });
216+
expect(agent.interrupt!()).toBeDefined();
217+
expect(source.pendingInterrupts).toHaveLength(1);
218+
219+
await agent.submit({ resume: { chosen_time: '2026-09-01T10:00' } });
220+
expect(agent.error()).toBeUndefined();
221+
222+
expect(bodies()).toHaveLength(2);
223+
const body = bodies()[1];
224+
const measured = readCapturedRequest('mastra-resume-correct.request.json');
225+
expect(body['forwardedProps']).toEqual(measured['forwardedProps']);
226+
expect(body['resume']).toBeUndefined();
227+
expect(body['threadId']).toBe(measured['threadId']);
228+
});
229+
});

0 commit comments

Comments
 (0)