# 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:

- `userMessage` matches against the content of the last message with `role: "user"` — everything before it is ignored.
- `toolCallId` matches against the `tool_call_id` of the last message with `role: "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.

Two additional fields inspect the full message array rather than just the tail:

- `turnIndex` counts how many `role: "assistant"` messages are in the request. On the first user turn (no assistant reply yet) the count is 0; after one assistant reply it is 1, and so on. This is stateless — derived entirely from the request content, not from a server-side counter.
- `hasToolResult` checks whether any `role: "tool"` message exists in the request. `true` means “this request carries tool results”; `false` means “no tools have executed yet.”

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.

## Stateless turn matching

`turnIndex` and `hasToolResult` are stateless match fields added in v1.16.0. Unlike `sequenceIndex` (which uses a mutable server-side counter), they derive their value from the request’s message array. This makes them safe for shared aimock instances serving multiple concurrent test runners.

### turnIndex — match by conversation depth

`turnIndex` is the count of `role: "assistant"` messages in the request. It tells you how many times the LLM has already replied in this conversation.

fixtures/hitl-turnindex.json json

    {
      "fixtures": [
        {
          "match": { "userMessage": "plan a trip", "turnIndex": 0 },
          "response": {
            "toolCalls": [{
              "id": "call_001",
              "name": "generate_steps",
              "arguments": "{}"
            }]
          }
        },
        {
          "match": { "userMessage": "plan a trip", "turnIndex": 1 },
          "response": { "content": "Great choices! Your trip is booked." }
        }
      ]
    }

Turn 0: no assistant messages yet → returns tool call.
Turn 1: one assistant message in history → returns final answer.
Both fixtures share the same `userMessage`; `turnIndex` disambiguates them without relying on ordering or server-side state.

### hasToolResult — match by tool execution state

`hasToolResult` checks whether the request contains any `role: "tool"` messages. For a simple two-step tool round, this is often simpler than `turnIndex`:

fixtures/hitl-hastoolresult.json json

    {
      "fixtures": [
        {
          "match": { "userMessage": "plan a trip", "hasToolResult": false },
          "response": {
            "toolCalls": [{
              "id": "call_001",
              "name": "generate_steps",
              "arguments": "{}"
            }]
          }
        },
        {
          "match": { "userMessage": "plan a trip", "hasToolResult": true },
          "response": { "content": "Great choices! Your trip is booked." }
        }
      ]
    }

Async fixture responses for race-free multi-turn tests. When a multi-turn test depends on side effects between turns (database writes, entity creation, external API calls), async fixture responses let you `await` those operations before constructing the response — eliminating race conditions without `setTimeout` hacks. See Dynamic / Async Responses on the Examples page.

### Programmatic API

The `onTurn()` convenience method combines `turnIndex` with a `userMessage` pattern:

programmatic.ts ts

    mock.onTurn(0, "plan a trip", {
      toolCalls: [{ id: "call_001", name: "generate_steps", arguments: "{}" }],
    });
    mock.onTurn(1, "plan a trip", { content: "Great choices! Your trip is booked." });

    // Or use on() directly for hasToolResult:
    mock.on(
      { userMessage: "plan a trip", hasToolResult: false },
      { toolCalls: [{ id: "call_001", name: "generate_steps", arguments: "{}" }] }
    );
    mock.on(
      { userMessage: "plan a trip", hasToolResult: true },
      { content: "Great choices! Your trip is booked." }
    );

## 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/example-multi-turn.json json

    {
      "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, turnIndex, hasToolResult, context, and predicate

Five mechanisms handle 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.
Same user prompt, different response based on how many assistant turns have occurred (HITL, multi-step agents) | `turnIndex` | Stateless count of assistant messages in the request. Works with concurrent clients. See above.
Different behavior before vs. after any tool has executed (simpler than toolCallId for 2-step flows) | `hasToolResult` | Boolean check for `role: "tool"` presence. Stateless. Does not require pinning a specific `tool_call_id`.
Same user prompt, different response per integration or caller identity | `context` | Exact match on the `X-AIMock-Context` header. Fixtures with `context` only match requests carrying that value; fixtures without it remain shared. Stateless.
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.

predicate-by-turn-count.ts ts

    // 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. Only `userMessage` and `inputText` flip; fields like `toolName` and `toolCallId` are always exact. Pin exact strings in your fixtures when you use a transform.
- Duplicate `userMessage` warnings. `validateFixtures` warns when two fixtures share the same `userMessage` with identical `turnIndex`, `hasToolResult`, and `sequenceIndex` values. Fixtures that differ on any of these discriminators do not trigger the warning. Other fields like `toolCallId`, `model`, and `predicate` are not factored in, so the warning may still fire when those discriminators are present. Treat it as advisory.
- 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.
- Prefer stateless criteria for shared instances. `turnIndex` and `hasToolResult` are derived from the request content and safe for concurrent clients. `sequenceIndex` uses a mutable server-side counter that drifts when multiple test runners share a single aimock instance. See Sequential Responses for when `sequenceIndex` is the right tool.
- `context` is an additional discriminator, not a replacement. `context` scopes fixtures by integration identity (`X-AIMock-Context` header). It combines with all other match fields via AND. Two fixtures with the same `userMessage` but different `context` values are not duplicates — the validator accounts for this.