Skip to content

Commit 2e9c52c

Browse files
authored
fix: latency chunkSize replaySpeed logLevel in startFromConfig (#294)
I ran into this bug while trying to set `replaySpeed` to speed up an e2e suite which uses aimock The aimock-cli docs list latency/chunkSize/logLevel under `llm` and the example config sets them. But copying that example does not work Our fixtures have recordedTimings so every replay artificially slows down the e2e tests by over ~70s across the suite `relaySpeed` already exists in MockServerOptions as an llmock flag and as a fixture field but not in a config file... I ran it against our real suite and set `llm.replaySpeed: 10000` and successfully reduced a single test duration from `11.1s` to `0.9s` **Disclaimer**: This code was written with Claude Code but I reviewed and tested it manually
2 parents e6c3121 + b99092c commit 2e9c52c

4 files changed

Lines changed: 193 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## [Unreleased]
44

5+
### Fixed
6+
7+
- `aimock.json` now honours `llm.latency`, `llm.chunkSize`, `llm.replaySpeed` and `llm.logLevel`, which the config loader previously accepted and ignored. Each behaves as its `llmock` CLI flag and `createMockSuite({ llm })` equivalent. `llm.replaySpeed` divides every delay source (recorded timings, streaming profiles, `latency`), so a fixture suite can replay recorded inter-chunk timings faster than they were recorded; a fixture's own `replaySpeed` still takes precedence. A non-positive `llm.replaySpeed` is ignored with a warning, matching the existing fixture-level guard (#294)
8+
59
## [1.36.0] - 2026-07-13
610

711
### Added

docs/aimock-cli/index.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -135,9 +135,9 @@ <h3>Config Fields</h3>
135135
<td>object</td>
136136
<td>
137137
LLMock configuration. Accepts <code>fixtures</code>, <code>latency</code>,
138-
<code>chunkSize</code>, <code>logLevel</code>, <code>validateOnLoad</code>,
139-
<code>metrics</code>, <code>strict</code>, <code>chaos</code>,
140-
<code>streamingProfile</code>, <code>record</code>.
138+
<code>chunkSize</code>, <code>replaySpeed</code>, <code>logLevel</code>,
139+
<code>validateOnLoad</code>, <code>metrics</code>, <code>strict</code>,
140+
<code>chaos</code>, <code>streamingProfile</code>, <code>record</code>.
141141
</td>
142142
</tr>
143143
<tr>

src/__tests__/config-loader.test.ts

Lines changed: 170 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import { describe, it, expect, beforeEach, afterEach } from "vitest";
1+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
22
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
55
import { loadConfig, startFromConfig } from "../config-loader.js";
66
import type { AimockConfig } from "../config-loader.js";
7+
import type { RecordedTimings } from "../types.js";
8+
import { Logger } from "../logger.js";
79

810
function makeTmpDir(): string {
911
return mkdtempSync(join(tmpdir(), "config-loader-test-"));
@@ -32,6 +34,47 @@ function writeFixtureFile(dir: string, name = "fixtures.json"): string {
3234
return filePath;
3335
}
3436

37+
// Content is long enough to split into several chunks so per-chunk delays are observable
38+
function writeStreamFixtureFile(
39+
dir: string,
40+
recordedTimings?: RecordedTimings,
41+
name = "stream-fixtures.json",
42+
): string {
43+
const filePath = join(dir, name);
44+
writeFileSync(
45+
filePath,
46+
JSON.stringify({
47+
fixtures: [
48+
{
49+
match: { userMessage: "hello" },
50+
response: { content: "Hello world from the streaming config test!" },
51+
...(recordedTimings ? { recordedTimings } : {}),
52+
},
53+
],
54+
}),
55+
"utf-8",
56+
);
57+
return filePath;
58+
}
59+
60+
// Streams a completion and counts SSE frames. Without stream: true there is no SSE and no
61+
// chunk delays, so the timing assertions below would pass whether or not the options are wired.
62+
async function streamChunkCount(url: string): Promise<number> {
63+
const resp = await fetch(`${url}/v1/chat/completions`, {
64+
method: "POST",
65+
headers: { "Content-Type": "application/json" },
66+
body: JSON.stringify({
67+
model: "gpt-4",
68+
stream: true,
69+
messages: [{ role: "user", content: "hello" }],
70+
}),
71+
});
72+
const body = await resp.text();
73+
return body
74+
.split("\n\n")
75+
.filter((frame) => frame.startsWith("data: ") && !frame.includes("[DONE]")).length;
76+
}
77+
3578
describe("loadConfig", () => {
3679
let tmpDir: string;
3780

@@ -156,6 +199,108 @@ describe("startFromConfig", () => {
156199
expect(resp.status).toBe(500);
157200
});
158201

202+
it("with llm.chunkSize, the response is split into more chunks", async () => {
203+
const fixturePath = writeStreamFixtureFile(tmpDir);
204+
205+
const small = await startFromConfig({ llm: { fixtures: fixturePath, chunkSize: 5 } });
206+
cleanups.push(() => small.llmock.stop());
207+
const smallChunks = await streamChunkCount(small.url);
208+
209+
const large = await startFromConfig({ llm: { fixtures: fixturePath, chunkSize: 1000 } });
210+
cleanups.push(() => large.llmock.stop());
211+
const largeChunks = await streamChunkCount(large.url);
212+
213+
expect(smallChunks).toBeGreaterThan(largeChunks);
214+
});
215+
216+
it("with llm.latency, streamed chunks are delayed", async () => {
217+
const fixturePath = writeStreamFixtureFile(tmpDir);
218+
const config: AimockConfig = {
219+
llm: { fixtures: fixturePath, chunkSize: 5, latency: 100 },
220+
};
221+
const { llmock, url } = await startFromConfig(config);
222+
cleanups.push(() => llmock.stop());
223+
224+
const start = Date.now();
225+
const chunks = await streamChunkCount(url);
226+
const elapsed = Date.now() - start;
227+
228+
// 100ms per chunk across >= 5 chunks. Asserted as a lower bound so a slow
229+
// machine can never fail it; without the passthrough latency is 0.
230+
expect(chunks).toBeGreaterThanOrEqual(5);
231+
expect(elapsed).toBeGreaterThanOrEqual(250);
232+
});
233+
234+
it("with llm.replaySpeed, recorded timings replay faster", async () => {
235+
// 500ms TTFT + 4 x 500ms inter-chunk = ~2500ms at 1x speed
236+
const fixturePath = writeStreamFixtureFile(tmpDir, {
237+
ttftMs: 500,
238+
interChunkDelaysMs: [500, 500, 500, 500],
239+
totalDurationMs: 2500,
240+
});
241+
const config: AimockConfig = {
242+
llm: { fixtures: fixturePath, chunkSize: 5, replaySpeed: 100 },
243+
};
244+
const { llmock, url } = await startFromConfig(config);
245+
cleanups.push(() => llmock.stop());
246+
247+
const start = Date.now();
248+
await streamChunkCount(url);
249+
const elapsed = Date.now() - start;
250+
251+
// At 100x this lands in ~25ms; the bound is deliberately generous so only a
252+
// dropped passthrough (which costs the full ~2500ms) can fail it.
253+
expect(elapsed).toBeLessThan(1000);
254+
});
255+
256+
it("with non-positive llm.replaySpeed, warns and ignores it", async () => {
257+
const fixturePath = writeStreamFixtureFile(tmpDir);
258+
// Spy on the logger the code path actually uses rather than console, so the
259+
// assertion verifies the intended warn call without coupling to the
260+
// "[aimock]" prefix or the console transport.
261+
const warn = vi.spyOn(Logger.prototype, "warn").mockImplementation(() => {});
262+
263+
// 0 looks like "no delay" but hits the `speed > 0` guard in calculateDelay,
264+
// which would replay at full recorded speed.
265+
const { llmock } = await startFromConfig({
266+
llm: { fixtures: fixturePath, replaySpeed: 0 },
267+
});
268+
cleanups.push(() => llmock.stop());
269+
270+
expect(warn).toHaveBeenCalledWith(
271+
expect.stringContaining("llm.replaySpeed must be a positive number"),
272+
);
273+
warn.mockRestore();
274+
});
275+
276+
it("with llm.logLevel debug, the server logs debug output that silent suppresses", async () => {
277+
const fixturePath = writeFixtureFile(tmpDir);
278+
// The server logger writes debug/info lines via console.log; capture them so
279+
// we can assert the configured level actually reaches the running server.
280+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
281+
282+
// A matched request emits a "Fixture matched" debug line, but only when the
283+
// configured logLevel is high enough — so it is a direct probe of the passthrough.
284+
async function debugLineCount(logLevel: "debug" | "silent"): Promise<number> {
285+
log.mockClear();
286+
const { llmock, url } = await startFromConfig({
287+
llm: { fixtures: fixturePath, logLevel },
288+
});
289+
cleanups.push(() => llmock.stop());
290+
await fetch(`${url}/v1/chat/completions`, {
291+
method: "POST",
292+
headers: { "Content-Type": "application/json" },
293+
body: JSON.stringify({ model: "gpt-4", messages: [{ role: "user", content: "hello" }] }),
294+
});
295+
return log.mock.calls.filter((args) => String(args[1]).startsWith("Fixture matched")).length;
296+
}
297+
298+
expect(await debugLineCount("debug")).toBeGreaterThan(0);
299+
expect(await debugLineCount("silent")).toBe(0);
300+
301+
log.mockRestore();
302+
});
303+
159304
it("with mcp tools config, MCPMock created and tools/list works", async () => {
160305
const config: AimockConfig = {
161306
mcp: {
@@ -649,11 +794,8 @@ describe("startFromConfig", () => {
649794
{
650795
pattern: "stream",
651796
events: [
652-
{
653-
kind: "status-update",
654-
taskId: "t1",
655-
status: { state: "working", message: { parts: [{ text: "streaming..." }] } },
656-
},
797+
{ type: "status", state: "TASK_STATE_WORKING" },
798+
{ type: "artifact", parts: [{ text: "streaming..." }], name: "out" },
657799
],
658800
delayMs: 0,
659801
},
@@ -670,6 +812,28 @@ describe("startFromConfig", () => {
670812
expect(cardRes.status).toBe(200);
671813
const card = await cardRes.json();
672814
expect(card.name).toBe("stream-agent");
815+
816+
// Verify the streaming task actually streams the configured events over SSE
817+
const streamRes = await fetch(`${url}/a2a`, {
818+
method: "POST",
819+
headers: { "Content-Type": "application/json" },
820+
body: JSON.stringify({
821+
jsonrpc: "2.0",
822+
method: "SendStreamingMessage",
823+
params: { message: { parts: [{ text: "please stream" }] } },
824+
id: 1,
825+
}),
826+
});
827+
expect(streamRes.status).toBe(200);
828+
expect(streamRes.headers.get("content-type")).toBe("text/event-stream");
829+
const streamBody = await streamRes.text();
830+
const events = streamBody
831+
.split("\n\n")
832+
.filter((frame) => frame.startsWith("data: "))
833+
.map((frame) => JSON.parse(frame.slice("data: ".length)));
834+
expect(events.length).toBe(2);
835+
expect(events[0].result.task.status.state).toBe("TASK_STATE_WORKING");
836+
expect(events[1].result.artifact.parts[0].text).toBe("streaming...");
673837
});
674838

675839
it("with a2a custom path, mounts at specified path for tasks", async () => {

src/config-loader.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@ export interface VectorConfig {
9090
export interface AimockConfig {
9191
llm?: {
9292
fixtures?: string;
93+
latency?: number;
94+
chunkSize?: number;
95+
replaySpeed?: number;
96+
logLevel?: "silent" | "warn" | "info" | "debug";
9397
chaos?: ChaosConfig;
9498
record?: RecordConfig;
9599
};
@@ -115,10 +119,22 @@ export async function startFromConfig(
115119
): Promise<{ llmock: LLMock; url: string }> {
116120
const logger = new Logger("info");
117121

122+
// A non-positive replaySpeed fails calculateDelay's `speed > 0` check and applies the
123+
// full delay rather than none. Mirrors the fixture-level guard in fixture-loader.ts.
124+
let replaySpeed = config.llm?.replaySpeed;
125+
if (replaySpeed != null && (!Number.isFinite(replaySpeed) || replaySpeed <= 0)) {
126+
logger.warn(`llm.replaySpeed must be a positive number, got ${replaySpeed}. Ignoring.`);
127+
replaySpeed = undefined;
128+
}
129+
118130
// Load fixtures if specified
119131
const llmock = new LLMock({
120132
port: overrides?.port ?? config.port ?? 0,
121133
host: overrides?.host ?? config.host ?? "127.0.0.1",
134+
latency: config.llm?.latency,
135+
chunkSize: config.llm?.chunkSize,
136+
replaySpeed,
137+
logLevel: config.llm?.logLevel,
122138
chaos: config.llm?.chaos,
123139
record: config.llm?.record,
124140
metrics: config.metrics,

0 commit comments

Comments
 (0)