Skip to content

Commit 8c75516

Browse files
authored
feat: async function responses in fixtures (closes #154) (#156)
## Summary Fixture responses can now be sync or async functions that receive the request and return the response dynamically — eliminating race conditions in complex multi-turn E2E tests where side effects must complete before constructing the next response. - **`ResponseFactory` type**: `(req: ChatCompletionRequest) => FixtureResponse | Promise<FixtureResponse>` - **All 25 handler files** updated to use `await resolveResponse(fixture, req)` - **Auto-normalization**: factory results get the same `JSON.stringify` treatment as static fixtures (object content, tool call arguments) - **Convenience methods**: `on()`, `onMessage()`, `onTurn()`, `onToolCall()`, `onToolResult()`, `onEmbedding()` all accept `ResponseFactory` - **Error handling**: factory throws/rejects propagate to handler try/catch → 500; invalid return shapes fall through to 500 - **10 tests**: sync/async factories, streaming, error paths, invalid shapes, convenience wrappers Closes #154 — feature request by @5ebastianMeier ## Test plan - [x] `pnpm test` — 2799 passed, 36 skipped - [x] `npx tsc --noEmit` — clean - [x] `pnpm run lint` / `pnpm run format:check` — clean - [x] CR converged (R1: 4 findings fixed, R2: 0 findings, 7 agents per round) - [x] Adversarial review: solution verified against issue author's exact scenario
2 parents 1e95ef5 + 2c74c70 commit 8c75516

30 files changed

Lines changed: 657 additions & 234 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### Added
66

7+
- **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)
78
- **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)
89

910
### Fixed

docs/examples/index.html

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,52 @@ <h3>Tool-call cycle with hasToolResult</h3>
607607
]
608608
}</code></pre>
609609
</div>
610+
611+
<!-- ─── Dynamic / Async Responses ──────────────────────────── -->
612+
613+
<h2>Dynamic / Async Responses</h2>
614+
615+
<p>
616+
Fixture responses can be functions &mdash; sync or async &mdash; that receive the request
617+
and return the response dynamically. Use this when you need to await side effects, compute
618+
responses based on request content, or inject runtime data into fixtures.
619+
</p>
620+
621+
<h3>Async response with side-effect</h3>
622+
<p>
623+
Wait for an external operation to complete before constructing the fixture response.
624+
Eliminates race conditions in multi-turn E2E tests where entity creation happens
625+
out-of-band.
626+
</p>
627+
<div class="code-block">
628+
<div class="code-block-header">async-side-effect.ts <span class="lang-tag">ts</span></div>
629+
<pre><code><span class="op">mock</span>.<span class="fn">on</span>(
630+
{ <span class="prop">toolCallId</span>: <span class="str">"call_create_entity"</span> },
631+
<span class="kw">async</span> (<span class="op">req</span>) <span class="kw">=&gt;</span> {
632+
<span class="kw">const</span> <span class="op">entity</span> = <span class="kw">await</span> <span class="op">createEntityPromise</span>;
633+
<span class="kw">return</span> {
634+
<span class="prop">content</span>: <span class="str">`Entity "${entity.name}" created!`</span>,
635+
<span class="prop">toolCalls</span>: [{
636+
<span class="prop">name</span>: <span class="str">"next_step"</span>,
637+
<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> }),
638+
}],
639+
};
640+
},
641+
);</code></pre>
642+
</div>
643+
644+
<h3>Request-aware response</h3>
645+
<p>
646+
Compute the response from the incoming request content. Useful for echo-style fixtures,
647+
transformations, or conditional logic that goes beyond what match fields can express.
648+
</p>
649+
<div class="code-block">
650+
<div class="code-block-header">request-aware.ts <span class="lang-tag">ts</span></div>
651+
<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> {
652+
<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>;
653+
<span class="kw">return</span> { <span class="prop">content</span>: <span class="str">`Translated: ${text.toUpperCase()}`</span> };
654+
});</code></pre>
655+
</div>
610656
</main>
611657
<aside class="page-toc" id="page-toc"></aside>
612658
</div>

docs/fixtures/index.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,14 @@ <h2>Response Types</h2>
355355
</p>
356356
</div>
357357

