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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Fixed

- `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)

## [1.36.0] - 2026-07-13

### Added
Expand Down
6 changes: 3 additions & 3 deletions docs/aimock-cli/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,9 @@ <h3>Config Fields</h3>
<td>object</td>
<td>
LLMock configuration. Accepts <code>fixtures</code>, <code>latency</code>,
<code>chunkSize</code>, <code>logLevel</code>, <code>validateOnLoad</code>,
<code>metrics</code>, <code>strict</code>, <code>chaos</code>,
<code>streamingProfile</code>, <code>record</code>.
<code>chunkSize</code>, <code>replaySpeed</code>, <code>logLevel</code>,
<code>validateOnLoad</code>, <code>metrics</code>, <code>strict</code>,
<code>chaos</code>, <code>streamingProfile</code>, <code>record</code>.
</td>
</tr>
<tr>
Expand Down
176 changes: 170 additions & 6 deletions src/__tests__/config-loader.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { loadConfig, startFromConfig } from "../config-loader.js";
import type { AimockConfig } from "../config-loader.js";
import type { RecordedTimings } from "../types.js";
import { Logger } from "../logger.js";

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

// Content is long enough to split into several chunks so per-chunk delays are observable
function writeStreamFixtureFile(
dir: string,
recordedTimings?: RecordedTimings,
name = "stream-fixtures.json",
): string {
const filePath = join(dir, name);
writeFileSync(
filePath,
JSON.stringify({
fixtures: [
{
match: { userMessage: "hello" },
response: { content: "Hello world from the streaming config test!" },
...(recordedTimings ? { recordedTimings } : {}),
},
],
}),
"utf-8",
);
return filePath;
}

// Streams a completion and counts SSE frames. Without stream: true there is no SSE and no
// chunk delays, so the timing assertions below would pass whether or not the options are wired.
async function streamChunkCount(url: string): Promise<number> {
const resp = await fetch(`${url}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "gpt-4",
stream: true,
messages: [{ role: "user", content: "hello" }],
}),
});
const body = await resp.text();
return body
.split("\n\n")
.filter((frame) => frame.startsWith("data: ") && !frame.includes("[DONE]")).length;
}

