|
| 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