First-wins ordering. Fixtures are evaluated in registration order (and,
diff --git a/docs/record-replay/index.html b/docs/record-replay/index.html
index 6d49dfd4..d5974b30 100644
--- a/docs/record-replay/index.html
+++ b/docs/record-replay/index.html
@@ -1007,8 +1007,8 @@ Request Transform
- When requestTransform is set, string matching for
- userMessage and inputText switches from substring
+ When requestTransform is set, string matching for userMessage,
+ systemMessage, and inputText switches from substring
(includes) to exact equality (===). This prevents shortened keys
from accidentally matching unrelated prompts. Without a transform, the existing
includes behavior is preserved for backward compatibility.
diff --git a/package.json b/package.json
index 8fd650c2..b2fb7a41 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@copilotkit/aimock",
- "version": "1.36.1",
+ "version": "1.37.0",
"description": "Mock infrastructure for AI application testing — LLM APIs, image generation, image editing, text-to-speech, transcription, audio translation, audio generation, video generation, embeddings, MCP tools, A2A agents, AG-UI event streams, vector databases, search, rerank, and moderation. One package, one port, zero dependencies.",
"license": "MIT",
"keywords": [
@@ -169,7 +169,7 @@
"test:exports": "publint && attw --pack .",
"lint": "eslint .",
"format:check": "prettier --check .",
- "release": "pnpm build && pnpm test && pnpm lint && npm publish",
+ "release": "pnpm format:check && pnpm build && pnpm test && pnpm lint && pnpm test:exports && npm publish",
"prepare": "husky || true"
},
"lint-staged": {
diff --git a/packages/aimock-pytest/pyproject.toml b/packages/aimock-pytest/pyproject.toml
index 09e0894f..1cbd98c0 100644
--- a/packages/aimock-pytest/pyproject.toml
+++ b/packages/aimock-pytest/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "aimock-pytest"
-version = "0.4.0"
+version = "0.5.0"
description = "pytest fixtures for aimock — mock LLM APIs, multimedia, MCP, A2A, AG-UI, vector DBs, and more"
readme = "README.md"
requires-python = ">=3.10"
@@ -12,7 +12,7 @@ license = "MIT"
dependencies = ["requests>=2.28"]
[project.optional-dependencies]
-test = ["pytest>=7.0", "requests>=2.28"]
+test = ["pytest>=7.0"]
[project.entry-points."pytest11"]
aimock = "aimock_pytest.plugin"
diff --git a/packages/aimock-pytest/src/aimock_pytest/_server.py b/packages/aimock-pytest/src/aimock_pytest/_server.py
index 946902de..07d98a09 100644
--- a/packages/aimock-pytest/src/aimock_pytest/_server.py
+++ b/packages/aimock-pytest/src/aimock_pytest/_server.py
@@ -182,6 +182,7 @@ def url(self) -> str:
"systemMessage",
"inputText",
"toolCallId",
+ "toolResultContains",
"toolName",
"model",
"responseFormat",
diff --git a/packages/aimock-pytest/src/aimock_pytest/_version.py b/packages/aimock-pytest/src/aimock_pytest/_version.py
index e1f62ee5..9c863968 100644
--- a/packages/aimock-pytest/src/aimock_pytest/_version.py
+++ b/packages/aimock-pytest/src/aimock_pytest/_version.py
@@ -8,4 +8,4 @@
control routes ship in the next release). Keep it tracking npm releases.
"""
-AIMOCK_VERSION = "1.35.1"
+AIMOCK_VERSION = "1.37.0"
diff --git a/skills/write-fixtures/SKILL.md b/skills/write-fixtures/SKILL.md
index 8c3de1a4..c9c7e05a 100644
--- a/skills/write-fixtures/SKILL.md
+++ b/skills/write-fixtures/SKILL.md
@@ -19,25 +19,26 @@ aimock is a zero-dependency mock infrastructure for AI apps. Fixture-driven. Mul
## Match Field Reference
-| Field | Type | Matches Against |
-| ---------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| `userMessage` | `string` | Substring of last `role: "user"` message text |
-| `userMessage` | `RegExp` | Pattern test on last `role: "user"` message text |
-| `systemMessage` | `string` | Substring of the concatenated text of every `role: "system"` message in the request. Use to gate a fixture on host-supplied context (persona, agent-context entries) so changes to that context cause the fixture to fall through instead of returning a stale baked response |
-| `systemMessage` | `string[]` | Array of substrings — ALL must be present in the joined system text (AND semantics). Use when the gate must combine multiple non-adjacent tokens whose serialisation order isn't stable |
-| `systemMessage` | `RegExp` | Pattern test on the concatenated system-message text |
-| `inputText` | `string` | Substring of embedding input text (concatenated if multiple inputs) |
-| `inputText` | `RegExp` | Pattern test on embedding input text |
-| `toolName` | `string` | Exact match on any tool in request's `tools[]` array (by `function.name`) |
-| `toolCallId` | `string` | Exact match on `tool_call_id` of last `role: "tool"` message |
-| `model` | `string` | Exact match on `req.model` |
-| `model` | `RegExp` | Pattern test on `req.model` |
-| `responseFormat` | `string` | Exact match on `req.response_format.type` (`"json_object"`, `"json_schema"`) |
-| `sequenceIndex` | `number` | Matches only when this fixture's match count equals the given index (0-based) |
-| `turnIndex` | `number` | Stateless conversation-depth matching. Counts `role: "assistant"` messages in the request; matches when that count equals the value. `turnIndex: 0` = first turn (no prior assistant messages). Use instead of `sequenceIndex` for shared/deployed instances where stateful counters break under concurrency |
-| `hasToolResult` | `boolean` | Stateless tool-message presence matching. `true` matches when any `role: "tool"` message exists in the request; `false` matches when none exist. Provider-consistent across all aimock handlers (OpenAI, Claude, Gemini, Bedrock, Ollama, Cohere) |
-| `endpoint` | `string` | Restrict to endpoint type: `"chat"`, `"image"`, `"speech"`, `"transcription"`, `"video"`, `"embedding"` |
-| `predicate` | `(req: ChatCompletionRequest) => boolean` | Custom function — full access to request |
+| Field | Type | Matches Against |
+| -------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `userMessage` | `string` | Substring of last `role: "user"` message text |
+| `userMessage` | `RegExp` | Pattern test on last `role: "user"` message text |
+| `systemMessage` | `string` | Substring of the concatenated text of every `role: "system"` message in the request. Use to gate a fixture on host-supplied context (persona, agent-context entries) so changes to that context cause the fixture to fall through instead of returning a stale baked response |
+| `systemMessage` | `string[]` | Array of substrings — ALL must be present in the joined system text (AND semantics). Use when the gate must combine multiple non-adjacent tokens whose serialisation order isn't stable |
+| `systemMessage` | `RegExp` | Pattern test on the concatenated system-message text |
+| `inputText` | `string` | Substring of embedding input text (concatenated if multiple inputs) |
+| `inputText` | `RegExp` | Pattern test on embedding input text |
+| `toolName` | `string` | Exact match on any tool in request's `tools[]` array (by `function.name`) |
+| `toolCallId` | `string` | Exact match on `tool_call_id` of last `role: "tool"` message |
+| `toolResultContains` | `string` | Substring of the last tool message's text content, gated on that message being the request's LAST message (same rule as `toolCallId`). Discriminates resume paths that share a `tool_call_id` and differ only inside the tool-result payload (e.g. approve `{"chosen_time": …}` vs cancel `{"cancelled": true}`) |
+| `model` | `string` | Exact match on `req.model` |
+| `model` | `RegExp` | Pattern test on `req.model` |
+| `responseFormat` | `string` | Exact match on `req.response_format.type` (`"json_object"`, `"json_schema"`) |
+| `sequenceIndex` | `number` | Matches only when this fixture's match count equals the given index (0-based) |
+| `turnIndex` | `number` | Stateless conversation-depth matching. Counts `role: "assistant"` messages in the request; matches when that count equals the value. `turnIndex: 0` = first turn (no prior assistant messages). Use instead of `sequenceIndex` for shared/deployed instances where stateful counters break under concurrency |
+| `hasToolResult` | `boolean` | Stateless tool-message presence matching. `true` matches when any `role: "tool"` message exists in the request; `false` matches when none exist. Provider-consistent across all aimock handlers (OpenAI, Claude, Gemini, Bedrock, Ollama, Cohere) |
+| `endpoint` | `string` | Restrict to endpoint type: `"chat"`, `"image"`, `"speech"`, `"transcription"`, `"video"`, `"embedding"` |
+| `predicate` | `(req: ChatCompletionRequest) => boolean` | Custom function — full access to request |
**AND logic**: all specified fields must match. Empty match `{}` = catch-all.
@@ -45,14 +46,15 @@ Multi-part content (e.g., `[{type: "text", text: "hello"}]`) is automatically ex
### When to Use Each Multi-turn Matching Approach
-| Approach | Stateless? | Best For |
-| --------------- | ---------- | --------------------------------------------------------------------------------------------------------- |
-| `turnIndex` | Yes | Shared/deployed instances; matches on conversation depth (count of assistant messages in request) |
-| `hasToolResult` | Yes | Simplest option for 2-step tool flows — boolean: are there tool results in the request? |
-| `sequenceIndex` | No | Single-client unit tests with repeated identical requests (server-side counter, breaks under concurrency) |
-| `toolCallId` | Yes | Matching specific tool result IDs in the conversation history |
+| Approach | Stateless? | Best For |
+| -------------------- | ---------- | --------------------------------------------------------------------------------------------------------- |
+| `turnIndex` | Yes | Shared/deployed instances; matches on conversation depth (count of assistant messages in request) |
+| `hasToolResult` | Yes | Simplest option for 2-step tool flows — boolean: are there tool results in the request? |
+| `sequenceIndex` | No | Single-client unit tests with repeated identical requests (server-side counter, breaks under concurrency) |
+| `toolCallId` | Yes | Matching specific tool result IDs in the conversation history |
+| `toolResultContains` | Yes | Same tool call id, different outcomes — match on the tool-result payload (approve vs cancel legs) |
-**Prefer stateless approaches** (`turnIndex`, `hasToolResult`) for shared aimock instances (deployed via Docker, used by multiple test runners). Use `sequenceIndex` only in isolated single-client unit tests where the counter won't be corrupted by concurrent requests.
+**Prefer stateless approaches** (`turnIndex`, `hasToolResult`, `toolResultContains`) for shared aimock instances (deployed via Docker, used by multiple test runners). Use `sequenceIndex` only in isolated single-client unit tests where the counter won't be corrupted by concurrent requests.
### Multi-turn fixture examples
@@ -64,6 +66,11 @@ Multi-part content (e.g., `[{type: "text", text: "hello"}]`) is automatically ex
// Same thing with hasToolResult (simpler for 2-step)
{"match": {"userMessage": "trip to mars", "hasToolResult": false}, "response": {"toolCalls": [{"id": "call_001", "name": "generate_steps", "arguments": "{}"}]}}
{"match": {"userMessage": "trip to mars", "hasToolResult": true}, "response": {"content": "Great choices!"}}
+
+// HITL suspend tool where approve and cancel resume with the SAME tool call id —
+// discriminate on the tool-result payload; put the cancel leg first (first match wins)
+{"match": {"toolCallId": "call_001", "toolResultContains": "\"cancelled\""}, "response": {"content": "No problem — nothing was booked."}}
+{"match": {"toolCallId": "call_001"}, "response": {"content": "Booked: Monday 9:00 AM confirmed."}}
```
## Response Types
@@ -255,7 +262,7 @@ mock.on(
mock.on({ userMessage: "status", sequenceIndex: 1 }, { content: "All systems operational." });
```
-Match counts are tracked per fixture group and reset with `reset()` or `resetMatchCounts()`.
+Match counts are tracked per fixture group. Use `resetMatchCounts()` between tests to reset counts while keeping loaded fixtures. `reset()` also clears the fixture pool, so avoid it between tests that share a loaded fixture set.
### Streaming physics (realistic timing)
@@ -500,7 +507,7 @@ These fields map correctly across all provider formats — for example, `finishR
10. **Embeddings auto-generate if no fixture matches** — deterministic vectors are generated from the input text hash. You don't need a catch-all for embedding requests.
-11. **Sequential response counts are tracked per fixture** — counts reset with `reset()` or `resetMatchCounts()`. The count increments after each match of that fixture group (all fixtures sharing the same non-`sequenceIndex` match fields).
+11. **Sequential response counts are tracked per fixture** — use `resetMatchCounts()` between tests to reset counts while keeping loaded fixtures; `reset()` also clears the fixture pool, so don't use it between tests that share a loaded fixture set. The count increments after each match of that fixture group (all fixtures sharing the same non-`sequenceIndex` match fields).
12. **Bedrock uses Anthropic Messages format internally** — the adapter normalizes Bedrock requests to `ChatCompletionRequest`, so the same fixtures work. Bedrock supports both non-streaming (`/invoke`, `/converse`) and streaming (`/invoke-with-response-stream`, `/converse-stream`) endpoints.
@@ -549,7 +556,7 @@ await suite.start();
// suite.llm — the LLMock instance
// suite.url — base URL
-afterEach(() => suite.reset()); // resets everything
+afterEach(() => suite.llm.resetMatchCounts()); // reset sequence counts, keep fixtures
afterAll(() => suite.stop());
```
@@ -684,8 +691,8 @@ mock.loadFixtureDir("./fixtures");
await mock.start();
process.env.OPENAI_BASE_URL = `${mock.url}/v1`;
-// Per-test cleanup
-afterEach(() => mock.reset()); // clears fixtures AND journal
+// Per-test cleanup — reset sequence match counts, keep the loaded fixtures
+afterEach(() => mock.resetMatchCounts());
// Teardown
afterAll(async () => await mock.stop());
@@ -736,6 +743,8 @@ const mock = await LLMock.create({ port: 0 }); // creates + starts in one call
| `url` / `baseUrl` | Server URL (throws if not started) |
| `port` | Server port number |
+Between tests that share a loaded fixture set, use `resetMatchCounts()` (not `reset()`, which also clears fixtures). For a `MockSuite`, call `suite.llm.resetMatchCounts()` — the suite itself has no `resetMatchCounts()`.
+
Sequential responses use `on()` with `sequenceIndex` in the match — there is no dedicated convenience method.
## Record-and-Replay (VCR Mode)
diff --git a/src/__tests__/fixture-loader.test.ts b/src/__tests__/fixture-loader.test.ts
index ca4719c1..1617eda2 100644
--- a/src/__tests__/fixture-loader.test.ts
+++ b/src/__tests__/fixture-loader.test.ts
@@ -316,6 +316,32 @@ describe("loadFixtureFile", () => {
expect(fixtures[0].disconnectAfterMs).toBeUndefined();
});
+ it("loads sibling fixtures when one entry has a non-array interChunkDelaysMs", () => {
+ // Untrusted fixture JSON: interChunkDelaysMs is typed number[] but may be
+ // null/missing/non-array. Without an Array.isArray guard, .filter throws a
+ // TypeError inside entryToFixture, aborting the entire file's load and
+ // silently dropping every fixture in it.
+ const filePath = writeJson(tmpDir, "bad-timings.json", {
+ fixtures: [
+ {
+ match: { userMessage: "malformed" },
+ response: { content: "bad" },
+ recordedTimings: { ttftMs: 0, interChunkDelaysMs: null, totalDurationMs: 0 },
+ },
+ {
+ match: { userMessage: "valid" },
+ response: { content: "good" },
+ },
+ ],
+ });
+
+ const fixtures = loadFixtureFile(filePath);
+ expect(fixtures).toHaveLength(2);
+ expect(fixtures[1].match.userMessage).toBe("valid");
+ // The malformed entry is sanitized: non-array interChunkDelaysMs -> [].
+ expect(fixtures[0].recordedTimings?.interChunkDelaysMs).toEqual([]);
+ });
+
it("warns and returns empty array for invalid JSON", () => {
const filePath = join(tmpDir, "bad.json");
writeFileSync(filePath, "{ not valid json", "utf-8");
@@ -1233,6 +1259,34 @@ describe("validateFixtures", () => {
expect(results.filter((r) => r.message.includes("hasToolResult"))).toHaveLength(0);
});
+ // --- match.toolResultContains type checks ---
+
+ it("error: toolResultContains is a number", () => {
+ const fixtures = [
+ makeFixture({ match: { userMessage: "test", toolResultContains: 42 as never } }),
+ ];
+ const results = validateFixtures(fixtures);
+ expect(
+ results.some((r) => r.severity === "error" && r.message.includes("toolResultContains")),
+ ).toBe(true);
+ });
+
+ it("error: toolResultContains is an empty string", () => {
+ const fixtures = [makeFixture({ match: { userMessage: "test", toolResultContains: "" } })];
+ const results = validateFixtures(fixtures);
+ expect(
+ results.some((r) => r.severity === "error" && r.message.includes("toolResultContains")),
+ ).toBe(true);
+ });
+
+ it("no error: toolResultContains is a non-empty string", () => {
+ const fixtures = [
+ makeFixture({ match: { userMessage: "test", toolResultContains: "cancelled" } }),
+ ];
+ const results = validateFixtures(fixtures);
+ expect(results.filter((r) => r.message.includes("toolResultContains"))).toHaveLength(0);
+ });
+
// --- match.systemMessage type checks ---
it("error: systemMessage is a number", () => {
@@ -1348,6 +1402,18 @@ describe("validateFixtures", () => {
expect(duplicateWarnings).toHaveLength(0);
});
+ it("no warning: same userMessage but different toolResultContains", () => {
+ const fixtures = [
+ makeFixture({ match: { userMessage: "hello", toolResultContains: "cancelled" } }),
+ makeFixture({ match: { userMessage: "hello", toolResultContains: "chosen_" } }),
+ ];
+ const results = validateFixtures(fixtures);
+ const duplicateWarnings = results.filter(
+ (r) => r.severity === "warning" && r.message.includes("duplicate"),
+ );
+ expect(duplicateWarnings).toHaveLength(0);
+ });
+
it("no warning: same userMessage but different sequenceIndex", () => {
const fixtures = [
makeFixture({ match: { userMessage: "hello", sequenceIndex: 0 } }),
@@ -1360,6 +1426,90 @@ describe("validateFixtures", () => {
expect(duplicateWarnings).toHaveLength(0);
});
+ it("no warning: same userMessage but different toolCallId", () => {
+ const fixtures = [
+ makeFixture({ match: { userMessage: "hello", toolCallId: "call_a" } }),
+ makeFixture({ match: { userMessage: "hello", toolCallId: "call_b" } }),
+ ];
+ const results = validateFixtures(fixtures);
+ const duplicateWarnings = results.filter(
+ (r) => r.severity === "warning" && r.message.includes("duplicate"),
+ );
+ expect(duplicateWarnings).toHaveLength(0);
+ });
+
+ it("no warning: same userMessage but different systemMessage", () => {
+ const fixtures = [
+ makeFixture({ match: { userMessage: "hello", systemMessage: "persona A" } }),
+ makeFixture({ match: { userMessage: "hello", systemMessage: "persona B" } }),
+ ];
+ const results = validateFixtures(fixtures);
+ const duplicateWarnings = results.filter(
+ (r) => r.severity === "warning" && r.message.includes("duplicate"),
+ );
+ expect(duplicateWarnings).toHaveLength(0);
+ });
+
+ it("no warning: same userMessage but different model", () => {
+ const fixtures = [
+ makeFixture({ match: { userMessage: "hello", model: "gpt-4o" } }),
+ makeFixture({ match: { userMessage: "hello", model: "claude-opus-4" } }),
+ ];
+ const results = validateFixtures(fixtures);
+ const duplicateWarnings = results.filter(
+ (r) => r.severity === "warning" && r.message.includes("duplicate"),
+ );
+ expect(duplicateWarnings).toHaveLength(0);
+ });
+
+ it("no warning: same userMessage but different toolName", () => {
+ const fixtures = [
+ makeFixture({ match: { userMessage: "hello", toolName: "search" } }),
+ makeFixture({ match: { userMessage: "hello", toolName: "fetch" } }),
+ ];
+ const results = validateFixtures(fixtures);
+ const duplicateWarnings = results.filter(
+ (r) => r.severity === "warning" && r.message.includes("duplicate"),
+ );
+ expect(duplicateWarnings).toHaveLength(0);
+ });
+
+ it("no warning: same userMessage but different responseFormat", () => {
+ const fixtures = [
+ makeFixture({ match: { userMessage: "hello", responseFormat: "text" } }),
+ makeFixture({ match: { userMessage: "hello", responseFormat: "json_object" } }),
+ ];
+ const results = validateFixtures(fixtures);
+ const duplicateWarnings = results.filter(
+ (r) => r.severity === "warning" && r.message.includes("duplicate"),
+ );
+ expect(duplicateWarnings).toHaveLength(0);
+ });
+
+ it("no warning: same userMessage but different endpoint", () => {
+ const fixtures = [
+ makeFixture({ match: { userMessage: "hello", endpoint: "chat" } }),
+ makeFixture({ match: { userMessage: "hello", endpoint: "video" } }),
+ ];
+ const results = validateFixtures(fixtures);
+ const duplicateWarnings = results.filter(
+ (r) => r.severity === "warning" && r.message.includes("duplicate"),
+ );
+ expect(duplicateWarnings).toHaveLength(0);
+ });
+
+ it("no warning: same userMessage but different inputText", () => {
+ const fixtures = [
+ makeFixture({ match: { userMessage: "hello", inputText: "embed A" } }),
+ makeFixture({ match: { userMessage: "hello", inputText: "embed B" } }),
+ ];
+ const results = validateFixtures(fixtures);
+ const duplicateWarnings = results.filter(
+ (r) => r.severity === "warning" && r.message.includes("duplicate"),
+ );
+ expect(duplicateWarnings).toHaveLength(0);
+ });
+
it("warning: same userMessage with identical turnIndex/hasToolResult/sequenceIndex", () => {
const fixtures = [
makeFixture({ match: { userMessage: "hello", turnIndex: 1, hasToolResult: true } }),
@@ -1868,6 +2018,15 @@ describe("auto-stringify JSON objects in fixture entries", () => {
expect(fixture.match.systemMessage).toBe("name=Atai");
});
+ it("passes toolResultContains through entryToFixture", () => {
+ const entry: FixtureFileEntry = {
+ match: { toolCallId: "call_x", toolResultContains: '"cancelled"' },
+ response: { content: "ok" },
+ };
+ const fixture = entryToFixture(entry);
+ expect(fixture.match.toolResultContains).toBe('"cancelled"');
+ });
+
it("stringifies nested objects in arguments", () => {
const entry: FixtureFileEntry = {
match: { userMessage: "test" },
diff --git a/src/__tests__/router.test.ts b/src/__tests__/router.test.ts
index 50885730..8ac4396d 100644
--- a/src/__tests__/router.test.ts
+++ b/src/__tests__/router.test.ts
@@ -585,6 +585,127 @@ describe("matchFixture — toolCallId", () => {
});
});
+// ---------------------------------------------------------------------------
+// matchFixture — toolResultContains
+// ---------------------------------------------------------------------------
+
+describe("matchFixture — toolResultContains", () => {
+ it("discriminates approve vs cancel legs that share a toolCallId", () => {
+ // The motivating case: a human-in-the-loop suspend tool resumes with the
+ // SAME tool_call_id for both outcomes; only the tool-result JSON differs.
+ const cancelled = makeFixture(
+ { toolCallId: "call_schedule_001", toolResultContains: '"cancelled"' },
+ { content: "No problem — nothing was booked." },
+ );
+ const confirmed = makeFixture(
+ { toolCallId: "call_schedule_001", toolResultContains: '"chosen_' },
+ { content: "Booked: Monday 9:00 AM confirmed." },
+ );
+ const base = [
+ { role: "user" as const, content: "schedule a meeting" },
+ {
+ role: "assistant" as const,
+ content: null,
+ tool_calls: [
+ {
+ id: "call_schedule_001",
+ type: "function" as const,
+ function: { name: "schedule_meeting", arguments: "{}" },
+ },
+ ],
+ },
+ ];
+ const cancelReq = makeReq({
+ messages: [
+ ...base,
+ { role: "tool", content: '{"cancelled": true}', tool_call_id: "call_schedule_001" },
+ ],
+ });
+ const pickReq = makeReq({
+ messages: [
+ ...base,
+ {
+ role: "tool",
+ content: '{"chosen_time": "2026-07-20T09:00:00Z", "chosen_label": "Monday 9:00 AM"}',
+ tool_call_id: "call_schedule_001",
+ },
+ ],
+ });
+ const fixtures = [cancelled, confirmed];
+ expect(matchFixture(fixtures, cancelReq)).toBe(cancelled);
+ expect(matchFixture(fixtures, pickReq)).toBe(confirmed);
+ });
+
+ it("matches when the last tool message contains the substring", () => {
+ const fixture = makeFixture({ toolResultContains: "cancelled" });
+ const req = makeReq({
+ messages: [
+ { role: "user", content: "do thing" },
+ { role: "tool", content: '{"cancelled": true}', tool_call_id: "call_x" },
+ ],
+ });
+ expect(matchFixture([fixture], req)).toBe(fixture);
+ });
+
+ it("does not match when the substring is absent", () => {
+ const fixture = makeFixture({ toolResultContains: "cancelled" });
+ const req = makeReq({
+ messages: [
+ { role: "user", content: "do thing" },
+ { role: "tool", content: '{"chosen_time": "9am"}', tool_call_id: "call_x" },
+ ],
+ });
+ expect(matchFixture([fixture], req)).toBeNull();
+ });
+
+ it("does not match a request with no tool message", () => {
+ const fixture = makeFixture({ toolResultContains: "cancelled" });
+ const req = makeReq({ messages: [{ role: "user", content: "cancelled my plans" }] });
+ expect(matchFixture([fixture], req)).toBeNull();
+ });
+
+ it("does not match when a newer user turn follows the tool message", () => {
+ // Same last-message rule as toolCallId: a stale tool result in history
+ // must not shadow matchers for the new turn.
+ const fixture = makeFixture({ toolResultContains: "cancelled" });
+ const req = makeReq({
+ messages: [
+ { role: "user", content: "do thing" },
+ { role: "tool", content: '{"cancelled": true}', tool_call_id: "call_x" },
+ { role: "assistant", content: "Cancelled." },
+ { role: "user", content: "something else" },
+ ],
+ });
+ expect(matchFixture([fixture], req)).toBeNull();
+ });
+
+ it("matches array-of-parts tool content", () => {
+ const fixture = makeFixture({ toolResultContains: "cancelled" });
+ const req = makeReq({
+ messages: [
+ { role: "user", content: "do thing" },
+ {
+ role: "tool",
+ content: [{ type: "text", text: '{"cancelled": true}' }],
+ tool_call_id: "call_x",
+ },
+ ],
+ });
+ expect(matchFixture([fixture], req)).toBe(fixture);
+ });
+
+ it("does not match tool content with no extractable text", () => {
+ const fixture = makeFixture({ toolResultContains: "cancelled" });
+ const req = makeReq({
+ messages: [
+ { role: "user", content: "do thing" },
+ { role: "tool", content: null, tool_call_id: "call_x" },
+ ],
+ });
+ expect(matchFixture([fixture], req)).toBeNull();
+ });
+});
+
// ---------------------------------------------------------------------------
// matchFixture — toolName
// ---------------------------------------------------------------------------
diff --git a/src/fixture-loader.ts b/src/fixture-loader.ts
index 9d911fcb..4fe26366 100644
Binary files a/src/fixture-loader.ts and b/src/fixture-loader.ts differ
diff --git a/src/router.ts b/src/router.ts
index fe6ec21b..c74f465c 100644
--- a/src/router.ts
+++ b/src/router.ts
@@ -347,6 +347,19 @@ export function matchFixtureDiagnostic(
if (!last || last.role !== "tool" || last.tool_call_id !== match.toolCallId) continue;
}
+ // toolResultContains — substring gate on the LAST message's text content
+ // when that message is a tool result. Same last-message rule as toolCallId
+ // (a tool-result fixture answers the model's next call after a tool round),
+ // but discriminates on the result PAYLOAD instead of the call id — needed
+ // when approve/cancel resumes share the same toolCallId and differ only
+ // inside the tool-result JSON.
+ if (match.toolResultContains !== undefined) {
+ const last = effective.messages[effective.messages.length - 1];
+ if (!last || last.role !== "tool") continue;
+ const text = getTextContent(last.content);
+ if (text === null || !text.includes(match.toolResultContains)) continue;
+ }
+
// toolName — match against any tool definition by function.name
if (match.toolName !== undefined) {
const tools = effective.tools ?? [];
diff --git a/src/types.ts b/src/types.ts
index b7a0aebe..51301447 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -92,6 +92,14 @@ export interface FixtureMatch {
systemMessage?: string | string[] | RegExp;
inputText?: string | RegExp;
toolCallId?: string;
+ /**
+ * Substring matched against the text content of the LAST message when that
+ * message is a `tool` result (same last-message rule as `toolCallId`).
+ * Discriminates fixtures whose requests differ ONLY inside the tool-result
+ * payload — e.g. a human-in-the-loop tool where approve and cancel resume
+ * with the same toolCallId but different result JSON.
+ */
+ toolResultContains?: string;
toolName?: string;
model?: string | RegExp;
responseFormat?: string;
@@ -541,6 +549,11 @@ export interface FixtureFileEntry {
systemMessage?: string | string[];
inputText?: string;
toolCallId?: string;
+ /**
+ * Substring matched against the last tool-result message's text content.
+ * Mirrors the runtime FixtureMatch.toolResultContains.
+ */
+ toolResultContains?: string;
toolName?: string;
model?: string;
responseFormat?: string;