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

### Added

- **Async fixture responses** — Fixture responses can now be sync or async functions that receive the request and return the response dynamically. Enables awaiting side effects (database writes, API calls) before constructing the response — eliminating race conditions in complex multi-turn E2E tests. Works with all providers, streaming, and convenience methods (`on()`, `onMessage()`, `onTurn()`). (Feature request by @5ebastianMeier, issue #154)
- **Snapshot-style recording** — When `X-Test-Id` is present, recorded fixtures are saved to `<fixturePath>/<slugified-testId>/<provider>.json` instead of timestamp-based filenames. Multiple fixtures for the same test+provider merge into one file. Stable paths enable meaningful PR diffs and easy test-to-fixture mapping. (Feature request by @jantimon, issue #155)

## [1.18.0] - 2026-05-04
Expand Down
46 changes: 46 additions & 0 deletions docs/examples/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,52 @@ <h3>Tool-call cycle with hasToolResult</h3>
]
}</code></pre>
</div>

<!-- ─── Dynamic / Async Responses ──────────────────────────── -->

<h2>Dynamic / Async Responses</h2>

<p>
Fixture responses can be functions &mdash; sync or async &mdash; that receive the request
and return the response dynamically. Use this when you need to await side effects, compute
responses based on request content, or inject runtime data into fixtures.
</p>

<h3>Async response with side-effect</h3>
<p>
Wait for an external operation to complete before constructing the fixture response.
Eliminates race conditions in multi-turn E2E tests where entity creation happens
out-of-band.
</p>
<div class="code-block">
<div class="code-block-header">async-side-effect.ts <span class="lang-tag">ts</span></div>
<pre><code><span class="op">mock</span>.<span class="fn">on</span>(
{ <span class="prop">toolCallId</span>: <span class="str">"call_create_entity"</span> },
<span class="kw">async</span> (<span class="op">req</span>) <span class="kw">=&gt;</span> {
<span class="kw">const</span> <span class="op">entity</span> = <span class="kw">await</span> <span class="op">createEntityPromise</span>;
<span class="kw">return</span> {
<span class="prop">content</span>: <span class="str">`Entity "${entity.name}" created!`</span>,
<span class="prop">toolCalls</span>: [{
<span class="prop">name</span>: <span class="str">"next_step"</span>,
<span class="prop">arguments</span>: <span class="op">JSON</span>.<span class="fn">stringify</span>({ <span class="prop">entityId</span>: <span class="op">entity</span>.<span class="prop">id</span> }),
}],
};
},
);</code></pre>
</div>

<h3>Request-aware response</h3>
<p>
Compute the response from the incoming request content. Useful for echo-style fixtures,
transformations, or conditional logic that goes beyond what match fields can express.
</p>
<div class="code-block">
<div class="code-block-header">request-aware.ts <span class="lang-tag">ts</span></div>
<pre><code><span class="op">mock</span>.<span class="fn">onMessage</span>(<span class="str">"translate"</span>, (<span class="op">req</span>) <span class="kw">=&gt;</span> {
<span class="kw">const</span> <span class="op">text</span> = <span class="op">req</span>.<span class="prop">messages</span>.<span class="fn">at</span>(-<span class="num">1</span>)?.<span class="prop">content</span> <span class="kw">??</span> <span class="str">""</span>;
<span class="kw">return</span> { <span class="prop">content</span>: <span class="str">`Translated: ${text.toUpperCase()}`</span> };
});</code></pre>
</div>
</main>
<aside class="page-toc" id="page-toc"></aside>
</div>
Expand Down
8 changes: 8 additions & 0 deletions docs/fixtures/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,14 @@ <h2>Response Types</h2>
</p>
</div>

<div class="info-box">
<p>
<strong>Dynamic responses:</strong> Responses can also be sync or async functions that
receive the request and return the response dynamically. See
<a href="/examples#dynamic-async-responses">Dynamic Responses</a> on the Examples page.
</p>
</div>

<h2>Response Override Fields</h2>
<p>
Fixture responses can include optional fields to override auto-generated envelope values.
Expand Down
12 changes: 12 additions & 0 deletions docs/multi-turn/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,18 @@ <h3>hasToolResult &mdash; match by tool execution state</h3>
}</code></pre>
</div>

