From 31626ced30c3b87a8c16d9de19901d82c905629b Mon Sep 17 00:00:00 2001 From: Ayush7614 Date: Wed, 2 Sep 2026 14:12:31 +0530 Subject: [PATCH 1/2] feat(control-api): add GET /__aimock/fixtures introspection endpoint Returns the current number of loaded fixtures as { count }. Useful for CI debugging, health checks, and verifying fixture loads after dynamic POST /__aimock/fixtures adds or DELETE clears. The endpoint is read-only, CORS-enabled, and respects inbound API-key auth like the other control routes. --- src/server.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/server.ts b/src/server.ts index 7a77f46f..ece104ac 100644 --- a/src/server.ts +++ b/src/server.ts @@ -350,6 +350,13 @@ async function handleControlAPI( return true; } + // GET /__aimock/fixtures — inspect current fixture count + if (subPath === "/fixtures" && req.method === "GET") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ count: fixtures.length })); + return true; + } + // POST /__aimock/fixtures — add fixtures dynamically if (subPath === "/fixtures" && req.method === "POST") { let raw: string; From bacad1fbb08a4d82cb5bd1d10fd496145a08f2fd Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Tue, 8 Sep 2026 16:46:28 -0700 Subject: [PATCH 2/2] test(control-api): cover GET /__aimock/fixtures, and document it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The endpoint shipped with no test, no docs entry and no changelog line. The code itself is correct — I verified both claims in the PR description against the source rather than taking them: `setCorsHeaders(res)` runs once at the top of `handleControlAPI` before any branch, so the new route inherits CORS, and `validateRequestApiKey` gates everything that is not `/health`, `/ready` or `/metrics`, so it inherits the API-key boundary too. Four tests: the count for a seeded server, zero for an empty one, the count tracking POST and DELETE on the same route, and a leak check that the body carries `count` and nothing else (fixtures hold predicate closures, and the stated reason for returning a count rather than the fixtures is that none of that should cross the wire). The tracking test earns its place: a hardcoded `{ count: 0 }` would satisfy the empty case and a hardcoded `{ count: 2 }` the seeded one, so without observing the number MOVE across the mutating routes the suite would pass on a constant. Mutation-tested — deleting the endpoint reds 4 of the 23 tests in the file; restored, 23 pass. Docs: the endpoint table listed POST and DELETE for this path but not GET, and the Fixtures section had a subsection per method. Both now cover it. typecheck (all three configs) exit 0; full suite 180 files / 5642 tests; eslint and prettier clean. --- CHANGELOG.md | 2 ++ docs/control-api/index.html | 21 +++++++++++++ src/__tests__/control-api.test.ts | 50 +++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e675887e..19db39b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- **`GET /__aimock/fixtures` — read-only fixture-count introspection.** The control API could already add fixtures (`POST`) and clear them (`DELETE`), but offered no way to ASK what was loaded, so a CI job or test harness wanting to confirm its tape was registered had to send a probe chat request and infer the answer from the reply. `GET /__aimock/fixtures` returns `{ count }`. It sits behind the same `AIMOCK_API_KEYS` boundary and CORS headers as every other `/__aimock/*` route, and serializes the count ONLY — fixtures carry predicate closures, so none of their contents cross the wire (#407) + - **AG-UI subagent lifecycle events, and `subagentRunId` attribution mirrored where canonical declares it.** Upstream `@ag-ui/core` added `SUBAGENT_STARTED` / `SUBAGENT_FINISHED` / `SUBAGENT_ERROR` and threaded an optional `subagentRunId` through the events a subagent can emit; aimock's AG-UI types carried none of it. The three literals now join `AGUIEventType` and the `AGUIEvent` union, backed by `AGUISubagentStartedEvent` (required `subagentRunId` and `name`, optional `description` / `parentSubagentRunId`, plus `parentToolCallId` / `parentMessageId` so an agents-as-tools subagent can be correlated to the call that spawned it without inspecting `rawEvent.metadata`), `AGUISubagentFinishedEvent` (optional `result`, plus an `AGUISubagentFinishedOutcome` of `{ type: "success" }` or `{ type: "suspended"; interruptIds?: string[] }` — `AGUIRunFinishedOutcome` one level down, where absent still means success, `interruptIds` names only the run-level interrupts that subagent directly owns, and unlike `RUN_FINISHED.outcome` the field postdates the valueless-field cleanup so it never tolerates `null`), and `AGUISubagentErrorEvent` (`message`, optional `code`). `subagentRunId` itself is declared **per event, not on a shared base type**, because that is how canonical declares it: optional on 24 event interfaces, required on the three subagent events, and deliberately absent from `RUN_STARTED` / `RUN_FINISHED` / `RUN_ERROR`, `MESSAGES_SNAPSHOT` and the deprecated `THINKING_*` family. Putting it once on `AGUIBaseEvent` would have cleared the drift report while granting the field to events canonical does not give it. The same optional field is mirrored onto `AGUIMessage` and `AGUIInterrupt`. Canonical `types.ts` declares it INDEPENDENTLY on `BaseMessageSchema`, `ToolMessageSchema`, `ActivityMessageSchema` and `ReasoningMessageSchema` — the latter three are standalone `z.object`s that do NOT extend the base, so there is no inheritance carrying the field to them — and on `InterruptSchema`; aimock deliberately flattens the canonical message union into one `role`-discriminated `AGUIMessage`, which is why two edits cover all four message declarations. So a replayed message or approval request keeps the attribution that lets a client group it under the subagent that produced it instead of reading as root-raised. All four new names — the three event interfaces and `AGUISubagentFinishedOutcome` — are exported from both the package root and the `agui-stub` entrypoint, guarded by a public-surface test that fails whenever any `agui-types.ts` type is missing from either barrel. The `AGUIMessage` / `AGUIInterrupt` mirroring is NOT guarded: the existing drift suite walks `*EventSchema` declarations only and cannot see the non-event schemas, so nothing fails if that field is later dropped — closing that gap is deferred to the drift-harness rewrite rather than bolted on here. **Scope: this is a type surface only.** Unlike 1.39.0's `AGUITokenUsage`, which was reachable through `AGUIBuildOpts`, no RUNTIME code CONSUMES these types — they are exported and guarded, but `agui-handler.ts` gained no builder and no build option, so aimock can TYPE a subagent event but cannot yet EMIT one. Every added field is optional or sits on a new type, so existing fixtures and callers are byte-identical (#391) ### Changed diff --git a/docs/control-api/index.html b/docs/control-api/index.html index 337237c1..5fa8a47f 100644 --- a/docs/control-api/index.html +++ b/docs/control-api/index.html @@ -82,6 +82,11 @@

