From b2751c997c55683fe9f8d961d69978e3e7a8b1a4 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Thu, 9 Apr 2026 16:18:08 -0700 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20add=20AGUIMock=20=E2=80=94=20AG-UI?= =?UTF-8?q?=20protocol=20mock=20with=20record/replay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 33 AG-UI event types, 11 convenience builders, fluent registration API, SSE streaming with disconnect handling, and record/replay with tee streaming and proxy-only mode. --- src/agui-handler.ts | 448 +++++++++++++++++++++++++++++++++++++++++++ src/agui-mock.ts | 277 ++++++++++++++++++++++++++ src/agui-recorder.ts | 241 +++++++++++++++++++++++ src/agui-stub.ts | 68 +++++++ src/agui-types.ts | 372 +++++++++++++++++++++++++++++++++++ 5 files changed, 1406 insertions(+) create mode 100644 src/agui-handler.ts create mode 100644 src/agui-mock.ts create mode 100644 src/agui-recorder.ts create mode 100644 src/agui-stub.ts create mode 100644 src/agui-types.ts diff --git a/src/agui-handler.ts b/src/agui-handler.ts new file mode 100644 index 0000000..84e9e68 --- /dev/null +++ b/src/agui-handler.ts @@ -0,0 +1,448 @@ +// ─── AG-UI Handler ─────────────────────────────────────────────────────────── +// +// Matching functions, event builders, and SSE writer for AG-UI protocol. + +import * as http from "node:http"; +import { randomUUID } from "node:crypto"; + +import type { + AGUIRunAgentInput, + AGUIFixtureMatch, + AGUIFixture, + AGUIEvent, + AGUIMessage, + AGUIRunStartedEvent, + AGUIRunFinishedEvent, + AGUIRunErrorEvent, + AGUITextMessageStartEvent, + AGUITextMessageContentEvent, + AGUITextMessageEndEvent, + AGUITextMessageChunkEvent, + AGUIToolCallStartEvent, + AGUIToolCallArgsEvent, + AGUIToolCallEndEvent, + AGUIToolCallResultEvent, + AGUIStateSnapshotEvent, + AGUIStateDeltaEvent, + AGUIMessagesSnapshotEvent, + AGUIStepStartedEvent, + AGUIStepFinishedEvent, + AGUIReasoningStartEvent, + AGUIReasoningMessageStartEvent, + AGUIReasoningMessageContentEvent, + AGUIReasoningMessageEndEvent, + AGUIReasoningEndEvent, + AGUIActivitySnapshotEvent, +} from "./agui-types.js"; + +// ─── Matching functions ────────────────────────────────────────────────────── + +/** + * Extract the content of the last message with role "user" from the input. + */ +export function extractLastUserMessage(input: AGUIRunAgentInput): string { + if (!input.messages || input.messages.length === 0) return ""; + for (let i = input.messages.length - 1; i >= 0; i--) { + const msg = input.messages[i]; + if (msg.role === "user" && typeof msg.content === "string") { + return msg.content; + } + } + return ""; +} + +/** + * Check whether an input matches a fixture's match criteria. + * All specified criteria must pass (AND logic). + */ +export function matchesFixture(input: AGUIRunAgentInput, match: AGUIFixtureMatch): boolean { + if (match.message !== undefined) { + const text = extractLastUserMessage(input); + if (typeof match.message === "string") { + if (!text.includes(match.message)) return false; + } else { + if (!match.message.test(text)) return false; + } + } + + if (match.toolName !== undefined) { + const tools = input.tools ?? []; + if (!tools.some((t) => t.name === match.toolName)) return false; + } + + if (match.stateKey !== undefined) { + if ( + input.state === null || + input.state === undefined || + typeof input.state !== "object" || + !(match.stateKey in (input.state as Record)) + ) { + return false; + } + } + + if (match.predicate !== undefined) { + if (!match.predicate(input)) return false; + } + + return true; +} + +/** + * Find the first fixture whose match criteria pass for the given input. + */ +export function findFixture(input: AGUIRunAgentInput, fixtures: AGUIFixture[]): AGUIFixture | null { + for (const fixture of fixtures) { + if (matchesFixture(input, fixture.match)) { + return fixture; + } + } + return null; +} + +// ─── Builder options ───────────────────────────────────────────────────────── + +export interface AGUIBuildOpts { + threadId?: string; + runId?: string; + parentRunId?: string; + /** For tool call builder: include a result event */ + result?: string; +} + +// ─── Event builders ────────────────────────────────────────────────────────── + +function makeRunStarted(opts?: AGUIBuildOpts): AGUIRunStartedEvent { + return { + type: "RUN_STARTED", + threadId: opts?.threadId ?? randomUUID(), + runId: opts?.runId ?? randomUUID(), + ...(opts?.parentRunId ? { parentRunId: opts.parentRunId } : {}), + }; +} + +function makeRunFinished(started: AGUIRunStartedEvent): AGUIRunFinishedEvent { + return { + type: "RUN_FINISHED", + threadId: started.threadId, + runId: started.runId, + }; +} + +/** + * Build a complete text message response sequence. + * [RUN_STARTED, TEXT_MESSAGE_START, TEXT_MESSAGE_CONTENT, TEXT_MESSAGE_END, RUN_FINISHED] + */ +export function buildTextResponse(text: string, opts?: AGUIBuildOpts): AGUIEvent[] { + const started = makeRunStarted(opts); + const messageId = randomUUID(); + return [ + started, + { + type: "TEXT_MESSAGE_START", + messageId, + role: "assistant", + } as AGUITextMessageStartEvent, + { + type: "TEXT_MESSAGE_CONTENT", + messageId, + delta: text, + } as AGUITextMessageContentEvent, + { + type: "TEXT_MESSAGE_END", + messageId, + } as AGUITextMessageEndEvent, + makeRunFinished(started), + ]; +} + +/** + * Build a text chunk response (single chunk, no start/end envelope). + * [RUN_STARTED, TEXT_MESSAGE_CHUNK, RUN_FINISHED] + */ +export function buildTextChunkResponse(text: string, opts?: AGUIBuildOpts): AGUIEvent[] { + const started = makeRunStarted(opts); + return [ + started, + { + type: "TEXT_MESSAGE_CHUNK", + messageId: randomUUID(), + role: "assistant", + delta: text, + } as AGUITextMessageChunkEvent, + makeRunFinished(started), + ]; +} + +/** + * Build a tool call response sequence. + * [RUN_STARTED, TOOL_CALL_START, TOOL_CALL_ARGS, TOOL_CALL_END, (TOOL_CALL_RESULT)?, RUN_FINISHED] + */ +export function buildToolCallResponse( + toolName: string, + args: string, + opts?: AGUIBuildOpts, +): AGUIEvent[] { + const started = makeRunStarted(opts); + const toolCallId = randomUUID(); + const events: AGUIEvent[] = [ + started, + { + type: "TOOL_CALL_START", + toolCallId, + toolCallName: toolName, + } as AGUIToolCallStartEvent, + { + type: "TOOL_CALL_ARGS", + toolCallId, + delta: args, + } as AGUIToolCallArgsEvent, + { + type: "TOOL_CALL_END", + toolCallId, + } as AGUIToolCallEndEvent, + ]; + + if (opts?.result !== undefined) { + events.push({ + type: "TOOL_CALL_RESULT", + messageId: randomUUID(), + toolCallId, + content: opts.result, + role: "tool", + } as AGUIToolCallResultEvent); + } + + events.push(makeRunFinished(started)); + return events; +} + +/** + * Build a state snapshot response. + * [RUN_STARTED, STATE_SNAPSHOT, RUN_FINISHED] + */ +export function buildStateUpdate(snapshot: unknown, opts?: AGUIBuildOpts): AGUIEvent[] { + const started = makeRunStarted(opts); + return [ + started, + { + type: "STATE_SNAPSHOT", + snapshot, + } as AGUIStateSnapshotEvent, + makeRunFinished(started), + ]; +} + +/** + * Build a state delta response (JSON Patch). + * [RUN_STARTED, STATE_DELTA, RUN_FINISHED] + */ +export function buildStateDelta(patches: unknown[], opts?: AGUIBuildOpts): AGUIEvent[] { + const started = makeRunStarted(opts); + return [ + started, + { + type: "STATE_DELTA", + delta: patches, + } as AGUIStateDeltaEvent, + makeRunFinished(started), + ]; +} + +/** + * Build a messages snapshot response. + * [RUN_STARTED, MESSAGES_SNAPSHOT, RUN_FINISHED] + */ +export function buildMessagesSnapshot(messages: AGUIMessage[], opts?: AGUIBuildOpts): AGUIEvent[] { + const started = makeRunStarted(opts); + return [ + started, + { + type: "MESSAGES_SNAPSHOT", + messages, + } as AGUIMessagesSnapshotEvent, + makeRunFinished(started), + ]; +} + +/** + * Build a reasoning response sequence. + * [RUN_STARTED, REASONING_START, REASONING_MESSAGE_START, REASONING_MESSAGE_CONTENT, + * REASONING_MESSAGE_END, REASONING_END, RUN_FINISHED] + */ +export function buildReasoningResponse(text: string, opts?: AGUIBuildOpts): AGUIEvent[] { + const started = makeRunStarted(opts); + const messageId = randomUUID(); + return [ + started, + { + type: "REASONING_START", + messageId, + } as AGUIReasoningStartEvent, + { + type: "REASONING_MESSAGE_START", + messageId, + role: "reasoning", + } as AGUIReasoningMessageStartEvent, + { + type: "REASONING_MESSAGE_CONTENT", + messageId, + delta: text, + } as AGUIReasoningMessageContentEvent, + { + type: "REASONING_MESSAGE_END", + messageId, + } as AGUIReasoningMessageEndEvent, + { + type: "REASONING_END", + messageId, + } as AGUIReasoningEndEvent, + makeRunFinished(started), + ]; +} + +/** + * Build an activity snapshot response. + * [RUN_STARTED, ACTIVITY_SNAPSHOT, RUN_FINISHED] + */ +export function buildActivityResponse( + messageId: string, + activityType: string, + content: Record, + opts?: AGUIBuildOpts, +): AGUIEvent[] { + const started = makeRunStarted(opts); + return [ + started, + { + type: "ACTIVITY_SNAPSHOT", + messageId, + activityType, + content, + replace: true, + } as AGUIActivitySnapshotEvent, + makeRunFinished(started), + ]; +} + +/** + * Build an error response. + * [RUN_STARTED, RUN_ERROR] (no RUN_FINISHED — the run errored) + */ +export function buildErrorResponse( + message: string, + code?: string, + opts?: AGUIBuildOpts, +): AGUIEvent[] { + const started = makeRunStarted(opts); + return [ + started, + { + type: "RUN_ERROR", + message, + ...(code !== undefined ? { code } : {}), + } as AGUIRunErrorEvent, + ]; +} + +/** + * Build a step-wrapped text response. + * [RUN_STARTED, STEP_STARTED, TEXT_MESSAGE_START, TEXT_MESSAGE_CONTENT, + * TEXT_MESSAGE_END, STEP_FINISHED, RUN_FINISHED] + */ +export function buildStepWithText( + stepName: string, + text: string, + opts?: AGUIBuildOpts, +): AGUIEvent[] { + const started = makeRunStarted(opts); + const messageId = randomUUID(); + return [ + started, + { + type: "STEP_STARTED", + stepName, + } as AGUIStepStartedEvent, + { + type: "TEXT_MESSAGE_START", + messageId, + role: "assistant", + } as AGUITextMessageStartEvent, + { + type: "TEXT_MESSAGE_CONTENT", + messageId, + delta: text, + } as AGUITextMessageContentEvent, + { + type: "TEXT_MESSAGE_END", + messageId, + } as AGUITextMessageEndEvent, + { + type: "STEP_FINISHED", + stepName, + } as AGUIStepFinishedEvent, + makeRunFinished(started), + ]; +} + +/** + * Combine multiple builder outputs into a single run. + * Strips RUN_STARTED/RUN_FINISHED from each input, wraps all inner events + * in one RUN_STARTED...RUN_FINISHED pair. + */ +export function buildCompositeResponse( + builderOutputs: AGUIEvent[][], + opts?: AGUIBuildOpts, +): AGUIEvent[] { + const started = makeRunStarted(opts); + const inner: AGUIEvent[] = []; + + for (const events of builderOutputs) { + for (const event of events) { + if (event.type !== "RUN_STARTED" && event.type !== "RUN_FINISHED") { + inner.push(event); + } + } + } + + return [started, ...inner, makeRunFinished(started)]; +} + +// ─── SSE writer ────────────────────────────────────────────────────────────── + +/** + * Write AG-UI events as an SSE stream to an HTTP response. + * Sets appropriate headers, serializes each event as `data: {...}\n\n`, + * and optionally delays between events. + */ +export async function writeAGUIEventStream( + res: http.ServerResponse, + events: AGUIEvent[], + opts?: { delayMs?: number; signal?: AbortSignal }, +): Promise { + const delayMs = opts?.delayMs ?? 0; + + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + + for (const event of events) { + if (opts?.signal?.aborted) break; + if (res.socket?.destroyed) break; + + const stamped = { ...event, timestamp: Date.now() }; + try { + res.write(`data: ${JSON.stringify(stamped)}\n\n`); + } catch { + break; // client disconnected or stream error — stop writing + } + + if (delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + + if (!res.writableEnded) res.end(); +} diff --git a/src/agui-mock.ts b/src/agui-mock.ts new file mode 100644 index 0000000..cbdf6c8 --- /dev/null +++ b/src/agui-mock.ts @@ -0,0 +1,277 @@ +import * as http from "node:http"; +import type { Mountable } from "./types.js"; +import type { Journal } from "./journal.js"; +import type { MetricsRegistry } from "./metrics.js"; +import type { + AGUIFixture, + AGUIMockOptions, + AGUIRecordConfig, + AGUIEvent, + AGUIRunAgentInput, +} from "./agui-types.js"; +import { + findFixture, + buildTextResponse, + buildToolCallResponse, + buildStateUpdate, + buildReasoningResponse, + writeAGUIEventStream, +} from "./agui-handler.js"; +import { flattenHeaders, readBody } from "./helpers.js"; +import { proxyAndRecordAGUI } from "./agui-recorder.js"; +import { Logger } from "./logger.js"; + +export class AGUIMock implements Mountable { + private fixtures: AGUIFixture[] = []; + private server: http.Server | null = null; + private journal: Journal | null = null; + private registry: MetricsRegistry | null = null; + private options: AGUIMockOptions; + private baseUrl = ""; + private recordConfig: AGUIRecordConfig | undefined; + private logger: Logger; + + constructor(options?: AGUIMockOptions) { + this.options = options ?? {}; + this.logger = new Logger("silent"); + } + + // ---- Fluent registration API ---- + + addFixture(fixture: AGUIFixture): this { + this.fixtures.push(fixture); + return this; + } + + onMessage(pattern: string | RegExp, text: string, opts?: { delayMs?: number }): this { + const events = buildTextResponse(text); + this.fixtures.push({ + match: { message: pattern }, + events, + delayMs: opts?.delayMs, + }); + return this; + } + + onRun(pattern: string | RegExp, events: AGUIEvent[], delayMs?: number): this { + this.fixtures.push({ + match: { message: pattern }, + events, + delayMs, + }); + return this; + } + + onToolCall( + pattern: string | RegExp, + toolName: string, + args: string, + opts?: { result?: string; delayMs?: number }, + ): this { + const events = buildToolCallResponse(toolName, args, { + result: opts?.result, + }); + this.fixtures.push({ + match: { message: pattern }, + events, + delayMs: opts?.delayMs, + }); + return this; + } + + onStateKey(key: string, snapshot: Record, delayMs?: number): this { + const events = buildStateUpdate(snapshot); + this.fixtures.push({ + match: { stateKey: key }, + events, + delayMs, + }); + return this; + } + + onReasoning(pattern: string | RegExp, text: string, opts?: { delayMs?: number }): this { + const events = buildReasoningResponse(text); + this.fixtures.push({ + match: { message: pattern }, + events, + delayMs: opts?.delayMs, + }); + return this; + } + + onPredicate( + predicate: (input: AGUIRunAgentInput) => boolean, + events: AGUIEvent[], + delayMs?: number, + ): this { + this.fixtures.push({ + match: { predicate }, + events, + delayMs, + }); + return this; + } + + enableRecording(config: AGUIRecordConfig): this { + this.recordConfig = config; + return this; + } + + reset(): this { + this.fixtures = []; + this.recordConfig = undefined; + return this; + } + + // ---- Mountable interface ---- + + async handleRequest( + req: http.IncomingMessage, + res: http.ServerResponse, + pathname: string, + ): Promise { + if (req.method !== "POST" || (pathname !== "/" && pathname !== "")) { + return false; + } + + if (this.registry) { + this.registry.incrementCounter("aimock_agui_requests_total", { method: "POST" }); + } + + const body = await readBody(req); + + let input: AGUIRunAgentInput; + try { + input = JSON.parse(body) as AGUIRunAgentInput; + } catch { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Invalid JSON body" })); + this.journalRequest(req, pathname, 400); + return true; + } + + const fixture = findFixture(input, this.fixtures); + + if (fixture) { + await writeAGUIEventStream(res, fixture.events, { delayMs: fixture.delayMs }); + this.journalRequest(req, pathname, 200); + return true; + } + + // No match — if recording is enabled, proxy to upstream + if (this.recordConfig) { + const proxied = await proxyAndRecordAGUI( + req, + res, + input, + this.fixtures, + this.recordConfig, + this.logger, + ); + if (proxied) { + this.journalRequest(req, pathname, 200); + return true; + } + } + + // No match, no recorder — 404 + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "No matching AG-UI fixture" })); + this.journalRequest(req, pathname, 404); + return true; + } + + health(): { status: string; fixtures: number } { + return { + status: "ok", + fixtures: this.fixtures.length, + }; + } + + setJournal(journal: Journal): void { + this.journal = journal; + } + + setBaseUrl(url: string): void { + this.baseUrl = url; + } + + setRegistry(registry: MetricsRegistry): void { + this.registry = registry; + } + + // ---- Standalone mode ---- + + async start(): Promise { + if (this.server) { + throw new Error("AGUIMock server already started"); + } + + const host = this.options.host ?? "127.0.0.1"; + const port = this.options.port ?? 0; + + return new Promise((resolve, reject) => { + const srv = http.createServer(async (req, res) => { + const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); + const handled = await this.handleRequest(req, res, url.pathname).catch((err) => { + this.logger.error(`AGUIMock request error: ${err instanceof Error ? err.message : err}`); + if (!res.headersSent) { + res.writeHead(500); + res.end("Internal server error"); + } else if (!res.writableEnded) { + res.end(); + } + return true; + }); + if (!handled && !res.headersSent) { + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Not found" })); + } + }); + + srv.on("error", reject); + + srv.listen(port, host, () => { + const addr = srv.address(); + if (typeof addr === "object" && addr !== null) { + this.baseUrl = `http://${host}:${addr.port}`; + } + this.server = srv; + resolve(this.baseUrl); + }); + }); + } + + async stop(): Promise { + if (!this.server) { + throw new Error("AGUIMock server not started"); + } + const srv = this.server; + await new Promise((resolve, reject) => { + srv.close((err: Error | undefined) => (err ? reject(err) : resolve())); + }); + this.server = null; + } + + get url(): string { + if (!this.server) { + throw new Error("AGUIMock server not started"); + } + return this.baseUrl; + } + + // ---- Private helpers ---- + + private journalRequest(req: http.IncomingMessage, pathname: string, status: number): void { + if (this.journal) { + this.journal.add({ + method: req.method ?? "POST", + path: req.url ?? pathname, + headers: flattenHeaders(req.headers), + body: null, + service: "agui", + response: { status, fixture: null }, + }); + } + } +} diff --git a/src/agui-recorder.ts b/src/agui-recorder.ts new file mode 100644 index 0000000..12c0d2d --- /dev/null +++ b/src/agui-recorder.ts @@ -0,0 +1,241 @@ +import * as http from "node:http"; +import * as https from "node:https"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as crypto from "node:crypto"; +import type { AGUIFixture, AGUIRecordConfig, AGUIEvent, AGUIRunAgentInput } from "./agui-types.js"; +import { extractLastUserMessage } from "./agui-handler.js"; +import type { Logger } from "./logger.js"; + +/** + * Proxy an unmatched AG-UI request to a real upstream agent, record the + * SSE event stream as a fixture on disk and in memory, and relay the + * response back to the original client in real time. + * + * Returns `true` if the request was proxied, `false` if no upstream is configured. + */ +export async function proxyAndRecordAGUI( + req: http.IncomingMessage, + res: http.ServerResponse, + input: AGUIRunAgentInput, + fixtures: AGUIFixture[], + config: AGUIRecordConfig, + logger: Logger, +): Promise { + if (!config.upstream) { + logger.warn("No upstream URL configured for AG-UI recording — cannot proxy"); + return false; + } + + let target: URL; + try { + target = new URL(config.upstream); + } catch { + logger.error(`Invalid upstream AG-UI URL: ${config.upstream}`); + res.writeHead(502, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Invalid upstream AG-UI URL" })); + return true; + } + + logger.warn(`NO AG-UI FIXTURE MATCH — proxying to ${config.upstream}`); + + // Build upstream request headers + const forwardHeaders: Record = { + "Content-Type": "application/json", + Accept: "text/event-stream", + }; + // Forward auth headers if present + const authorization = req.headers["authorization"]; + if (authorization) { + forwardHeaders["Authorization"] = Array.isArray(authorization) + ? authorization.join(", ") + : authorization; + } + const apiKey = req.headers["x-api-key"]; + if (apiKey) { + forwardHeaders["x-api-key"] = Array.isArray(apiKey) ? apiKey.join(", ") : apiKey; + } + + const requestBody = JSON.stringify(input); + + try { + await teeUpstreamStream( + target, + forwardHeaders, + requestBody, + res, + input, + fixtures, + config, + logger, + ); + } catch (err) { + const msg = err instanceof Error ? err.message : "Unknown proxy error"; + logger.error(`AG-UI proxy request failed: ${msg}`); + if (!res.headersSent) { + res.writeHead(502, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Upstream AG-UI agent unreachable" })); + } + } + + return true; +} + +// --------------------------------------------------------------------------- +// Internal: tee the upstream SSE stream to the client and buffer for recording +// --------------------------------------------------------------------------- + +function teeUpstreamStream( + target: URL, + headers: Record, + body: string, + clientRes: http.ServerResponse, + input: AGUIRunAgentInput, + fixtures: AGUIFixture[], + config: AGUIRecordConfig, + logger: Logger, +): Promise { + return new Promise((resolve, reject) => { + const transport = target.protocol === "https:" ? https : http; + const UPSTREAM_TIMEOUT_MS = 30_000; + + const upstreamReq = transport.request( + target, + { + method: "POST", + timeout: UPSTREAM_TIMEOUT_MS, + headers: { + ...headers, + "Content-Length": Buffer.byteLength(body).toString(), + }, + }, + (upstreamRes) => { + // Set SSE headers on the client response + if (!clientRes.headersSent) { + clientRes.writeHead(upstreamRes.statusCode ?? 200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + } + + const chunks: Buffer[] = []; + + upstreamRes.on("data", (chunk: Buffer) => { + // Relay to client in real time + try { + clientRes.write(chunk); + } catch { + // Client connection may have closed — continue buffering for recording + } + // Buffer for fixture construction + chunks.push(chunk); + }); + + upstreamRes.on("error", (err) => { + if (!clientRes.headersSent) { + clientRes.writeHead(502, { "Content-Type": "application/json" }); + clientRes.end(JSON.stringify({ error: "Upstream AG-UI agent unreachable" })); + } else if (!clientRes.writableEnded) { + clientRes.end(); + } + reject(err); + }); + + upstreamRes.on("end", () => { + if (!clientRes.writableEnded) clientRes.end(); + + // Parse buffered SSE events + const buffered = Buffer.concat(chunks).toString(); + const events = parseSSEEvents(buffered, logger); + + // Build fixture + const message = extractLastUserMessage(input); + if (!message) { + logger.warn("Recorded AG-UI fixture has no message match — it will match ALL requests"); + } + const fixture: AGUIFixture = { + match: { message: message || undefined }, + events, + }; + + if (!config.proxyOnly) { + // Register in memory first (always available even if disk write fails) + fixtures.push(fixture); + + // Write to disk + const fixturePath = config.fixturePath ?? "./fixtures/agui-recorded"; + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const filename = `agui-${timestamp}-${crypto.randomUUID().slice(0, 8)}.json`; + const filepath = path.join(fixturePath, filename); + + try { + fs.mkdirSync(fixturePath, { recursive: true }); + fs.writeFileSync( + filepath, + JSON.stringify( + { fixtures: [{ match: fixture.match, events: fixture.events }] }, + null, + 2, + ), + "utf-8", + ); + logger.warn(`AG-UI response recorded → ${filepath}`); + } catch (err) { + const msg = err instanceof Error ? err.message : "Unknown filesystem error"; + logger.error( + `Failed to save AG-UI fixture to disk: ${msg} (fixture retained in memory)`, + ); + } + } else { + logger.info("Proxied AG-UI request (proxy-only mode)"); + } + + resolve(); + }); + }, + ); + + upstreamReq.on("timeout", () => { + if (!clientRes.writableEnded) clientRes.end(); + upstreamReq.destroy( + new Error(`Upstream AG-UI request timed out after ${UPSTREAM_TIMEOUT_MS / 1000}s`), + ); + }); + + upstreamReq.on("error", (err) => { + if (!clientRes.headersSent) { + clientRes.writeHead(502, { "Content-Type": "application/json" }); + clientRes.end(JSON.stringify({ error: "Upstream AG-UI agent unreachable" })); + } else if (!clientRes.writableEnded) { + clientRes.end(); + } + reject(err); + }); + + upstreamReq.write(body); + upstreamReq.end(); + }); +} + +/** + * Parse SSE data lines from buffered stream text. + */ +function parseSSEEvents(text: string, logger?: Logger): AGUIEvent[] { + const events: AGUIEvent[] = []; + const blocks = text.split("\n\n"); + for (const block of blocks) { + const lines = block.split("\n"); + for (const line of lines) { + if (line.startsWith("data: ")) { + try { + const parsed = JSON.parse(line.slice(6)) as AGUIEvent; + events.push(parsed); + } catch { + logger?.warn(`Skipping unparseable SSE data line: ${line.slice(0, 200)}`); + } + } + } + } + return events; +} diff --git a/src/agui-stub.ts b/src/agui-stub.ts new file mode 100644 index 0000000..b304451 --- /dev/null +++ b/src/agui-stub.ts @@ -0,0 +1,68 @@ +export { AGUIMock } from "./agui-mock.js"; +export type { + AGUIEventType, + AGUIBaseEvent, + AGUIRunStartedEvent, + AGUIRunFinishedEvent, + AGUIRunErrorEvent, + AGUIStepStartedEvent, + AGUIStepFinishedEvent, + AGUITextMessageStartEvent, + AGUITextMessageContentEvent, + AGUITextMessageEndEvent, + AGUITextMessageChunkEvent, + AGUIToolCallStartEvent, + AGUIToolCallArgsEvent, + AGUIToolCallEndEvent, + AGUIToolCallChunkEvent, + AGUIToolCallResultEvent, + AGUIStateSnapshotEvent, + AGUIStateDeltaEvent, + AGUIMessagesSnapshotEvent, + AGUIActivitySnapshotEvent, + AGUIActivityDeltaEvent, + AGUIReasoningStartEvent, + AGUIReasoningMessageStartEvent, + AGUIReasoningMessageContentEvent, + AGUIReasoningMessageEndEvent, + AGUIReasoningMessageChunkEvent, + AGUIReasoningEndEvent, + AGUIReasoningEncryptedValueEvent, + AGUIRawEvent, + AGUICustomEvent, + AGUIThinkingStartEvent, + AGUIThinkingEndEvent, + AGUIThinkingTextMessageStartEvent, + AGUIThinkingTextMessageContentEvent, + AGUIThinkingTextMessageEndEvent, + AGUIEvent, + AGUITextMessageRole, + AGUIReasoningEncryptedValueSubtype, + AGUIRunAgentInput, + AGUIToolCall, + AGUIMessage, + AGUIToolDefinition, + AGUIFixtureMatch, + AGUIFixture, + AGUIMockOptions, + AGUIRecordConfig, +} from "./agui-types.js"; +export { + extractLastUserMessage, + matchesFixture, + findFixture, + buildTextResponse, + buildTextChunkResponse, + buildToolCallResponse, + buildStateUpdate, + buildStateDelta, + buildMessagesSnapshot, + buildReasoningResponse, + buildActivityResponse, + buildErrorResponse, + buildStepWithText, + buildCompositeResponse, + writeAGUIEventStream, +} from "./agui-handler.js"; +export type { AGUIBuildOpts } from "./agui-handler.js"; +export { proxyAndRecordAGUI } from "./agui-recorder.js"; diff --git a/src/agui-types.ts b/src/agui-types.ts new file mode 100644 index 0000000..0d1cb11 --- /dev/null +++ b/src/agui-types.ts @@ -0,0 +1,372 @@ +// ─── AG-UI Protocol Types ──────────────────────────────────────────────────── +// +// Type definitions for the AG-UI (Agent-User Interaction) protocol. +// Canonical source: @ag-ui/core (ag-ui/sdks/typescript/packages/core/src/events.ts) + +// ─── Event type string union ───────────────────────────────────────────────── + +export type AGUIEventType = + // Lifecycle + | "RUN_STARTED" + | "RUN_FINISHED" + | "RUN_ERROR" + | "STEP_STARTED" + | "STEP_FINISHED" + // Text messages + | "TEXT_MESSAGE_START" + | "TEXT_MESSAGE_CONTENT" + | "TEXT_MESSAGE_END" + | "TEXT_MESSAGE_CHUNK" + // Tool calls + | "TOOL_CALL_START" + | "TOOL_CALL_ARGS" + | "TOOL_CALL_END" + | "TOOL_CALL_CHUNK" + | "TOOL_CALL_RESULT" + // State + | "STATE_SNAPSHOT" + | "STATE_DELTA" + | "MESSAGES_SNAPSHOT" + // Activity + | "ACTIVITY_SNAPSHOT" + | "ACTIVITY_DELTA" + // Reasoning + | "REASONING_START" + | "REASONING_MESSAGE_START" + | "REASONING_MESSAGE_CONTENT" + | "REASONING_MESSAGE_END" + | "REASONING_MESSAGE_CHUNK" + | "REASONING_END" + | "REASONING_ENCRYPTED_VALUE" + // Special + | "RAW" + | "CUSTOM" + // Deprecated (pre-1.0) + | "THINKING_START" + | "THINKING_END" + | "THINKING_TEXT_MESSAGE_START" + | "THINKING_TEXT_MESSAGE_CONTENT" + | "THINKING_TEXT_MESSAGE_END"; + +// ─── Base event fields ─────────────────────────────────────────────────────── + +export interface AGUIBaseEvent { + type: AGUIEventType; + timestamp?: number; + rawEvent?: unknown; +} + +// ─── Individual event interfaces ───────────────────────────────────────────── + +// Lifecycle + +export interface AGUIRunStartedEvent extends AGUIBaseEvent { + type: "RUN_STARTED"; + threadId: string; + runId: string; + parentRunId?: string; + input?: AGUIRunAgentInput; +} + +export interface AGUIRunFinishedEvent extends AGUIBaseEvent { + type: "RUN_FINISHED"; + threadId: string; + runId: string; + result?: unknown; +} + +export interface AGUIRunErrorEvent extends AGUIBaseEvent { + type: "RUN_ERROR"; + message: string; + code?: string; +} + +export interface AGUIStepStartedEvent extends AGUIBaseEvent { + type: "STEP_STARTED"; + stepName: string; +} + +export interface AGUIStepFinishedEvent extends AGUIBaseEvent { + type: "STEP_FINISHED"; + stepName: string; +} + +// Text messages + +export type AGUITextMessageRole = "developer" | "system" | "assistant" | "user"; + +export interface AGUITextMessageStartEvent extends AGUIBaseEvent { + type: "TEXT_MESSAGE_START"; + messageId: string; + role: AGUITextMessageRole; + name?: string; +} + +export interface AGUITextMessageContentEvent extends AGUIBaseEvent { + type: "TEXT_MESSAGE_CONTENT"; + messageId: string; + delta: string; +} + +export interface AGUITextMessageEndEvent extends AGUIBaseEvent { + type: "TEXT_MESSAGE_END"; + messageId: string; +} + +export interface AGUITextMessageChunkEvent extends AGUIBaseEvent { + type: "TEXT_MESSAGE_CHUNK"; + messageId?: string; + role?: AGUITextMessageRole; + delta?: string; + name?: string; +} + +// Tool calls + +export interface AGUIToolCallStartEvent extends AGUIBaseEvent { + type: "TOOL_CALL_START"; + toolCallId: string; + toolCallName: string; + parentMessageId?: string; +} + +export interface AGUIToolCallArgsEvent extends AGUIBaseEvent { + type: "TOOL_CALL_ARGS"; + toolCallId: string; + delta: string; +} + +export interface AGUIToolCallEndEvent extends AGUIBaseEvent { + type: "TOOL_CALL_END"; + toolCallId: string; +} + +export interface AGUIToolCallChunkEvent extends AGUIBaseEvent { + type: "TOOL_CALL_CHUNK"; + toolCallId?: string; + toolCallName?: string; + parentMessageId?: string; + delta?: string; +} + +export interface AGUIToolCallResultEvent extends AGUIBaseEvent { + type: "TOOL_CALL_RESULT"; + messageId: string; + toolCallId: string; + content: string; + role?: "tool"; +} + +// State + +export interface AGUIStateSnapshotEvent extends AGUIBaseEvent { + type: "STATE_SNAPSHOT"; + snapshot: unknown; +} + +export interface AGUIStateDeltaEvent extends AGUIBaseEvent { + type: "STATE_DELTA"; + delta: unknown[]; // JSON Patch (RFC 6902) +} + +export interface AGUIMessagesSnapshotEvent extends AGUIBaseEvent { + type: "MESSAGES_SNAPSHOT"; + messages: AGUIMessage[]; +} + +// Activity + +export interface AGUIActivitySnapshotEvent extends AGUIBaseEvent { + type: "ACTIVITY_SNAPSHOT"; + messageId: string; + activityType: string; + content: Record; + replace?: boolean; +} + +export interface AGUIActivityDeltaEvent extends AGUIBaseEvent { + type: "ACTIVITY_DELTA"; + messageId: string; + activityType: string; + patch: unknown[]; +} + +// Reasoning + +export interface AGUIReasoningStartEvent extends AGUIBaseEvent { + type: "REASONING_START"; + messageId: string; +} + +export interface AGUIReasoningMessageStartEvent extends AGUIBaseEvent { + type: "REASONING_MESSAGE_START"; + messageId: string; + role: "reasoning"; +} + +export interface AGUIReasoningMessageContentEvent extends AGUIBaseEvent { + type: "REASONING_MESSAGE_CONTENT"; + messageId: string; + delta: string; +} + +export interface AGUIReasoningMessageEndEvent extends AGUIBaseEvent { + type: "REASONING_MESSAGE_END"; + messageId: string; +} + +export interface AGUIReasoningMessageChunkEvent extends AGUIBaseEvent { + type: "REASONING_MESSAGE_CHUNK"; + messageId?: string; + delta?: string; +} + +export interface AGUIReasoningEndEvent extends AGUIBaseEvent { + type: "REASONING_END"; + messageId: string; +} + +export type AGUIReasoningEncryptedValueSubtype = "tool-call" | "message"; + +export interface AGUIReasoningEncryptedValueEvent extends AGUIBaseEvent { + type: "REASONING_ENCRYPTED_VALUE"; + subtype: AGUIReasoningEncryptedValueSubtype; + entityId: string; + encryptedValue: string; +} + +// Special + +export interface AGUIRawEvent extends AGUIBaseEvent { + type: "RAW"; + event: unknown; + source?: string; +} + +export interface AGUICustomEvent extends AGUIBaseEvent { + type: "CUSTOM"; + name: string; + value: unknown; +} + +// Deprecated + +export interface AGUIThinkingStartEvent extends AGUIBaseEvent { + type: "THINKING_START"; + title?: string; +} + +export interface AGUIThinkingEndEvent extends AGUIBaseEvent { + type: "THINKING_END"; +} + +export interface AGUIThinkingTextMessageStartEvent extends AGUIBaseEvent { + type: "THINKING_TEXT_MESSAGE_START"; +} + +export interface AGUIThinkingTextMessageContentEvent extends AGUIBaseEvent { + type: "THINKING_TEXT_MESSAGE_CONTENT"; + delta: string; +} + +export interface AGUIThinkingTextMessageEndEvent extends AGUIBaseEvent { + type: "THINKING_TEXT_MESSAGE_END"; +} + +// ─── Discriminated union of all events ─────────────────────────────────────── + +export type AGUIEvent = + | AGUIRunStartedEvent + | AGUIRunFinishedEvent + | AGUIRunErrorEvent + | AGUIStepStartedEvent + | AGUIStepFinishedEvent + | AGUITextMessageStartEvent + | AGUITextMessageContentEvent + | AGUITextMessageEndEvent + | AGUITextMessageChunkEvent + | AGUIToolCallStartEvent + | AGUIToolCallArgsEvent + | AGUIToolCallEndEvent + | AGUIToolCallChunkEvent + | AGUIToolCallResultEvent + | AGUIStateSnapshotEvent + | AGUIStateDeltaEvent + | AGUIMessagesSnapshotEvent + | AGUIActivitySnapshotEvent + | AGUIActivityDeltaEvent + | AGUIReasoningStartEvent + | AGUIReasoningMessageStartEvent + | AGUIReasoningMessageContentEvent + | AGUIReasoningMessageEndEvent + | AGUIReasoningMessageChunkEvent + | AGUIReasoningEndEvent + | AGUIReasoningEncryptedValueEvent + | AGUIRawEvent + | AGUICustomEvent + | AGUIThinkingStartEvent + | AGUIThinkingEndEvent + | AGUIThinkingTextMessageStartEvent + | AGUIThinkingTextMessageContentEvent + | AGUIThinkingTextMessageEndEvent; + +// ─── Request types ─────────────────────────────────────────────────────────── + +export interface AGUIRunAgentInput { + threadId?: string; + runId?: string; + parentRunId?: string; + state?: unknown; + messages?: AGUIMessage[]; + tools?: AGUIToolDefinition[]; + context?: Array<{ description: string; value: string }>; + forwardedProps?: unknown; +} + +export interface AGUIToolCall { + id: string; + type: "function"; + function: { name: string; arguments: string }; + encryptedValue?: string; +} + +export interface AGUIMessage { + id?: string; + role: string; + content?: string; + name?: string; + toolCallId?: string; + toolCalls?: AGUIToolCall[]; +} + +export interface AGUIToolDefinition { + name: string; + description?: string; + parameters?: unknown; // JSON Schema +} + +// ─── Fixture types ─────────────────────────────────────────────────────────── + +export interface AGUIFixtureMatch { + message?: string | RegExp; + toolName?: string; + stateKey?: string; + predicate?: (input: AGUIRunAgentInput) => boolean; +} + +export interface AGUIFixture { + match: AGUIFixtureMatch; + events: AGUIEvent[]; + delayMs?: number; +} + +export interface AGUIMockOptions { + port?: number; + host?: string; +} + +export interface AGUIRecordConfig { + upstream: string; + fixturePath?: string; + proxyOnly?: boolean; +} From 9fafbd1cd6f86718ad1c89d761dfccbd400369f8 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Thu, 9 Apr 2026 16:18:17 -0700 Subject: [PATCH 2/5] test: add AGUIMock tests and AG-UI schema drift detection 32 unit tests (core, builders, edge cases, record/replay) plus 8 schema drift tests comparing against canonical @ag-ui/core Zod schemas. --- src/__tests__/agui-mock.test.ts | 1000 ++++++++++++++++++++++ src/__tests__/drift/agui-schema.drift.ts | 400 +++++++++ 2 files changed, 1400 insertions(+) create mode 100644 src/__tests__/agui-mock.test.ts create mode 100644 src/__tests__/drift/agui-schema.drift.ts diff --git a/src/__tests__/agui-mock.test.ts b/src/__tests__/agui-mock.test.ts new file mode 100644 index 0000000..2d8081a --- /dev/null +++ b/src/__tests__/agui-mock.test.ts @@ -0,0 +1,1000 @@ +import { describe, it, expect, afterEach, beforeEach } from "vitest"; +import * as http from "node:http"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { AGUIEvent, AGUIRunAgentInput } from "../agui-types.js"; +import { AGUIMock } from "../agui-mock.js"; +import { + buildTextResponse, + buildToolCallResponse, + buildStateUpdate, + buildStateDelta, + buildMessagesSnapshot, + buildReasoningResponse, + buildActivityResponse, + buildErrorResponse, + buildStepWithText, + buildCompositeResponse, + buildTextChunkResponse, +} from "../agui-handler.js"; +import { LLMock } from "../llmock.js"; +import { Journal } from "../journal.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function parseSSEEvents(body: string): AGUIEvent[] { + return body + .split("\n\n") + .filter((chunk) => chunk.startsWith("data: ")) + .map((chunk) => JSON.parse(chunk.slice(6))); +} + +function post( + url: string, + body: object, +): Promise<{ status: number; body: string; headers: http.IncomingHttpHeaders }> { + return new Promise((resolve, reject) => { + const data = JSON.stringify(body); + const parsed = new URL(url); + const req = http.request( + { + hostname: parsed.hostname, + port: parsed.port, + path: parsed.pathname, + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(data), + }, + }, + (res) => { + let responseBody = ""; + res.on("data", (chunk: Buffer) => (responseBody += chunk)); + res.on("end", () => + resolve({ status: res.statusCode!, body: responseBody, headers: res.headers }), + ); + }, + ); + req.on("error", reject); + req.write(data); + req.end(); + }); +} + +function postRaw( + url: string, + rawBody: string, + contentType?: string, +): Promise<{ status: number; body: string; headers: http.IncomingHttpHeaders }> { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const req = http.request( + { + hostname: parsed.hostname, + port: parsed.port, + path: parsed.pathname, + method: "POST", + headers: { + "Content-Type": contentType ?? "text/plain", + "Content-Length": Buffer.byteLength(rawBody), + }, + }, + (res) => { + let responseBody = ""; + res.on("data", (chunk: Buffer) => (responseBody += chunk)); + res.on("end", () => + resolve({ status: res.statusCode!, body: responseBody, headers: res.headers }), + ); + }, + ); + req.on("error", reject); + req.write(rawBody); + req.end(); + }); +} + +function aguiInput(userMessage: string, extra?: Partial): AGUIRunAgentInput { + return { + messages: [{ role: "user", content: userMessage }], + ...extra, + }; +} + +// --------------------------------------------------------------------------- +// Test state +// --------------------------------------------------------------------------- + +let agui: AGUIMock | null = null; +let llmock: LLMock | null = null; + +afterEach(async () => { + if (agui) { + try { + await agui.stop(); + } catch { + /* already stopped */ + } + agui = null; + } + if (llmock) { + try { + await llmock.stop(); + } catch { + /* already stopped */ + } + llmock = null; + } +}); + +// --------------------------------------------------------------------------- +// Core tests (1-14) +// --------------------------------------------------------------------------- + +describe("AGUIMock core", () => { + it("1. standalone start/stop", async () => { + agui = new AGUIMock({ port: 0 }); + const url = await agui.start(); + expect(url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + expect(agui.url).toBe(url); + await agui.stop(); + expect(() => agui!.url).toThrow("not started"); + agui = null; // prevent afterEach double-stop + }); + + it("2. text response", async () => { + agui = new AGUIMock({ port: 0 }); + agui.onMessage("hello", "Hi!"); + await agui.start(); + + const resp = await post(agui.url, aguiInput("hello")); + expect(resp.status).toBe(200); + expect(resp.headers["content-type"]).toBe("text/event-stream"); + + const events = parseSSEEvents(resp.body); + const types = events.map((e) => e.type); + expect(types).toEqual([ + "RUN_STARTED", + "TEXT_MESSAGE_START", + "TEXT_MESSAGE_CONTENT", + "TEXT_MESSAGE_END", + "RUN_FINISHED", + ]); + + const content = events.find((e) => e.type === "TEXT_MESSAGE_CONTENT") as unknown as Record< + string, + unknown + >; + expect(content.delta).toBe("Hi!"); + }); + + it("3. tool call", async () => { + agui = new AGUIMock({ port: 0 }); + agui.onToolCall(/search/, "web_search", '{"q":"test"}', { result: "[]" }); + await agui.start(); + + const resp = await post(agui.url, aguiInput("search for stuff")); + expect(resp.status).toBe(200); + + const events = parseSSEEvents(resp.body); + const types = events.map((e) => e.type); + expect(types).toContain("TOOL_CALL_START"); + expect(types).toContain("TOOL_CALL_ARGS"); + expect(types).toContain("TOOL_CALL_END"); + expect(types).toContain("TOOL_CALL_RESULT"); + + const start = events.find((e) => e.type === "TOOL_CALL_START") as unknown as Record< + string, + unknown + >; + expect(start.toolCallName).toBe("web_search"); + + const args = events.find((e) => e.type === "TOOL_CALL_ARGS") as unknown as Record< + string, + unknown + >; + expect(args.delta).toBe('{"q":"test"}'); + + const result = events.find((e) => e.type === "TOOL_CALL_RESULT") as unknown as Record< + string, + unknown + >; + expect(result.content).toBe("[]"); + }); + + it("4. state snapshot", async () => { + agui = new AGUIMock({ port: 0 }); + agui.onStateKey("counter", { counter: 42 }); + await agui.start(); + + const resp = await post(agui.url, { + messages: [{ role: "user", content: "increment" }], + state: { counter: 10 }, + }); + expect(resp.status).toBe(200); + + const events = parseSSEEvents(resp.body); + const snapshot = events.find((e) => e.type === "STATE_SNAPSHOT") as unknown as Record< + string, + unknown + >; + expect(snapshot).toBeDefined(); + expect(snapshot.snapshot).toEqual({ counter: 42 }); + }); + + it("5. state delta", async () => { + agui = new AGUIMock({ port: 0 }); + const patches = [{ op: "replace", path: "/counter", value: 43 }]; + const events = buildStateDelta(patches); + agui.addFixture({ + match: { stateKey: "counter" }, + events, + }); + await agui.start(); + + const resp = await post(agui.url, { + messages: [], + state: { counter: 42 }, + }); + expect(resp.status).toBe(200); + + const parsed = parseSSEEvents(resp.body); + const delta = parsed.find((e) => e.type === "STATE_DELTA") as unknown as Record< + string, + unknown + >; + expect(delta).toBeDefined(); + expect(delta.delta).toEqual(patches); + }); + + it("6. messages snapshot", async () => { + agui = new AGUIMock({ port: 0 }); + const msgs = [ + { role: "user", content: "hi" }, + { role: "assistant", content: "hello" }, + ]; + const events = buildMessagesSnapshot(msgs); + agui.addFixture({ + match: { message: "snapshot" }, + events, + }); + await agui.start(); + + const resp = await post(agui.url, aguiInput("get snapshot")); + expect(resp.status).toBe(200); + + const parsed = parseSSEEvents(resp.body); + const snap = parsed.find((e) => e.type === "MESSAGES_SNAPSHOT") as unknown as Record< + string, + unknown + >; + expect(snap).toBeDefined(); + expect(snap.messages).toEqual(msgs); + }); + + it("7. raw events", async () => { + agui = new AGUIMock({ port: 0 }); + const rawEvents: AGUIEvent[] = [ + { type: "RUN_STARTED", threadId: "t1", runId: "r1" }, + { type: "TEXT_MESSAGE_START", messageId: "m1", role: "assistant" }, + { type: "TEXT_MESSAGE_CONTENT", messageId: "m1", delta: "raw text" }, + { type: "TEXT_MESSAGE_END", messageId: "m1" }, + { type: "RUN_FINISHED", threadId: "t1", runId: "r1" }, + ]; + agui.onRun("custom", rawEvents); + await agui.start(); + + const resp = await post(agui.url, aguiInput("custom request")); + expect(resp.status).toBe(200); + + const parsed = parseSSEEvents(resp.body); + expect(parsed.map((e) => e.type)).toEqual([ + "RUN_STARTED", + "TEXT_MESSAGE_START", + "TEXT_MESSAGE_CONTENT", + "TEXT_MESSAGE_END", + "RUN_FINISHED", + ]); + // Verify the exact threadId/runId we specified + const started = parsed[0] as unknown as Record; + expect(started.threadId).toBe("t1"); + expect(started.runId).toBe("r1"); + }); + + it("8. predicate matching", async () => { + agui = new AGUIMock({ port: 0 }); + const events = buildTextResponse("predicate matched"); + agui.onPredicate( + (input) => input.state?.["mode" as keyof typeof input.state] === "test", + events, + ); + await agui.start(); + + const resp = await post(agui.url, { + messages: [{ role: "user", content: "anything" }], + state: { mode: "test" }, + }); + expect(resp.status).toBe(200); + + const parsed = parseSSEEvents(resp.body); + const content = parsed.find((e) => e.type === "TEXT_MESSAGE_CONTENT") as unknown as Record< + string, + unknown + >; + expect(content.delta).toBe("predicate matched"); + }); + + it("9. no match returns 404", async () => { + agui = new AGUIMock({ port: 0 }); + agui.onMessage("specific", "response"); + await agui.start(); + + const resp = await post(agui.url, aguiInput("no match here")); + expect(resp.status).toBe(404); + const body = JSON.parse(resp.body); + expect(body.error).toContain("No matching"); + }); + + it("10. mounted on LLMock", async () => { + llmock = new LLMock({ port: 0 }); + agui = new AGUIMock(); + agui.onMessage("hello", "Hi from mount!"); + llmock.mount("/agui", agui); + await llmock.start(); + + const resp = await post(`${llmock.url}/agui`, aguiInput("hello")); + expect(resp.status).toBe(200); + + const events = parseSSEEvents(resp.body); + const types = events.map((e) => e.type); + expect(types).toContain("TEXT_MESSAGE_CONTENT"); + const content = events.find((e) => e.type === "TEXT_MESSAGE_CONTENT") as unknown as Record< + string, + unknown + >; + expect(content.delta).toBe("Hi from mount!"); + }); + + it("11. journal integration", async () => { + agui = new AGUIMock({ port: 0 }); + const journal = new Journal(); + agui.setJournal(journal); + agui.onMessage("hello", "Hi!"); + await agui.start(); + + await post(agui.url, aguiInput("hello")); + + const entries = journal.getAll(); + expect(entries.length).toBe(1); + expect(entries[0].service).toBe("agui"); + expect(entries[0].response.status).toBe(200); + }); + + it("12. timing (delayMs)", async () => { + agui = new AGUIMock({ port: 0 }); + agui.onMessage("slow", "delayed", { delayMs: 50 }); + await agui.start(); + + const start = Date.now(); + const resp = await post(agui.url, aguiInput("slow request")); + const elapsed = Date.now() - start; + + expect(resp.status).toBe(200); + // 5 events * 50ms = 250ms minimum + expect(elapsed).toBeGreaterThanOrEqual(200); + }); + + it("13. reset clears fixtures", async () => { + agui = new AGUIMock({ port: 0 }); + agui.onMessage("hello", "Hi!"); + agui.reset(); + await agui.start(); + + const resp = await post(agui.url, aguiInput("hello")); + expect(resp.status).toBe(404); + }); + + it("14. threadId/runId propagation", async () => { + agui = new AGUIMock({ port: 0 }); + const events = buildTextResponse("ok", { + threadId: "thread-abc", + runId: "run-xyz", + }); + agui.addFixture({ match: { message: "prop" }, events }); + await agui.start(); + + const resp = await post(agui.url, aguiInput("prop test")); + const parsed = parseSSEEvents(resp.body); + + const started = parsed.find((e) => e.type === "RUN_STARTED") as unknown as Record< + string, + unknown + >; + expect(started.threadId).toBe("thread-abc"); + expect(started.runId).toBe("run-xyz"); + + const finished = parsed.find((e) => e.type === "RUN_FINISHED") as unknown as Record< + string, + unknown + >; + expect(finished.threadId).toBe("thread-abc"); + expect(finished.runId).toBe("run-xyz"); + }); +}); + +// --------------------------------------------------------------------------- +// Builder tests (15-19) +// --------------------------------------------------------------------------- + +describe("AGUIMock builders", () => { + it("15. each builder produces correct event types", () => { + // buildTextResponse + const text = buildTextResponse("hello"); + expect(text.map((e) => e.type)).toEqual([ + "RUN_STARTED", + "TEXT_MESSAGE_START", + "TEXT_MESSAGE_CONTENT", + "TEXT_MESSAGE_END", + "RUN_FINISHED", + ]); + const textContent = text.find((e) => e.type === "TEXT_MESSAGE_CONTENT") as unknown as Record< + string, + unknown + >; + expect(textContent.delta).toBe("hello"); + + // buildToolCallResponse + const tool = buildToolCallResponse("search", '{"q":"x"}', { result: "found" }); + const toolTypes = tool.map((e) => e.type); + expect(toolTypes).toContain("TOOL_CALL_START"); + expect(toolTypes).toContain("TOOL_CALL_ARGS"); + expect(toolTypes).toContain("TOOL_CALL_END"); + expect(toolTypes).toContain("TOOL_CALL_RESULT"); + const toolStart = tool.find((e) => e.type === "TOOL_CALL_START") as unknown as Record< + string, + unknown + >; + expect(toolStart.toolCallName).toBe("search"); + + // buildToolCallResponse without result + const toolNoResult = buildToolCallResponse("fn", "{}"); + expect(toolNoResult.map((e) => e.type)).not.toContain("TOOL_CALL_RESULT"); + + // buildStateUpdate + const state = buildStateUpdate({ x: 1 }); + expect(state.map((e) => e.type)).toEqual(["RUN_STARTED", "STATE_SNAPSHOT", "RUN_FINISHED"]); + const snap = state.find((e) => e.type === "STATE_SNAPSHOT") as unknown as Record< + string, + unknown + >; + expect(snap.snapshot).toEqual({ x: 1 }); + + // buildStateDelta + const delta = buildStateDelta([{ op: "add", path: "/y", value: 2 }]); + expect(delta.map((e) => e.type)).toEqual(["RUN_STARTED", "STATE_DELTA", "RUN_FINISHED"]); + + // buildMessagesSnapshot + const msgs = buildMessagesSnapshot([{ role: "user", content: "hi" }]); + expect(msgs.map((e) => e.type)).toEqual(["RUN_STARTED", "MESSAGES_SNAPSHOT", "RUN_FINISHED"]); + + // buildErrorResponse + const err = buildErrorResponse("something broke", "ERR_500"); + expect(err.map((e) => e.type)).toEqual(["RUN_STARTED", "RUN_ERROR"]); + const errEvent = err.find((e) => e.type === "RUN_ERROR") as unknown as Record; + expect(errEvent.message).toBe("something broke"); + expect(errEvent.code).toBe("ERR_500"); + + // buildStepWithText + const step = buildStepWithText("analyze", "step result"); + expect(step.map((e) => e.type)).toEqual([ + "RUN_STARTED", + "STEP_STARTED", + "TEXT_MESSAGE_START", + "TEXT_MESSAGE_CONTENT", + "TEXT_MESSAGE_END", + "STEP_FINISHED", + "RUN_FINISHED", + ]); + const stepStarted = step.find((e) => e.type === "STEP_STARTED") as unknown as Record< + string, + unknown + >; + expect(stepStarted.stepName).toBe("analyze"); + + // buildReasoningResponse + const reasoning = buildReasoningResponse("thinking..."); + expect(reasoning.map((e) => e.type)).toEqual([ + "RUN_STARTED", + "REASONING_START", + "REASONING_MESSAGE_START", + "REASONING_MESSAGE_CONTENT", + "REASONING_MESSAGE_END", + "REASONING_END", + "RUN_FINISHED", + ]); + const reasonContent = reasoning.find( + (e) => e.type === "REASONING_MESSAGE_CONTENT", + ) as unknown as Record; + expect(reasonContent.delta).toBe("thinking..."); + + // buildActivityResponse + const activity = buildActivityResponse("msg-1", "progress", { percent: 50 }); + expect(activity.map((e) => e.type)).toEqual([ + "RUN_STARTED", + "ACTIVITY_SNAPSHOT", + "RUN_FINISHED", + ]); + const actSnap = activity.find((e) => e.type === "ACTIVITY_SNAPSHOT") as unknown as Record< + string, + unknown + >; + expect(actSnap.activityType).toBe("progress"); + expect(actSnap.content).toEqual({ percent: 50 }); + }); + + it("16. buildCompositeResponse wraps multiple builder outputs", () => { + const text = buildTextResponse("hello"); + const tool = buildToolCallResponse("fn", "{}"); + const composite = buildCompositeResponse([text, tool]); + + const types = composite.map((e) => e.type); + // Should have exactly one RUN_STARTED and one RUN_FINISHED + expect(types.filter((t) => t === "RUN_STARTED")).toHaveLength(1); + expect(types.filter((t) => t === "RUN_FINISHED")).toHaveLength(1); + expect(types[0]).toBe("RUN_STARTED"); + expect(types[types.length - 1]).toBe("RUN_FINISHED"); + + // Should contain inner events from both builders + expect(types).toContain("TEXT_MESSAGE_START"); + expect(types).toContain("TEXT_MESSAGE_CONTENT"); + expect(types).toContain("TOOL_CALL_START"); + expect(types).toContain("TOOL_CALL_ARGS"); + }); + + it("17. CHUNK events stream correctly", async () => { + agui = new AGUIMock({ port: 0 }); + const chunkEvents = buildTextChunkResponse("chunked text"); + agui.addFixture({ match: { message: "chunk" }, events: chunkEvents }); + await agui.start(); + + const resp = await post(agui.url, aguiInput("chunk me")); + expect(resp.status).toBe(200); + + const events = parseSSEEvents(resp.body); + const types = events.map((e) => e.type); + expect(types).toContain("TEXT_MESSAGE_CHUNK"); + const chunk = events.find((e) => e.type === "TEXT_MESSAGE_CHUNK") as unknown as Record< + string, + unknown + >; + expect(chunk.delta).toBe("chunked text"); + }); + + it("18. reasoning sequence", async () => { + agui = new AGUIMock({ port: 0 }); + agui.onReasoning("think", "reasoning text"); + await agui.start(); + + const resp = await post(agui.url, aguiInput("think about this")); + expect(resp.status).toBe(200); + + const events = parseSSEEvents(resp.body); + const types = events.map((e) => e.type); + expect(types).toEqual([ + "RUN_STARTED", + "REASONING_START", + "REASONING_MESSAGE_START", + "REASONING_MESSAGE_CONTENT", + "REASONING_MESSAGE_END", + "REASONING_END", + "RUN_FINISHED", + ]); + const content = events.find((e) => e.type === "REASONING_MESSAGE_CONTENT") as unknown as Record< + string, + unknown + >; + expect(content.delta).toBe("reasoning text"); + }); + + it("19. activity events", async () => { + agui = new AGUIMock({ port: 0 }); + const events = buildActivityResponse("act-1", "loading", { step: "fetching" }); + agui.addFixture({ match: { message: "activity" }, events }); + await agui.start(); + + const resp = await post(agui.url, aguiInput("show activity")); + expect(resp.status).toBe(200); + + const parsed = parseSSEEvents(resp.body); + const act = parsed.find((e) => e.type === "ACTIVITY_SNAPSHOT") as unknown as Record< + string, + unknown + >; + expect(act).toBeDefined(); + expect(act.activityType).toBe("loading"); + expect(act.content).toEqual({ step: "fetching" }); + }); +}); + +// --------------------------------------------------------------------------- +// Edge cases (20-26) +// --------------------------------------------------------------------------- + +describe("AGUIMock edge cases", () => { + it("20. client disconnect mid-stream does not crash", async () => { + agui = new AGUIMock({ port: 0 }); + // Use delay to give us time to disconnect + agui.onMessage("slow", "delayed response", { delayMs: 100 }); + await agui.start(); + + await new Promise((resolve) => { + const parsed = new URL(agui!.url); + const data = JSON.stringify(aguiInput("slow stream")); + const req = http.request( + { + hostname: parsed.hostname, + port: parsed.port, + path: parsed.pathname, + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(data), + }, + }, + (res) => { + // Read first chunk then destroy + res.once("data", () => { + req.destroy(); + // Give the server a moment to notice the disconnect + setTimeout(resolve, 150); + }); + }, + ); + req.write(data); + req.end(); + }); + + // Server should still be responsive + agui.onMessage("after", "still alive"); + // Clear existing fixture first — we want to verify the server is still up + const resp = await post(agui.url, aguiInput("after disconnect")); + expect(resp.status).toBe(200); + }); + + it("21. invalid POST body returns 400", async () => { + agui = new AGUIMock({ port: 0 }); + await agui.start(); + + const resp = await postRaw(agui.url, "not json {{{{", "application/json"); + expect(resp.status).toBe(400); + const body = JSON.parse(resp.body); + expect(body.error).toContain("Invalid JSON"); + }); + + it("22. multiple sequential runs in one fixture", async () => { + agui = new AGUIMock({ port: 0 }); + // Manually construct events with two RUN_STARTED/RUN_FINISHED pairs + const events: AGUIEvent[] = [ + { type: "RUN_STARTED", threadId: "t1", runId: "r1" }, + { type: "TEXT_MESSAGE_START", messageId: "m1", role: "assistant" }, + { type: "TEXT_MESSAGE_CONTENT", messageId: "m1", delta: "first" }, + { type: "TEXT_MESSAGE_END", messageId: "m1" }, + { type: "RUN_FINISHED", threadId: "t1", runId: "r1" }, + { type: "RUN_STARTED", threadId: "t1", runId: "r2" }, + { type: "TEXT_MESSAGE_START", messageId: "m2", role: "assistant" }, + { type: "TEXT_MESSAGE_CONTENT", messageId: "m2", delta: "second" }, + { type: "TEXT_MESSAGE_END", messageId: "m2" }, + { type: "RUN_FINISHED", threadId: "t1", runId: "r2" }, + ]; + agui.addFixture({ match: { message: "multi" }, events }); + await agui.start(); + + const resp = await post(agui.url, aguiInput("multi run")); + expect(resp.status).toBe(200); + + const parsed = parseSSEEvents(resp.body); + const runStarted = parsed.filter((e) => e.type === "RUN_STARTED"); + const runFinished = parsed.filter((e) => e.type === "RUN_FINISHED"); + expect(runStarted).toHaveLength(2); + expect(runFinished).toHaveLength(2); + }); + + it("23. deprecated THINKING events stream", async () => { + agui = new AGUIMock({ port: 0 }); + const events: AGUIEvent[] = [ + { type: "RUN_STARTED", threadId: "t1", runId: "r1" }, + { type: "THINKING_TEXT_MESSAGE_START" }, + { type: "THINKING_TEXT_MESSAGE_CONTENT", delta: "pondering..." }, + { type: "THINKING_TEXT_MESSAGE_END" }, + { type: "RUN_FINISHED", threadId: "t1", runId: "r1" }, + ]; + agui.addFixture({ match: { message: "think" }, events }); + await agui.start(); + + const resp = await post(agui.url, aguiInput("think deeply")); + expect(resp.status).toBe(200); + + const parsed = parseSSEEvents(resp.body); + const types = parsed.map((e) => e.type); + expect(types).toContain("THINKING_TEXT_MESSAGE_START"); + expect(types).toContain("THINKING_TEXT_MESSAGE_CONTENT"); + expect(types).toContain("THINKING_TEXT_MESSAGE_END"); + const thinking = parsed.find( + (e) => e.type === "THINKING_TEXT_MESSAGE_CONTENT", + ) as unknown as Record; + expect(thinking.delta).toBe("pondering..."); + }); + + it("24. CUSTOM and RAW events stream", async () => { + agui = new AGUIMock({ port: 0 }); + const events: AGUIEvent[] = [ + { type: "RUN_STARTED", threadId: "t1", runId: "r1" }, + { type: "CUSTOM", name: "my-event", value: { foo: "bar" } }, + { type: "RAW", event: { raw: true }, source: "test" }, + { type: "RUN_FINISHED", threadId: "t1", runId: "r1" }, + ]; + agui.addFixture({ match: { message: "special" }, events }); + await agui.start(); + + const resp = await post(agui.url, aguiInput("special events")); + expect(resp.status).toBe(200); + + const parsed = parseSSEEvents(resp.body); + const custom = parsed.find((e) => e.type === "CUSTOM") as unknown as Record; + expect(custom.name).toBe("my-event"); + expect(custom.value).toEqual({ foo: "bar" }); + + const raw = parsed.find((e) => e.type === "RAW") as unknown as Record; + expect(raw.event).toEqual({ raw: true }); + expect(raw.source).toBe("test"); + }); + + it("25. concurrent SSE streams", async () => { + agui = new AGUIMock({ port: 0 }); + agui.onMessage("alpha", "Alpha response"); + agui.onMessage("beta", "Beta response"); + await agui.start(); + + const [respA, respB] = await Promise.all([ + post(agui.url, aguiInput("alpha request")), + post(agui.url, aguiInput("beta request")), + ]); + + expect(respA.status).toBe(200); + expect(respB.status).toBe(200); + + const eventsA = parseSSEEvents(respA.body); + const eventsB = parseSSEEvents(respB.body); + + const contentA = eventsA.find((e) => e.type === "TEXT_MESSAGE_CONTENT") as unknown as Record< + string, + unknown + >; + const contentB = eventsB.find((e) => e.type === "TEXT_MESSAGE_CONTENT") as unknown as Record< + string, + unknown + >; + + expect(contentA.delta).toBe("Alpha response"); + expect(contentB.delta).toBe("Beta response"); + }); + + it("26. empty messages array still matches predicates/stateKey", async () => { + agui = new AGUIMock({ port: 0 }); + agui.onStateKey("mode", { mode: "active" }); + await agui.start(); + + const resp = await post(agui.url, { + messages: [], + state: { mode: "idle" }, + }); + expect(resp.status).toBe(200); + + const events = parseSSEEvents(resp.body); + const snap = events.find((e) => e.type === "STATE_SNAPSHOT") as unknown as Record< + string, + unknown + >; + expect(snap.snapshot).toEqual({ mode: "active" }); + }); +}); + +// --------------------------------------------------------------------------- +// Record & replay (27-32) +// --------------------------------------------------------------------------- + +describe("AGUIMock record & replay", () => { + let upstream: AGUIMock | null = null; + let tmpDir: string = ""; + let requestCount = 0; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agui-rec-")); + requestCount = 0; + }); + + afterEach(async () => { + if (upstream) { + try { + await upstream.stop(); + } catch { + /* already stopped */ + } + upstream = null; + } + // Clean up temp dir + if (tmpDir) { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + } + }); + + /** + * Start an upstream AGUIMock that counts requests via a predicate fixture. + */ + async function startUpstreamWithCounter(responseText: string): Promise { + upstream = new AGUIMock({ port: 0 }); + const events = buildTextResponse(responseText); + upstream.onPredicate(() => { + requestCount++; + return true; + }, events); + return upstream.start(); + } + + it("27. proxy-only mode proxies to upstream, does NOT write to disk", async () => { + const upstreamUrl = await startUpstreamWithCounter("upstream reply"); + + agui = new AGUIMock({ port: 0 }); + agui.enableRecording({ upstream: upstreamUrl, proxyOnly: true, fixturePath: tmpDir }); + await agui.start(); + + const resp = await post(agui.url, aguiInput("hello proxy")); + expect(resp.status).toBe(200); + + const events = parseSSEEvents(resp.body); + const content = events.find((e) => e.type === "TEXT_MESSAGE_CONTENT") as unknown as Record< + string, + unknown + >; + expect(content.delta).toBe("upstream reply"); + + // Verify no files were written to temp dir + const files = fs.readdirSync(tmpDir); + expect(files).toHaveLength(0); + }); + + it("28. record mode proxies, writes fixture, caches in memory", async () => { + const upstreamUrl = await startUpstreamWithCounter("recorded reply"); + + agui = new AGUIMock({ port: 0 }); + agui.enableRecording({ upstream: upstreamUrl, proxyOnly: false, fixturePath: tmpDir }); + await agui.start(); + + const resp = await post(agui.url, aguiInput("hello record")); + expect(resp.status).toBe(200); + + const events = parseSSEEvents(resp.body); + const content = events.find((e) => e.type === "TEXT_MESSAGE_CONTENT") as unknown as Record< + string, + unknown + >; + expect(content.delta).toBe("recorded reply"); + + // Verify fixture file was created + const files = fs.readdirSync(tmpDir); + expect(files.length).toBeGreaterThan(0); + + // Verify file is valid JSON with fixtures array + const fileContent = fs.readFileSync(path.join(tmpDir, files[0]), "utf-8"); + const parsed = JSON.parse(fileContent); + expect(parsed.fixtures).toBeDefined(); + expect(Array.isArray(parsed.fixtures)).toBe(true); + expect(parsed.fixtures.length).toBe(1); + }); + + it("29. second identical request matches recorded fixture (record mode)", async () => { + const upstreamUrl = await startUpstreamWithCounter("once only"); + + agui = new AGUIMock({ port: 0 }); + agui.enableRecording({ upstream: upstreamUrl, proxyOnly: false, fixturePath: tmpDir }); + await agui.start(); + + // First request — hits upstream + await post(agui.url, aguiInput("hello cached")); + expect(requestCount).toBe(1); + + // Second identical request — should match in-memory fixture + const resp2 = await post(agui.url, aguiInput("hello cached")); + expect(resp2.status).toBe(200); + expect(requestCount).toBe(1); // upstream NOT hit again + + const events = parseSSEEvents(resp2.body); + const content = events.find((e) => e.type === "TEXT_MESSAGE_CONTENT") as unknown as Record< + string, + unknown + >; + expect(content.delta).toBe("once only"); + }); + + it("30. second identical request re-proxies (proxy-only mode)", async () => { + const upstreamUrl = await startUpstreamWithCounter("always proxy"); + + agui = new AGUIMock({ port: 0 }); + agui.enableRecording({ upstream: upstreamUrl, proxyOnly: true, fixturePath: tmpDir }); + await agui.start(); + + // First request + await post(agui.url, aguiInput("hello again")); + expect(requestCount).toBe(1); + + // Second identical request — should hit upstream again (no caching) + await post(agui.url, aguiInput("hello again")); + expect(requestCount).toBe(2); + }); + + it("31. recorded fixture file format is valid", async () => { + const upstreamUrl = await startUpstreamWithCounter("format check"); + + agui = new AGUIMock({ port: 0 }); + agui.enableRecording({ upstream: upstreamUrl, proxyOnly: false, fixturePath: tmpDir }); + await agui.start(); + + await post(agui.url, aguiInput("validate format")); + + const files = fs.readdirSync(tmpDir); + expect(files.length).toBe(1); + + const fileContent = fs.readFileSync(path.join(tmpDir, files[0]), "utf-8"); + const parsed = JSON.parse(fileContent); + + // Verify structure: { fixtures: [{ match: { message: ... }, events: [...] }] } + expect(parsed).toHaveProperty("fixtures"); + expect(parsed.fixtures).toHaveLength(1); + + const fixture = parsed.fixtures[0]; + expect(fixture).toHaveProperty("match"); + expect(fixture.match).toHaveProperty("message"); + expect(fixture.match.message).toBe("validate format"); + + expect(fixture).toHaveProperty("events"); + expect(Array.isArray(fixture.events)).toBe(true); + expect(fixture.events.length).toBeGreaterThan(0); + + // Verify events contain expected AG-UI types + const types = fixture.events.map((e: AGUIEvent) => e.type); + expect(types).toContain("RUN_STARTED"); + expect(types).toContain("RUN_FINISHED"); + expect(types).toContain("TEXT_MESSAGE_CONTENT"); + }); + + it("32. client receives real-time stream during recording", async () => { + const upstreamUrl = await startUpstreamWithCounter("streamed"); + + agui = new AGUIMock({ port: 0 }); + agui.enableRecording({ upstream: upstreamUrl, proxyOnly: false, fixturePath: tmpDir }); + await agui.start(); + + const resp = await post(agui.url, aguiInput("stream check")); + expect(resp.status).toBe(200); + expect(resp.headers["content-type"]).toBe("text/event-stream"); + + // Verify proper SSE format — body should contain "data: " lines separated by double newlines + expect(resp.body).toContain("data: "); + expect(resp.body).toContain("\n\n"); + + // Verify we can parse all events from the stream + const events = parseSSEEvents(resp.body); + expect(events.length).toBeGreaterThan(0); + + const types = events.map((e) => e.type); + expect(types).toContain("RUN_STARTED"); + expect(types).toContain("TEXT_MESSAGE_CONTENT"); + expect(types).toContain("RUN_FINISHED"); + }); +}); diff --git a/src/__tests__/drift/agui-schema.drift.ts b/src/__tests__/drift/agui-schema.drift.ts new file mode 100644 index 0000000..fd64f96 --- /dev/null +++ b/src/__tests__/drift/agui-schema.drift.ts @@ -0,0 +1,400 @@ +/** + * AG-UI schema drift test. + * + * Compares aimock's AGUIEventType union and event interfaces against the + * canonical Zod schemas in @ag-ui/core (read from disk via static analysis). + * No runtime dependency on @ag-ui/core — purely regex-based parsing. + */ + +import { describe, it, expect } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +// --------------------------------------------------------------------------- +// Paths +// --------------------------------------------------------------------------- + +const CANONICAL_EVENTS_PATH = path.resolve( + import.meta.dirname, + "../../../../ag-ui/sdks/typescript/packages/core/src/events.ts", +); +const AIMOCK_TYPES_PATH = path.resolve(import.meta.dirname, "../../agui-types.ts"); + +// --------------------------------------------------------------------------- +// Canonical parser — extract EventType enum values from ag-ui events.ts +// --------------------------------------------------------------------------- + +interface FieldInfo { + name: string; + optional: boolean; +} + +interface SchemaInfo { + eventType: string; + fields: FieldInfo[]; +} + +function parseCanonicalEventTypes(source: string): string[] { + const enumBlock = source.match(/export enum EventType\s*\{([\s\S]*?)\}/); + if (!enumBlock) return []; + const members: string[] = []; + for (const m of enumBlock[1].matchAll(/(\w+)\s*=\s*"(\w+)"/g)) { + members.push(m[2]); + } + return members; +} + +/** + * Extract field definitions from a Zod `.extend({...})` block body. + */ +function extractExtendFields(extendBody: string): FieldInfo[] { + const fields: FieldInfo[] = []; + for (const fieldMatch of extendBody.matchAll(/(\w+)\s*:\s*([^\n,]+)/g)) { + const fieldName = fieldMatch[1]; + const fieldDef = fieldMatch[2].trim(); + if (fieldDef.startsWith("//")) continue; + const optional = fieldDef.includes(".optional()") || fieldDef.includes(".default("); + fields.push({ name: fieldName, optional }); + } + return fields; +} + +/** + * Parse Zod `.extend({...})` blocks to extract field names and optionality. + * + * Two-pass approach: + * 1. First pass: collect all schema definitions and their raw extend fields. + * 2. Second pass: resolve parent schema chains to inherit fields correctly. + * + * This handles chains like: + * TextMessageContentEventSchema.omit({...}).extend({...}) + * where ThinkingTextMessageContentEventSchema inherits delta from TextMessageContent. + */ +function parseCanonicalSchemas(source: string): Map { + const schemas = new Map(); + + // Base event fields (always inherited) + const baseFields: FieldInfo[] = [ + { name: "type", optional: false }, + { name: "timestamp", optional: true }, + { name: "rawEvent", optional: true }, + ]; + + // Pass 1: collect raw schema definitions keyed by schema name + interface RawSchema { + schemaName: string; + body: string; + eventType: string; + parentSchemaName: string | null; // null = BaseEventSchema + } + + const rawSchemas = new Map(); + // Also store fields per schema name (not event type) for parent resolution + const fieldsBySchemaName = new Map(); + + const schemaPattern = + /export const (\w+EventSchema)\s*=\s*([\s\S]*?)(?=\nexport const |\nexport type |\nexport enum |\n\/\/ |$)/g; + + for (const match of source.matchAll(schemaPattern)) { + const schemaName = match[1]; + const body = match[2]; + + if (schemaName === "BaseEventSchema" || schemaName === "EventSchemas") continue; + if (schemaName === "ReasoningEncryptedValueSubtypeSchema") continue; + + const typeMatch = body.match(/z\.literal\(EventType\.(\w+)\)/); + if (!typeMatch) continue; + const eventType = typeMatch[1]; + + // Detect parent schema: anything before .omit() or .extend() + // e.g. "TextMessageContentEventSchema.omit({...}).extend({...})" + const parentMatch = body.match(/^(\w+EventSchema)(?:\.omit|\.extend)/); + const parentSchemaName = + parentMatch && parentMatch[1] !== "BaseEventSchema" ? parentMatch[1] : null; + + rawSchemas.set(schemaName, { schemaName, body, eventType, parentSchemaName }); + + // Collect this schema's own extend fields + const ownFields: FieldInfo[] = []; + const extendPattern = /\.extend\(\{([\s\S]*?)\}\)/g; + for (const extendMatch of body.matchAll(extendPattern)) { + ownFields.push(...extractExtendFields(extendMatch[1])); + } + fieldsBySchemaName.set(schemaName, ownFields); + } + + // Pass 2: resolve full field sets with parent inheritance + for (const [, raw] of rawSchemas) { + const fields = new Map(); + + // Start with base fields + for (const f of baseFields) { + fields.set(f.name, { ...f }); + } + + // If there's a parent schema (not BaseEventSchema), inherit its extend fields + if (raw.parentSchemaName) { + const parentFields = fieldsBySchemaName.get(raw.parentSchemaName); + if (parentFields) { + for (const f of parentFields) { + fields.set(f.name, { ...f }); + } + } + } + + // Apply .omit() — removes fields + const omitMatch = raw.body.match(/\.omit\(\{([\s\S]*?)\}\)/); + if (omitMatch) { + for (const omitField of omitMatch[1].matchAll(/(\w+)\s*:\s*true/g)) { + fields.delete(omitField[1]); + } + } + + // Apply this schema's own extend fields (overrides parent) + const ownFields = fieldsBySchemaName.get(raw.schemaName) || []; + for (const f of ownFields) { + fields.set(f.name, { ...f }); + } + + schemas.set(raw.eventType, { + eventType: raw.eventType, + fields: Array.from(fields.values()), + }); + } + + return schemas; +} + +// --------------------------------------------------------------------------- +// Aimock parser — extract AGUIEventType members and interface fields +// --------------------------------------------------------------------------- + +function parseAimockEventTypes(source: string): string[] { + const unionBlock = source.match(/export type AGUIEventType\s*=([\s\S]*?);/); + if (!unionBlock) return []; + const members: string[] = []; + for (const m of unionBlock[1].matchAll(/"(\w+)"/g)) { + members.push(m[1]); + } + return members; +} + +function parseAimockInterfaces(source: string): Map { + const interfaces = new Map(); + + // Match interface blocks + const interfacePattern = /export interface AGUI(\w+Event)\s+extends\s+\w+\s*\{([\s\S]*?)\}/g; + + for (const match of source.matchAll(interfacePattern)) { + const body = match[2]; + + // Extract the event type from the `type: "XXX"` field + const typeMatch = body.match(/type:\s*"(\w+)"/); + if (!typeMatch) continue; + const eventType = typeMatch[1]; + + // Start with base fields (all extend AGUIBaseEvent) + const fields: FieldInfo[] = [ + { name: "type", optional: false }, + { name: "timestamp", optional: true }, + { name: "rawEvent", optional: true }, + ]; + + // Parse fields from the interface body + for (const fieldMatch of body.matchAll(/(\w+)(\??)\s*:\s*([^;]+);/g)) { + const fieldName = fieldMatch[1]; + if (fieldName === "type") continue; // already added from base + const optional = fieldMatch[2] === "?"; + fields.push({ name: fieldName, optional }); + } + + interfaces.set(eventType, { + eventType, + fields, + }); + } + + return interfaces; +} + +// --------------------------------------------------------------------------- +// Drift reporting +// --------------------------------------------------------------------------- + +type Severity = "CRITICAL" | "WARNING" | "OK"; + +interface DriftItem { + severity: Severity; + message: string; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +const canonicalExists = fs.existsSync(CANONICAL_EVENTS_PATH); +const aimockExists = fs.existsSync(AIMOCK_TYPES_PATH); + +describe.skipIf(!canonicalExists)("AG-UI schema drift", () => { + let canonicalSource: string; + let aimockSource: string; + let canonicalTypes: string[]; + let aimockTypes: string[]; + let canonicalSchemas: Map; + let aimockInterfaces: Map; + + // Parse sources once + if (canonicalExists && aimockExists) { + canonicalSource = fs.readFileSync(CANONICAL_EVENTS_PATH, "utf-8"); + aimockSource = fs.readFileSync(AIMOCK_TYPES_PATH, "utf-8"); + canonicalTypes = parseCanonicalEventTypes(canonicalSource); + aimockTypes = parseAimockEventTypes(aimockSource); + canonicalSchemas = parseCanonicalSchemas(canonicalSource); + aimockInterfaces = parseAimockInterfaces(aimockSource); + } + + it("should have canonical events.ts available", () => { + expect(canonicalExists).toBe(true); + expect(aimockExists).toBe(true); + }); + + it("should parse canonical event types", () => { + expect(canonicalTypes.length).toBeGreaterThan(0); + expect(canonicalTypes).toContain("RUN_STARTED"); + expect(canonicalTypes).toContain("TEXT_MESSAGE_START"); + }); + + it("should parse aimock event types", () => { + expect(aimockTypes.length).toBeGreaterThan(0); + expect(aimockTypes).toContain("RUN_STARTED"); + expect(aimockTypes).toContain("TEXT_MESSAGE_START"); + }); + + it("all canonical event types are present in aimock", () => { + const aimockSet = new Set(aimockTypes); + const missing: DriftItem[] = []; + + for (const eventType of canonicalTypes) { + if (!aimockSet.has(eventType)) { + missing.push({ + severity: "CRITICAL", + message: `Event type "${eventType}" exists in canonical @ag-ui/core but is missing from aimock AGUIEventType`, + }); + } + } + + if (missing.length > 0) { + const report = missing.map((d) => `[${d.severity}] ${d.message}`).join("\n"); + expect(missing, `Missing event types:\n${report}`).toEqual([]); + } + }); + + it("no unknown event types in aimock", () => { + const canonicalSet = new Set(canonicalTypes); + const extras: DriftItem[] = []; + + for (const eventType of aimockTypes) { + if (!canonicalSet.has(eventType)) { + extras.push({ + severity: "WARNING", + message: `Event type "${eventType}" exists in aimock but not in canonical @ag-ui/core (extra or deprecated?)`, + }); + } + } + + if (extras.length > 0) { + const report = extras.map((d) => `[${d.severity}] ${d.message}`).join("\n"); + // Warnings don't fail the test, just log + console.warn(`Extra event types in aimock:\n${report}`); + } + + // This test always passes — extras are warnings, not failures + expect(true).toBe(true); + }); + + it("event field shapes match canonical schemas", () => { + const drifts: DriftItem[] = []; + + for (const [eventType, canonical] of canonicalSchemas) { + const aimock = aimockInterfaces.get(eventType); + if (!aimock) { + // Missing event type is already caught by the event types test + continue; + } + + const canonicalFieldMap = new Map(canonical.fields.map((f) => [f.name, f])); + const aimockFieldMap = new Map(aimock.fields.map((f) => [f.name, f])); + + // Fields in canonical but missing from aimock + for (const [fieldName, fieldInfo] of canonicalFieldMap) { + const aimockField = aimockFieldMap.get(fieldName); + if (!aimockField) { + drifts.push({ + severity: "CRITICAL", + message: `${eventType}: field "${fieldName}" (${fieldInfo.optional ? "optional" : "required"}) exists in canonical but missing from aimock`, + }); + } + } + + // Fields in aimock but not in canonical + for (const [fieldName] of aimockFieldMap) { + if (!canonicalFieldMap.has(fieldName)) { + drifts.push({ + severity: "WARNING", + message: `${eventType}: field "${fieldName}" exists in aimock but not in canonical`, + }); + } + } + + // Optionality mismatches + for (const [fieldName, canonicalField] of canonicalFieldMap) { + const aimockField = aimockFieldMap.get(fieldName); + if (aimockField && canonicalField.optional !== aimockField.optional) { + drifts.push({ + severity: "WARNING", + message: `${eventType}: field "${fieldName}" optionality mismatch — canonical: ${canonicalField.optional ? "optional" : "required"}, aimock: ${aimockField.optional ? "optional" : "required"}`, + }); + } + } + } + + const criticals = drifts.filter((d) => d.severity === "CRITICAL"); + const warnings = drifts.filter((d) => d.severity === "WARNING"); + + if (warnings.length > 0) { + console.warn( + `Field warnings:\n${warnings.map((d) => ` [${d.severity}] ${d.message}`).join("\n")}`, + ); + } + + if (criticals.length > 0) { + const report = criticals.map((d) => ` [${d.severity}] ${d.message}`).join("\n"); + expect(criticals, `Critical field drift:\n${report}`).toEqual([]); + } + }); + + it("canonical schemas were parsed successfully", () => { + // Sanity check: we should have parsed schemas for most event types + expect(canonicalSchemas.size).toBeGreaterThan(20); + + // Spot-check a few known schemas + const runStarted = canonicalSchemas.get("RUN_STARTED"); + expect(runStarted).toBeDefined(); + expect(runStarted!.fields.map((f) => f.name)).toContain("threadId"); + expect(runStarted!.fields.map((f) => f.name)).toContain("runId"); + + const toolCallStart = canonicalSchemas.get("TOOL_CALL_START"); + expect(toolCallStart).toBeDefined(); + expect(toolCallStart!.fields.map((f) => f.name)).toContain("toolCallId"); + expect(toolCallStart!.fields.map((f) => f.name)).toContain("toolCallName"); + }); + + it("aimock interfaces were parsed successfully", () => { + expect(aimockInterfaces.size).toBeGreaterThan(20); + + const runStarted = aimockInterfaces.get("RUN_STARTED"); + expect(runStarted).toBeDefined(); + expect(runStarted!.fields.map((f) => f.name)).toContain("threadId"); + expect(runStarted!.fields.map((f) => f.name)).toContain("runId"); + }); +}); From 9dc9ef935e81406d0ffb3760ab06e5ef76d2b88d Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Thu, 9 Apr 2026 16:18:25 -0700 Subject: [PATCH 3/5] feat: wire AGUIMock into config, suite, exports, and CLI --- src/cli.ts | 51 ++++++++++++++++++++++++++++++++++---------- src/config-loader.ts | 47 ++++++++++++++++++++++++++++++++++++++++ src/index.ts | 49 ++++++++++++++++++++++++++++++++++++++++++ src/suite.ts | 12 +++++++++++ 4 files changed, 148 insertions(+), 11 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 0fc7d27..edd089e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,6 +6,7 @@ import { createServer } from "./server.js"; import { loadFixtureFile, loadFixturesFromDir, validateFixtures } from "./fixture-loader.js"; import { Logger, type LogLevel } from "./logger.js"; import { watchFixtures } from "./watcher.js"; +import { AGUIMock } from "./agui-mock.js"; import type { ChaosConfig, RecordConfig } from "./types.js"; const HELP = ` @@ -32,6 +33,9 @@ Options: --provider-azure Upstream URL for Azure OpenAI --provider-ollama Upstream URL for Ollama --provider-cohere Upstream URL for Cohere + --agui-record Enable AG-UI recording (proxy unmatched AG-UI requests) + --agui-upstream Upstream AG-UI agent URL (used with --agui-record) + --agui-proxy-only AG-UI proxy mode: forward without saving --chaos-drop Probability (0-1) of dropping requests with 500 --chaos-malformed Probability (0-1) of returning malformed JSON --chaos-disconnect Probability (0-1) of destroying connection @@ -60,6 +64,9 @@ const { values } = parseArgs({ "provider-azure": { type: "string" }, "provider-ollama": { type: "string" }, "provider-cohere": { type: "string" }, + "agui-record": { type: "boolean", default: false }, + "agui-upstream": { type: "string" }, + "agui-proxy-only": { type: "boolean", default: false }, "chaos-drop": { type: "string" }, "chaos-malformed": { type: "string" }, "chaos-disconnect": { type: "string" }, @@ -168,6 +175,22 @@ if (values.record || values["proxy-only"]) { }; } +// Parse AG-UI record/proxy config from CLI flags +let aguiMount: { path: string; handler: AGUIMock } | undefined; +if (values["agui-record"] || values["agui-proxy-only"]) { + if (!values["agui-upstream"]) { + console.error("Error: --agui-record/--agui-proxy-only requires --agui-upstream"); + process.exit(1); + } + const agui = new AGUIMock(); + agui.enableRecording({ + upstream: values["agui-upstream"], + fixturePath: resolve(fixturePath, "agui-recorded"), + proxyOnly: values["agui-proxy-only"], + }); + aguiMount = { path: "/agui", handler: agui }; +} + async function main() { // Load fixtures from path (detect file vs directory) let isDir: boolean; @@ -219,17 +242,23 @@ async function main() { } } - const instance = await createServer(fixtures, { - port, - host, - latency, - chunkSize, - logLevel, - chaos, - metrics: values.metrics, - record, - strict: values.strict, - }); + const mounts = aguiMount ? [aguiMount] : undefined; + + const instance = await createServer( + fixtures, + { + port, + host, + latency, + chunkSize, + logLevel, + chaos, + metrics: values.metrics, + record, + strict: values.strict, + }, + mounts, + ); logger.info(`aimock server listening on ${instance.url}`); diff --git a/src/config-loader.ts b/src/config-loader.ts index df67772..2127e7f 100644 --- a/src/config-loader.ts +++ b/src/config-loader.ts @@ -3,9 +3,11 @@ import * as path from "node:path"; import { LLMock } from "./llmock.js"; import { MCPMock } from "./mcp-mock.js"; import { A2AMock } from "./a2a-mock.js"; +import { AGUIMock } from "./agui-mock.js"; import type { ChaosConfig, RecordConfig } from "./types.js"; import type { MCPToolDefinition, MCPPromptDefinition } from "./mcp-types.js"; import type { A2AAgentDefinition, A2APart, A2AArtifact, A2AStreamEvent } from "./a2a-types.js"; +import type { AGUIEvent } from "./agui-types.js"; import { VectorMock } from "./vector-mock.js"; import type { QueryResult } from "./vector-types.js"; import { Logger } from "./logger.js"; @@ -56,6 +58,18 @@ export interface A2AConfig { agents?: A2AConfigAgent[]; } +export interface AGUIConfigFixture { + match: { message?: string; toolName?: string; stateKey?: string }; + text?: string; // shorthand: uses buildTextResponse + events?: AGUIEvent[]; // raw events + delayMs?: number; +} + +export interface AGUIConfig { + path?: string; // mount path, default "/agui" + fixtures?: AGUIConfigFixture[]; +} + export interface VectorConfigCollection { name: string; dimension: number; @@ -80,6 +94,7 @@ export interface AimockConfig { }; mcp?: MCPConfig; a2a?: A2AConfig; + agui?: AGUIConfig; vector?: VectorConfig; services?: { search?: boolean; rerank?: boolean; moderate?: boolean }; metrics?: boolean; @@ -198,6 +213,38 @@ export async function startFromConfig( logger.info(`A2AMock mounted at ${a2aPath}`); } + // AG-UI + if (config.agui) { + const aguiConfig = config.agui; + const agui = new AGUIMock(); + + if (aguiConfig.fixtures) { + for (const f of aguiConfig.fixtures) { + if (f.text) { + agui.onMessage(f.match.message ?? /.*/, f.text, { delayMs: f.delayMs }); + } else if (f.events) { + agui.addFixture({ + match: { + message: f.match.message, + toolName: f.match.toolName, + stateKey: f.match.stateKey, + }, + events: f.events, + delayMs: f.delayMs, + }); + } else { + logger.warn( + `AG-UI fixture has neither text nor events — it will be skipped (match: ${JSON.stringify(f.match)})`, + ); + } + } + } + + const aguiPath = aguiConfig.path ?? "/agui"; + llmock.mount(aguiPath, agui); + logger.info(`AGUIMock mounted at ${aguiPath}`); + } + // Vector if (config.vector) { const vectorConfig = config.vector; diff --git a/src/index.ts b/src/index.ts index c59b6c2..a5e9b29 100644 --- a/src/index.ts +++ b/src/index.ts @@ -156,6 +156,55 @@ export type { A2ATaskState, } from "./a2a-types.js"; +// AG-UI +export { AGUIMock } from "./agui-mock.js"; +export { proxyAndRecordAGUI } from "./agui-recorder.js"; +export type { + AGUIMockOptions, + AGUIRunAgentInput, + AGUIMessage, + AGUIToolDefinition, + AGUIToolCall, + AGUIEvent, + AGUIEventType, + AGUIFixture, + AGUIFixtureMatch, + AGUIRecordConfig, + // Key individual event types + AGUIRunStartedEvent, + AGUIRunFinishedEvent, + AGUIRunErrorEvent, + AGUITextMessageStartEvent, + AGUITextMessageContentEvent, + AGUITextMessageEndEvent, + AGUITextMessageChunkEvent, + AGUIToolCallStartEvent, + AGUIToolCallArgsEvent, + AGUIToolCallEndEvent, + AGUIToolCallResultEvent, + AGUIStateSnapshotEvent, + AGUIStateDeltaEvent, + AGUIMessagesSnapshotEvent, + AGUIActivitySnapshotEvent, + AGUIActivityDeltaEvent, +} from "./agui-types.js"; +export { + buildTextResponse as buildAGUITextResponse, + buildTextChunkResponse as buildAGUITextChunkResponse, + buildToolCallResponse as buildAGUIToolCallResponse, + buildStateUpdate as buildAGUIStateUpdate, + buildStateDelta as buildAGUIStateDelta, + buildMessagesSnapshot as buildAGUIMessagesSnapshot, + buildReasoningResponse as buildAGUIReasoningResponse, + buildActivityResponse as buildAGUIActivityResponse, + buildErrorResponse as buildAGUIErrorResponse, + buildStepWithText as buildAGUIStepWithText, + buildCompositeResponse as buildAGUICompositeResponse, + extractLastUserMessage as extractAGUILastUserMessage, + findFixture as findAGUIFixture, + writeAGUIEventStream, +} from "./agui-handler.js"; + // JSON-RPC export { createJsonRpcDispatcher } from "./jsonrpc.js"; export type { JsonRpcResponse, MethodHandler, JsonRpcDispatcherOptions } from "./jsonrpc.js"; diff --git a/src/suite.ts b/src/suite.ts index 788c500..2c9076c 100644 --- a/src/suite.ts +++ b/src/suite.ts @@ -2,16 +2,19 @@ import { LLMock } from "./llmock.js"; import { MCPMock } from "./mcp-mock.js"; import { A2AMock } from "./a2a-mock.js"; import { VectorMock } from "./vector-mock.js"; +import { AGUIMock } from "./agui-mock.js"; import type { MockServerOptions } from "./types.js"; import type { MCPMockOptions } from "./mcp-types.js"; import type { A2AMockOptions } from "./a2a-types.js"; import type { VectorMockOptions } from "./vector-types.js"; +import type { AGUIMockOptions } from "./agui-types.js"; export interface MockSuiteOptions { llm?: MockServerOptions; mcp?: MCPMockOptions; a2a?: A2AMockOptions; vector?: VectorMockOptions; + agui?: AGUIMockOptions; } export interface MockSuite { @@ -19,6 +22,7 @@ export interface MockSuite { mcp?: MCPMock; a2a?: A2AMock; vector?: VectorMock; + agui?: AGUIMock; start(): Promise; stop(): Promise; reset(): void; @@ -29,6 +33,7 @@ export async function createMockSuite(options: MockSuiteOptions = {}): Promise Date: Thu, 9 Apr 2026 16:18:34 -0700 Subject: [PATCH 4/5] docs: add AGUIMock page, remove section bar, update homepage and CI New AGUIMock docs page. Remove section bar from all pages (dead code). Update homepage feature grid and competitive matrix with AG-UI. Add AG-UI schema drift CI workflow. --- .github/workflows/publish-docker.yml | 1 - .github/workflows/test-drift.yml | 23 ++ docs/a2a-mock/index.html | 2 - docs/agui-mock/index.html | 289 ++++++++++++++++++++++ docs/aimock-cli/index.html | 2 - docs/aws-bedrock/index.html | 2 - docs/azure-openai/index.html | 2 - docs/chaos-testing/index.html | 2 - docs/chat-completions/index.html | 2 - docs/claude-messages/index.html | 2 - docs/cohere/index.html | 2 - docs/compatible-providers/index.html | 2 - docs/docker/index.html | 2 - docs/docs/index.html | 114 ++------- docs/drift-detection/index.html | 2 - docs/embeddings/index.html | 2 - docs/error-injection/index.html | 2 - docs/fixtures/index.html | 2 - docs/gemini/index.html | 2 - docs/index.html | 31 ++- docs/mcp-mock/index.html | 2 - docs/metrics/index.html | 2 - docs/migrate-from-mock-llm/index.html | 6 +- docs/migrate-from-mokksy/index.html | 12 +- docs/migrate-from-msw/index.html | 6 +- docs/migrate-from-piyook/index.html | 11 +- docs/migrate-from-python-mocks/index.html | 11 +- docs/migrate-from-vidaimock/index.html | 15 +- docs/mount/index.html | 2 - docs/ollama/index.html | 2 - docs/record-replay/index.html | 2 - docs/responses-api/index.html | 2 - docs/sequential-responses/index.html | 2 - docs/services/index.html | 2 - docs/sidebar.js | 105 +------- docs/streaming-physics/index.html | 2 - docs/structured-output/index.html | 2 - docs/style.css | 6 +- docs/vector-mock/index.html | 2 - docs/vertex-ai/index.html | 2 - docs/websocket/index.html | 2 - scripts/update-competitive-matrix.ts | 5 + 42 files changed, 384 insertions(+), 307 deletions(-) create mode 100644 docs/agui-mock/index.html diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index 9110548..2a75812 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -7,7 +7,6 @@ on: pull_request: branches: - main - workflow_dispatch: env: REGISTRY: ghcr.io diff --git a/.github/workflows/test-drift.yml b/.github/workflows/test-drift.yml index e636ae7..b9d5e5d 100644 --- a/.github/workflows/test-drift.yml +++ b/.github/workflows/test-drift.yml @@ -2,9 +2,32 @@ name: Drift Tests on: schedule: - cron: "0 6 * * *" # Daily 6am UTC + pull_request: + paths: + - "src/agui-types.ts" + - "src/__tests__/drift/agui-schema.drift.ts" workflow_dispatch: # Manual trigger jobs: + agui-schema-drift: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + + - name: Clone ag-ui repo + run: git clone --depth 1 https://github.com/ag-ui-protocol/ag-ui.git ../ag-ui + + - name: Run AG-UI schema drift test + run: npx vitest run src/__tests__/drift/agui-schema.drift.ts --config vitest.config.drift.ts + drift: + if: github.event_name != 'pull_request' runs-on: ubuntu-latest timeout-minutes: 15 steps: diff --git a/docs/a2a-mock/index.html b/docs/a2a-mock/index.html index 3517df6..6b651ba 100644 --- a/docs/a2a-mock/index.html +++ b/docs/a2a-mock/index.html @@ -43,8 +43,6 @@ -
-
diff --git a/docs/agui-mock/index.html b/docs/agui-mock/index.html new file mode 100644 index 0000000..12cd338 --- /dev/null +++ b/docs/agui-mock/index.html @@ -0,0 +1,289 @@ + + + + + + AG-UI Mock — aimock + + + + + + + + + +
+ + +
+

