Skip to content

Commit f652d60

Browse files
authored
feat: toolResultContains match gate on the last tool-result payload (#299)
## Why The showcase mastra interrupt demos use a native suspend tool where pick and cancel resume with the SAME tool_call_id; the requests differ only inside the tool-result message content (`{"chosen_time": ...}` vs `{"cancelled": true}`). Every existing JSON-expressible gate is identical between the two requests, so first-match-wins replays the pick confirmation after the user cancelled. `predicate` cannot help: the JSON fixture loader has no way to express it. ## What New `match.toolResultContains: string` gate: passes when the request's LAST message is a `role: "tool"` result whose text content contains the substring. Mirrors the existing `toolCallId` last-message rule so the two stay consistent. - `src/router.ts`: gate placed with the other shape predicates, right after `toolCallId` - `src/types.ts`: `FixtureMatch` + `FixtureFileEntry.match` - `src/fixture-loader.ts`: `entryToFixture` carry-through; validation (string, non-empty — empty substring would silently act as a catch-all); userMessage dedup key; catch-all discriminator set - `packages/aimock-pytest`: `toolResultContains` added to `_MATCH_LEVEL_OPT_KEYS` (0.5.0) - docs: fixtures + multi-turn pages, README, write-fixtures skill - tests: approve/cancel discrimination sharing one toolCallId; substring present/absent; no-tool-message request never matches; stale tool result followed by a new user turn never matches; array-of-parts content; null content; loader validation + dedup + carry-through ## Version 1.37.0 (changelog entry added with this PR's number in a follow-up commit on this branch). --- ### Maintainer note — additional fixes folded in during review Beyond the `toolResultContains` gate, this branch now also carries (all under the single 1.37.0 release): - **fix:** completed the duplicate-userMessage dedup key so legitimately-distinct fixtures no longer emit spurious shadow warnings (kind-aware matcher serialization; `predicate` keyed by identity). - **fix:** guarded `recordedTimings.interChunkDelaysMs` against non-array values — a malformed entry no longer throws and silently drops every fixture in its file. - **fix:** bumped the `aimock-pytest` default server pin to 1.37.0 so the auto-download path actually supports the new kwarg the client forwards. - **ci:** the `release` script now runs `format:check` + `test:exports` before publish. - **docs:** corrected the match-field / dedup / `toolCallId` semantics docs, and fixed the write-fixtures E2E lifecycle to use `resetMatchCounts()` instead of a full `reset()` between tests. (These consolidate PR #300, now closed, into this one release. CHANGELOG updated accordingly.)
2 parents 06e51c7 + 5a395a5 commit f652d60

15 files changed

Lines changed: 460 additions & 73 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# @copilotkit/aimock
22

3+
## [1.37.0] - 2026-07-15
4+
5+
### Added
6+
7+
- New `match.toolResultContains` string gate: passes when the request's LAST message is a `role: "tool"` result whose text content contains the substring (same last-message rule as `toolCallId`, and composable with it). Discriminates fixtures whose requests differ only inside the tool-result payload — e.g. a human-in-the-loop suspend tool where approve and cancel resume with the same `tool_call_id` but different result JSON. JSON-expressible, so fixture files can finally split those legs without a programmatic `predicate`. Validated at load time (non-empty string — an empty substring would silently act as a catch-all), included in the duplicate-userMessage dedup key and the catch-all discriminator set, and routed through the `aimock-pytest` match-level kwarg keys (aimock-pytest 0.5.0) (#299)
8+
9+
### Fixed
10+
11+
- The duplicate-userMessage shadow check no longer warns for fixtures that share a `userMessage` but are legitimately disambiguated by a matcher the dedup key previously omitted (`systemMessage`, `model`, `toolName`, `toolCallId`, `inputText`, `responseFormat`, `endpoint`). The dedup key now covers every discriminator the router actually matches on and serialises RegExp / array matchers kind-aware so they don't collide; a `predicate`-bearing fixture is keyed by identity so it is never a false duplicate. This only suppresses spurious warnings — the check never dropped or deduped fixtures (#299)
12+
- Bumped the `aimock-pytest` default server pin (`_version.py`) to 1.37.0 so the auto-download path (used when `AIMOCK_CLI_PATH` is unset) runs a server that supports the new `toolResultContains` match kwarg the client forwards (#299)
13+
- Guarded `recordedTimings.interChunkDelaysMs` against non-array values: a fixture whose `interChunkDelaysMs` is malformed (null, missing, or not an array) no longer throws during load and silently drops every fixture in that file — the value is coerced to an empty array (#299)
14+
315
## [1.36.1] - 2026-07-14
416

517
### Fixed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ Run them all on one port with `npx @copilotkit/aimock --config aimock.json`, or
5050

5151
- **[Record & Replay](https://aimock.copilotkit.dev/record-replay)** — Proxy real APIs, save as fixtures, replay deterministically forever
5252
- **Timing-aware recording and replay** — Recorded fixtures capture per-frame arrival timestamps; replay uses recorded timings for approximate timing reproduction based on recorded TTFT and inter-frame cadence (replay chunk count may differ from recording — TTFT and average pace are preserved, not per-token fidelity) with configurable `--replay-speed` multiplier
53-
- **[Multi-turn Conversations](https://aimock.copilotkit.dev/multi-turn)** — Record and replay multi-turn traces with tool rounds; match distinct turns via `turnIndex`, `hasToolResult`, `toolCallId`, `sequenceIndex`, `systemMessage` (gate on host-supplied agent context), or custom predicates
53+
- **[Multi-turn Conversations](https://aimock.copilotkit.dev/multi-turn)** — Record and replay multi-turn traces with tool rounds; match distinct turns via `turnIndex`, `hasToolResult`, `toolCallId`, `toolResultContains` (gate on the tool-result payload), `sequenceIndex`, `systemMessage` (gate on host-supplied agent context), or custom predicates
5454
- **[12 providers across 14 API surfaces](https://aimock.copilotkit.dev/docs)** — OpenAI Chat, OpenAI Responses, OpenAI Realtime (GA + Beta shim), Claude, Gemini (REST + embedContent), Gemini Live, Gemini Interactions, Azure, Bedrock, Vertex AI, Ollama (chat + embeddings), Cohere (chat + embed), ElevenLabs TTS — full streaming support
5555
- **Multimedia APIs**[image generation](https://aimock.copilotkit.dev/images) (DALL-E, Imagen), [image editing](https://aimock.copilotkit.dev/images) (/v1/images/edits), [text-to-speech](https://aimock.copilotkit.dev/speech) (OpenAI + ElevenLabs), [audio transcription](https://aimock.copilotkit.dev/transcription), [audio translation](https://aimock.copilotkit.dev/transcription) (/v1/audio/translations), [video generation](https://aimock.copilotkit.dev/video), [OpenRouter video generation](https://aimock.copilotkit.dev/openrouter-video) (/api/v1/videos with async job lifecycle), [Google Veo video generation](https://aimock.copilotkit.dev/veo-video) (:predictLongRunning + /v1beta/operations async lifecycle), [Grok Imagine video generation](https://aimock.copilotkit.dev/grok-video) (/v1/videos/generations with async job lifecycle), [fal.ai](https://aimock.copilotkit.dev/fal-ai) (image / video / audio with queue lifecycle)
5656
- **[MCP](https://aimock.copilotkit.dev/mcp-mock) / [A2A](https://aimock.copilotkit.dev/a2a-mock) / [AG-UI](https://aimock.copilotkit.dev/agui-mock) / [Vector](https://aimock.copilotkit.dev/vector-mock)** — Mock every protocol your AI agents use

docs/fixtures/index.html

Lines changed: 46 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,16 @@ <h2>Match Fields</h2>
9797
<code>requestTransform</code> is set) or regex (pattern match)
9898
</td>
9999
</tr>
100+
<tr>
101+
<td>systemMessage</td>
102+
<td>string | RegExp | string[]</td>
103+
<td>
104+
Match on the joined text of the request&rsquo;s system messages &mdash; string
105+
(case-sensitive substring, or exact when <code>requestTransform</code> is set),
106+
regex (pattern match), or an array of strings (all substrings must be present). Gate
107+
a fixture on host-supplied context
108+
</td>
109+
</tr>
100110
<tr>
101111
<td>inputText</td>
102112
<td>string | RegExp</td>
@@ -110,6 +120,16 @@ <h2>Match Fields</h2>
110120
The <code>onToolResult(id, response)</code> helper is sugar over this field
111121
</td>
112122
</tr>
123+
<tr>
124+
<td>toolResultContains</td>
125+
<td>string</td>
126+
<td>
127+
Substring match on the text content of the last message when it is a
128+
<code>role: "tool"</code> result. Discriminates fixtures whose requests differ only
129+
inside the tool-result payload (e.g. approve vs cancel resuming with the same
130+
<code>tool_call_id</code>). See <a href="/multi-turn">Multi-Turn Conversations</a>
131+
</td>
132+
</tr>
113133
<tr>
114134
<td>toolName</td>
115135
<td>string</td>
@@ -200,12 +220,16 @@ <h3>1. <code>userMessage</code> matches only the LAST user message</h3>
200220
tool-round idiom.
201221
</p>
202222

203-
<h3>2. <code>toolCallId</code> matches the LAST tool message</h3>
223+
<h3>2. <code>toolCallId</code> matches the overall last message</h3>
204224
<p>
205-
<code>toolCallId</code> is compared against the <code>tool_call_id</code> of the
206-
<em>last <code>role: "tool"</code> message</em> in the request &mdash; regardless of
207-
whether that&rsquo;s the overall last message. If no tool message is present in the
208-
history, <code>toolCallId</code> never matches. See
225+
<code>toolCallId</code> matches only when the request&rsquo;s
226+
<em>overall last message</em> is a <code>role: "tool"</code> result whose
227+
<code>tool_call_id</code> equals the given value. This mirrors the API contract: a tool
228+
result is the last message only while the model is answering that tool round. If the last
229+
message is anything else &mdash; a newer <code>user</code> turn, or no tool result at all
230+
&mdash; <code>toolCallId</code> never matches, so a stale tool round can&rsquo;t shadow
231+
<code>userMessage</code> matchers. This is the same overall-last-message rule as
232+
<code>toolResultContains</code>. See
209233
<a href="/multi-turn">Multi-Turn Conversations</a> for the tool-round idiom.
210234
</p>
211235

@@ -234,10 +258,10 @@ <h3>4. Substring by default, exact when a <code>requestTransform</code> is set</
234258
<p>
235259
If the router is configured with a <code>requestTransform</code> (typically used to strip
236260
dynamic data like timestamps or UUIDs from the request before matching), string
237-
<code>userMessage</code> and <code>inputText</code> flip to strict equality
238-
(<code>===</code>). The rationale: transforms normalize requests to a canonical form, and
239-
once normalized, the sensible comparison is exact &mdash; substring matching on a
240-
normalized string is more likely to hide bugs than catch flexible input.
261+
<code>userMessage</code>, <code>systemMessage</code>, and <code>inputText</code> flip to
262+
strict equality (<code>===</code>). The rationale: transforms normalize requests to a
263+
canonical form, and once normalized, the sensible comparison is exact &mdash; substring
264+
matching on a normalized string is more likely to hide bugs than catch flexible input.
241265
</p>
242266

243267
<h3>5. Validation warnings surface shadowing at load time</h3>
@@ -252,15 +276,19 @@ <h3>5. Validation warnings surface shadowing at load time</h3>
252276
<code>duplicate userMessage 'hello' — shadows fixture 0</code>, where
253277
<code>'hello'</code> is the duplicated message and <code>0</code> is the zero-based
254278
index of the earlier fixture being shadowed. This is advisory, not a hard error: the
255-
check now factors in <code>turnIndex</code>, <code>hasToolResult</code>,
256-
<code>context</code>, and <code>sequenceIndex</code> when deciding whether two fixtures
257-
truly collide, but it does <em>not</em> consider <code>toolCallId</code>,
258-
<code>model</code>, or <code>predicate</code>, so the warning may still fire when those
259-
discriminators are present. Treat it as advisory: if a runtime differentiator is in
260-
place, the fixtures won't actually shadow each other at match time. Only fixtures with
261-
no differentiator at all will truly shadow on match &mdash; that's the case where the
262-
second is never reached because the first wins. Safe to ignore in the former case;
263-
investigate in the latter.
279+
check factors in <em>every</em> match discriminator the router gates on when deciding
280+
whether two fixtures truly collide &mdash; <code>userMessage</code>,
281+
<code>systemMessage</code>, <code>inputText</code>, <code>toolCallId</code>,
282+
<code>toolResultContains</code>, <code>toolName</code>, <code>model</code>,
283+
<code>responseFormat</code>, <code>endpoint</code>, <code>context</code>,
284+
<code>sequenceIndex</code>, <code>turnIndex</code>, and
285+
<code>hasToolResult</code> &mdash; so two fixtures that differ on any one of them do not
286+
trigger the warning. A <code>predicate</code> is a function that cannot be compared for
287+
equivalence, so any fixture carrying one is keyed by its own identity: it is treated as
288+
unconditionally distinct and never reported as a duplicate (and two fixtures with
289+
distinct predicates are likewise treated as distinct). Only fixtures with no
290+
differentiator at all will truly shadow on match &mdash; that's the case where the
291+
second is never reached because the first wins. That is the case worth investigating.
264292
</li>
265293
<li>
266294
<strong>Catch-all not last</strong> &mdash; a fixture with an empty <code>match</code>

docs/multi-turn/index.html

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,17 @@ <h2>How matching works across turns</h2>
7272
distinguish the turn that <em>requests</em> a tool from the turn that
7373
<em>follows up</em> on a tool result.
7474
</li>
75+
<li>
76+
<code>toolResultContains</code> (v1.37.0) is a substring match against the
77+
<strong>text content of the last tool message</strong> (same last-message rule as
78+
<code>toolCallId</code>). Use it when two resume paths share the same
79+
<code>tool_call_id</code> and differ only inside the tool-result payload &mdash; e.g. a
80+
human-in-the-loop tool where approve resumes with
81+
<code>{"chosen_time": &hellip;}</code> and cancel resumes with
82+
<code>{"cancelled": true}</code>. Gate the cancel fixture on
83+
<code>"toolResultContains": "\"cancelled\""</code> (match the quoted JSON key so it
84+
can&rsquo;t match the word inside prose) and order it before the approve leg.
85+
</li>
7586
</ul>
7687
<p>
7788
A request carrying a 20-message history still only matches on its last user message (and,
@@ -100,8 +111,10 @@ <h2>How matching works across turns</h2>
100111
<strong>Substring by default, exact when transformed.</strong>
101112
<code>userMessage</code> is a substring match by default (<code>"hello"</code> matches
102113
<code>"say hello world"</code>). When you register a <code>requestTransform</code>,
103-
matching flips to <strong>exact string equality</strong> &mdash; but only for
104-
<code>userMessage</code> and <code>inputText</code>; other fields like
114+
matching flips to <strong>exact string equality</strong> &mdash; but only for the
115+
<em>string</em> forms of <code>userMessage</code>, <code>systemMessage</code>, and
116+
<code>inputText</code>. Regex and array-of-substrings forms are unaffected, and
117+
<code>toolResultContains</code> stays a substring match. Fields like
105118
<code>toolName</code> and <code>toolCallId</code> are always exact. This trips people up
106119
&mdash; see <a href="#gotchas">Gotchas</a> below.
107120
</p>
@@ -292,10 +305,10 @@ <h3>Turn 2 &mdash; client runs the tool, sends the result</h3>
292305
</div>
293306

294307
<h2>
295-
Choosing between sequenceIndex, toolCallId, turnIndex, hasToolResult, context, and
296-
predicate
308+
Choosing between sequenceIndex, toolCallId, toolResultContains, turnIndex, hasToolResult,
309+
context, and predicate
297310
</h2>
298-
<p>Five mechanisms handle different shapes of &ldquo;the same prompt twice&rdquo;:</p>
311+
<p>Seven mechanisms handle different shapes of &ldquo;the same prompt twice&rdquo;:</p>
299312

300313
<table class="endpoint-table">
301314
<thead>
@@ -318,8 +331,20 @@ <h2>
318331
<td>Different behavior before vs. after tool execution (tool-call round trip)</td>
319332
<td><code>toolCallId</code></td>
320333
<td>
321-
Matches the <code>tool_call_id</code> of the last <code>role: "tool"</code>
322-
message. Turn 1 has no tool message; turn 2 does.
334+
Matches when the request&rsquo;s overall last message is a
335+
<code>role: "tool"</code> result with that <code>tool_call_id</code>. Turn 1 has no
336+
tool result last; turn 2 does.
337+
</td>
338+
</tr>
339+
<tr>
340+
<td>
341+
Two tool-result outcomes that share a <code>tool_call_id</code> and differ only
342+
inside the result payload (e.g. approve vs. cancel)
343+
</td>
344+
<td><code>toolResultContains</code></td>
345+
<td>
346+
Substring match on the last <code>role: "tool"</code> result&rsquo;s text. Use when
347+
<code>toolCallId</code> alone can&rsquo;t tell the outcomes apart. Stateless.
323348
</td>
324349
</tr>
325350
<tr>
@@ -420,19 +445,25 @@ <h2 id="gotchas">Gotchas</h2>
420445
<strong>Substring vs. exact matching.</strong> Default matching is substring. Adding a
421446
<code>requestTransform</code> (e.g. to strip timestamps or request ids) flips matching
422447
to exact string equality &mdash; fixtures that previously matched as substrings will
423-
silently stop matching. Only <code>userMessage</code> and <code>inputText</code> flip;
424-
fields like <code>toolName</code> and <code>toolCallId</code> are always exact. Pin
425-
exact strings in your fixtures when you use a transform.
448+
silently stop matching. Only the <em>string</em> forms of <code>userMessage</code>,
449+
<code>systemMessage</code>, and <code>inputText</code> flip; regex and
450+
array-of-substrings forms are unaffected, and <code>toolResultContains</code> remains a
451+
substring match even under a transform. Fields like <code>toolName</code> and
452+
<code>toolCallId</code> are always exact. Pin exact strings in your fixtures when you
453+
use a transform.
426454
</li>
427455
<li>
428456
<strong>Duplicate <code>userMessage</code> warnings.</strong>
429457
<code>validateFixtures</code> warns when two fixtures share the same
430-
<code>userMessage</code> with identical <code>turnIndex</code>,
431-
<code>hasToolResult</code>, and <code>sequenceIndex</code> values. Fixtures that differ
432-
on any of these discriminators do not trigger the warning. Other fields like
433-
<code>toolCallId</code>, <code>model</code>, and <code>predicate</code> are not factored
434-
in, so the warning may still fire when those discriminators are present. Treat it as
435-
advisory.
458+
<code>userMessage</code> with identical values for <em>every</em> match discriminator
459+
the router gates on &mdash; <code>userMessage</code>, <code>systemMessage</code>,
460+
<code>inputText</code>, <code>toolCallId</code>, <code>toolResultContains</code>,
461+
<code>toolName</code>, <code>model</code>, <code>responseFormat</code>,
462+
<code>endpoint</code>, <code>context</code>, <code>sequenceIndex</code>,
463+
<code>turnIndex</code>, and <code>hasToolResult</code>. Fixtures that differ on any one
464+
of these discriminators do not trigger the warning. A <code>predicate</code> cannot be
465+
compared for equivalence, so any fixture carrying one is keyed by its own identity and
466+
is never reported as a duplicate. Treat it as advisory.
436467
</li>
437468
<li>
438469
<strong>First-wins ordering.</strong> Fixtures are evaluated in registration order (and,

docs/record-replay/index.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1007,8 +1007,8 @@ <h2>Request Transform</h2>
10071007
</div>
10081008

10091009
<p>
1010-
When <code>requestTransform</code> is set, string matching for
1011-
<code>userMessage</code> and <code>inputText</code> switches from substring
1010+
When <code>requestTransform</code> is set, string matching for <code>userMessage</code>,
1011+
<code>systemMessage</code>, and <code>inputText</code> switches from substring
10121012
(<code>includes</code>) to exact equality (<code>===</code>). This prevents shortened keys
10131013
from accidentally matching unrelated prompts. Without a transform, the existing
10141014
<code>includes</code> behavior is preserved for backward compatibility.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@copilotkit/aimock",
3-
"version": "1.36.1",
3+
"version": "1.37.0",
44
"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.",
55
"license": "MIT",
66
"keywords": [
@@ -169,7 +169,7 @@
169169
"test:exports": "publint && attw --pack .",
170170
"lint": "eslint .",
171171
"format:check": "prettier --check .",
172-
"release": "pnpm build && pnpm test && pnpm lint && npm publish",
172+
"release": "pnpm format:check && pnpm build && pnpm test && pnpm lint && pnpm test:exports && npm publish",
173173
"prepare": "husky || true"
174174
},
175175
"lint-staged": {

packages/aimock-pytest/pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "aimock-pytest"
7-
version = "0.4.0"
7+
version = "0.5.0"
88
description = "pytest fixtures for aimock — mock LLM APIs, multimedia, MCP, A2A, AG-UI, vector DBs, and more"
99
readme = "README.md"
1010
requires-python = ">=3.10"
1111
license = "MIT"
1212
dependencies = ["requests>=2.28"]
1313

1414
[project.optional-dependencies]
15-
test = ["pytest>=7.0", "requests>=2.28"]
15+
test = ["pytest>=7.0"]
1616

1717
[project.entry-points."pytest11"]
1818
aimock = "aimock_pytest.plugin"

0 commit comments

Comments
 (0)