diff --git a/CHANGELOG.md b/CHANGELOG.md
index 324d133c..8016a060 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,19 @@
## [Unreleased]
+### Changed
+
+- `POST /__aimock/reset` is now the canonical full reset and returns a plain `{ "reset": true }` with no deprecation header or body fields. `POST /__aimock/reset/journal` is unaffected.
+- **`LLMock.reset()` and the control-API full reset are now one implementation.** Both call a shared `performFullReset`, which clears the fixtures array, the journal (entries _and_ per-test fixture match-counts, across every testId), video and fal.ai job/queue state, and the Gemini interaction/event-id counters. The in-process `reset()` previously left `veoVideoJobs`, `grokVideoJobs` and both Gemini counters populated, so an in-process test suite could see a stale Veo/Grok poll resolve after a reset and interaction ids that never restarted at `aimock-int-0`. `reset()` additionally clears the search / rerank / moderation fixtures, which are registrable only through `LLMock` and that the control API cannot reach.
+ - **Behavior change for multi-instance in-process users:** `reset()` now also clears state that is MODULE-GLOBAL, not per-instance — the Gemini interaction and event-id counters, and the fal.ai job/queue maps. With two `LLMock` instances live in one process, `a.reset()` rewinds the Gemini id sequence that instance `b` is mid-way through, so `b` re-emits `aimock-int-0` / `evt_1` — ids it has already handed out — and drops `b`'s in-flight fal jobs. The fal maps were already global before this change; the counters are newly reached. Give each instance its own process (or its own vitest worker) if that matters.
+- `aimock-pytest`: `AIMockServer.reset()` and `.reset_fixtures()` now call `POST /__aimock/reset`. Both already performed a full reset, so the observable behavior is unchanged.
+ - **This does not yet avoid the deprecation warning.** `_version.py` pins `AIMOCK_VERSION = "1.38.0"`, and on that published server `/reset` is still the deprecated alias — so the client trips a deprecation on every reset until the pin moves. **Release follow-up (required):** bump `AIMOCK_VERSION` to the first npm release containing this change before publishing the next `aimock-pytest`. Until then the client is deprecation-clean only against a server built from this branch.
+
+### Deprecated
+
+- `POST /__aimock/reset/fixtures` — now a deprecated alias for `POST /__aimock/reset`. The name promised a fixtures-only reset while it always performed the full reset, so `/reset` is the honest route and the deprecation moves onto the alias: it still performs the same full reset but emits a `Deprecation: true` response header, `deprecated` / `deprecation` fields in the body, and a log warning. Use `POST /__aimock/reset` for a full reset, `POST /__aimock/reset/journal` for a journal-only one, or `DELETE /__aimock/fixtures` to clear fixtures and nothing else.
+ - The alias's **reset semantics are unchanged** — it clears exactly what it always did, so existing callers keep working. Its **response body is additively extended**: it now carries `deprecated` and `deprecation` alongside `reset`, plus the `Deprecation: true` header. A caller asserting strict equality on the old `{ "reset": true }` body will need to relax that assertion; a caller reading `body.reset` is unaffected.
+
## [1.38.0] - 2026-08-03
### Added
diff --git a/docs/control-api/index.html b/docs/control-api/index.html
index 9a5db20b..337237c1 100644
--- a/docs/control-api/index.html
+++ b/docs/control-api/index.html
@@ -94,18 +94,21 @@
POST |
@@ -118,11 +121,14 @@ Route Overview
Reset Routes
aimock keeps several kinds of in-memory state between requests: the loaded
- fixtures, the per-provider generation state (video,
- fal.ai, and Gemini counters), the fixture match-counts (sequence
- position), and the request journal (recorded requests). The reset routes
- let you clear these selectively — a full reset clears everything, while a journal
- reset clears only the recorded requests and leaves the rest intact.
+ fixtures, the per-provider generation state (video and
+ fal.ai jobs, plus the Gemini interaction and event-id counters), the
+ fixture match-counts (sequence position), and the
+ request journal (recorded requests). Two routes clear it:
+ POST /__aimock/reset clears all of it, and
+ POST /__aimock/reset/journal clears only the recorded requests and leaves
+ everything else intact. A third, POST /__aimock/reset/fixtures, is a
+ deprecated alias for the full reset.
Reset Routes
margin: 1.5rem 0;
"
>
- The footgun this split fixes — a
- caller that wanted a clean journal between test runs used to call
- POST /__aimock/reset and unintentionally wiped the loaded fixtures too. Every
- subsequent request then returned no_fixture_match until the server was
- restarted. If you only want a clean read between runs, use
+ Pick the narrower route —
+ POST /__aimock/reset wipes the loaded fixtures along with everything else,
+ and what happens to the next request then depends on the mode. In replay mode it fails
+ with 404, and in strict mode with 503 — both carry
+ code: "no_fixture_match". But
+ in record mode, with a provider key configured, an unmatched request is proxied to the
+ real provider
+ — so a full reset mid-recording means live upstream calls and real spend, not an
+ error. If all you want is a clean read between test runs, use
POST /__aimock/reset/journal — it leaves your fixtures intact.
- POST /__aimock/reset/fixtures
+ POST /__aimock/reset
- Full reset. Clears the in-memory fixtures, the generation state (video /
- fal.ai / Gemini counters), and the journal. Use this when you want the server
- returned to a pristine, fixture-free state.
+ Full reset. Returns the server to a pristine, fixture-free state. It
+ clears the in-memory fixtures, the journal entries and the per-test fixture
+ match-counts (so sequenced fixtures rewind to their first response), the video and fal.ai
+ job and queue state, and the Gemini interaction and event-id counters.
-
$ curl -X POST http://localhost:4010/__aimock/reset/fixtures
+
$ curl -X POST http://localhost:4010/__aimock/reset
@@ -177,17 +190,21 @@
POST /__aimock/reset/journal
{ "reset": true }
- POST /__aimock/reset (Deprecated)
+
+ POST /__aimock/reset/fixtures (Deprecated)
+
- Deprecated alias for /__aimock/reset/fixtures. It performs
- the same full reset, but additionally sets a Deprecation: true response
- header and adds deprecated / deprecation fields to the body.
- Prefer the explicit /reset/fixtures or /reset/journal routes
- — they make the intent (and blast radius) of the reset unambiguous.
+ Deprecated alias for /__aimock/reset. The name promises a
+ fixtures-only reset, but it performs the same full reset — journal, match-counts,
+ job state and counters all go with it. It additionally sets a
+ Deprecation: true response header and adds deprecated /
+ deprecation fields to the body. Use /reset for a full reset, or
+ /reset/journal for a journal-only one; to clear fixtures and nothing else,
+ use DELETE /__aimock/fixtures.
-
$ curl -i -X POST http://localhost:4010/__aimock/reset
+
$ curl -i -X POST http://localhost:4010/__aimock/reset/fixtures
@@ -195,7 +212,7 @@
POST /__aimock/reset (Deprecated)
{
"reset": true,
"deprecated": true,
- "deprecation": "POST /__aimock/reset is deprecated; use POST /__aimock/reset/fixtures (full reset) or POST /__aimock/reset/journal (journal only)"
+ "deprecation": "POST /__aimock/reset/fixtures is deprecated; use POST /__aimock/reset (full reset) or POST /__aimock/reset/journal (journal only)"
}
@@ -253,7 +270,7 @@ POST /__aimock/fixtures
DELETE /__aimock/fixtures
Clears all registered fixtures. Generation state and the journal are left untouched. To
- clear everything at once, use /__aimock/reset/fixtures instead.
+ clear everything at once, use /__aimock/reset instead.
diff --git a/packages/aimock-pytest/README.md b/packages/aimock-pytest/README.md
index c3540626..d89dabdb 100644
--- a/packages/aimock-pytest/README.md
+++ b/packages/aimock-pytest/README.md
@@ -69,10 +69,11 @@ aimock.get_last_request() # most recent request or None
aimock.next_error(429, {"message": "Rate limited"})
# Reset
-aimock.clear_fixtures() # remove all fixtures
-aimock.reset_fixtures() # clear fixtures + generation state (and journal)
+aimock.clear_fixtures() # remove all fixtures, nothing else
+aimock.reset() # full reset: fixtures, journal entries + match-counts,
+ # video/fal job state, Gemini counters
aimock.reset_journal() # clear only the request journal (fixtures preserved)
-aimock.reset() # alias for reset_fixtures()
+aimock.reset_fixtures() # alias for reset() — a full reset, despite the name
```
## CLI Options
diff --git a/packages/aimock-pytest/src/aimock_pytest/_server.py b/packages/aimock-pytest/src/aimock_pytest/_server.py
index 27eaf134..bf802314 100644
--- a/packages/aimock-pytest/src/aimock_pytest/_server.py
+++ b/packages/aimock-pytest/src/aimock_pytest/_server.py
@@ -324,15 +324,19 @@ def clear_fixtures(self) -> AIMockServer:
return self
def reset(self) -> AIMockServer:
- """Full reset: clear fixtures + generation state + journal (alias for
- :meth:`reset_fixtures`)."""
- return self.reset_fixtures()
+ """Full reset via ``POST /__aimock/reset``: clears fixtures, journal
+ entries and fixture match-counts, video/fal job state, and the Gemini
+ counters."""
+ self._control_request("POST", "/reset", timeout=5).raise_for_status()
+ return self
def reset_fixtures(self) -> AIMockServer:
- """Clear fixtures + generation state (and journal) via
- ``POST /__aimock/reset/fixtures``."""
- self._control_request("POST", "/reset/fixtures", timeout=5).raise_for_status()
- return self
+ """Alias for :meth:`reset` — a full reset, not a fixtures-only one.
+
+ The name is kept for compatibility; ``DELETE /__aimock/fixtures``
+ (:meth:`clear_fixtures`) is the fixtures-only call.
+ """
+ return self.reset()
def reset_journal(self) -> AIMockServer:
"""Clear ONLY the request journal, leaving fixtures intact, via
diff --git a/packages/aimock-pytest/tests/test_basic.py b/packages/aimock-pytest/tests/test_basic.py
index fb43b4a8..cacf05f2 100644
--- a/packages/aimock-pytest/tests/test_basic.py
+++ b/packages/aimock-pytest/tests/test_basic.py
@@ -184,6 +184,82 @@ def test_reset_clears_fixtures(aimock):
assert r.status_code == 404
+def _capture_control_posts(monkeypatch):
+ """Spy on requests.post, recording every /__aimock/ control call.
+
+ Returns the list the spy appends ``(url, response)`` to. The real request
+ still goes out, so the recorded response is the SERVER's, not a stub.
+ """
+ captured = []
+ real_post = requests.post
+
+ def spy(url, *args, **kwargs):
+ response = real_post(url, *args, **kwargs)
+ if "/__aimock/" in url:
+ captured.append((url, response))
+ return response
+
+ monkeypatch.setattr(requests, "post", spy)
+ return captured
+
+
+def test_reset_targets_the_canonical_route_not_the_deprecated_alias(aimock, monkeypatch):
+ """reset() must POST /__aimock/reset, which carries no deprecation signal.
+
+ Both routes perform the same full reset, so a functional assertion cannot
+ tell them apart. The deprecation signal can: the alias returns
+ ``deprecated``/``deprecation`` in the body and a ``Deprecation`` header,
+ the canonical route returns neither.
+ """
+ captured = _capture_control_posts(monkeypatch)
+ aimock.reset()
+
+ assert len(captured) == 1
+ url, response = captured[0]
+ assert url.endswith("/__aimock/reset")
+ assert response.status_code == 200
+
+ body = response.json()
+ assert body == {"reset": True}
+ assert "deprecated" not in body
+ assert "deprecation" not in body
+ assert "Deprecation" not in response.headers
+
+ # Anchor the discriminator: the alias DOES signal deprecation, so the
+ # assertions above genuinely distinguish the two routes rather than
+ # passing for both.
+ alias = requests.post(f"{aimock.base_url}/__aimock/reset/fixtures", timeout=5)
+ assert alias.status_code == 200
+ alias_body = alias.json()
+ assert alias_body["deprecated"] is True
+ # The discriminating substring: a self-referential message would read
+ # "use POST /__aimock/reset/fixtures (full reset)". A bare
+ # "POST /__aimock/reset" check would be satisfied by that too.
+ assert "use POST /__aimock/reset (full reset)" in alias_body["deprecation"]
+ assert alias.headers["Deprecation"] == "true"
+
+
+def test_reset_fixtures_is_a_deprecation_free_alias_for_reset(aimock, monkeypatch):
+ """reset_fixtures() delegates to reset(), so it too uses /__aimock/reset."""
+ aimock.on_message("test", {"content": "response"})
+
+ captured = _capture_control_posts(monkeypatch)
+ aimock.reset_fixtures()
+
+ assert len(captured) == 1
+ url, response = captured[0]
+ assert url.endswith("/__aimock/reset")
+ assert response.json() == {"reset": True}
+ assert "Deprecation" not in response.headers
+
+ # ...and it still performs the full reset its name promises.
+ r = requests.post(
+ f"{aimock.base_url}/v1/chat/completions",
+ json={"model": "gpt-4", "messages": [{"role": "user", "content": "test"}]},
+ )
+ assert r.status_code == 404
+
+
def test_reset_journal_preserves_fixtures(aimock):
"""reset_journal() clears the journal but leaves fixtures intact."""
aimock.on_message("hello", {"content": "Hi there!"})
diff --git a/src/__tests__/control-api.test.ts b/src/__tests__/control-api.test.ts
index 7a4c1969..248922b9 100644
--- a/src/__tests__/control-api.test.ts
+++ b/src/__tests__/control-api.test.ts
@@ -1,4 +1,4 @@
-import { describe, it, expect, afterEach } from "vitest";
+import { describe, it, expect, afterEach, vi } from "vitest";
import * as http from "node:http";
import type { Fixture, ChatCompletionRequest } from "../types.js";
import { createServer, type ServerInstance } from "../server.js";
@@ -11,7 +11,7 @@ function httpRequest(
url: string,
method: string,
body?: object,
-): Promise<{ status: number; body: string }> {
+): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const opts: http.RequestOptions = {
@@ -27,6 +27,7 @@ function httpRequest(
res.on("end", () =>
resolve({
status: res.statusCode ?? 0,
+ headers: res.headers,
body: Buffer.concat(chunks).toString(),
}),
);
@@ -194,15 +195,19 @@ describe("/__aimock control API", () => {
];
instance = await createServer(fixtures);
- // Make a request to populate journal
+ // Make a request to populate the journal AND the fixture's match-count.
await httpRequest(`${instance.url}/v1/chat/completions`, "POST", chatRequest("hello"));
expect(instance.journal.size).toBeGreaterThan(0);
+ const fixture = fixtures[0];
+ expect(instance.journal.getFixtureMatchCount(fixture)).toBeGreaterThan(0);
const res = await httpRequest(`${instance.url}/__aimock/reset`, "POST");
expect(res.status).toBe(200);
expect(JSON.parse(res.body)).toMatchObject({ reset: true });
expect(fixtures.length).toBe(0);
expect(instance.journal.size).toBe(0);
+ // The name of this test promises match counts — assert them.
+ expect(instance.journal.getFixtureMatchCount(fixture)).toBe(0);
});
});
@@ -219,7 +224,7 @@ describe("/__aimock control API", () => {
const res = await httpRequest(`${instance.url}/__aimock/reset/fixtures`, "POST");
expect(res.status).toBe(200);
- expect(JSON.parse(res.body)).toEqual({ reset: true });
+ expect(JSON.parse(res.body)).toMatchObject({ reset: true });
expect(fixtures.length).toBe(0);
expect(instance.journal.size).toBe(0);
});
@@ -267,18 +272,77 @@ describe("/__aimock control API", () => {
expect(instance.journal.getFixtureMatchCount(fixtures[0])).toBe(countBefore);
});
- it("POST /__aimock/reset is a deprecated alias that still performs a full reset", async () => {
+ // The alias's deprecation signal and its full-reset behaviour are covered
+ // together, and more strictly, by "full-reset deprecation direction" below.
+ });
+
+ describe("full-reset deprecation direction", () => {
+ it("POST /__aimock/reset is canonical — success body carries no deprecation signal", async () => {
const fixtures: Fixture[] = [
{ match: { userMessage: "hello" }, response: { content: "Hi" } },
];
instance = await createServer(fixtures);
+ await httpRequest(`${instance.url}/v1/chat/completions`, "POST", chatRequest("hello"));
+ expect(instance.journal.size).toBeGreaterThan(0);
const res = await httpRequest(`${instance.url}/__aimock/reset`, "POST");
expect(res.status).toBe(200);
- const body = JSON.parse(res.body);
- expect(body).toMatchObject({ reset: true, deprecated: true });
- expect(typeof body.deprecation).toBe("string");
+ expect(res.headers.deprecation).toBeUndefined();
+ expect(JSON.parse(res.body)).toEqual({ reset: true });
+ expect(fixtures.length).toBe(0);
+ expect(instance.journal.size).toBe(0);
+ });
+
+ it("POST /__aimock/reset/fixtures is the deprecated alias — signals deprecation and still full-resets", async () => {
+ const fixtures: Fixture[] = [
+ { match: { userMessage: "hello" }, response: { content: "Hi" } },
+ ];
+ instance = await createServer(fixtures);
+ await httpRequest(`${instance.url}/v1/chat/completions`, "POST", chatRequest("hello"));
+ expect(instance.journal.size).toBeGreaterThan(0);
+ expect(instance.journal.getFixtureMatchCount(fixtures[0])).toBeGreaterThan(0);
+
+ const res = await httpRequest(`${instance.url}/__aimock/reset/fixtures`, "POST");
+ expect(res.status).toBe(200);
+ expect(res.headers.deprecation).toBe("true");
+
+ const body = JSON.parse(res.body) as {
+ reset: boolean;
+ deprecated: boolean;
+ deprecation: string;
+ };
+ expect(body.reset).toBe(true);
+ expect(body.deprecated).toBe(true);
+ // Points callers at the canonical route, not back at itself. Asserting
+ // the whole string: any substring of it that mentions "/__aimock/reset"
+ // is also present in a message that points back at /reset/fixtures.
+ expect(body.deprecation).toBe(
+ "POST /__aimock/reset/fixtures is deprecated; use POST /__aimock/reset (full reset) or POST /__aimock/reset/journal (journal only)",
+ );
+
+ // Back-compat: the alias still performs the same full reset.
expect(fixtures.length).toBe(0);
+ expect(instance.journal.size).toBe(0);
+ });
+
+ // The log warning is the third documented deprecation signal, alongside
+ // the header and the body fields. The suite defaults to logLevel silent,
+ // so the level has to be raised for the warning to reach console.warn.
+ it("logs a deprecation warning for the alias and stays silent for the canonical route", async () => {
+ instance = await createServer([], { logLevel: "warn" });
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ try {
+ await httpRequest(`${instance.url}/__aimock/reset`, "POST");
+ expect(warn).not.toHaveBeenCalled();
+
+ await httpRequest(`${instance.url}/__aimock/reset/fixtures`, "POST");
+ expect(warn).toHaveBeenCalledTimes(1);
+ expect(warn.mock.calls[0].join(" ")).toContain(
+ "POST /__aimock/reset/fixtures is deprecated; use /__aimock/reset or /__aimock/reset/journal",
+ );
+ } finally {
+ warn.mockRestore();
+ }
});
});
diff --git a/src/__tests__/llmock.test.ts b/src/__tests__/llmock.test.ts
index b797c031..7ec9ba0d 100644
--- a/src/__tests__/llmock.test.ts
+++ b/src/__tests__/llmock.test.ts
@@ -1041,6 +1041,350 @@ describe("LLMock", () => {
// clearFixtures alone should not throw before start
expect(mock.clearFixtures()).toBe(mock);
});
+
+ // reset() must be the in-process equivalent of POST /__aimock/reset. These
+ // exercise the stores the in-process path used to leave behind.
+ it("clears Veo and Grok video job state — pre-reset poll ids stop resolving", async () => {
+ mock = new LLMock();
+ mock.addFixture({
+ match: { userMessage: "veo clip", endpoint: "video" },
+ response: {
+ video: { id: "veo_reset", status: "completed", url: "https://files.example/v.mp4" },
+ },
+ });
+ mock.addFixture({
+ match: { userMessage: "grok clip", endpoint: "video" },
+ response: {
+ video: { id: "vid_grok_reset", status: "completed", url: "https://cdn.x.ai/v.mp4" },
+ },
+ });
+ await mock.start();
+
+ const veoSubmit = (await (
+ await fetch(`${mock.url}/v1beta/models/veo-3.1-generate-preview:predictLongRunning`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ instances: [{ prompt: "veo clip" }] }),
+ })
+ ).json()) as { name: string };
+ expect(typeof veoSubmit.name).toBe("string");
+
+ const grokSubmit = (await (
+ await fetch(`${mock.url}/v1/videos/generations`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "grok-imagine-video", prompt: "grok clip" }),
+ })
+ ).json()) as { request_id: string };
+ expect(typeof grokSubmit.request_id).toBe("string");
+
+ // Both jobs resolve while they are still in the maps.
+ expect((await fetch(`${mock.url}/v1beta/${veoSubmit.name}`)).status).toBe(200);
+ expect((await fetch(`${mock.url}/v1/videos/${grokSubmit.request_id}`)).status).toBe(200);
+
+ mock.reset();
+
+ expect((await fetch(`${mock.url}/v1beta/${veoSubmit.name}`)).status).toBe(404);
+ expect((await fetch(`${mock.url}/v1/videos/${grokSubmit.request_id}`)).status).toBe(404);
+ });
+
+ it("rewinds the Gemini interaction-id counter", async () => {
+ mock = new LLMock();
+ mock.onMessage("hello", { content: "Hi there!" });
+ await mock.start();
+
+ const first = JSON.parse(
+ (
+ await postTo(mock.url, "/v1beta/interactions", {
+ model: "gemini-2.5-flash",
+ input: "hello",
+ stream: false,
+ })
+ ).data,
+ ) as { id: string };
+ const second = JSON.parse(
+ (
+ await postTo(mock.url, "/v1beta/interactions", {
+ model: "gemini-2.5-flash",
+ input: "hello",
+ stream: false,
+ })
+ ).data,
+ ) as { id: string };
+ // The counter really did advance, so a rewind is observable.
+ expect(first.id).not.toBe(second.id);
+
+ mock.reset();
+ mock.onMessage("hello", { content: "Hi there!" });
+
+ const afterReset = JSON.parse(
+ (
+ await postTo(mock.url, "/v1beta/interactions", {
+ model: "gemini-2.5-flash",
+ input: "hello",
+ stream: false,
+ })
+ ).data,
+ ) as { id: string };
+ expect(afterReset.id).toBe("aimock-int-0");
+ });
+
+ it("rewinds the Gemini interactions event-id counter", async () => {
+ mock = new LLMock();
+ mock.onMessage("hello", { content: "Hi there!" });
+ await mock.start();
+
+ // Burn some event ids on a streaming interaction.
+ await postTo(mock.url, "/v1beta/interactions", {
+ model: "gemini-2.5-flash",
+ input: "hello",
+ stream: true,
+ });
+
+ mock.reset();
+ mock.onMessage("hello", { content: "Hi there!" });
+
+ const res = await postTo(mock.url, "/v1beta/interactions", {
+ model: "gemini-2.5-flash",
+ input: "hello",
+ stream: true,
+ });
+ const firstEventLine = res.data.split("\n").find((l) => l.startsWith("data: "));
+ expect(firstEventLine).toBeDefined();
+ const firstEvent = JSON.parse(firstEventLine!.slice(6)) as { event_id: string };
+ expect(firstEvent.event_id).toBe("evt_1");
+ });
+
+ it("clears Sora video state — a pre-reset video id stops resolving", async () => {
+ mock = new LLMock();
+ mock.addFixture({
+ match: { userMessage: "sora clip", endpoint: "video" },
+ response: {
+ video: { id: "video_sora_reset", status: "completed", url: "https://s/v.mp4" },
+ },
+ });
+ await mock.start();
+
+ const created = (await (
+ await fetch(`${mock.url}/v1/videos`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "sora-2", prompt: "sora clip" }),
+ })
+ ).json()) as { id: string };
+ expect(typeof created.id).toBe("string");
+ expect((await fetch(`${mock.url}/v1/videos/${created.id}`)).status).toBe(200);
+
+ mock.reset();
+
+ const after = await fetch(`${mock.url}/v1/videos/${created.id}`);
+ expect(after.status).toBe(404);
+ expect(((await after.json()) as { error: { type: string } }).error.type).toBe("not_found");
+ });
+
+ it("clears fal.ai audio queue jobs — a pre-reset request_id stops resolving", async () => {
+ mock = new LLMock();
+ mock.onFalAudio("drum loop", { audio: "SGVsbG8=", format: "mp3" });
+ await mock.start();
+
+ const envelope = (await (
+ await fetch(`${mock.url}/fal/queue/submit/fal-ai/stable-audio`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ prompt: "drum loop" }),
+ })
+ ).json()) as { request_id: string };
+ expect(typeof envelope.request_id).toBe("string");
+ expect(
+ (await fetch(`${mock.url}/fal/queue/requests/${envelope.request_id}/status`)).status,
+ ).toBe(200);
+
+ mock.reset();
+
+ expect(
+ (await fetch(`${mock.url}/fal/queue/requests/${envelope.request_id}/status`)).status,
+ ).toBe(404);
+ });
+
+ it("clears fal.ai general queue state — a pre-reset request_id stops resolving", async () => {
+ mock = new LLMock();
+ mock.onFalQueue(/flux/, { images: [{ url: "https://example.com/cat.png" }] });
+ await mock.start();
+
+ const falHeaders = { "x-fal-target-host": "queue.fal.run" };
+ const envelope = (await (
+ await fetch(`${mock.url}/fal/fal-ai/flux/dev`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", ...falHeaders },
+ body: JSON.stringify({ input: { prompt: "a cat" } }),
+ })
+ ).json()) as { request_id: string };
+ expect(typeof envelope.request_id).toBe("string");
+ const statusUrl = `${mock.url}/fal/fal-ai/flux/dev/requests/${envelope.request_id}/status`;
+ expect((await fetch(statusUrl, { headers: falHeaders })).status).toBe(200);
+
+ mock.reset();
+
+ expect((await fetch(statusUrl, { headers: falHeaders })).status).toBe(404);
+ });
+
+ // The full reset clears journal ENTRIES *and* per-test fixture
+ // match-counts. Only the latter carries sequence position, so a reset that
+ // used clearEntries() would leave sequenced fixtures parked mid-sequence.
+ it("clears fixture match-counts, rewinding sequence position", async () => {
+ const first = {
+ match: { userMessage: "seq", sequenceIndex: 0 },
+ response: { content: "FIRST" },
+ };
+ const second = {
+ match: { userMessage: "seq", sequenceIndex: 1 },
+ response: { content: "SECOND" },
+ };
+ mock = new LLMock();
+ mock.addFixture(first).addFixture(second);
+ await mock.start();
+
+ // Counts are per-testId, so drive BOTH the default scope and a named
+ // one — a reset that only clears the default sentinel would strand
+ // every other tenant mid-sequence.
+ const asTenant = (msg: string) =>
+ fetch(`${mock!.url}/v1/chat/completions`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "x-test-id": "tenant-a" },
+ body: JSON.stringify(chatBody(msg, false)),
+ }).then((r) => r.text());
+
+ expect((await post(mock.url, chatBody("seq"))).data).toContain("FIRST");
+ expect((await post(mock.url, chatBody("seq"))).data).toContain("SECOND");
+ expect(mock.journal.getFixtureMatchCount(first)).toBe(2);
+
+ expect(await asTenant("seq")).toContain("FIRST");
+ expect(await asTenant("seq")).toContain("SECOND");
+ expect(mock.journal.getFixtureMatchCount(first, "tenant-a")).toBe(2);
+
+ mock.reset();
+ // Re-add the SAME fixture objects — counts are keyed by object identity,
+ // so a surviving count would still be attached to them.
+ mock.addFixture(first).addFixture(second);
+
+ expect(mock.journal.getFixtureMatchCount(first)).toBe(0);
+ expect(mock.journal.getFixtureMatchCount(first, "tenant-a")).toBe(0);
+ expect((await post(mock.url, chatBody("seq"))).data).toContain("FIRST");
+ expect(await asTenant("seq")).toContain("FIRST");
+ });
+
+ // The documented divergence from the HTTP reset: these three stores are
+ // in-process only, so nothing but this test guards them.
+ it("clears search, rerank and moderation fixtures", async () => {
+ mock = new LLMock();
+ mock.onSearch("weather", [
+ { title: "Weather Report", url: "https://example.com/weather", content: "Sunny today" },
+ ]);
+ mock.onRerank("machine learning", [{ index: 0, relevance_score: 0.99 }]);
+ mock.onModerate("violent", { flagged: true, categories: { violence: true } });
+ await mock.start();
+
+ const search = async () =>
+ JSON.parse(
+ (await postTo(mock!.url, "/search", { query: "What is the weather?" })).data,
+ ) as {
+ results: unknown[];
+ };
+ const rerank = async () =>
+ JSON.parse(
+ (
+ await postTo(mock!.url, "/v2/rerank", {
+ query: "What is machine learning?",
+ documents: ["ML is a subset of AI"],
+ model: "rerank-v3.5",
+ })
+ ).data,
+ ) as { results: unknown[] };
+ const moderate = async () =>
+ JSON.parse(
+ (await postTo(mock!.url, "/v1/moderations", { input: "This is violent content" })).data,
+ ) as { results: Array<{ flagged: boolean }> };
+
+ expect((await search()).results).toHaveLength(1);
+ expect((await rerank()).results).toHaveLength(1);
+ expect((await moderate()).results[0].flagged).toBe(true);
+
+ mock.reset();
+
+ // A cleared fixture store yields an empty/unflagged response, not an error.
+ expect((await search()).results).toHaveLength(0);
+ expect((await rerank()).results).toHaveLength(0);
+ expect((await moderate()).results[0].flagged).toBe(false);
+ });
+
+ // performFullReset takes a null target before start(). The process-global
+ // stores must still be cleared on that path.
+ it("clears process-global state even when called before start()", async () => {
+ // Seed the global fal stores and the Gemini counters through a first,
+ // fully-started instance, then stop it.
+ const seeder = new LLMock();
+ seeder.onFalAudio("drum loop", { audio: "SGVsbG8=", format: "mp3" });
+ seeder.onMessage("hello", { content: "Hi there!" });
+ await seeder.start();
+ // The file-level afterEach only stops `mock`, so this handle is ours to
+ // close on every path.
+ let envelope: { request_id: string };
+ try {
+ envelope = (await (
+ await fetch(`${seeder.url}/fal/queue/submit/fal-ai/stable-audio`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ prompt: "drum loop" }),
+ })
+ ).json()) as { request_id: string };
+ await postTo(seeder.url, "/v1beta/interactions", {
+ model: "gemini-2.5-flash",
+ input: "hello",
+ stream: false,
+ });
+ } finally {
+ await seeder.stop();
+ }
+
+ // A brand-new, NEVER-STARTED instance resets the process-global state.
+ const unstarted = new LLMock();
+ unstarted.onMessage("x", { content: "y" });
+ unstarted.reset();
+ expect(unstarted.getFixtures()).toHaveLength(0);
+
+ // Observe through a fresh server: the seeded fal job is gone and the
+ // Gemini interaction counter restarted.
+ mock = new LLMock();
+ mock.onMessage("hello", { content: "Hi there!" });
+ await mock.start();
+ expect(
+ (await fetch(`${mock.url}/fal/queue/requests/${envelope.request_id}/status`)).status,
+ ).toBe(404);
+ const interaction = JSON.parse(
+ (
+ await postTo(mock.url, "/v1beta/interactions", {
+ model: "gemini-2.5-flash",
+ input: "hello",
+ stream: false,
+ })
+ ).data,
+ ) as { id: string };
+ expect(interaction.id).toBe("aimock-int-0");
+ });
+
+ it("re-zeroes the aimock_fixtures_loaded gauge", async () => {
+ mock = new LLMock({ metrics: true });
+ mock.onMessage("a", { content: "1" });
+ mock.onMessage("b", { content: "2" });
+ await mock.start();
+
+ const scrape = async () => (await fetch(`${mock!.url}/metrics`)).text();
+ expect(await scrape()).toContain("aimock_fixtures_loaded{} 2");
+
+ mock.reset();
+
+ expect(await scrape()).toContain("aimock_fixtures_loaded{} 0");
+ });
});
describe("baseUrl getter", () => {
diff --git a/src/__tests__/openrouter-video-record.test.ts b/src/__tests__/openrouter-video-record.test.ts
index c83ba17a..c3da84b5 100644
--- a/src/__tests__/openrouter-video-record.test.ts
+++ b/src/__tests__/openrouter-video-record.test.ts
@@ -3927,7 +3927,7 @@ describe("OpenRouter video record — round 4 CR", () => {
await waitUntil(() => upstream!.counts.content === 1);
// The world resets mid-capture: fixtures array cleared, job map cleared.
- const reset = await fetch(`${m.url}/__aimock/reset/fixtures`, { method: "POST" });
+ const reset = await fetch(`${m.url}/__aimock/reset`, { method: "POST" });
expect(reset.status).toBe(200);
await reset.arrayBuffer();
@@ -4369,7 +4369,7 @@ describe("OpenRouter video record — round 5 CR", () => {
body: JSON.stringify({ model: "bytedance/seedance-2.0", prompt: "reset mid submit" }),
});
await sleep(150); // the upstream submit is in flight
- const reset = await fetch(`${m.url}/__aimock/reset/fixtures`, { method: "POST" });
+ const reset = await fetch(`${m.url}/__aimock/reset`, { method: "POST" });
expect(reset.status).toBe(200);
await reset.arrayBuffer();
diff --git a/src/__tests__/openrouter-video.test.ts b/src/__tests__/openrouter-video.test.ts
index ec87e550..68ed3d4c 100644
--- a/src/__tests__/openrouter-video.test.ts
+++ b/src/__tests__/openrouter-video.test.ts
@@ -2267,7 +2267,7 @@ describe("OpenRouter video — reset plumbing", () => {
return id;
}
- test("POST /__aimock/reset/fixtures clears job state (old jobId polls 404)", async () => {
+ test("POST /__aimock/reset clears job state (old jobId polls 404)", async () => {
mock = new LLMock({ port: 0 });
mock.addFixture({
match: { userMessage: "reset http", endpoint: "video" },
@@ -2280,8 +2280,33 @@ describe("OpenRouter video — reset plumbing", () => {
expect(before.status).toBe(200);
await before.arrayBuffer();
+ const reset = await fetch(`${mock.url}/__aimock/reset`, { method: "POST" });
+ expect(reset.status).toBe(200);
+ await reset.arrayBuffer();
+
+ const after = await fetch(`${mock.url}/api/v1/videos/${id}`);
+ expect(after.status).toBe(404);
+ expect((await after.json()).error.code).toBe(404);
+ });
+
+ // The deprecated alias must keep clearing job state identically — that
+ // back-compat is the reason it still exists.
+ test("POST /__aimock/reset/fixtures (deprecated alias) clears job state too", async () => {
+ mock = new LLMock({ port: 0 });
+ mock.addFixture({
+ match: { userMessage: "reset alias", endpoint: "video" },
+ response: { video: { id: "vid_ral", status: "completed" } },
+ });
+ await mock.start();
+ const id = await submitJob("reset alias");
+
+ const before = await fetch(`${mock.url}/api/v1/videos/${id}`);
+ expect(before.status).toBe(200);
+ await before.arrayBuffer();
+
const reset = await fetch(`${mock.url}/__aimock/reset/fixtures`, { method: "POST" });
expect(reset.status).toBe(200);
+ expect(reset.headers.get("deprecation")).toBe("true");
await reset.arrayBuffer();
const after = await fetch(`${mock.url}/api/v1/videos/${id}`);
@@ -3143,7 +3168,7 @@ describe("OpenRouter video submit — fixtures reset during a slow ResponseFacto
});
// The factory is mid-await — reset the world underneath it.
await new Promise((r) => setTimeout(r, 150));
- const reset = await fetch(`${mock.url}/__aimock/reset/fixtures`, { method: "POST" });
+ const reset = await fetch(`${mock.url}/__aimock/reset`, { method: "POST" });
expect(reset.status).toBe(200);
await reset.arrayBuffer();
diff --git a/src/llmock.ts b/src/llmock.ts
index fe6b840d..7fe91999 100644
--- a/src/llmock.ts
+++ b/src/llmock.ts
@@ -17,7 +17,12 @@ import type {
TranscriptionResponse,
VideoResponse,
} from "./types.js";
-import { createServer, createServerWithResolvedAuth, type ServerInstance } from "./server.js";
+import {
+ createServer,
+ createServerWithResolvedAuth,
+ performFullReset,
+ type ServerInstance,
+} from "./server.js";
import type { ResolvedInboundAuth } from "./api-key-auth.js";
import {
loadFixtureFile,
@@ -30,8 +35,7 @@ import { Journal } from "./journal.js";
import type { SearchFixture, SearchResult } from "./search.js";
import type { RerankFixture, RerankResult } from "./rerank.js";
import type { ModerationFixture, ModerationResult } from "./moderation.js";
-import { falJobs } from "./fal-audio.js";
-import { falQueueStates, imageResponseToFalJson, videoResponseToFalJson } from "./fal.js";
+import { imageResponseToFalJson, videoResponseToFalJson } from "./fal.js";
export class LLMock {
private fixtures: Fixture[] = [];
@@ -422,18 +426,33 @@ export class LLMock {
// ---- Reset ----
+ /**
+ * Full reset — the in-process equivalent of `POST /__aimock/reset`. Shares
+ * one implementation with the control-API route so the two cannot drift.
+ *
+ * The one deliberate difference: search / rerank / moderation fixtures are
+ * also cleared here. Those are registered through this class only — the
+ * control API has no route that creates them, so the HTTP reset can neither
+ * reach nor observe them.
+ *
+ * NOT ALL OF THIS IS PER-INSTANCE. `performFullReset` clears module-global
+ * state as well: the Gemini interaction and event-id counters
+ * (`resetInteractionCounter` / `resetEventIdCounter` in
+ * `./gemini-interactions.js`) and the fal.ai job/queue maps (`falJobs`,
+ * `falQueueStates`). With two `LLMock` instances live in one process,
+ * `a.reset()` rewinds the Gemini id sequence that `b` is mid-way through —
+ * `b` then re-emits `aimock-int-0` / `evt_1`, ids it has already handed
+ * out — and drops `b`'s in-flight fal jobs. Give each instance its own
+ * process (or its own vitest worker) if that matters.
+ *
+ * The global stores are cleared even before `start()`, when there is no
+ * server instance to reset.
+ */
reset(): this {
- this.clearFixtures();
this.searchFixtures.length = 0;
this.rerankFixtures.length = 0;
this.moderationFixtures.length = 0;
- falJobs.clear();
- falQueueStates.clear();
- if (this.serverInstance) {
- this.serverInstance.journal.clear();
- this.serverInstance.videoStates.clear();
- this.serverInstance.openRouterVideoJobs.clear();
- }
+ performFullReset(this.fixtures, this.serverInstance);
return this;
}
diff --git a/src/openrouter-video.ts b/src/openrouter-video.ts
index a24c612f..d674148f 100644
--- a/src/openrouter-video.ts
+++ b/src/openrouter-video.ts
@@ -2236,8 +2236,8 @@ async function proxyOpenRouterVideoRecordPoll(args: {
// upstream fetch was in flight (everything below the fetch is
// synchronous, so the first poll to resume wins atomically). Relay
// without persisting a duplicate fixture or re-registering it.
- // This identity check ALSO covers a fixtures reset landing during the
- // upstream fetch: performFixturesReset clears the job map, so a
+ // This identity check ALSO covers a full reset landing during the
+ // upstream fetch: performFullReset clears the job map, so a
// cleared world fails the check and the stale failure fixture never
// pollutes the next world's fixtures array — valid because everything
// from here to persistFixture below is synchronous (no interleaving
@@ -2504,11 +2504,10 @@ async function captureOpenRouterVideoRecordFixture(args: {
...(capturedB64 !== undefined ? { b64: capturedB64 } : {}),
...(cost !== undefined ? { cost } : {}),
};
- // World-generation guard: a fixtures reset (POST
- // /__aimock/reset/fixtures) landing during the multi-second capture
- // above clears BOTH the fixtures array and the job map
- // (performFixturesReset) — so map identity is a valid proxy for "same
- // world": if `jobs.get(key)` no longer returns this job, the world this
+ // World-generation guard: a full reset (POST /__aimock/reset) landing
+ // during the multi-second capture above clears BOTH the fixtures array
+ // and the job map (performFullReset) — so map identity is a valid proxy
+ // for "same world": if `jobs.get(key)` no longer returns this job, the world this
// capture belongs to is gone and persisting would push a stale fixture
// into the NEXT world's array (and write a file the new world never
// asked for). Checked immediately before persistFixture with no await
diff --git a/src/server.ts b/src/server.ts
index f746ae21..7a77f46f 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -272,32 +272,46 @@ function handleNotFound(res: http.ServerResponse, message: string): void {
const CONTROL_PREFIX = "/__aimock";
/**
- * Perform a full fixtures reset: clear the fixtures array, journal, video/fal
- * generation state, and the interaction/event-id counters, then zero the
- * `aimock_fixtures_loaded` gauge. Shared by `/reset/fixtures` and the
- * deprecated `/reset` alias.
+ * The per-server state a full reset clears. `ServerInstance` structurally
+ * satisfies this, so `LLMock.reset()` and the control-API full-reset route
+ * share a single definition of "everything" instead of two lists that drift.
*/
-function performFixturesReset(
- fixtures: Fixture[],
- journal: Journal,
- videoStates: VideoStateMap,
- openRouterVideoJobs: OpenRouterVideoJobMap,
- veoVideoJobs: VeoVideoJobMap,
- grokVideoJobs: GrokVideoJobMap,
- defaults: HandlerDefaults,
-): void {
+export interface FullResetTargets {
+ journal: Journal;
+ videoStates: VideoStateMap;
+ openRouterVideoJobs: OpenRouterVideoJobMap;
+ veoVideoJobs: VeoVideoJobMap;
+ grokVideoJobs: GrokVideoJobMap;
+ defaults: HandlerDefaults;
+}
+
+/**
+ * Perform a full reset: clear the fixtures array, the journal (entries *and*
+ * per-test fixture match-counts, i.e. sequence position), the video and fal.ai
+ * job/queue state, and the Gemini interaction/event-id counters, then re-zero
+ * the `aimock_fixtures_loaded` gauge.
+ *
+ * `targets` is `null` when no server is running (an in-process `reset()` before
+ * `start()`). The process-global generation state is reset either way, since it
+ * is not owned by any one server instance.
+ *
+ * Shared by `POST /__aimock/reset` (canonical), its deprecated
+ * `POST /__aimock/reset/fixtures` alias, and `LLMock.reset()`.
+ */
+export function performFullReset(fixtures: Fixture[], targets: FullResetTargets | null): void {
fixtures.length = 0;
- journal.clear();
- videoStates.clear();
- openRouterVideoJobs.clear();
- veoVideoJobs.clear();
- grokVideoJobs.clear();
falJobs.clear();
falQueueStates.clear();
resetInteractionCounter();
resetEventIdCounter();
- if (defaults.registry) {
- defaults.registry.setGauge("aimock_fixtures_loaded", {}, fixtures.length);
+ if (!targets) return;
+ targets.journal.clear();
+ targets.videoStates.clear();
+ targets.openRouterVideoJobs.clear();
+ targets.veoVideoJobs.clear();
+ targets.grokVideoJobs.clear();
+ if (targets.defaults.registry) {
+ targets.defaults.registry.setGauge("aimock_fixtures_loaded", {}, fixtures.length);
}
}
@@ -395,17 +409,19 @@ async function handleControlAPI(
return true;
}
- // POST /__aimock/reset/fixtures — full reset (fixtures + journal + match counts)
- if (subPath === "/reset/fixtures" && req.method === "POST") {
- performFixturesReset(
- fixtures,
- journal,
- videoStates,
- openRouterVideoJobs,
- veoVideoJobs,
- grokVideoJobs,
- defaults,
- );
+ const resetTargets = (): FullResetTargets => ({
+ journal,
+ videoStates,
+ openRouterVideoJobs,
+ veoVideoJobs,
+ grokVideoJobs,
+ defaults,
+ });
+
+ // POST /__aimock/reset — full reset (fixtures, journal entries + fixture
+ // match-counts, video/fal job state, Gemini counters)
+ if (subPath === "/reset" && req.method === "POST") {
+ performFullReset(fixtures, resetTargets());
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ reset: true }));
return true;
@@ -420,21 +436,15 @@ async function handleControlAPI(
return true;
}
- // POST /__aimock/reset — DEPRECATED alias for /reset/fixtures (full reset)
- if (subPath === "/reset" && req.method === "POST") {
- performFixturesReset(
- fixtures,
- journal,
- videoStates,
- openRouterVideoJobs,
- veoVideoJobs,
- grokVideoJobs,
- defaults,
- );
+ // POST /__aimock/reset/fixtures — DEPRECATED alias for /reset. The name
+ // promises a fixtures-only reset but it always performed the full reset;
+ // /reset is the honest route. Behaviour is unchanged for existing callers.
+ if (subPath === "/reset/fixtures" && req.method === "POST") {
+ performFullReset(fixtures, resetTargets());
const deprecation =
- "POST /__aimock/reset is deprecated; use POST /__aimock/reset/fixtures (full reset) or POST /__aimock/reset/journal (journal only)";
+ "POST /__aimock/reset/fixtures is deprecated; use POST /__aimock/reset (full reset) or POST /__aimock/reset/journal (journal only)";
defaults.logger.warn(
- "POST /__aimock/reset is deprecated; use /__aimock/reset/fixtures or /__aimock/reset/journal",
+ "POST /__aimock/reset/fixtures is deprecated; use /__aimock/reset or /__aimock/reset/journal",
);
res.writeHead(200, { "Content-Type": "application/json", Deprecation: "true" });
res.end(JSON.stringify({ reset: true, deprecated: true, deprecation }));
@@ -1977,7 +1987,7 @@ export async function createServerWithResolvedAuth(
// Clear only the request journal entries, preserving fixture
// match-counts (sequencing state). Clearing the request log must not
// silently rewind sequenced fixtures. For a full reset (entries +
- // match-counts), use POST /__aimock/reset/fixtures.
+ // match-counts), use POST /__aimock/reset.
journal.clearEntries();
res.writeHead(204);
res.end();