Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions docs/control-api/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ <h2>Route Overview</h2>
<td><code>/__aimock/journal</code></td>
<td>Read-only snapshot of recorded requests</td>
</tr>
<tr>
<td><code>GET</code></td>
<td><code>/__aimock/fixtures</code></td>
<td>How many fixtures are currently loaded</td>
</tr>
<tr>
<td><code>POST</code></td>
<td><code>/__aimock/fixtures</code></td>
Expand Down Expand Up @@ -250,6 +255,22 @@ <h3>GET /__aimock/journal</h3>

<h2>Fixtures</h2>

<h3>GET /__aimock/fixtures</h3>
<p>
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.
</p>
<div class="code-block">
<div class="code-block-header">Count fixtures <span class="lang-tag">shell</span></div>
<pre><code>$ curl http://localhost:4010/__aimock/fixtures</code></pre>
</div>
<div class="code-block">
<div class="code-block-header">Response <span class="lang-tag">json</span></div>
<pre><code>{ <span class="prop">"count"</span>: <span class="num">2</span> }</code></pre>
</div>

<h3>POST /__aimock/fixtures</h3>
<p>
Add fixtures at runtime without restarting the server. The body is an object with a
Expand Down
50 changes: 50 additions & 0 deletions src/__tests__/control-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down
7 changes: 7 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading