Skip to content

Commit a415eeb

Browse files
authored
fix(journal): read-path non-mutation + CLI hardening + finite createServer defaults (#115)
## Summary Follow-up polish to v1.14.1's journal OOM fix (#114). Three independent improvements surfaced by CR: 1. **Read-path non-mutation bug fix.** `Journal.getFixtureMatchCount(fixture, testId)` was a read method that silently inserted an empty Map + triggered FIFO eviction for unknown testIds. Reads could evict live testIds. Now split into a read-only public `getFixtureMatchCountsForTest` (returns transient empty Map on miss) and private `getOrCreateFixtureMatchCountsForTest` (insert+evict write path used only by `incrementFixtureMatchCount`). 2. **CLI validation hardening.** `--journal-max -5` was silently treated as unbounded; now rejected with a clear error. Same for the new `--fixture-counts-max` flag. 3. **`createServer()` default flip.** `journalMaxEntries` (1000) and `fixtureCountsMaxTestIds` (500) now default to finite caps for programmatic callers — long-running embedders no longer inherit the original leak. Tests using `new Journal()` directly remain unbounded by default (back-compat). Opt in to unbounded via `journalMaxEntries: 0`. ## Test plan - [x] 3 new tests: read-non-mutation, CLI negative rejection, `fixtureCountsMaxTestIds` cap FIFO eviction - [x] Full suite: 2461 tests pass - [x] Lint + build + prettier clean - [x] CR R2 on combined diff: 0 blocking bugs ## Breaking change note The `createServer()` default flip is a behavioral change for programmatic embedders that relied on unbounded journal retention. Opt back in with `createServer({ journalMaxEntries: 0 })` if needed. Documented in `types.ts` JSDoc.
2 parents 5fc7b89 + b2df584 commit a415eeb

11 files changed

Lines changed: 273 additions & 20 deletions

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
# @copilotkit/aimock
22

3+
## 1.14.2
4+
5+
### Fixed
6+
7+
- `Journal.getFixtureMatchCount()` is now read-only: calling it with an unknown testId no longer inserts an empty map or triggers FIFO eviction of a live testId. Reads never mutate cache state.
8+
- CLI rejects negative values for `--journal-max` and `--fixture-counts-max` with a clear error (previously silently treated as unbounded).
9+
10+
### Changed
11+
12+
- `createServer()` programmatic default: `journalMaxEntries` and `fixtureCountsMaxTestIds` now default to finite caps (1000 / 500) instead of unbounded. Long-running embedders that relied on unbounded retention must now opt in explicitly by passing `0`. Back-compat with test harnesses using `new Journal()` directly is preserved (they still default to unbounded).
13+
14+
### Added
15+
16+
- New `--fixture-counts-max <n>` CLI flag (default 500) to cap the fixture-match-counts map by testId.
17+
318
## 1.14.1
419

520
### Patch Changes

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@copilotkit/aimock",
3-
"version": "1.14.1",
3+
"version": "1.14.2",
44
"description": "Mock infrastructure for AI application testing — LLM APIs, image generation, text-to-speech, transcription, video generation, MCP tools, A2A agents, AG-UI event streams, vector databases, search, rerank, and moderation. One package, one port, zero dependencies.",
55
"license": "MIT",
66
"keywords": [

src/__tests__/cli.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,32 @@ describe.skipIf(!CLI_AVAILABLE)("CLI: argument validation", () => {
125125
expect(stderr).toContain("Invalid chunk-size");
126126
expect(code).toBe(1);
127127
});
128+
129+
it("rejects --journal-max=-5 (negative)", async () => {
130+
const { stderr, code } = await runCli(["--journal-max=-5"]);
131+
expect(stderr).toContain("Invalid journal-max");
132+
expect(stderr).toContain("non-negative");
133+
expect(code).toBe(1);
134+
});
135+
136+
it("rejects --journal-max=-1 (negative)", async () => {
137+
const { stderr, code } = await runCli(["--journal-max=-1"]);
138+
expect(stderr).toContain("Invalid journal-max");
139+
expect(code).toBe(1);
140+
});
141+
142+
it("rejects --journal-max 1.5 (non-integer)", async () => {
143+
const { stderr, code } = await runCli(["--journal-max", "1.5"]);
144+
expect(stderr).toContain("Invalid journal-max");
145+
expect(code).toBe(1);
146+
});
147+
148+
it("rejects --fixture-counts-max=-1 (negative)", async () => {
149+
const { stderr, code } = await runCli(["--fixture-counts-max=-1"]);
150+
expect(stderr).toContain("Invalid fixture-counts-max");
151+
expect(stderr).toContain("non-negative");
152+
expect(code).toBe(1);
153+
});
128154
});
129155

130156
describe.skipIf(!CLI_AVAILABLE)("CLI: fixture loading", () => {

src/__tests__/journal.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,4 +373,88 @@ describe("Journal", () => {
373373
expect(journal.getAll()[0].path).toBe("/99500");
374374
});
375375
});
376+
377+
describe("fixtureCountsMaxTestIds (FIFO eviction on testId map)", () => {
378+
// Minimal fixture shared by these tests — only the reference matters.
379+
const fixture: Fixture = { match: { userMessage: "x" }, response: { content: "X" } };
380+
381+
it("does not cap when fixtureCountsMaxTestIds is unset (backwards compat)", () => {
382+
const journal = new Journal();
383+
for (let i = 0; i < 2000; i++) {
384+
journal.incrementFixtureMatchCount(fixture, undefined, `test-${i}`);
385+
}
386+
// Every testId retained under unbounded default (historical behavior).
387+
expect(journal.getFixtureMatchCount(fixture, "test-0")).toBe(1);
388+
expect(journal.getFixtureMatchCount(fixture, "test-1999")).toBe(1);
389+
});
390+
391+
it("treats fixtureCountsMaxTestIds = 0 or negative as uncapped", () => {
392+
const j0 = new Journal({ fixtureCountsMaxTestIds: 0 });
393+
const jNeg = new Journal({ fixtureCountsMaxTestIds: -1 });
394+
for (let i = 0; i < 100; i++) {
395+
j0.incrementFixtureMatchCount(fixture, undefined, `test-${i}`);
396+
jNeg.incrementFixtureMatchCount(fixture, undefined, `test-${i}`);
397+
}
398+
expect(j0.getFixtureMatchCount(fixture, "test-0")).toBe(1);
399+
expect(jNeg.getFixtureMatchCount(fixture, "test-0")).toBe(1);
400+
});
401+
402+
it("evicts the oldest testId when size exceeds the cap (FIFO)", () => {
403+
const journal = new Journal({ fixtureCountsMaxTestIds: 3 });
404+
405+
journal.incrementFixtureMatchCount(fixture, undefined, "t1");
406+
journal.incrementFixtureMatchCount(fixture, undefined, "t2");
407+
journal.incrementFixtureMatchCount(fixture, undefined, "t3");
408+
// At cap (3). All three retained.
409+
expect(journal.getFixtureMatchCount(fixture, "t1")).toBe(1);
410+
expect(journal.getFixtureMatchCount(fixture, "t2")).toBe(1);
411+
expect(journal.getFixtureMatchCount(fixture, "t3")).toBe(1);
412+
413+
// Fourth unique testId triggers eviction of the oldest (t1).
414+
journal.incrementFixtureMatchCount(fixture, undefined, "t4");
415+
416+
// After eviction, t1's prior count is gone. Reads are non-mutating,
417+
// so looking up t1 returns 0 without re-inserting.
418+
expect(journal.getFixtureMatchCount(fixture, "t1")).toBe(0);
419+
expect(journal.getFixtureMatchCount(fixture, "t2")).toBe(1);
420+
expect(journal.getFixtureMatchCount(fixture, "t3")).toBe(1);
421+
expect(journal.getFixtureMatchCount(fixture, "t4")).toBe(1);
422+
});
423+
424+
it("getFixtureMatchCount does NOT mutate cache on unknown testId", () => {
425+
const journal = new Journal({ fixtureCountsMaxTestIds: 3 });
426+
427+
journal.incrementFixtureMatchCount(fixture, undefined, "t1");
428+
journal.incrementFixtureMatchCount(fixture, undefined, "t2");
429+
journal.incrementFixtureMatchCount(fixture, undefined, "t3");
430+
// Snapshot the internal size via a retained-testId probe: t1 is still
431+
// present (count=1). Reading an unknown testId must not evict it.
432+
expect(journal.getFixtureMatchCount(fixture, "t1")).toBe(1);
433+
434+
// Read many unknown testIds — each would have triggered insert+evict
435+
// under the old behavior, evicting t1/t2/t3 one by one.
436+
for (let i = 0; i < 50; i++) {
437+
expect(journal.getFixtureMatchCount(fixture, `unknown-${i}`)).toBe(0);
438+
}
439+
440+
// All original testIds must still be intact.
441+
expect(journal.getFixtureMatchCount(fixture, "t1")).toBe(1);
442+
expect(journal.getFixtureMatchCount(fixture, "t2")).toBe(1);
443+
expect(journal.getFixtureMatchCount(fixture, "t3")).toBe(1);
444+
});
445+
446+
it("holds steady at the cap under sustained load with many unique testIds", () => {
447+
// Red-green anchor: 10k unique testIds with cap=100 must stay at 100.
448+
const journal = new Journal({ fixtureCountsMaxTestIds: 100 });
449+
for (let i = 0; i < 10_000; i++) {
450+
journal.incrementFixtureMatchCount(fixture, undefined, `t-${i}`);
451+
}
452+
// Only the last 100 testIds should have counts > 0 retained.
453+
// Access an early one — since it was evicted, getFixtureMatchCount
454+
// returns 0 (the read path is non-mutating on miss).
455+
expect(journal.getFixtureMatchCount(fixture, "t-0")).toBe(0);
456+
// Most recently added testIds retained.
457+
expect(journal.getFixtureMatchCount(fixture, "t-9999")).toBe(1);
458+
});
459+
});
376460
});