Route Overview

/__aimock/journal Read-only snapshot of recorded requests + + GET + /__aimock/fixtures + How many fixtures are currently loaded + POST /__aimock/fixtures @@ -250,6 +255,22 @@

GET /__aimock/journal

Fixtures

+

GET /__aimock/fixtures

+

+ Read-only: how many fixtures are currently registered. Useful for asserting a CI job or + test harness actually loaded its tape before it starts making requests, without having to + send a probe request and infer the answer from the reply. Returns the count only — + fixtures hold predicate functions, so nothing about their contents is serialized. +

+
+
Count fixtures shell
+
$ curl http://localhost:4010/__aimock/fixtures
+
+
+
Response json
+
{ "count": 2 }
+
+

POST /__aimock/fixtures

Add fixtures at runtime without restarting the server. The body is an object with a diff --git a/src/__tests__/control-api.test.ts b/src/__tests__/control-api.test.ts index 248922b9..cd2de3d9 100644 --- a/src/__tests__/control-api.test.ts +++ b/src/__tests__/control-api.test.ts @@ -101,6 +101,56 @@ describe("/__aimock control API", () => { }); }); + describe("GET /__aimock/fixtures", () => { + it("returns the current fixture count", async () => { + const fixtures: Fixture[] = [ + { match: { userMessage: "hello" }, response: { content: "Hi" } }, + { match: { userMessage: "bye" }, response: { content: "Later" } }, + ]; + instance = await createServer(fixtures); + + const res = await httpRequest(`${instance.url}/__aimock/fixtures`, "GET"); + expect(res.status).toBe(200); + expect(JSON.parse(res.body)).toEqual({ count: 2 }); + }); + + it("reports zero for a server started with no fixtures", async () => { + instance = await createServer([]); + const res = await httpRequest(`${instance.url}/__aimock/fixtures`, "GET"); + expect(res.status).toBe(200); + expect(JSON.parse(res.body)).toEqual({ count: 0 }); + }); + + // The count is the POINT of the endpoint: a static 200 that always said the + // same number would satisfy the two cases above, so track it across the + // mutating routes it exists to let a caller observe. + it("tracks POST and DELETE on the same route", async () => { + instance = await createServer([ + { match: { userMessage: "hello" }, response: { content: "Hi" } }, + ]); + const url = `${instance.url}/__aimock/fixtures`; + + expect(JSON.parse((await httpRequest(url, "GET")).body)).toEqual({ count: 1 }); + + await httpRequest(url, "POST", { + fixtures: [{ match: { userMessage: "third" }, response: { content: "3" } }], + }); + expect(JSON.parse((await httpRequest(url, "GET")).body)).toEqual({ count: 2 }); + + await httpRequest(url, "DELETE"); + expect(JSON.parse((await httpRequest(url, "GET")).body)).toEqual({ count: 0 }); + }); + + it("does not leak fixture internals — the body carries the count and nothing else", async () => { + instance = await createServer([ + { match: { userMessage: "hello" }, response: { content: "Hi" } }, + ]); + const res = await httpRequest(`${instance.url}/__aimock/fixtures`, "GET"); + expect(Object.keys(JSON.parse(res.body))).toEqual(["count"]); + expect(res.body).not.toContain("hello"); + }); + }); + describe("POST /__aimock/fixtures", () => { it("adds fixtures and they match requests", async () => { const fixtures: Fixture[] = [];