describe("loadConfig", () => {
let tmpDir: string;

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

it("with llm.chunkSize, the response is split into more chunks", async () => {
const fixturePath = writeStreamFixtureFile(tmpDir);

const small = await startFromConfig({ llm: { fixtures: fixturePath, chunkSize: 5 } });
cleanups.push(() => small.llmock.stop());
const smallChunks = await streamChunkCount(small.url);

const large = await startFromConfig({ llm: { fixtures: fixturePath, chunkSize: 1000 } });
cleanups.push(() => large.llmock.stop());
const largeChunks = await streamChunkCount(large.url);

expect(smallChunks).toBeGreaterThan(largeChunks);
});

it("with llm.latency, streamed chunks are delayed", async () => {
const fixturePath = writeStreamFixtureFile(tmpDir);
const config: AimockConfig = {
llm: { fixtures: fixturePath, chunkSize: 5, latency: 100 },
};
const { llmock, url } = await startFromConfig(config);
cleanups.push(() => llmock.stop());

const start = Date.now();
const chunks = await streamChunkCount(url);
const elapsed = Date.now() - start;

// 100ms per chunk across >= 5 chunks. Asserted as a lower bound so a slow
// machine can never fail it; without the passthrough latency is 0.
expect(chunks).toBeGreaterThanOrEqual(5);
expect(elapsed).toBeGreaterThanOrEqual(250);
});

it("with llm.replaySpeed, recorded timings replay faster", async () => {
// 500ms TTFT + 4 x 500ms inter-chunk = ~2500ms at 1x speed
const fixturePath = writeStreamFixtureFile(tmpDir, {
ttftMs: 500,
interChunkDelaysMs: [500, 500, 500, 500],
totalDurationMs: 2500,
});
const config: AimockConfig = {
llm: { fixtures: fixturePath, chunkSize: 5, replaySpeed: 100 },
};
const { llmock, url } = await startFromConfig(config);
cleanups.push(() => llmock.stop());

const start = Date.now();
await streamChunkCount(url);
const elapsed = Date.now() - start;

// At 100x this lands in ~25ms; the bound is deliberately generous so only a
// dropped passthrough (which costs the full ~2500ms) can fail it.
expect(elapsed).toBeLessThan(1000);
});

it("with non-positive llm.replaySpeed, warns and ignores it", async () => {
const fixturePath = writeStreamFixtureFile(tmpDir);
// Spy on the logger the code path actually uses rather than console, so the
// assertion verifies the intended warn call without coupling to the
// "[aimock]" prefix or the console transport.
const warn = vi.spyOn(Logger.prototype, "warn").mockImplementation(() => {});

// 0 looks like "no delay" but hits the `speed > 0` guard in calculateDelay,
// which would replay at full recorded speed.
const { llmock } = await startFromConfig({
llm: { fixtures: fixturePath, replaySpeed: 0 },
});
cleanups.push(() => llmock.stop());

expect(warn).toHaveBeenCalledWith(
expect.stringContaining("llm.replaySpeed must be a positive number"),
);
warn.mockRestore();
});

it("with llm.logLevel debug, the server logs debug output that silent suppresses", async () => {
const fixturePath = writeFixtureFile(tmpDir);
// The server logger writes debug/info lines via console.log; capture them so
// we can assert the configured level actually reaches the running server.
const log = vi.spyOn(console, "log").mockImplementation(() => {});

// A matched request emits a "Fixture matched" debug line, but only when the
// configured logLevel is high enough — so it is a direct probe of the passthrough.
async function debugLineCount(logLevel: "debug" | "silent"): Promise<number> {
log.mockClear();
const { llmock, url } = await startFromConfig({
llm: { fixtures: fixturePath, logLevel },
});
cleanups.push(() => llmock.stop());
await fetch(`${url}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: "gpt-4", messages: [{ role: "user", content: "hello" }] }),
});
return log.mock.calls.filter((args) => String(args[1]).startsWith("Fixture matched")).length;
}

expect(await debugLineCount("debug")).toBeGreaterThan(0);
expect(await debugLineCount("silent")).toBe(0);

log.mockRestore();
});

it("with mcp tools config, MCPMock created and tools/list works", async () => {
const config: AimockConfig = {
mcp: {
Expand Down Expand Up @@ -649,11 +794,8 @@ describe("startFromConfig", () => {
{
pattern: "stream",
events: [
{
kind: "status-update",
taskId: "t1",
status: { state: "working", message: { parts: [{ text: "streaming..." }] } },
},
{ type: "status", state: "TASK_STATE_WORKING" },
{ type: "artifact", parts: [{ text: "streaming..." }], name: "out" },
],
delayMs: 0,
},
Expand All @@ -670,6 +812,28 @@ describe("startFromConfig", () => {
expect(cardRes.status).toBe(200);
const card = await cardRes.json();
expect(card.name).toBe("stream-agent");

// Verify the streaming task actually streams the configured events over SSE
const streamRes = await fetch(`${url}/a2a`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
method: "SendStreamingMessage",
params: { message: { parts: [{ text: "please stream" }] } },
id: 1,
}),
});
expect(streamRes.status).toBe(200);
expect(streamRes.headers.get("content-type")).toBe("text/event-stream");
const streamBody = await streamRes.text();
const events = streamBody
.split("\n\n")
.filter((frame) => frame.startsWith("data: "))
.map((frame) => JSON.parse(frame.slice("data: ".length)));
expect(events.length).toBe(2);
expect(events[0].result.task.status.state).toBe("TASK_STATE_WORKING");
expect(events[1].result.artifact.parts[0].text).toBe("streaming...");
});

it("with a2a custom path, mounts at specified path for tasks", async () => {
Expand Down
16 changes: 16 additions & 0 deletions src/config-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ export interface VectorConfig {
export interface AimockConfig {
llm?: {
fixtures?: string;
latency?: number;
chunkSize?: number;
replaySpeed?: number;
logLevel?: "silent" | "warn" | "info" | "debug";
chaos?: ChaosConfig;
record?: RecordConfig;
};
Expand All @@ -115,10 +119,22 @@ export async function startFromConfig(
): Promise<{ llmock: LLMock; url: string }> {
const logger = new Logger("info");

// A non-positive replaySpeed fails calculateDelay's `speed > 0` check and applies the
// full delay rather than none. Mirrors the fixture-level guard in fixture-loader.ts.
let replaySpeed = config.llm?.replaySpeed;
if (replaySpeed != null && (!Number.isFinite(replaySpeed) || replaySpeed <= 0)) {
logger.warn(`llm.replaySpeed must be a positive number, got ${replaySpeed}. Ignoring.`);
replaySpeed = undefined;
}

// Load fixtures if specified
const llmock = new LLMock({
port: overrides?.port ?? config.port ?? 0,
host: overrides?.host ?? config.host ?? "127.0.0.1",
latency: config.llm?.latency,
chunkSize: config.llm?.chunkSize,
replaySpeed,
logLevel: config.llm?.logLevel,
chaos: config.llm?.chaos,
record: config.llm?.record,
metrics: config.metrics,
Expand Down
Loading