Skip to content

Commit 3bd5557

Browse files
bloveclaude
andcommitted
fix(ag-ui): carry interrupt identity on the resume payload
submit({ resume }) always sent the LangGraph shape, forwardedProps.command.resume = <value>, regardless of what the inbound interrupt carried. Measured against the 2026-08-31 runtime spikes: Mastra requires command.interruptEvent{toolCallId,runId} alongside the resume value (it re-interrupts otherwise), and Microsoft Agent Framework wants one structured { id, status, payload } entry per pending interrupt. The outgoing forwardedProps is now keyed on the identifying fields the pending interrupt carried in: - opaque on_interrupt payload (LangGraph): { command: { resume } }, byte-for-byte unchanged — existing wire-shape specs pass unmodified; - on_interrupt payload with toolCallId/runId (Mastra): command.interruptEvent added — reproduces the measured working request; - RUN_FINISHED interrupt outcome (Microsoft Agent Framework, AWS Strands): command.resume becomes [{ id, status: 'resolved', payload }] entries addressing every pending interrupt — reproduces the measured working request; caller-authored entry arrays pass through untouched. Adopting the protocol-standard TOP-LEVEL resume array is an explicit follow-up: RunAgentInputSchema@0.0.52 has no such field, while forwardedProps is a schema field the runtimes read today. Tests replay the captured inbound interrupt transcripts through the adapter and assert the outgoing forwardedProps deep-equal to the captured requests measured to work (fixtures: mastra-reinterrupt.sse + mastra-resume-correct.request.json, maf-hitl-resume.sse __request__ line). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 9f22e92 commit 3bd5557

5 files changed

