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/.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/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/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
+
+
+
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
+
+ Client sends POST with RunAgentInput JSON body
+ AGUIMock matches the request against registered fixtures
+ On match: streams back AG-UI events as SSE
+ On miss with recording enabled: proxies to upstream, records events
+ On miss without recording: returns 404
+
+
+ Registration API
+ Fluent methods for registering fixture responses:
+
+
+
+ Method
+ Match on
+ Response
+
+
+
+
+ onMessage(pattern, text)
+ Last user message
+ Text response events
+
+
+ onRun(pattern, events)
+ Last user message
+ Raw event sequence
+
+
+ onToolCall(pattern, name, args, opts?)
+ Last user message
+ Tool call events
+
+
+ onStateKey(key, snapshot)
+ State key presence
+ State snapshot
+
+
+ onReasoning(pattern, text)
+ Last user message
+ Reasoning events
+
+
+ onPredicate(fn, events)
+ Custom function
+ Raw event sequence
+
+
+
+
+ Event Types
+ AG-UI protocol event categories:
+
+
+
+ Category
+ Events
+ Description
+
+
+
+
+ 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
+
+
+ Activity
+ ACTIVITY_SNAPSHOT, ACTIVITY_DELTA
+ Progress indicators
+
+
+ Reasoning
+ REASONING_START, ..., REASONING_END
+ Chain of thought
+
+
+ Special
+ RAW, CUSTOM
+ Extensibility
+
+
+
+
+ Event Builders
+ Convenience functions for constructing event sequences:
+
+
+
+ Builder
+ Returns
+
+
+
+
+ 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.
+
+
+
+
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
+
+
+
npx aimock --fixtures ./fixtures \
+ --agui-record \
+ --agui-upstream http://localhost:8000/agent
+
+
+ Flags: --agui-record, --agui-upstream,
+ --agui-proxy-only
+
+
+ JSON Config
+
+
+
{
+ "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 @@
-
-
-
+
+
+
+
Mock agent-to-UI event streams for frontend testing
+
+
Get started →
+
+
-
-
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
Protocols
-
MCP / A2A / Vector
-
- Mock MCP tool servers, A2A agent-to-agent endpoints, and vector database APIs
- alongside your LLM mocks on one port.
-
+
MCP / A2A / AG-UI / Vector
+
Mock your entire AI stack — LLM, MCP, A2A, AG-UI, vector — on one port.
Capture
@@ -234,7 +229,7 @@
Comparison table
Built-in
- MCP / A2A / Vector
+ MCP / A2A / AG-UI / Vector
No
Built-in
@@ -304,7 +299,7 @@
CLI / Docker quick start
With a config file
shell
-
# 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/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": {
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]) {
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");
+ });
+});
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;
+}
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