<div class="info-box">
<p>
<strong>Async fixture responses for race-free multi-turn tests.</strong> When a
multi-turn test depends on side effects between turns (database writes, entity creation,
external API calls), async fixture responses let you <code>await</code> those operations
before constructing the response &mdash; eliminating race conditions without
<code>setTimeout</code> hacks. See
<a href="/examples#dynamic-async-responses">Dynamic / Async Responses</a> on the
Examples page.
</p>
</div>

<h3>Programmatic API</h3>
<p>
The <code>onTurn()</code> convenience method combines <code>turnIndex</code> with a
Expand Down
251 changes: 251 additions & 0 deletions src/__tests__/async-fixture-response.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
import { describe, it, expect, afterEach } from "vitest";
import { LLMock } from "../llmock.js";
import type { ChatCompletionRequest, SSEChunk } from "../types.js";

function parseSSEChunks(body: string): SSEChunk[] {
return body
.split("\n\n")
.filter((line) => line.startsWith("data: ") && !line.includes("[DONE]"))
.map((line) => JSON.parse(line.slice(6)) as SSEChunk);
}

describe("async fixture response (function responses)", () => {
let mock: LLMock | null = null;

afterEach(async () => {
if (mock) {
await mock.stop();
mock = null;
}
});

it("resolves a sync function response", async () => {
mock = new LLMock({ port: 0 });
mock.on({ userMessage: "sync-fn" }, () => ({ content: "sync-factory-result" }));
await mock.start();

const res = await fetch(`${mock.url}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer test" },
body: JSON.stringify({
model: "gpt-4o",
messages: [{ role: "user", content: "sync-fn" }],
stream: false,
}),
});

expect(res.status).toBe(200);
const json = await res.json();
expect(json.choices[0].message.content).toBe("sync-factory-result");
});

it("resolves an async function response", async () => {
mock = new LLMock({ port: 0 });
mock.on({ userMessage: "async-fn" }, async () => {
return { content: "async-factory-result" };
});
await mock.start();

const res = await fetch(`${mock.url}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer test" },
body: JSON.stringify({
model: "gpt-4o",
messages: [{ role: "user", content: "async-fn" }],
stream: false,
}),
});

expect(res.status).toBe(200);
const json = await res.json();
expect(json.choices[0].message.content).toBe("async-factory-result");
});

it("receives the request object in the factory function", async () => {
mock = new LLMock({ port: 0 });
mock.on({ userMessage: "echo-model" }, (req: ChatCompletionRequest) => ({
content: `model=${req.model}`,
}));
await mock.start();

const res = await fetch(`${mock.url}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer test" },
body: JSON.stringify({
model: "gpt-4o-mini",
messages: [{ role: "user", content: "echo-model" }],
stream: false,
}),
});

expect(res.status).toBe(200);
const json = await res.json();
expect(json.choices[0].message.content).toBe("model=gpt-4o-mini");
});

it("works with streaming responses from a factory", async () => {
mock = new LLMock({ port: 0 });
mock.on({ userMessage: "stream-fn" }, () => ({ content: "streamed-from-factory" }));
await mock.start();

const res = await fetch(`${mock.url}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer test" },
body: JSON.stringify({
model: "gpt-4o",
messages: [{ role: "user", content: "stream-fn" }],
stream: true,
}),
});

expect(res.status).toBe(200);
const chunks = parseSSEChunks(await res.text());
const content = chunks.map((c) => c.choices?.[0]?.delta?.content ?? "").join("");
expect(content).toBe("streamed-from-factory");
});