AGUIMock

+

+ Mock the AG-UI (Agent-to-UI) protocol for CopilotKit frontend testing. Point your frontend + at aimock instead of a real agent backend and get deterministic SSE event streams from + fixtures. +

+ +

Quick Start

+
+
+ Standalone mode typescript +
+
import { AGUIMock } from "@copilotkit/aimock";
+
+const agui = new AGUIMock();
+agui.onMessage("hello", "Hi! How can I help?");
+agui.onToolCall(/search/, "web_search", '{"q":"test"}', { result: "[]" });
+
+const url = await agui.start();
+// POST to url with RunAgentInput body
+
+ +

How It Works

+
    +
  1. Client sends POST with RunAgentInput JSON body
  2. +
  3. AGUIMock matches the request against registered fixtures
  4. +
  5. On match: streams back AG-UI events as SSE
  6. +
  7. On miss with recording enabled: proxies to upstream, records events
  8. +
  9. On miss without recording: returns 404
  10. +
+ +

Registration API

+

Fluent methods for registering fixture responses:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodMatch onResponse
onMessage(pattern, text)Last user messageText response events
onRun(pattern, events)Last user messageRaw event sequence
onToolCall(pattern, name, args, opts?)Last user messageTool call events
onStateKey(key, snapshot)State key presenceState snapshot
onReasoning(pattern, text)Last user messageReasoning events
onPredicate(fn, events)Custom functionRaw event sequence
+ +