358+
<div class="info-box">
359+
<p>
360+
<strong>Dynamic responses:</strong> Responses can also be sync or async functions that
361+
receive the request and return the response dynamically. See
362+
<a href="/examples#dynamic-async-responses">Dynamic Responses</a> on the Examples page.
363+
</p>
364+
</div>
365+
358366
<h2>Response Override Fields</h2>
359367
<p>
360368
Fixture responses can include optional fields to override auto-generated envelope values.

docs/multi-turn/index.html

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,18 @@ <h3>hasToolResult &mdash; match by tool execution state</h3>
182182
}</code></pre>
183183
</div>
184184

185+
<div class="info-box">
186+
<p>
187+
<strong>Async fixture responses for race-free multi-turn tests.</strong> When a
188+
multi-turn test depends on side effects between turns (database writes, entity creation,
189+
external API calls), async fixture responses let you <code>await</code> those operations
190+
before constructing the response &mdash; eliminating race conditions without
191+
<code>setTimeout</code> hacks. See
192+
<a href="/examples#dynamic-async-responses">Dynamic / Async Responses</a> on the
193+
Examples page.
194+
</p>
195+
</div>
196+
185197
<h3>Programmatic API</h3>
186198
<p>
187199
The <code>onTurn()</code> convenience method combines <code>turnIndex</code> with a
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import { describe, it, expect, afterEach } from "vitest";
2+
import { LLMock } from "../llmock.js";
3+
import type { ChatCompletionRequest, SSEChunk } from "../types.js";
4+
5+
function parseSSEChunks(body: string): SSEChunk[] {
6+
return body
7+
.split("\n\n")
8+
.filter((line) => line.startsWith("data: ") && !line.includes("[DONE]"))
9+
.map((line) => JSON.parse(line.slice(6)) as SSEChunk);
10+
}
11+
12+
describe("async fixture response (function responses)", () => {
13+
let mock: LLMock | null = null;
14+
15+
afterEach(async () => {
16+
if (mock) {
17+
await mock.stop();
18+
mock = null;
19+
}
20+
});
21+
22+
it("resolves a sync function response", async () => {
23+
mock = new LLMock({ port: 0 });
24+
mock.on({ userMessage: "sync-fn" }, () => ({ content: "sync-factory-result" }));
25+
await mock.start();
26+
27+
const res = await fetch(`${mock.url}/v1/chat/completions`, {
28+
method: "POST",
29+
headers: { "Content-Type": "application/json", Authorization: "Bearer test" },
30+
body: JSON.stringify({
31+
model: "gpt-4o",
32+
messages: [{ role: "user", content: "sync-fn" }],
33+
stream: false,
34+
}),
35+
});
36+
37+
expect(res.status).toBe(200);
38+
const json = await res.json();
39+
expect(json.choices[0].message.content).toBe("sync-factory-result");
40+
});
41+
42+
it("resolves an async function response", async () => {
43+
mock = new LLMock({ port: 0 });
44+
mock.on({ userMessage: "async-fn" }, async () => {
45+
return { content: "async-factory-result" };
46+
});
47+
await mock.start();
48+
49+
const res = await fetch(`${mock.url}/v1/chat/completions`, {
50+
method: "POST",
51+
headers: { "Content-Type": "application/json", Authorization: "Bearer test" },
52+
body: JSON.stringify({
53+
model: "gpt-4o",
54+
messages: [{ role: "user", content: "async-fn" }],
55+
stream: false,
56+
}),
57+
});
58+
59+
expect(res.status).toBe(200);
60+
const json = await res.json();
61+
expect(json.choices[0].message.content).toBe("async-factory-result");
62+
});
63+
64+
it("receives the request object in the factory function", async () => {
65+
mock = new LLMock({ port: 0 });
66+
mock.on({ userMessage: "echo-model" }, (req: ChatCompletionRequest) => ({
67+
content: `model=${req.model}`,
68+
}));
69+
await mock.start();
70+
71+
const res = await fetch(`${mock.url}/v1/chat/completions`, {
72+
method: "POST",
73+
headers: { "Content-Type": "application/json", Authorization: "Bearer test" },
74+
body: JSON.stringify({
75+
model: "gpt-4o-mini",
76+
messages: [{ role: "user", content: "echo-model" }],
77+
stream: false,
78+
}),
79+
});
80+
81+
expect(res.status).toBe(200);
82+
const json = await res.json();
83+
expect(json.choices[0].message.content).toBe("model=gpt-4o-mini");
84+
});
85+
86+
it("works with streaming responses from a factory", async () => {
87+
mock = new LLMock({ port: 0 });
88+
mock.on({ userMessage: "stream-fn" }, () => ({ content: "streamed-from-factory" }));
89+
await mock.start();
90+
91+
const res = await fetch(`${mock.url}/v1/chat/completions`, {
92+
method: "POST",
93+
headers: { "Content-Type": "application/json", Authorization: "Bearer test" },
94+
body: JSON.stringify({
95+
model: "gpt-4o",
96+
messages: [{ role: "user", content: "stream-fn" }],
97+
stream: true,
98+
}),
99+
});
100+
101+
expect(res.status).toBe(200);
102+
const chunks = parseSSEChunks(await res.text());
103+
const content = chunks.map((c) => c.choices?.[0]?.delta?.content ?? "").join("");
104+
expect(content).toBe("streamed-from-factory");
105+
});
106+
107+
it("works with onMessage convenience method", async () => {
108+
mock = new LLMock({ port: 0 });
109+
mock.onMessage("convenience-fn", (req: ChatCompletionRequest) => ({
110+
content: `msg-count=${req.messages.length}`,
111+
}));
112+
await mock.start();
113+
114+
const res = await fetch(`${mock.url}/v1/chat/completions`, {
115+
method: "POST",
116+
headers: { "Content-Type": "application/json", Authorization: "Bearer test" },
117+
body: JSON.stringify({
118+
model: "gpt-4o",
119+
messages: [
120+
{ role: "system", content: "you are helpful" },
121+
{ role: "user", content: "convenience-fn" },
122+
],
123+
stream: false,
124+
}),
125+
});
126+
127+
expect(res.status).toBe(200);
128+
const json = await res.json();
129+
expect(json.choices[0].message.content).toBe("msg-count=2");
130+
});
131+
132+
it("static response still works alongside function responses", async () => {
133+
mock = new LLMock({ port: 0 });
134+
mock.on({ userMessage: "static" }, { content: "plain-static" });
135+
mock.on({ userMessage: "dynamic" }, () => ({ content: "from-function" }));
136+
await mock.start();
137+
138+
const [staticRes, dynamicRes] = await Promise.all([
139+
fetch(`${mock.url}/v1/chat/completions`, {
140+
method: "POST",
141+
headers: { "Content-Type": "application/json", Authorization: "Bearer test" },
142+
body: JSON.stringify({
143+
model: "gpt-4o",
144+
messages: [{ role: "user", content: "static" }],
145+
stream: false,
146+
}),
147+
}),
148+
fetch(`${mock.url}/v1/chat/completions`, {
149+
method: "POST",
150+
headers: { "Content-Type": "application/json", Authorization: "Bearer test" },
151+
body: JSON.stringify({
152+
model: "gpt-4o",
153+
messages: [{ role: "user", content: "dynamic" }],
154+
stream: false,
155+
}),
156+
}),
157+
]);
158+
159+
expect(staticRes.status).toBe(200);
160+
expect(dynamicRes.status).toBe(200);
161+
162+
const staticJson = await staticRes.json();
163+
const dynamicJson = await dynamicRes.json();
164+
165+
expect(staticJson.choices[0].message.content).toBe("plain-static");
166+
expect(dynamicJson.choices[0].message.content).toBe("from-function");
167+
});
168+
169+
it("returns 500 when factory throws", async () => {
170+
mock = new LLMock({ port: 0 });
171+
mock.on({ userMessage: "boom" }, () => {
172+
throw new Error("factory exploded");
173+
});
174+
await mock.start();
175+
176+
const res = await fetch(`${mock.url}/v1/chat/completions`, {
177+
method: "POST",
178+
headers: { "Content-Type": "application/json", Authorization: "Bearer test" },
179+
body: JSON.stringify({
180+
model: "gpt-4",
181+
messages: [{ role: "user", content: "boom" }],
182+
stream: false,
183+
}),
184+
});
185+
186+
expect(res.status).toBe(500);
187+
});
188+
189+
it("returns 500 when async factory rejects", async () => {
190+
mock = new LLMock({ port: 0 });
191+
mock.on({ userMessage: "reject" }, async () => {
192+
throw new Error("async rejection");
193+
});
194+
await mock.start();
195+
196+
const res = await fetch(`${mock.url}/v1/chat/completions`, {
197+
method: "POST",
198+
headers: { "Content-Type": "application/json", Authorization: "Bearer test" },
199+
body: JSON.stringify({
200+
model: "gpt-4",
201+
messages: [{ role: "user", content: "reject" }],
202+
stream: false,
203+
}),
204+
});
205+
206+
expect(res.status).toBe(500);
207+
});
208+
209+
it("returns 500 when factory returns invalid response shape", async () => {
210+
mock = new LLMock({ port: 0 });
211+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
212+
mock.on({ userMessage: "bad" }, () => ({ notAValidField: true }) as any);
213+
await mock.start();
214+
215+
const res = await fetch(`${mock.url}/v1/chat/completions`, {
216+
method: "POST",
217+
headers: { "Content-Type": "application/json", Authorization: "Bearer test" },
218+
body: JSON.stringify({
219+
model: "gpt-4",
220+
messages: [{ role: "user", content: "bad" }],
221+
stream: false,
222+
}),
223+
});
224+
225+
expect(res.status).toBe(500);
226+
});
227+
228+
it("works with async factory and streaming", async () => {
229+
mock = new LLMock({ port: 0 });
230+
mock.on({ userMessage: "async-stream" }, async () => {
231+
await new Promise((r) => setTimeout(r, 10));
232+
return { content: "async-streamed-result" };
233+
});
234+
await mock.start();
235+
236+
const res = await fetch(`${mock.url}/v1/chat/completions`, {
237+
method: "POST",
238+
headers: { "Content-Type": "application/json", Authorization: "Bearer test" },
239+
body: JSON.stringify({
240+
model: "gpt-4",
241+
messages: [{ role: "user", content: "async-stream" }],
242+
stream: true,
243+
}),
244+
});
245+
246+
expect(res.status).toBe(200);
247+
const chunks = parseSSEChunks(await res.text());
248+
const content = chunks.map((c) => c.choices?.[0]?.delta?.content ?? "").join("");
249+
expect(content).toBe("async-streamed-result");
250+
});
251+
});