Lines changed: 349 additions & 3 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{"__request__": {"threadId": "thread-06-hitl-interrupt", "runId": "4df61fb0-a261-42fd-b4f8-071ead4f8f3b", "messages": [{"id": "4b79402e-e291-42bb-be55-ccacb46ddb38", "role": "user", "content": "Plan the task: build a birdhouse."}, {"id": "1ac8f18f-4d6c-4cc8-8165-5ad820c437b8", "role": "assistant", "toolCalls": [{"id": "call_VlNsrwdW5hhp2G8Ufp6i8ueQ", "type": "function", "function": {"name": "generate_task_steps", "arguments": "{\"steps\":[{\"description\":\"Choose design\",\"status\":\"enabled\"},{\"description\":\"Gather materials\",\"status\":\"enabled\"},{\"description\":\"Cut wood pieces\",\"status\":\"enabled\"},{\"description\":\"Assemble walls\",\"status\":\"enabled\"},{\"description\":\"Attach base\",\"status\":\"enabled\"},{\"description\":\"Add roof\",\"status\":\"enabled\"},{\"description\":\"Drill entrance hole\",\"status\":\"enabled\"},{\"description\":\"Sand surfaces\",\"status\":\"enabled\"},{\"description\":\"Paint or stain\",\"status\":\"enabled\"},{\"description\":\"Install hangers or hooks\",\"status\":\"enabled\"}]}"}}, {"id": "171b2e37-ad9b-4e41-9cfe-d051602195d5", "type": "function", "function": {"name": "confirm_changes", "arguments": "{\"function_name\": \"generate_task_steps\", \"function_call_id\": \"call_VlNsrwdW5hhp2G8Ufp6i8ueQ\", \"function_arguments\": {\"steps\": [{\"description\": \"Choose design\", \"status\": \"enabled\"}, {\"description\": \"Gather materials\", \"status\": \"enabled\"}, {\"description\": \"Cut wood pieces\", \"status\": \"enabled\"}, {\"description\": \"Assemble walls\", \"status\": \"enabled\"}, {\"description\": \"Attach base\", \"status\": \"enabled\"}, {\"description\": \"Add roof\", \"status\": \"enabled\"}, {\"description\": \"Drill entrance hole\", \"status\": \"enabled\"}, {\"description\": \"Sand surfaces\", \"status\": \"enabled\"}, {\"description\": \"Paint or stain\", \"status\": \"enabled\"}, {\"description\": \"Install hangers or hooks\", \"status\": \"enabled\"}]}, \"steps\": [{\"description\": \"Execute generate_task_steps\", \"status\": \"enabled\"}]}"}}]}], "tools": [], "context": [], "state": {}, "forwardedProps": {"command": {"resume": [{"id": "call_VlNsrwdW5hhp2G8Ufp6i8ueQ", "status": "resolved", "payload": {"approved": true}}]}}}}
2+
3+
data: {"type":"RUN_STARTED","threadId":"thread-06-hitl-interrupt","runId":"4df61fb0-a261-42fd-b4f8-071ead4f8f3b"}
4+
5+
data: {"type":"TOOL_CALL_RESULT","messageId":"4a3b0279-af4c-471a-8372-e782124b1493","toolCallId":"call_VlNsrwdW5hhp2G8Ufp6i8ueQ","content":"Generated 10 execution steps for the task.","role":"tool"}
6+
7+
data: {"type":"TEXT_MESSAGE_START","messageId":"284fd5f2-2168-489d-b6a7-b31253529dae","role":"assistant"}
8+
9+
data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"284fd5f2-2168-489d-b6a7-b31253529dae","delta":"The"}
10+
11+
data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"284fd5f2-2168-489d-b6a7-b31253529dae","delta":" plan"}
12+
13+
data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"284fd5f2-2168-489d-b6a7-b31253529dae","delta":" has"}
14+
15+
data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"284fd5f2-2168-489d-b6a7-b31253529dae","delta":" been"}
16+
17+
data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"284fd5f2-2168-489d-b6a7-b31253529dae","delta":" created"}
18+
19+
data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"284fd5f2-2168-489d-b6a7-b31253529dae","delta":" with"}
20+
21+
data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"284fd5f2-2168-489d-b6a7-b31253529dae","delta":" "}
22+
23+
data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"284fd5f2-2168-489d-b6a7-b31253529dae","delta":"10"}
24+
25+
data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"284fd5f2-2168-489d-b6a7-b31253529dae","delta":" steps"}
26+
27+
data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"284fd5f2-2168-489d-b6a7-b31253529dae","delta":" selected"}
28+
29+
data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"284fd5f2-2168-489d-b6a7-b31253529dae","delta":"."}
30+
31+
data: {"type":"CUSTOM","name":"usage","value":{"input_token_count":521,"output_token_count":12,"total_token_count":533,"completion/reasoning_tokens":0,"reasoning_output_token_count":0,"prompt/cached_tokens":0,"cache_read_input_token_count":0}}
32+
33+
data: {"type":"TEXT_MESSAGE_END","messageId":"284fd5f2-2168-489d-b6a7-b31253529dae"}
34+
35+
data: {"type":"MESSAGES_SNAPSHOT","messages":[{"id":"4b79402e-e291-42bb-be55-ccacb46ddb38","role":"user","content":"Plan the task: build a birdhouse."},{"id":"1ac8f18f-4d6c-4cc8-8165-5ad820c437b8","role":"assistant","content":"","toolCalls":[{"id":"call_VlNsrwdW5hhp2G8Ufp6i8ueQ","type":"function","function":{"name":"generate_task_steps","arguments":"{\"steps\":[{\"description\":\"Choose design\",\"status\":\"enabled\"},{\"description\":\"Gather materials\",\"status\":\"enabled\"},{\"description\":\"Cut wood pieces\",\"status\":\"enabled\"},{\"description\":\"Assemble walls\",\"status\":\"enabled\"},{\"description\":\"Attach base\",\"status\":\"enabled\"},{\"description\":\"Add roof\",\"status\":\"enabled\"},{\"description\":\"Drill entrance hole\",\"status\":\"enabled\"},{\"description\":\"Sand surfaces\",\"status\":\"enabled\"},{\"description\":\"Paint or stain\",\"status\":\"enabled\"},{\"description\":\"Install hangers or hooks\",\"status\":\"enabled\"}]}"}},{"id":"171b2e37-ad9b-4e41-9cfe-d051602195d5","type":"function","function":{"name":"confirm_changes","arguments":"{\"function_name\": \"generate_task_steps\", \"function_call_id\": \"call_VlNsrwdW5hhp2G8Ufp6i8ueQ\", \"function_arguments\": {\"steps\": [{\"description\": \"Choose design\", \"status\": \"enabled\"}, {\"description\": \"Gather materials\", \"status\": \"enabled\"}, {\"description\": \"Cut wood pieces\", \"status\": \"enabled\"}, {\"description\": \"Assemble walls\", \"status\": \"enabled\"}, {\"description\": \"Attach base\", \"status\": \"enabled\"}, {\"description\": \"Add roof\", \"status\": \"enabled\"}, {\"description\": \"Drill entrance hole\", \"status\": \"enabled\"}, {\"description\": \"Sand surfaces\", \"status\": \"enabled\"}, {\"description\": \"Paint or stain\", \"status\": \"enabled\"}, {\"description\": \"Install hangers or hooks\", \"status\": \"enabled\"}]}, \"steps\": [{\"description\": \"Execute generate_task_steps\", \"status\": \"enabled\"}]}"}}]},{"id":"approval-response-call_VlNsrwdW5hhp2G8Ufp6i8ueQ","role":"tool","content":"Generated 10 execution steps for the task.","toolCallId":"call_VlNsrwdW5hhp2G8Ufp6i8ueQ"},{"id":"284fd5f2-2168-489d-b6a7-b31253529dae","role":"assistant","content":"The plan has been created with 10 steps selected."}]}
36+
37+
data: {"type":"RUN_FINISHED","threadId":"thread-06-hitl-interrupt","runId":"4df61fb0-a261-42fd-b4f8-071ead4f8f3b"}
38+
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
data: {"type":"RUN_STARTED","threadId":"thread-hitl-1","runId":"run-hitl-2"}
2+
3+
data: {"type":"CUSTOM","name":"on_interrupt","value":"{\"type\":\"mastra_suspend\",\"toolCallId\":\"call_MYPy83hJNJl68Qe2HuX24UqT\",\"toolName\":\"schedule_meeting\",\"suspendPayload\":{\"topic\":\"Meeting with Dana about the Q4 roadmap\"},\"args\":{\"topic\":\"Meeting with Dana about the Q4 roadmap\"},\"resumeSchema\":\"{\\\"type\\\":\\\"object\\\",\\\"properties\\\":{\\\"chosen_time\\\":{\\\"type\\\":\\\"string\\\"},\\\"cancelled\\\":{\\\"type\\\":\\\"boolean\\\"}},\\\"additionalProperties\\\":false,\\\"$schema\\\":\\\"http://json-schema.org/draft-07/schema#\\\"}\",\"runId\":\"run-hitl-2\"}"}
4+
5+
data: {"type":"RUN_FINISHED","threadId":"thread-hitl-1","runId":"run-hitl-2","outcome":{"type":"interrupt","interrupts":[{"id":"run-hitl-2::call_MYPy83hJNJl68Qe2HuX24UqT","reason":"mastra:tool_suspend","toolCallId":"call_MYPy83hJNJl68Qe2HuX24UqT","responseSchema":{"type":"object","properties":{"chosen_time":{"type":"string"},"cancelled":{"type":"boolean"}},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"},"metadata":{"mastra":{"type":"mastra_suspend","toolName":"schedule_meeting","suspendPayload":{"topic":"Meeting with Dana about the Q4 roadmap"},"args":{"topic":"Meeting with Dana about the Q4 roadmap"},"resumeSchema":"{\"type\":\"object\",\"properties\":{\"chosen_time\":{\"type\":\"string\"},\"cancelled\":{\"type\":\"boolean\"}},\"additionalProperties\":false,\"$schema\":\"http://json-schema.org/draft-07/schema#\"}","runId":"run-hitl-2"}}}]}}
6+
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"threadId": "thread-hitl-1",
3+
"runId": "run-hitl-3",
4+
"state": {},
5+
"messages": [
6+
{ "id": "u1", "role": "user", "content": "Schedule a meeting with Dana about the Q4 roadmap." }
7+
],
8+
"tools": [],
9+
"context": [],
10+
"forwardedProps": {
11+
"command": {
12+
"resume": { "chosen_time": "2026-09-01T10:00" },
13+
"interruptEvent": { "toolCallId": "call_MYPy83hJNJl68Qe2HuX24UqT", "runId": "run-hitl-2" }
14+
}
15+
}
16+
}
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
// SPDX-License-Identifier: MIT
2+
//
3+
// Transcript-driven resume-payload tests.
4+
//
5+
// The fixtures under libs/ag-ui/fixtures/runtime-transcripts/ are REAL
6+
// captures from the 2026-08-31 runtime-portability spikes. Each test replays
7+
// a captured inbound interrupt through the adapter, submits a resume, and
8+
// asserts the outgoing forwardedProps against the request shape MEASURED to
9+
// work for that runtime (also committed as fixtures). Payloads are verbatim
10+
// from the wire — do not edit them.
11+
import { describe, it, expect, vi } from 'vitest';
12+
import { readFileSync } from 'node:fs';
13+
import { join } from 'node:path';
14+
import type { AbstractAgent, BaseEvent } from '@ag-ui/client';
15+
import { toAgent, type AgUiAgent } from './to-agent';
16+
17+
const FIXTURES_DIR = join(__dirname, '../../fixtures/runtime-transcripts');
18+
19+
/** Parse an SSE capture into its event objects (one per `data:` line). */
20+
function readSseFixture(name: string): BaseEvent[] {
21+
const raw = readFileSync(join(FIXTURES_DIR, name), 'utf8');
22+
return raw
23+
.split('\n')
24+
.filter((line) => line.startsWith('data:'))
25+
.map((line) => JSON.parse(line.slice('data:'.length)) as BaseEvent);
26+
}
27+
28+
/** Read a fixture's captured request JSON (a `.request.json` file, or the
29+
* `__request__` first line of a captured SSE response). */
30+
function readCapturedRequest(name: string): Record<string, unknown> {
31+
const raw = readFileSync(join(FIXTURES_DIR, name), 'utf8');
32+
if (name.endsWith('.request.json')) return JSON.parse(raw) as Record<string, unknown>;
33+
const firstLine = raw.split('\n', 1)[0];
34+
return (JSON.parse(firstLine) as { __request__: Record<string, unknown> }).__request__;
35+
}
36+
37+
/** Minimal AbstractAgent stand-in (mirrors to-agent.spec.ts's StubAgent). */
38+
class StubAgent {
39+
state: Record<string, unknown> = {};
40+
private readonly subscribers: Array<{
41+
onEvent?: (p: { event: BaseEvent; input: { runId?: string } }) => void;
42+
}> = [];
43+
subscribe(sub: { onEvent?: (p: { event: BaseEvent; input: { runId?: string } }) => void }) {
44+
this.subscribers.push(sub);
45+
return { unsubscribe: () => undefined };
46+
}
47+
emit(event: BaseEvent, callbackRunId?: string): void {
48+
for (const sub of this.subscribers) sub.onEvent?.({ event, input: { runId: callbackRunId } });
49+
}
50+
runAgent = vi.fn(async () => ({ result: undefined, newMessages: [] }));
51+
abortRun = vi.fn();
52+
addMessage = vi.fn();
53+
setMessages = vi.fn();
54+
}
55+
56+
/** Drive a full submit through the adapter while replaying a captured
57+
* transcript, so the interrupt is stored exactly as production stores it. */
58+
async function replayInterruptRun(
59+
stub: StubAgent,
60+
agent: AgUiAgent,
61+
fixture: string,
62+
callbackRunId: string,
63+
): Promise<void> {
64+
let finishRun!: () => void;
65+
stub.runAgent.mockImplementationOnce(() => new Promise((resolve) => {
66+
finishRun = () => resolve({ result: undefined, newMessages: [] });
67+
}));
68+
const submitted = agent.submit({ message: 'trigger the interrupt' });
69+
for (const event of readSseFixture(fixture)) stub.emit(event, callbackRunId);
70+
finishRun();
71+
await submitted;
72+
expect(agent.interrupt!()).toBeDefined();
73+
}
74+
75+
function lastRunAgentArg(stub: StubAgent): { forwardedProps?: Record<string, unknown> } {
76+
const calls = stub.runAgent.mock.calls as unknown as ReadonlyArray<ReadonlyArray<unknown>>;
77+
return calls[calls.length - 1][0] as { forwardedProps?: Record<string, unknown> };
78+
}
79+
80+
describe('submit({ resume }) — LangGraph wire shape is unchanged', () => {
81+
it('sends exactly { command: { resume } } when no interrupt is pending', async () => {
82+
const stub = new StubAgent();
83+
const agent = toAgent(stub as unknown as AbstractAgent);
84+
await agent.submit({ resume: { approved: true } });
85+
expect(stub.runAgent).toHaveBeenCalledWith({
86+
forwardedProps: { command: { resume: { approved: true } } },
87+
});
88+
});
89+
90+
it('sends exactly { command: { resume } } for an on_interrupt payload without identifying fields', async () => {
91+
const stub = new StubAgent();
92+
const agent = toAgent(stub as unknown as AbstractAgent);
93+
// The LangGraph bridge's on_interrupt carries an opaque app payload —
94+
// no toolCallId / runId / interrupt entries.
95+
stub.emit({
96+
type: 'CUSTOM', name: 'on_interrupt', value: { kind: 'refund_approval', amount: 42 },
97+
} as unknown as BaseEvent);
98+
expect(agent.interrupt!()).toBeDefined();
99+
100+
await agent.submit({ resume: { approved: true } });
101+
102+
expect(lastRunAgentArg(stub)).toEqual({
103+
forwardedProps: { command: { resume: { approved: true } } },
104+
});
105+
expect(agent.interrupt!()).toBeUndefined();
106+
});
107+
});
108+
109+
describe('submit({ resume }) — Mastra transcript round-trip', () => {
110+
// Inbound: spike-mastra/transcripts/05b-resume-ourstyle.sse — the run in
111+
// which Mastra RE-interrupted after receiving our historical bare
112+
// command.resume shape (measured proof the old shape does not resume), and
113+
// whose CUSTOM on_interrupt carries toolCallId + runId. Expected outbound:
114+
// the measured request that DID resume this exact interrupt,
115+
// spike-mastra/transcripts/input-05c-resume-correct.json — command.resume
116+
// plus command.interruptEvent{toolCallId,runId}.
117+
it('reproduces the measured command.resume + command.interruptEvent shape', async () => {
118+
const stub = new StubAgent();
119+
const agent = toAgent(stub as unknown as AbstractAgent);
120+
await replayInterruptRun(stub, agent, 'mastra-reinterrupt.sse', 'run-hitl-2');
121+
122+
await agent.submit({ resume: { chosen_time: '2026-09-01T10:00' } });
123+
124+
const measured = readCapturedRequest('mastra-resume-correct.request.json');
125+
expect(lastRunAgentArg(stub).forwardedProps).toEqual(measured['forwardedProps']);
126+
expect(lastRunAgentArg(stub).forwardedProps).toEqual({
127+
command: {
128+
resume: { chosen_time: '2026-09-01T10:00' },
129+
interruptEvent: { toolCallId: 'call_MYPy83hJNJl68Qe2HuX24UqT', runId: 'run-hitl-2' },
130+
},
131+
});
132+
});
133+
});
134+
135+
describe('submit({ resume }) — Microsoft Agent Framework transcript round-trip', () => {
136+
// Inbound: spike-maf/transcripts/06-hitl-interrupt.sse (RUN_FINISHED
137+
// interrupt outcome). Expected outbound: the measured working request
138+
// captured on the __request__ line of spike-maf/transcripts/
139+
// 08-hitl-resume.sse — one structured { id, status, payload } entry per
140+
// pending interrupt under command.resume.
141+
it('reproduces the measured structured per-interrupt command.resume entries', async () => {
142+
const stub = new StubAgent();
143+
const agent = toAgent(stub as unknown as AbstractAgent);
144+
await replayInterruptRun(
145+
stub, agent, 'maf-hitl-interrupt.sse', '83caa76f-b177-4c68-9b2b-ad1be07589e5',
146+
);
147+
148+
await agent.submit({ resume: { approved: true } });
149+
150+
const measured = readCapturedRequest('maf-hitl-resume.sse');
151+
expect(lastRunAgentArg(stub).forwardedProps).toEqual(measured['forwardedProps']);
152+
expect(lastRunAgentArg(stub).forwardedProps).toEqual({
153+
command: {
154+
resume: [{
155+
id: 'call_VlNsrwdW5hhp2G8Ufp6i8ueQ',
156+
status: 'resolved',
157+
payload: { approved: true },
158+
}],
159+
},
160+
});
161+
});
162+
});
163+
164+
describe('submit({ resume }) — AWS Strands interrupt outcome', () => {
165+
// Inbound: spike-strands/transcripts/interrupt_phase1.sse. The outcome
166+
// entry's id must ride back so the backend can address the pending
167+
// interrupt. (The Strands spike resumed via the protocol-standard TOP-LEVEL
168+
// resume array, which RunAgentInputSchema@0.0.52 cannot send — adopting it
169+
// is an explicit follow-up; forwardedProps carries the same identity today.)
170+
it('carries the interrupt id in structured entries with the resume payload', async () => {
171+
const stub = new StubAgent();
172+
const agent = toAgent(stub as unknown as AbstractAgent);
173+
await replayInterruptRun(stub, agent, 'strands-interrupt.sse', 'run-1');
174+
175+
await agent.submit({ resume: { chosen_label: 'Tuesday 10:00' } });
176+
177+
expect(lastRunAgentArg(stub).forwardedProps).toEqual({
178+
command: {
179+
resume: [{
180+
id: 'v1:tool_call:call_A9ckGX1LrvO82OhqZinzDsom:340a4daa-b874-5aad-8309-a63b92d507dd',
181+
status: 'resolved',
182+
payload: { chosen_label: 'Tuesday 10:00' },
183+
}],
184+
},
185+
});
186+
});
187+
188+
// SYNTHETIC: no transcript covers a caller that authors its own structured
189+
// entries; those must pass through untouched rather than be re-wrapped.
190+
it('passes caller-authored structured entries through untouched', async () => {
191+
const stub = new StubAgent();
192+
const agent = toAgent(stub as unknown as AbstractAgent);
193+
await replayInterruptRun(stub, agent, 'strands-interrupt.sse', 'run-1');
194+
195+
const structured = [{
196+
interruptId: 'v1:tool_call:call_A9ckGX1LrvO82OhqZinzDsom:340a4daa-b874-5aad-8309-a63b92d507dd',
197+
status: 'resolved',
198+
payload: { chosen_label: 'Tuesday 10:00' },
199+
}];
200+
await agent.submit({ resume: structured });
201+
202+
expect(lastRunAgentArg(stub).forwardedProps).toEqual({
203+
command: { resume: structured },
204+
});
205+
});
206+
});

0 commit comments

Comments
 (0)