src/__tests__/server.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -793,6 +793,51 @@ describe("journal", () => {
793793
});
794794
});
795795

796+
describe("createServer journal caps (defaults)", () => {
797+
it("applies a finite default journalMaxEntries (1000) when not specified", async () => {
798+
// Red-green anchor: programmatic embedders must inherit a finite cap.
799+
instance = await createServer(allFixtures);
800+
// Seed 1500 synthetic entries via the journal add() API (much faster
801+
// than 1500 HTTP requests; the cap lives on the Journal itself).
802+
for (let i = 0; i < 1500; i++) {
803+
instance.journal.add({
804+
method: "POST",
805+
path: "/v1/chat/completions",
806+
headers: {},
807+
body: { model: "gpt-4", messages: [{ role: "user", content: `msg-${i}` }] },
808+
response: { status: 200, fixture: null },
809+
});
810+
}
811+
expect(instance.journal.size).toBe(1000);
812+
});
813+
814+
it("honors explicit journalMaxEntries: 0 (opt-in to unbounded)", async () => {
815+
instance = await createServer(allFixtures, { journalMaxEntries: 0 });
816+
for (let i = 0; i < 1500; i++) {
817+
instance.journal.add({
818+
method: "POST",
819+
path: "/v1/chat/completions",
820+
headers: {},
821+
body: { model: "gpt-4", messages: [{ role: "user", content: `msg-${i}` }] },
822+
response: { status: 200, fixture: null },
823+
});
824+
}
825+
expect(instance.journal.size).toBe(1500);
826+
});
827+
828+
it("applies a finite default fixtureCountsMaxTestIds (500) when not specified", async () => {
829+
instance = await createServer(allFixtures);
830+
const fixture: Fixture = { match: { userMessage: "x" }, response: { content: "X" } };
831+
for (let i = 0; i < 1000; i++) {
832+
instance.journal.incrementFixtureMatchCount(fixture, undefined, `test-${i}`);
833+
}
834+
// Oldest testId (test-0) should be evicted under the default cap.
835+
expect(instance.journal.getFixtureMatchCount(fixture, "test-0")).toBe(0);
836+
// Most-recently-added testId retained.
837+
expect(instance.journal.getFixtureMatchCount(fixture, "test-999")).toBe(1);
838+
});
839+
});
840+
796841
describe("readBody error path", () => {
797842
it("returns 500 when the request body stream is destroyed mid-read", async () => {
798843
instance = await createServer(allFixtures);

src/__tests__/test-id-isolation.test.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,15 @@ describe("Journal per-testId match counting", () => {
1111
response: { content: "Hi" },
1212
};
1313

14-
const mapA = journal.getFixtureMatchCountsForTest("test-A");
15-
const mapB = journal.getFixtureMatchCountsForTest("test-B");
16-
1714
journal.incrementFixtureMatchCount(f, [f], "test-A");
1815
journal.incrementFixtureMatchCount(f, [f], "test-A");
1916

17+
// Reads are non-mutating: fetch the maps AFTER writes so we observe
18+
// the live backing maps for known testIds. Unknown testIds return
19+
// a transient empty map (does not insert into the cache).
20+
const mapA = journal.getFixtureMatchCountsForTest("test-A");
21+
const mapB = journal.getFixtureMatchCountsForTest("test-B");
22+
2023
expect(mapA.get(f)).toBe(2);
2124
expect(mapB.get(f)).toBeUndefined();
2225
});
@@ -28,8 +31,8 @@ describe("Journal per-testId match counting", () => {
2831
response: { content: "Hi" },
2932
};
3033

31-
const defaultMap = journal.getFixtureMatchCountsForTest(DEFAULT_TEST_ID);
3234
journal.incrementFixtureMatchCount(f, [f]);
35+
const defaultMap = journal.getFixtureMatchCountsForTest(DEFAULT_TEST_ID);
3336

3437
expect(defaultMap.get(f)).toBe(1);
3538
});

src/cli.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Options:
2626
--proxy-only Proxy mode: forward unmatched requests without saving
2727
--strict Strict mode: fail on unmatched requests
2828
--journal-max <n> Max request entries retained in memory (default: 1000, 0 = unbounded)
29+
--fixture-counts-max <n> Max unique testIds retained in fixture match-count map (default: 500, 0 = unbounded)
2930
--provider-openai <url> Upstream URL for OpenAI (used with --record)
3031
--provider-anthropic <url> Upstream URL for Anthropic
3132
--provider-gemini <url> Upstream URL for Gemini
@@ -72,6 +73,7 @@ const { values } = parseArgs({
7273
"chaos-malformed": { type: "string" },
7374
"chaos-disconnect": { type: "string" },
7475
"journal-max": { type: "string", default: "1000" },
76+
"fixture-counts-max": { type: "string", default: "500" },
7577
help: { type: "boolean", default: false },
7678
},
7779
strict: true,
@@ -113,8 +115,19 @@ if (Number.isNaN(chunkSize) || chunkSize < 1) {
113115
}
114116

115117
const journalMax = Number(values["journal-max"]);
116-
if (Number.isNaN(journalMax) || !Number.isInteger(journalMax)) {
117-
console.error(`Invalid journal-max: ${values["journal-max"]} (must be an integer)`);
118+
if (Number.isNaN(journalMax) || !Number.isInteger(journalMax) || journalMax < 0) {
119+
console.error(
120+
`Invalid journal-max: ${values["journal-max"]} (must be a non-negative integer; 0 or omitted = unbounded)`,
121+
);
122+
process.exit(1);
123+
}
124+
125+
const fixtureCountsMaxStr = values["fixture-counts-max"];
126+
const fixtureCountsMax = Number(fixtureCountsMaxStr);
127+
if (Number.isNaN(fixtureCountsMax) || !Number.isInteger(fixtureCountsMax) || fixtureCountsMax < 0) {
128+
console.error(
129+
`Invalid fixture-counts-max: ${fixtureCountsMaxStr} (must be a non-negative integer; 0 = unbounded)`,
130+
);
118131
process.exit(1);
119132
}
120133

@@ -265,6 +278,7 @@ async function main() {
265278
record,
266279
strict: values.strict,
267280
journalMaxEntries: journalMax,
281+
fixtureCountsMaxTestIds: fixtureCountsMax,
268282
},
269283
mounts,
270284
);

0 commit comments

Comments
 (0)