Most eval suites we inherit are single-turn: one input, one trajectory, one set of assertions. They are worth having. They also miss the failures we see most often in production support and ops agents, because those failures need a conversation to appear.
The pattern is familiar. Turn one is fine. On turn two the user changes their mind. On turn three the agent re-reads its own earlier tool result as if it were fresh, drops the constraint the user gave on turn one, and issues a second refund. No single turn in that transcript looks wrong in isolation.
To catch that you need to drive the conversation, not just prompt it. This tutorial builds a simulated-user harness: a small script that plays the user, runs the agent for N turns, and asserts on the whole transcript and the end state. It is about 80 lines of Python and it runs anywhere pytest runs.
What a simulated user is and is not
A simulated user is a second model whose job is to act like a specific person with a specific goal, one message at a time, without seeing the agent's internals. It is not a judge; it does not score anything. Keeping those two roles separate matters. The moment the same model both drives and grades a conversation, you have a system that agrees with itself.
Two things make the simulator usable as a test fixture:
- The persona is data, not prose in a prompt. Goal, constraints, facts the user knows, and a stopping condition all live in a case file so a reviewer can read the suite.
- The assertions are mostly deterministic. Judge the transcript with a model only for the parts that are genuinely fuzzy, and only after the judge has been through the calibration procedure.
Step 1: write the cases as data
# cases/refund_changes_mind.yaml
id: refund_changes_mind
persona: |
You are a customer who ordered two items. You are brief and slightly impatient.
Never reveal your order number unless the agent asks for it.
goal: Get a refund for the damaged lamp only, not the whole order.
facts:
order_id: "4832"
items: ["lamp (damaged)", "rug (fine)"]
turn_plan:
- Ask for a refund on your order.
- After the agent responds, clarify that only the lamp is damaged.
max_turns: 6
expect:
refunds_issued: 1
refunded_items: ["lamp"]
turn_plan is the part people leave out. A free-running simulator wanders, and a wandering test is a flaky test. Scripting the beats the user must hit — while letting the simulator phrase them — keeps the case reproducible and still exercises the language handling.
Step 2: the simulator loop
# harness/simulate.py
import json
from openai import OpenAI
client = OpenAI()
SIM_MODEL = "gpt-4.1-mini-2025-04-14" # pin the version; record it in the run
SIM_SYSTEM = """You are role-playing a customer talking to a support agent.
Persona: {persona}
Your goal: {goal}
Facts you know: {facts}
Send ONE short message at a time. Do not narrate. Do not break character.
When your goal is met or clearly refused, reply with exactly: <END>"""
def user_turn(case, transcript, beat):
messages = [
{"role": "system", "content": SIM_SYSTEM.format(
persona=case["persona"], goal=case["goal"],
facts=json.dumps(case["facts"]))},
{"role": "user", "content": f"Conversation so far:\n{transcript}\n\nYour next move: {beat}"},
]
reply = client.chat.completions.create(
model=SIM_MODEL, messages=messages, temperature=0.0)
return reply.choices[0].message.content.strip()
def run_conversation(case, agent):
"""agent: callable(user_message) -> {'reply': str, 'tool_calls': [...]}"""
transcript, tool_calls = [], []
beats = case["turn_plan"]
for i in range(case["max_turns"]):
beat = beats[i] if i < len(beats) else "Continue toward your goal."
msg = user_turn(case, _render(transcript), beat)
if msg == "<END>":
break
transcript.append(("user", msg))
step = agent(msg)
transcript.append(("agent", step["reply"]))
tool_calls.extend(step["tool_calls"])
return {"transcript": transcript, "tool_calls": tool_calls}
The agent is passed in as a callable that keeps its own session state, so the harness works whether your agent is a LangGraph app, a bare tool loop or an HTTP endpoint. Run it against a staging tool environment or recorded fixtures. A suite that can issue a real refund is a liability, not a test.
Step 3: assert on the whole conversation
Three layers, cheapest first.
End state. What is true in the fake world when the conversation stops? This is the assertion that actually protects you.
def test_refund_changes_mind(agent_env):
case = load_case("cases/refund_changes_mind.yaml")
run = run_conversation(case, agent_env.agent)
refunds = [c for c in run["tool_calls"] if c["name"] == "issue_refund"]
assert len(refunds) == 1, f"expected one refund, got {len(refunds)}"
assert refunds[0]["arguments"]["items"] == ["lamp"]
assert agent_env.db.order("4832").status != "fully_refunded"
Cross-turn invariants. These are the multi-turn equivalent of a tool budget, and they catch the drift that single-turn cases cannot see:
| Invariant | Assertion |
|---|---|
| No irreversible action repeated | count write calls across the whole run, not per turn |
| Constraint from turn 1 still honoured at turn 5 | check final tool arguments against case["expect"] |
| No re-asking for facts already given | order id appears once in user messages |
| Conversation terminates | len(transcript) < 2 * max_turns |
| Cost stays bounded | total tokens or tool calls under a cap |
Rubric, last. Only for things structure cannot express — tone, whether the agent explained the partial refund. Send the judge the transcript with roles labelled, ask for one binary judgement per rubric line, and keep the judge model separate and pinned.
Step 4: handle non-determinism honestly
Multi-turn runs compound variance: two models, several tool calls, N turns. Before this suite can gate anything:
- Temperature 0 on both agent and simulator, seeds where the provider supports them, tool responses from fixtures.
- Run each new case five times and keep only the cases that pass five out of five. A case that passes four out of five is not a test, it is a coin.
- Record the simulator model version, the agent commit and the fixture set with every run. When a result moves, that record tells you which of the three moved.
- Cap wall-clock and spend per case in the runner. Conversations that loop are exactly the failure you are testing for, and they will happily burn your CI budget while doing it.
Once cases are stable, they run like any other eval — locally, then on every prompt, model or tool-schema change in CI, gated the same way as your single-turn suite. If you have not built that gate yet, the mechanics are in gating a pull request on agent evals, and the per-run scores go alongside your other trace scores in Langfuse.
What this does not cover
A simulated user is a model imitating a person, and it will be politer, more literal and more patient than your actual users. It does not discover new failure modes on its own; it re-runs the conversations you thought to write down. It is also not an adversarial test — a multi-turn attack that builds state across sessions is a red-team case with a different threat model, and belongs with the injection harness rather than here.
What it does give you is coverage of the region where agents actually break: turn three onward, with state. Start with the five conversations your support team says go wrong most often. Write them as cases, run each five times, and see what the end-state assertions say. In our readiness assessments this is usually where the first genuine surprise shows up.