Event Types

+

AG-UI protocol event categories:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CategoryEventsDescription
Lifecycle + RUN_STARTED, RUN_FINISHED, RUN_ERROR, + STEP_STARTED, STEP_FINISHED + Run management
Text + TEXT_MESSAGE_START, TEXT_MESSAGE_CONTENT, + TEXT_MESSAGE_END, TEXT_MESSAGE_CHUNK + Streaming text
Tool Calls + TOOL_CALL_START, TOOL_CALL_ARGS, + TOOL_CALL_END, TOOL_CALL_RESULT + Tool execution
State + STATE_SNAPSHOT, STATE_DELTA, + MESSAGES_SNAPSHOT + Frontend state sync
ActivityACTIVITY_SNAPSHOT, ACTIVITY_DELTAProgress indicators
ReasoningREASONING_START, ..., REASONING_ENDChain of thought
SpecialRAW, CUSTOMExtensibility
+ +

Event Builders

+

Convenience functions for constructing event sequences:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BuilderReturns
buildTextResponse(text)Full text response with lifecycle
buildToolCallResponse(name, args)Tool call with optional result
buildStateUpdate(snapshot)State snapshot
buildStateDelta(patches)JSON Patch incremental update
buildErrorResponse(message)Error termination
buildCompositeResponse(outputs[])Multiple builders merged
+ +