src/bedrock-converse.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
isErrorResponse,
2727
flattenHeaders,
2828
getTestId,
29+
resolveResponse,
2930
} from "./helpers.js";
3031
import { matchFixture } from "./router.js";
3132
import { writeErrorResponse } from "./sse-writer.js";
@@ -659,7 +660,7 @@ export async function handleConverse(
659660
return;
660661
}
661662

662-
const response = fixture.response;
663+
const response = await resolveResponse(fixture, completionReq);
663664

664665
// Error response
665666
if (isErrorResponse(response)) {
@@ -923,7 +924,7 @@ export async function handleConverseStream(
923924
return;
924925
}
925926

926-
const response = fixture.response;
927+
const response = await resolveResponse(fixture, completionReq);
927928
const latency = fixture.latency ?? defaults.latency;
928929
const chunkSize = Math.max(1, fixture.chunkSize ?? defaults.chunkSize);
929930

src/bedrock.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
isErrorResponse,
3838
flattenHeaders,
3939
getTestId,
40+
resolveResponse,
4041
} from "./helpers.js";
4142
import { matchFixture } from "./router.js";
4243
import { writeErrorResponse } from "./sse-writer.js";
@@ -472,7 +473,7 @@ export async function handleBedrock(
472473
return;
473474
}
474475

475-
const response = fixture.response;
476+
const response = await resolveResponse(fixture, completionReq);
476477

477478
// Error response
478479
if (isErrorResponse(response)) {
@@ -1069,7 +1070,7 @@ export async function handleBedrockStream(
10691070
return;
10701071
}
10711072

1072-
const response = fixture.response;
1073+
const response = await resolveResponse(fixture, completionReq);
10731074
const latency = fixture.latency ?? defaults.latency;
10741075
const chunkSize = Math.max(1, fixture.chunkSize ?? defaults.chunkSize);
10751076

0 commit comments

Comments
 (0)