Multi-Turn Conversations
How aimock routes requests that carry a full conversation history — user turns, assistant tool calls, tool results, and follow-ups — using match fields on the tail of the message array.
How matching works across turns
aimock’s router does not look at the whole conversation. It inspects only the
tail of the messages array:
-
userMessagematches against the content of the last message withrole: "user"— everything before it is ignored. -
toolCallIdmatches against thetool_call_idof the last message withrole: "tool"— this is how you distinguish the turn that requests a tool from the turn that follows up on a tool result.
A request carrying a 20-message history still only matches on its last user message (and, if present, its last tool message). Prior turns do not participate in matching.
Substring by default, exact when transformed.
userMessage is a substring match by default ("hello" matches
"say hello world"). When you register a requestTransform,
matching flips to exact string equality — but only for
userMessage and inputText; other fields like
toolName and toolCallId are always exact. This trips people up
— see Gotchas below.
The tool-round idiom
A single “tool round” is a two-turn pattern: the user asks for something, the assistant emits a tool call, your client executes it and sends the result back, and the assistant produces a final answer. aimock handles this with two fixtures — one keyed on the user message, one keyed on the tool call id.
{
"fixtures": [
{
"match": { "toolCallId": "call_background" },
"response": { "content": "Done! I've changed the background." }
},
{
"match": { "userMessage": "change background to blue" },
"response": {
"toolCalls": [
{
"id": "call_background",
"name": "change_background",
"arguments": { "background": "blue" }
}
]
}
}
]
}
Turn 1 — user asks, assistant calls the tool
The client sends a request whose last message is
{ role: "user", content: "change background to blue" }. There is no tool
message in the history yet, so the first fixture’s toolCallId criterion
cannot match and the router falls through to the second fixture. That fixture
substring-matches the last user message and returns the tool_calls response.
Pinning the tool call’s id ("call_background") in the
fixture is what lets turn 2 match — if you omit it, aimock auto-generates a fresh id
and the first fixture’s toolCallId criterion will never match.
Turn 2 — client runs the tool, sends the result
The client executes change_background, then sends a new request whose history
now contains the original user turn, the assistant’s tool-call turn, and a new
{ role: "tool", tool_call_id: "call_background", content: "..." } message at
the end. The last user message is still "change background to blue", but
there is now also a last tool message with tool_call_id: "call_background".
The first fixture’s toolCallId criterion matches and returns the final
text response — the broader userMessage fixture is never consulted.
Order matters: put toolCallId before userMessage.
Matching is first-wins, and turn 2 still has the same last user
message as turn 1. If the broader userMessage fixture were listed first, it
would shadow the toolCallId fixture on turn 2 and the follow-up response
would never fire. More-specific fixtures (toolCallId) must precede broader
ones (userMessage). As an alternative to ordering, gate
both fixtures with predicates on the last message’s role: the
turn-1 fixture only matches when last.role === "user", and the turn-2
fixture only matches when last.role === "tool". Then the two fixtures are
mutually exclusive regardless of registration order.
Choosing between sequenceIndex, toolCallId, and predicate
Three mechanisms handle three different shapes of “the same prompt twice”:
| You need… | Use | Why |
|---|---|---|
| Same user prompt, different response per call (retry loops, multi-step plans) | sequenceIndex |
Stateful per-fixture counter. Reset on mock.reset(). See
Sequential Responses.
|
| Different behavior before vs. after tool execution (tool-call round trip) | toolCallId |
Matches the tool_call_id of the last role: "tool"
message. Turn 1 has no tool message; turn 2 does.
|
| Arbitrary inspection — message count, specific content at any position, custom conversation state | predicate |
A (req) => boolean you supply. Receives the original request.
Programmatic only — not expressible in JSON fixtures.
|
// Different response depending on how far into the conversation we are
mock.on(
{ predicate: (req) => req.messages.length <= 2 },
{ content: "Welcome! What can I help with?" }
);
mock.on(
{ predicate: (req) => req.messages.length > 2 },
{ content: "Continuing our conversation..." }
);
These two predicates are disjoint — every request matches exactly one, so
registration order doesn’t matter for this specific example. But if you
later widen the second predicate from > 2 to >= 2, the two
ranges overlap at length === 2 and first-wins means whichever fixture is
registered first wins both turns. Register the more-specific predicate first.
Recording multi-turn conversations
aimock’s recorder is stateless across turns. Every recorded fixture
is keyed on the last role: "user" message of the request that
produced it — the recorder does not infer that two requests are part of the same
conversation. On a tool-round follow-up request, the last user message is still the
original turn-1 user message, because the assistant’s tool call and the
client’s tool result have different roles. So the recorder emits two fixtures with
identical match.userMessage — on replay the second will be
shadowed by the first until you disambiguate it (add toolCallId,
sequenceIndex, or a predicate).
After recording, you will usually hand-edit the follow-up fixture to key on
toolCallId so replay routes correctly. Two remedies exist for recorder
collisions: rewrite the match to use toolCallId (the right fix
for tool rounds, covered here) or add sequenceIndex (the right fix for the
same user prompt repeating, covered on the record-replay page). See
Recording Multi-Turn Conversations
on the Record & Replay page for the full recorder workflow and the
sequenceIndex remedy.
Gotchas
-
Substring vs. exact matching. Default matching is substring. Adding a
requestTransform(e.g. to strip timestamps or request ids) flips matching to exact string equality — fixtures that previously matched as substrings will silently stop matching. OnlyuserMessageandinputTextflip; fields liketoolNameandtoolCallIdare always exact. Pin exact strings in your fixtures when you use a transform. -
Duplicate
userMessagewarnings.validateFixtureswarns when two fixtures share the same stringuserMessage. The check looks atuserMessageonly — it does not factor insequenceIndex,toolCallId,model, orpredicate, so the warning still fires when those discriminators are present. Treat it as advisory: if a runtime differentiator is in place, the fixtures won't actually shadow each other at match time. Only fixtures with no differentiator at all will truly shadow on match. - First-wins ordering. Fixtures are evaluated in registration order (and, when loaded from a directory, in filename-sorted order). A broader fixture registered first will shadow narrower fixtures registered later. See the full routing rules on Fixtures.
-
Prior turns are invisible. If you need to vary behavior based on
something in the middle of the conversation — e.g. “did the user
mention ‘urgent’ three turns ago?” — use
predicate. No built-in match field inspects non-tail messages.