Record & Replay

+

+ Record mode proxies unmatched requests to an upstream agent, saves the event stream as a + fixture, and replays it on subsequent matches. Proxy-only mode forwards every time without + saving, ideal for demos mixing canned and live scenarios. +

+
+
+ Recording setup typescript +
+
const agui = new AGUIMock();
+agui.onMessage("hello", "Hi!"); // known scenario
+agui.enableRecording({
+  upstream: "http://localhost:8000/agent",
+  proxyOnly: true, // false to save fixtures
+});
+
+ +

CLI Usage

+
+
CLI flags shell
+
npx aimock --fixtures ./fixtures \
+  --agui-record \
+  --agui-upstream http://localhost:8000/agent
+
+

+ Flags: --agui-record, --agui-upstream, + --agui-proxy-only +

+ +

JSON Config

+
+
aimock.json json
+
{
+  "agui": {
+    "path": "/agui",
+    "fixtures": [
+      { "match": { "message": "hello" }, "text": "Hi!" }
+    ]
+  }
+}
+
+ +

Mounting

+

+ AGUIMock implements Mountable and can be mounted at any path on an LLMock + server via llm.mount("/agui", agui). See + Mount & Composition for details. +

+
+ +
+
+ +
+ + + + diff --git a/docs/aimock-cli/index.html b/docs/aimock-cli/index.html index 47ba677..d15e72d 100644 --- a/docs/aimock-cli/index.html +++ b/docs/aimock-cli/index.html @@ -43,8 +43,6 @@
-
-
diff --git a/docs/aws-bedrock/index.html b/docs/aws-bedrock/index.html index a549fa0..1d6c73e 100644 --- a/docs/aws-bedrock/index.html +++ b/docs/aws-bedrock/index.html @@ -43,8 +43,6 @@
-
-
diff --git a/docs/azure-openai/index.html b/docs/azure-openai/index.html index 5c594f0..4eb29df 100644 --- a/docs/azure-openai/index.html +++ b/docs/azure-openai/index.html @@ -43,8 +43,6 @@
-
-
diff --git a/docs/chaos-testing/index.html b/docs/chaos-testing/index.html index 0c5f3ae..89538bf 100644 --- a/docs/chaos-testing/index.html +++ b/docs/chaos-testing/index.html @@ -43,8 +43,6 @@
-
-
diff --git a/docs/chat-completions/index.html b/docs/chat-completions/index.html index 75d1ac9..1a689bb 100644 --- a/docs/chat-completions/index.html +++ b/docs/chat-completions/index.html @@ -43,8 +43,6 @@
-
-
diff --git a/docs/claude-messages/index.html b/docs/claude-messages/index.html index 5ec12f1..5c97580 100644 --- a/docs/claude-messages/index.html +++ b/docs/claude-messages/index.html @@ -43,8 +43,6 @@
-
-
diff --git a/docs/cohere/index.html b/docs/cohere/index.html index b6831ae..539791b 100644 --- a/docs/cohere/index.html +++ b/docs/cohere/index.html @@ -43,8 +43,6 @@
-
-
diff --git a/docs/compatible-providers/index.html b/docs/compatible-providers/index.html index f4df0ba..dbc96f8 100644 --- a/docs/compatible-providers/index.html +++ b/docs/compatible-providers/index.html @@ -43,8 +43,6 @@
-
-
diff --git a/docs/docker/index.html b/docs/docker/index.html index ad6b2d1..c7bccd3 100644 --- a/docs/docker/index.html +++ b/docs/docker/index.html @@ -43,8 +43,6 @@
-
-
diff --git a/docs/docs/index.html b/docs/docs/index.html index 1b50a80..5097d3c 100644 --- a/docs/docs/index.html +++ b/docs/docs/index.html @@ -17,78 +17,6 @@ /> @@ -263,30 +188,6 @@
- - -
@@ -351,6 +252,21 @@