it("works with onMessage convenience method", async () => {
mock = new LLMock({ port: 0 });
mock.onMessage("convenience-fn", (req: ChatCompletionRequest) => ({
content: `msg-count=${req.messages.length}`,
}));
await mock.start();

const res = await fetch(`${mock.url}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer test" },
body: JSON.stringify({
model: "gpt-4o",
messages: [
{ role: "system", content: "you are helpful" },
{ role: "user", content: "convenience-fn" },
],
stream: false,
}),
});

expect(res.status).toBe(200);
const json = await res.json();
expect(json.choices[0].message.content).toBe("msg-count=2");
});

it("static response still works alongside function responses", async () => {
mock = new LLMock({ port: 0 });
mock.on({ userMessage: "static" }, { content: "plain-static" });
mock.on({ userMessage: "dynamic" }, () => ({ content: "from-function" }));
await mock.start();

const [staticRes, dynamicRes] = await Promise.all([
fetch(`${mock.url}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer test" },
body: JSON.stringify({
model: "gpt-4o",
messages: [{ role: "user", content: "static" }],
stream: false,
}),
}),
fetch(`${mock.url}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer test" },
body: JSON.stringify({
model: "gpt-4o",
messages: [{ role: "user", content: "dynamic" }],
stream: false,
}),
}),
]);

expect(staticRes.status).toBe(200);
expect(dynamicRes.status).toBe(200);

const staticJson = await staticRes.json();
const dynamicJson = await dynamicRes.json();

expect(staticJson.choices[0].message.content).toBe("plain-static");
expect(dynamicJson.choices[0].message.content).toBe("from-function");
});

it("returns 500 when factory throws", async () => {
mock = new LLMock({ port: 0 });
mock.on({ userMessage: "boom" }, () => {
throw new Error("factory exploded");
});
await mock.start();

const res = await fetch(`${mock.url}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer test" },
body: JSON.stringify({
model: "gpt-4",
messages: [{ role: "user", content: "boom" }],
stream: false,
}),
});

expect(res.status).toBe(500);
});

it("returns 500 when async factory rejects", async () => {
mock = new LLMock({ port: 0 });
mock.on({ userMessage: "reject" }, async () => {
throw new Error("async rejection");
});
await mock.start();

const res = await fetch(`${mock.url}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer test" },
body: JSON.stringify({
model: "gpt-4",
messages: [{ role: "user", content: "reject" }],
stream: false,
}),
});

expect(res.status).toBe(500);
});

it("returns 500 when factory returns invalid response shape", async () => {
mock = new LLMock({ port: 0 });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mock.on({ userMessage: "bad" }, () => ({ notAValidField: true }) as any);
await mock.start();

const res = await fetch(`${mock.url}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer test" },
body: JSON.stringify({
model: "gpt-4",
messages: [{ role: "user", content: "bad" }],
stream: false,
}),
});

expect(res.status).toBe(500);
});

it("works with async factory and streaming", async () => {
mock = new LLMock({ port: 0 });
mock.on({ userMessage: "async-stream" }, async () => {
await new Promise((r) => setTimeout(r, 10));
return { content: "async-streamed-result" };
});
await mock.start();

const res = await fetch(`${mock.url}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer test" },
body: JSON.stringify({
model: "gpt-4",
messages: [{ role: "user", content: "async-stream" }],
stream: true,
}),
});

expect(res.status).toBe(200);
const chunks = parseSSEChunks(await res.text());
const content = chunks.map((c) => c.choices?.[0]?.delta?.content ?? "").join("");
expect(content).toBe("async-streamed-result");
});
});
5 changes: 3 additions & 2 deletions src/bedrock-converse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
isErrorResponse,
flattenHeaders,
getTestId,
resolveResponse,
} from "./helpers.js";
import { matchFixture } from "./router.js";
import { writeErrorResponse } from "./sse-writer.js";
Expand Down Expand Up @@ -659,7 +660,7 @@ export async function handleConverse(
return;
}

const response = fixture.response;
const response = await resolveResponse(fixture, completionReq);

// Error response
if (isErrorResponse(response)) {
Expand Down Expand Up @@ -923,7 +924,7 @@ export async function handleConverseStream(
return;
}

const response = fixture.response;
const response = await resolveResponse(fixture, completionReq);
const latency = fixture.latency ?? defaults.latency;
const chunkSize = Math.max(1, fixture.chunkSize ?? defaults.chunkSize);

Expand Down
5 changes: 3 additions & 2 deletions src/bedrock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
isErrorResponse,
flattenHeaders,
getTestId,
resolveResponse,
} from "./helpers.js";
import { matchFixture } from "./router.js";
import { writeErrorResponse } from "./sse-writer.js";
Expand Down Expand Up @@ -472,7 +473,7 @@ export async function handleBedrock(
return;
}

const response = fixture.response;
const response = await resolveResponse(fixture, completionReq);

// Error response
if (isErrorResponse(response)) {
Expand Down Expand Up @@ -1069,7 +1070,7 @@ export async function handleBedrockStream(
return;
}

const response = fixture.response;
const response = await resolveResponse(fixture, completionReq);
const latency = fixture.latency ?? defaults.latency;
const chunkSize = Math.max(1, fixture.chunkSize ?? defaults.chunkSize);

Expand Down
Loading
Loading