The Suite

Get started
+ +
+
+ 🧞 + AG-UI Protocol +
+

Mock agent-to-UI event streams for frontend testing

+ + Get started +
+
diff --git a/docs/drift-detection/index.html b/docs/drift-detection/index.html index c18f984..701f39e 100644 --- a/docs/drift-detection/index.html +++ b/docs/drift-detection/index.html @@ -43,8 +43,6 @@
-
-
diff --git a/docs/embeddings/index.html b/docs/embeddings/index.html index e41949c..cccc361 100644 --- a/docs/embeddings/index.html +++ b/docs/embeddings/index.html @@ -43,8 +43,6 @@
-
-
diff --git a/docs/error-injection/index.html b/docs/error-injection/index.html index f46fed4..d3b1dc9 100644 --- a/docs/error-injection/index.html +++ b/docs/error-injection/index.html @@ -43,8 +43,6 @@
-
-
diff --git a/docs/fixtures/index.html b/docs/fixtures/index.html index 4617bb1..208611c 100644 --- a/docs/fixtures/index.html +++ b/docs/fixtures/index.html @@ -43,8 +43,6 @@
-
-
diff --git a/docs/gemini/index.html b/docs/gemini/index.html index 7d17594..672e803 100644 --- a/docs/gemini/index.html +++ b/docs/gemini/index.html @@ -43,8 +43,6 @@
-
-
diff --git a/docs/index.html b/docs/index.html index c8e2d2b..75d3c5f 100644 --- a/docs/index.html +++ b/docs/index.html @@ -681,6 +681,9 @@ .service-card-link:hover .service-card.gray { border-top-color: #7777a0; } + .service-card-link:hover .service-card.cyan { + border-top-color: #67e8f9; + } .service-card .service-icon { font-size: 1.75rem; } @@ -705,6 +708,9 @@ .service-card.gray { border-top: 2px solid var(--text-dim); } + .service-card.cyan { + border-top: 2px solid #22d3ee; + } /* ─── Section: Suite Reveal ──────────────────────────────────── */ .section-suite { @@ -1258,6 +1264,12 @@

A2A Agents

+ +
+ 🖥 + AG-UI +
+
📦 @@ -1370,6 +1382,12 @@

A2A Protocol

+
🖥
+

AG-UI Protocol

+

Mock agent-to-UI event streams for CopilotKit frontend testing.

+
+ +
📦

Vector Databases

@@ -1378,7 +1396,7 @@

Vector Databases

-
+
💥

Chaos Testing

@@ -1387,7 +1405,8 @@

Chaos Testing

-
+
+
📊

Drift Detection

Fixtures stay accurate as providers evolve. Fixes ship before your tests break.

@@ -1632,6 +1651,14 @@

How aimock compares

+ + AG-UI event mocking + Built-in ✓ + + + + + Vector DB mocking Built-in ✓ diff --git a/docs/mcp-mock/index.html b/docs/mcp-mock/index.html index 2a2e501..e6be679 100644 --- a/docs/mcp-mock/index.html +++ b/docs/mcp-mock/index.html @@ -43,8 +43,6 @@
-
-
diff --git a/docs/metrics/index.html b/docs/metrics/index.html index 52fb3e3..4dd413a 100644 --- a/docs/metrics/index.html +++ b/docs/metrics/index.html @@ -43,8 +43,6 @@
-
-
diff --git a/docs/migrate-from-mock-llm/index.html b/docs/migrate-from-mock-llm/index.html index 8b10648..1d54570 100644 --- a/docs/migrate-from-mock-llm/index.html +++ b/docs/migrate-from-mock-llm/index.html @@ -138,8 +138,6 @@
-
-
@@ -147,8 +145,8 @@

Switching from mock-llm to aimock

mock-llm is solid for OpenAI mocking with Kubernetes. aimock gives you 9 more providers, - zero dependencies, and full MCP/A2A/Vector support—with the same Helm chart workflow - you're used to. + zero dependencies, and full MCP/A2A/AG-UI/Vector support—with the same Helm chart + workflow you're used to.

diff --git a/docs/migrate-from-mokksy/index.html b/docs/migrate-from-mokksy/index.html index 7b54b8c..28b4517 100644 --- a/docs/migrate-from-mokksy/index.html +++ b/docs/migrate-from-mokksy/index.html @@ -123,8 +123,6 @@
-
-
@@ -132,7 +130,7 @@

Switching from Mokksy to aimock

Mokksy (AI-Mocks) is a solid Kotlin/Ktor-based mock for JVM teams. aimock gives you the - same LLM mocking from any language—plus MCP, A2A, vector databases, + same LLM mocking from any language—plus MCP, A2A, AG-UI, vector databases, record-and-replay, and drift detection that Mokksy doesn't have.

@@ -193,11 +191,8 @@

Cross-language testing

🔌
-

MCP / A2A / Vector mocking

-

- Mock your entire AI stack: MCP tool servers, A2A agent protocols, and vector - databases—not just LLM completions. -

+

MCP / A2A / AG-UI / Vector

+

Mock your entire AI stack — LLM, MCP, A2A, AG-UI, vector — on one port.

@@ -231,6 +226,7 @@

Chaos testing

degraded AI services gracefully.

+
📦

Docker + Helm native

diff --git a/docs/migrate-from-msw/index.html b/docs/migrate-from-msw/index.html index 8f9a1e5..66171e8 100644 --- a/docs/migrate-from-msw/index.html +++ b/docs/migrate-from-msw/index.html @@ -139,8 +139,6 @@
-
-
@@ -269,8 +267,8 @@

Fixture files

🧩
-

MCP + A2A + Vector

-

Mock your entire AI stack, not just LLM calls.

+

MCP + A2A + AG-UI + Vector

+

Mock your entire AI stack — LLM, MCP, A2A, AG-UI, vector — on one port.

diff --git a/docs/migrate-from-piyook/index.html b/docs/migrate-from-piyook/index.html index 5059390..98d2ccf 100644 --- a/docs/migrate-from-piyook/index.html +++ b/docs/migrate-from-piyook/index.html @@ -138,8 +138,6 @@
-
-
@@ -256,11 +254,8 @@

Sequential responses

🧩
-

MCP / A2A / Vector

-

- Mock MCP tool servers, A2A agent endpoints, and vector database APIs alongside LLM - mocks on one port. -

+

MCP / A2A / AG-UI / Vector

+

Mock your entire AI stack — LLM, MCP, A2A, AG-UI, vector — on one port.

@@ -338,7 +333,7 @@

Comparison table

✓ - MCP / A2A / Vector mocking + MCP / A2A / AG-UI / Vector mocking ✗ ✓ diff --git a/docs/migrate-from-python-mocks/index.html b/docs/migrate-from-python-mocks/index.html index e7999f2..90aeafa 100644 --- a/docs/migrate-from-python-mocks/index.html +++ b/docs/migrate-from-python-mocks/index.html @@ -139,8 +139,6 @@
-
-
@@ -330,11 +328,8 @@

Record & replay

🧩
-

MCP / A2A / Vector

-

- Mock your entire AI stack—MCP tool servers, A2A agent endpoints, vector - databases—not just LLM calls. -

+

MCP / A2A / AG-UI / Vector

+

Mock your entire AI stack — LLM, MCP, A2A, AG-UI, vector — on one port.

🔌
@@ -416,7 +411,7 @@

What you lose (honestly)

- MCP / A2A / Vector + MCP / A2A / AG-UI / Vector ✗ ✓ diff --git a/docs/migrate-from-vidaimock/index.html b/docs/migrate-from-vidaimock/index.html index adbf7ad..6a8e0b2 100644 --- a/docs/migrate-from-vidaimock/index.html +++ b/docs/migrate-from-vidaimock/index.html @@ -47,8 +47,6 @@
-
-
@@ -57,7 +55,7 @@

Switching from VidaiMock to aimock

VidaiMock is a capable Rust binary with broad provider support. aimock matches that coverage and adds what VidaiMock can’t — a programmatic TypeScript API, - WebSocket support, fixture files, request journal, and MCP/A2A/Vector mocking. + WebSocket support, fixture files, request journal, and MCP/A2A/AG-UI/Vector mocking.

@@ -166,11 +164,8 @@

Request journal

-
# Full config-driven setup (LLM + MCP + A2A on one port)
+              
# Full config-driven setup (LLM + MCP + A2A + AG-UI on one port)
 npx aimock --config aimock.json --port 4010
diff --git a/docs/mount/index.html b/docs/mount/index.html index 6631a80..76a1667 100644 --- a/docs/mount/index.html +++ b/docs/mount/index.html @@ -43,8 +43,6 @@ -
-
diff --git a/docs/ollama/index.html b/docs/ollama/index.html index ae66604..83d954a 100644 --- a/docs/ollama/index.html +++ b/docs/ollama/index.html @@ -43,8 +43,6 @@
-
-
diff --git a/docs/record-replay/index.html b/docs/record-replay/index.html index a7089e2..95e1ccf 100644 --- a/docs/record-replay/index.html +++ b/docs/record-replay/index.html @@ -43,8 +43,6 @@
-
-
diff --git a/docs/responses-api/index.html b/docs/responses-api/index.html index 8789e8e..2dcd9d6 100644 --- a/docs/responses-api/index.html +++ b/docs/responses-api/index.html @@ -43,8 +43,6 @@
-
-
diff --git a/docs/sequential-responses/index.html b/docs/sequential-responses/index.html index d7b891c..b6d8302 100644 --- a/docs/sequential-responses/index.html +++ b/docs/sequential-responses/index.html @@ -43,8 +43,6 @@
-
-
diff --git a/docs/services/index.html b/docs/services/index.html index 516e86e..d7e29fa 100644 --- a/docs/services/index.html +++ b/docs/services/index.html @@ -43,8 +43,6 @@
-
-
diff --git a/docs/sidebar.js b/docs/sidebar.js index b60268a..5025839 100644 --- a/docs/sidebar.js +++ b/docs/sidebar.js @@ -46,6 +46,7 @@ links: [ { label: "MCPMock", href: "/mcp-mock" }, { label: "A2AMock", href: "/a2a-mock" }, + { label: "AGUIMock", href: "/agui-mock" }, { label: "VectorMock", href: "/vector-mock" }, { label: "Services", href: "/services" }, ], @@ -71,21 +72,6 @@ }, ]; - // ─── Section Bar Items ────────────────────────────────────────── - var sectionBarItems = [ - { icon: "📡", label: "LLM Mocking", color: "pill-green", href: "/chat-completions" }, - { icon: "🔌", label: "MCP Protocol", color: "pill-blue", href: "/mcp-mock" }, - { icon: "🤝", label: "A2A Protocol", color: "pill-purple", href: "/a2a-mock" }, - { icon: "📦", label: "Vector DBs", color: "pill-amber", href: "/vector-mock" }, - { icon: "🔍", label: "Search & Rerank", color: "pill-red", href: "/services" }, - { - icon: "⚙", - label: "Chaos & DevOps", - color: "pill-gray", - href: "/chaos-testing", - }, - ]; - // ─── Detect current page ──────────────────────────────────────── var p = window.location.pathname.replace(/\/index\.html$/, "").replace(/\/$/, ""); var currentPage = p || "/"; @@ -107,90 +93,6 @@ return html; } - // ─── Build Section Bar HTML ───────────────────────────────────── - function buildSectionBar() { - var html = '"; - return html; - } - - // ─── Inject Section Bar CSS ───────────────────────────────────── - var style = document.createElement("style"); - style.textContent = - ".section-bar {" + - " position: sticky;" + - " top: 57px;" + - " z-index: 90;" + - " background: rgba(10, 10, 15, 0.85);" + - " backdrop-filter: blur(20px) saturate(1.4);" + - " -webkit-backdrop-filter: blur(20px) saturate(1.4);" + - " border-bottom: 1px solid var(--border);" + - " padding: 0.85rem 0;" + - " overflow-x: auto;" + - " -webkit-overflow-scrolling: touch;" + - " scrollbar-width: none;" + - "}" + - ".section-bar::-webkit-scrollbar { display: none; }" + - ".section-bar-inner {" + - " max-width: 1400px;" + - " margin: 0 auto;" + - " padding: 0 2rem;" + - " display: flex;" + - " align-items: center;" + - " gap: 0.65rem;" + - "}" + - ".section-pill {" + - " display: inline-flex;" + - " align-items: center;" + - " gap: 0.4rem;" + - " padding: 0.5rem 0.85rem;" + - " background: var(--bg-card);" + - " border: 1px solid var(--border);" + - " border-radius: 4px;" + - " font-family: var(--font-mono);" + - " font-size: 0.72rem;" + - " font-weight: 500;" + - " color: var(--text-secondary);" + - " white-space: nowrap;" + - " transition: all 0.2s var(--ease-out-expo);" + - " text-decoration: none;" + - "}" + - ".section-pill:hover {" + - " color: var(--text-primary);" + - " border-color: var(--border-bright);" + - " background: var(--bg-card-hover);" + - " text-decoration: none;" + - " transform: translateY(-1px);" + - "}" + - ".section-pill.pill-green { border-left: 3px solid var(--accent); }" + - ".section-pill.pill-blue { border-left: 3px solid var(--blue); }" + - ".section-pill.pill-purple { border-left: 3px solid var(--purple); }" + - ".section-pill.pill-amber { border-left: 3px solid var(--warning); }" + - ".section-pill.pill-red { border-left: 3px solid var(--error); }" + - ".section-pill.pill-gray { border-left: 3px solid var(--text-dim); }" + - ".section-pill-icon {" + - " font-size: 0.85rem;" + - " line-height: 1;" + - "}" + - "@media (max-width: 900px) {" + - " .section-bar-inner { padding: 0 1rem; }" + - "}"; - document.head.appendChild(style); - // ─── Inject into DOM ──────────────────────────────────────────── var sidebarEl = document.getElementById("sidebar"); if (sidebarEl) { @@ -199,11 +101,6 @@ if (active) active.scrollIntoView({ block: "center" }); } - // Only inject section bar on the overview page (/docs) — inner pages should not show it - var isOverview = currentPage === "/docs"; - var sectionBarEl = document.getElementById("section-bar"); - if (sectionBarEl && isOverview) sectionBarEl.innerHTML = buildSectionBar(); - // ─── Page TOC (right sidebar) ────────────────────────────────── function buildPageToc() { var tocEl = document.getElementById("page-toc"); diff --git a/docs/streaming-physics/index.html b/docs/streaming-physics/index.html index bb64510..6d0b25f 100644 --- a/docs/streaming-physics/index.html +++ b/docs/streaming-physics/index.html @@ -43,8 +43,6 @@
-
-
diff --git a/docs/structured-output/index.html b/docs/structured-output/index.html index 487750d..ffdbe7d 100644 --- a/docs/structured-output/index.html +++ b/docs/structured-output/index.html @@ -43,8 +43,6 @@
-
-
diff --git a/docs/style.css b/docs/style.css index 6a36057..1209062 100644 --- a/docs/style.css +++ b/docs/style.css @@ -144,14 +144,14 @@ body::before { /* ─── Docs Layout ─────────────────────────────────────────────── */ .docs-layout { display: flex; - margin-top: calc(57px + 50px); /* nav height + section bar */ - min-height: calc(100vh - 57px - 50px); + margin-top: 57px; /* nav height */ + min-height: calc(100vh - 57px); } /* ─── Sidebar ─────────────────────────────────────────────────── */ .sidebar { position: fixed; - top: calc(57px + 50px); /* nav height + section bar */ + top: 57px; /* nav height */ left: 0; width: var(--sidebar-width); height: calc(100vh - 57px - 50px); diff --git a/docs/vector-mock/index.html b/docs/vector-mock/index.html index 988fafc..cef9e54 100644 --- a/docs/vector-mock/index.html +++ b/docs/vector-mock/index.html @@ -43,8 +43,6 @@
-
-
diff --git a/docs/vertex-ai/index.html b/docs/vertex-ai/index.html index 744e5f2..078ee98 100644 --- a/docs/vertex-ai/index.html +++ b/docs/vertex-ai/index.html @@ -43,8 +43,6 @@
-
-
diff --git a/docs/websocket/index.html b/docs/websocket/index.html index 7f3fca5..bd0a9ce 100644 --- a/docs/websocket/index.html +++ b/docs/websocket/index.html @@ -43,8 +43,6 @@
-
-
diff --git a/scripts/update-competitive-matrix.ts b/scripts/update-competitive-matrix.ts index eced20b..2c20fb4 100644 --- a/scripts/update-competitive-matrix.ts +++ b/scripts/update-competitive-matrix.ts @@ -120,6 +120,10 @@ const FEATURE_RULES: FeatureRule[] = [ rowLabel: "Error injection (one-shot)", keywords: ["error injection", "fault injection", "error simulation", "inject.*error"], }, + { + rowLabel: "AG-UI event mocking", + keywords: ["ag-ui", "agui", "agent-ui", "copilotkit.*frontend", "event stream mock"], + }, ]; /** Maps competitor display names to their migration page paths (relative to docs/) */ @@ -295,6 +299,7 @@ function buildMigrationRowPatterns(rowLabel: string): string[] { "Error injection (one-shot)": ["Error injection"], "Request journal": ["Request journal"], "Drift detection": ["Drift detection"], + "AG-UI event mocking": ["AG-UI event mocking", "AG-UI mocking", "AG-UI"], }; if (variants[rowLabel]) { From 3f36d909743c295d8e815b1aa26e6c33370f6ced Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Thu, 9 Apr 2026 16:26:35 -0700 Subject: [PATCH 5/5] chore: release v1.11.0 --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- CHANGELOG.md | 10 ++++++++++ charts/aimock/Chart.yaml | 2 +- package.json | 2 +- 5 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 7e4c690..1294746 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "source": { "source": "npm", "package": "@copilotkit/aimock", - "version": "^1.10.0" + "version": "^1.11.0" }, "description": "Fixture authoring skill for @copilotkit/aimock — match fields, response types, embeddings, structured output, sequential responses, streaming physics, agent loop patterns, gotchas, and debugging" } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 9bf930e..f01d5ff 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "llmock", - "version": "1.10.0", + "version": "1.11.0", "description": "Fixture authoring guidance for @copilotkit/aimock", "author": { "name": "CopilotKit" diff --git a/CHANGELOG.md b/CHANGELOG.md index 4df1210..65aa5b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # @copilotkit/aimock +## 1.11.0 + +### Minor Changes + +- Add `AGUIMock` — mock the AG-UI (Agent-to-UI) protocol for CopilotKit frontend testing. All 33 event types, 11 convenience builders, fluent registration API, SSE streaming with disconnect handling (#100) +- Add AG-UI record & replay with tee streaming — proxy to real AG-UI agents, record event streams as fixtures, replay on subsequent requests. Includes `--proxy-only` mode for demos (#100) +- Add AG-UI schema drift detection — compares aimock event types against canonical `@ag-ui/core` Zod schemas to catch protocol changes (#100) +- Add `--agui-record`, `--agui-upstream`, `--agui-proxy-only` CLI flags (#100) +- Remove section bar from docs pages (cleanup) + ## 1.10.0 ### Minor Changes diff --git a/charts/aimock/Chart.yaml b/charts/aimock/Chart.yaml index a3dab29..9fa1f59 100644 --- a/charts/aimock/Chart.yaml +++ b/charts/aimock/Chart.yaml @@ -3,4 +3,4 @@ name: aimock description: Mock infrastructure for AI application testing (OpenAI, Anthropic, Gemini, MCP, A2A, vector) type: application version: 0.1.0 -appVersion: "1.10.0" +appVersion: "1.11.0" diff --git a/package.json b/package.json index 53a9066..2fc63eb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@copilotkit/aimock", - "version": "1.10.0", + "version": "1.11.0", "description": "Mock infrastructure for AI application testing — LLM APIs, MCP tools, A2A agents, vector databases, search, and more. Zero dependencies.", "license": "MIT", "repository": {