What Is Agent Simulation Testing? A 3-Actor Harness
Tom PiaggioCo-Founder at Autonoma
Agent simulation testing runs a synthetic user, driven by an LLM playing a persona and a goal, against your AI agent across multiple turns, then scores the resulting transcript against explicit pass/fail criteria. It is the multi-turn counterpart to a single-shot eval: instead of grading one reply, it validates a whole conversation, including whether the agent recovers when a tool call fails partway through. The pattern needs three actors, a user simulator, the agent under test, and a judge, and none of them require a vendor SDK to build.
LangWatch calls its version Scenario testing, and their docs are, honestly, the best public artifact of this pattern anywhere. The pytest harness ships an AgentAdapter, a UserSimulatorAgent, and a JudgeAgent, and the six-turn order-cancellation example in their guide simulates a tool call failing mid-conversation and asserts the agent recovers from it. It is a genuinely good piece of work. The catch: every part of it only runs inside LangWatch's SDK. If you are not already on their platform, the pattern is not portable, even though nothing about the pattern itself is proprietary. Three LLM calls, a loop, and an assertion. This piece builds the same harness in raw Python, so it works with whatever agent framework, whatever model provider, and whatever CI system you already run, and it stays the reference the rest of this agent-testing series links back to.
The Three-Actor Pattern, Explained Generically
A single-shot eval answers one question: is this one reply good? That is enough for a classifier or a summarizer, but an agent's failures rarely show up in a single reply. They show up three turns in, after the user has pushed back once, right after a tool call comes back with an error the agent has to react to. Testing that requires a conversation, not a prompt-response pair, and a conversation needs someone to have it with.
The pattern that works is three actors, each doing one job.
The UserSimulatorAgent is an LLM given a persona and a goal, not a script. It generates the next user message given the conversation so far, and it decides on its own when the goal has been reached (or when to give up), the same way a real user would. Because it is a model reasoning about intent rather than a fixed list of inputs, it can push back, get confused, or rephrase, which is exactly the behavior that breaks agents in production and never shows up in a scripted test.
The AgentAdapter wraps the agent under test behind one interface: take the conversation history, return a reply and whatever tool calls the agent made. It does not care if the agent underneath is LangGraph, CrewAI, an OpenAI Assistants thread, or code you wrote yourself. That adapter boundary is what makes the harness reusable across every agent you own, instead of one test file per framework.
The JudgeAgent is a third, independent LLM call, graded against criteria written as an explicit, checkable rubric, not a vague "was this a good response." A criteria string like "PASS only if the order status changed to cancelled AND the agent acknowledged the failed attempt before retrying" gives the judge something it can actually check line by line. A criteria string like "PASS if the response was helpful" gives it nothing to check, and you will get a different verdict every run for reasons that have nothing to do with your agent.
A SimulationRunner ties the three together: it alternates user and agent turns up to a turn limit, stops early if the simulator signals the goal is done, and always hands the full transcript to the judge at the end, even if the conversation ended early. That last detail matters. An agent that gives up after turn two should fail the judge's rubric just as loudly as one that runs all eight turns and gets the answer wrong.
Three LLM-backed roles, one loop, one verdict. Nothing here is vendor-specific.
Here's the harness itself, as a single, provider-agnostic module. It only touches the OpenAI SDK in one place (LLMClient), so swapping providers means changing one class, not rewriting the pattern:
"""harness.pyA reusable, provider-agnostic three-actor simulation harness for testingconversational AI agents: - UserSimulatorAgent: an LLM playing a persona with a goal, generating the next user turn (or signaling the conversation is done). - AgentAdapter: a thin wrapper around ANY agent implementation, so the harness works regardless of the framework (LangGraph, CrewAI, a raw OpenAI Assistants thread, or hand-rolled code) sitting behind it. - JudgeAgent: an independent LLM call that scores the full transcript against an explicit, checkable rubric, defaulting to FAIL whenever the verdict is ambiguous.`SimulationRunner` alternates user and agent turns until the simulatorsignals it is done (or `max_turns` is reached), then always hands the fulltranscript to the judge, even if the conversation ended early.`LLMClient` is the only class in this module that touches the OpenAI SDK.Swapping model providers means changing `LLMClient`, not the pattern.Requires the OPENAI_API_KEY environment variable to be set. This modulehas no __main__ block; it is imported by `order_cancellation_scenario.py`and the pytest suite in `tests/test_non_determinism.py`."""from __future__ import annotationsimport osfrom dataclasses import dataclassfrom typing import Any, Callable, Optionalfrom openai import OpenAI@dataclassclass Message: """A single turn in a conversation.""" role: str # "user" | "assistant" | "system" | "tool" content: str@dataclassclass JudgeVerdict: """The outcome of a JudgeAgent evaluating a transcript.""" passed: bool reasoning: str@dataclassclass SimulationResult: """The full output of a SimulationRunner.run() call.""" transcript: list[Message] verdict: JudgeVerdictclass LLMClient: """ Thin wrapper around the OpenAI chat completions API. This is the ONLY class in this module that touches the OpenAI SDK. Swap it for a different provider's client to port the whole harness to another model provider without touching any other class in this file. """ def __init__(self, model: str = "gpt-4o-mini", temperature: float = 0.7) -> None: api_key = os.environ.get("OPENAI_API_KEY") if not api_key: raise RuntimeError( "OPENAI_API_KEY is not set. Export it before running any " "code in this repository: export OPENAI_API_KEY=sk-..." ) self._client = OpenAI(api_key=api_key) self.model = model self.temperature = temperature def complete(self, system_prompt: str, messages: list[Message]) -> str: """ Send a system prompt plus a list of Message turns to the model and return the assistant's reply text. """ api_messages: list[dict[str, str]] = [ {"role": "system", "content": system_prompt} ] for msg in messages: api_messages.append({"role": msg.role, "content": msg.content}) response = self._client.chat.completions.create( model=self.model, temperature=self.temperature, messages=api_messages, # type: ignore[arg-type] ) content = response.choices[0].message.content return content.strip() if content else ""class UserSimulatorAgent: """ An LLM playing a persona with a goal. Generates the next user message given the conversation so far, and decides on its own when the goal has been reached (or when to give up) by returning None instead of a string. """ DONE_TOKEN = "<<SIMULATION_DONE>>" def __init__(self, persona: str, goal: str, llm: LLMClient) -> None: self.persona = persona self.goal = goal self.llm = llm def _system_prompt(self) -> str: return ( "You are role-playing as a customer talking to a support agent. " f"Persona: {self.persona}\n" f"Goal: {self.goal}\n\n" "Speak ONLY as the customer, in first person, one message at a " "time. Do not narrate, and do not describe the agent's behavior " "from the outside, and do not break character.\n" f"If your goal has been fully reached, or you have decided to " f"give up, respond with exactly the token {self.DONE_TOKEN} and " "nothing else." ) def next_message(self, history: list[Message]) -> Optional[str]: """ Return the next user message given the conversation so far, or None if the goal has been reached (or the simulator has given up). """ reply = self.llm.complete(self._system_prompt(), history) if self.DONE_TOKEN in reply: return None return replyAgentFn = Callable[[list[Message]], "tuple[str, list[dict[str, Any]]]"]class AgentAdapter: """ Wraps an arbitrary agent implementation behind one interface: take the conversation history, return a reply and whatever tool calls were made. `agent_fn` can be backed by LangGraph, CrewAI, an OpenAI Assistants thread, or hand-written code. As long as it matches the signature `(history: list[Message]) -> (reply_text: str, tool_calls: list[dict])` it plugs into this harness unchanged. """ def __init__(self, agent_fn: AgentFn) -> None: self.agent_fn = agent_fn def respond(self, history: list[Message]) -> "tuple[str, list[dict[str, Any]]]": return self.agent_fn(history)class JudgeAgent: """ An independent LLM call that scores a full transcript against an explicit, checkable rubric. Defaults to FAIL whenever the model's verdict is ambiguous or unparseable, rather than optimistically passing. """ def __init__(self, criteria: str, llm: LLMClient) -> None: self.criteria = criteria self.llm = llm def _system_prompt(self) -> str: return ( "You are a strict QA judge reviewing a conversation transcript " "between a simulated customer and a support agent.\n\n" f"Pass/fail criteria:\n{self.criteria}\n\n" "Respond with exactly two lines, in this exact format:\n" "VERDICT: PASS\n" "REASONING: <one or two sentences citing specific parts of the transcript>\n" "(or VERDICT: FAIL with the same REASONING line format)\n\n" "If the transcript is ambiguous with respect to the criteria, " "or evidence is missing, default to VERDICT: FAIL. Never guess " "in the agent's favor." ) def evaluate(self, transcript: list[Message]) -> JudgeVerdict: rendered = "\n".join(f"{m.role.upper()}: {m.content}" for m in transcript) judge_input = [Message(role="user", content=f"Transcript:\n{rendered}")] raw = self.llm.complete(self._system_prompt(), judge_input) passed = False reasoning = raw.strip() or "Judge returned an empty response; defaulting to FAIL." for line in raw.splitlines(): stripped = line.strip() if stripped.upper().startswith("VERDICT:"): verdict_text = stripped.split(":", 1)[1].strip().upper() passed = verdict_text.startswith("PASS") elif stripped.upper().startswith("REASONING:"): reasoning = stripped.split(":", 1)[1].strip() if "VERDICT:" not in raw.upper(): # Model didn't follow the format at all; ambiguous -> FAIL. passed = False reasoning = ( "Judge response did not include a parseable VERDICT line; " f"defaulting to FAIL. Raw response: {raw!r}" ) return JudgeVerdict(passed=passed, reasoning=reasoning)class SimulationRunner: """ Ties the three actors together: alternates user and agent turns up to `max_turns`, stops early if the simulator signals the goal is reached (or given up on), and always hands the full transcript to the judge at the end, even if the conversation ended early. """ def __init__( self, user_simulator: UserSimulatorAgent, agent_adapter: AgentAdapter, judge: JudgeAgent, max_turns: int = 8, ) -> None: self.user_simulator = user_simulator self.agent_adapter = agent_adapter self.judge = judge self.max_turns = max_turns def run(self) -> SimulationResult: transcript: list[Message] = [] for _ in range(self.max_turns): user_text = self.user_simulator.next_message(transcript) if user_text is None: break transcript.append(Message(role="user", content=user_text)) reply_text, _tool_calls = self.agent_adapter.respond(transcript) transcript.append(Message(role="assistant", content=reply_text)) # Always judge whatever transcript exists, even a short/early one. verdict = self.judge.evaluate(transcript) return SimulationResult(transcript=transcript, verdict=verdict)
A Worked Scenario: The Order-Cancellation Tool Failure
The happy path is not where agents fail. They fail when a downstream call times out, a third-party API returns a 500, or a database write silently doesn't land, and the agent has to notice, tell the user, and try again instead of pretending nothing happened or looping the same failing call forever. That is the scenario worth building a simulation test around, and it is close to the one LangWatch demonstrates: a customer wants order A1092 cancelled, and the cancel_order tool fails on the first attempt.
The scenario injects a real failure mode: the first call to cancel_order raises a timeout, the second call (on retry) succeeds. The user simulator's persona is written to push back once if the agent seems to give up, which is the behavior most scripted tests never exercise because nobody writes a fixture for "the user gets annoyed." The judge's criteria are explicit and checkable: the order's status actually changed to cancelled by the end of the conversation, the transcript shows the agent acknowledging the failed attempt before it retries, and the agent never repeats the same failing action more than twice without telling the user.
That middle criterion is the one that separates a simulation test from a plain success check. An agent that silently retries and eventually succeeds looks fine if you only check the final state. It is not fine. A support agent that goes quiet for three tool calls and then reports success has a UX bug even though the outcome is technically correct, and a bug like that is invisible to any assertion that only checks whether cancel_order eventually returned 200.
The failure and the recovery are both part of the transcript the judge scores. A test that only checks the final "cancelled" state would miss the silent-retry bug entirely.
Here's the scenario built on top of the harness, including a second, deterministic recovery check that does not rely on the judge alone:
"""order_cancellation_scenario.pyA worked simulation-testing scenario built on top of harness.py: a customerwants order A1092 cancelled because the event it was for moved to a datethey can no longer attend. The `cancel_order` tool times out on its firstcall and succeeds on the second (retry), and the toy agent under test hasto notice the failure, tell the user, retry, and confirm success namingthe order id and the word "cancelled".Run standalone: python src/order_cancellation_scenario.pyRequires the OPENAI_API_KEY environment variable to be set."""from __future__ import annotationsimport sysfrom pathlib import Pathfrom typing import Any# Allow running this file directly (`python src/order_cancellation_scenario.py`)# in addition to being imported from tests/test_non_determinism.py.sys.path.insert(0, str(Path(__file__).resolve().parent))from harness import ( # noqa: E402 AgentAdapter, JudgeAgent, LLMClient, Message, SimulationResult, SimulationRunner, UserSimulatorAgent,)class ToolTimeoutError(Exception): """Raised by cancel_order() on its first call for a given order."""def build_scenario() -> "tuple[dict[str, dict[str, str]], Any]": """ Build a fresh, isolated instance of the order-cancellation scenario: an in-memory ORDERS table and a bound `cancel_order` tool whose first call for any order raises ToolTimeoutError and whose second call succeeds. Returns (orders, order_cancellation_agent). Each call to this function creates independent state (a fresh ORDERS dict and a fresh call counter), so callers that need to run the scenario multiple times in the same process (like the non-determinism pytest suite) get a clean slate every time instead of module-level globals that would only behave correctly once per process. """ orders: dict[str, dict[str, str]] = {"A1092": {"status": "confirmed"}} call_counts: dict[str, int] = {} def cancel_order(order_id: str) -> str: call_counts[order_id] = call_counts.get(order_id, 0) + 1 if call_counts[order_id] == 1: raise ToolTimeoutError(f"Timed out cancelling order {order_id}") orders[order_id]["status"] = "cancelled" return f"Order {order_id} cancelled." def order_cancellation_agent( history: list[Message], ) -> "tuple[str, list[dict[str, Any]]]": """ Toy agent under test. Attempts to cancel order A1092: calls cancel_order, catches the timeout, tells the user the attempt failed and that it is retrying, retries, then confirms success naming the order id and the word "cancelled". """ tool_calls: list[dict[str, Any]] = [] order_id = "A1092" try: cancel_order(order_id) tool_calls.append( {"name": "cancel_order", "args": {"order_id": order_id}, "result": "ok"} ) reply = f"Order {order_id} has been cancelled." except ToolTimeoutError as exc: tool_calls.append( {"name": "cancel_order", "args": {"order_id": order_id}, "error": str(exc)} ) # Acknowledge the failure to the user before retrying. acknowledgment = ( f"I tried to cancel order {order_id} but the request timed " "out. Retrying now." ) try: cancel_order(order_id) tool_calls.append( {"name": "cancel_order", "args": {"order_id": order_id}, "result": "ok"} ) reply = ( f"{acknowledgment} Good news: order {order_id} has now " "been cancelled." ) except ToolTimeoutError as exc2: tool_calls.append( {"name": "cancel_order", "args": {"order_id": order_id}, "error": str(exc2)} ) reply = ( f"{acknowledgment} Unfortunately the retry also failed. " "I've escalated this to a human agent." ) return reply, tool_calls return orders, order_cancellation_agentdef run_scenario() -> "tuple[SimulationResult, dict[str, dict[str, str]]]": """ Wire up the three actors around a fresh scenario instance and run one full simulation. Returns (SimulationResult, orders) so callers can make both the judge-based assertion and a deterministic final-state assertion. """ orders, order_cancellation_agent = build_scenario() llm = LLMClient() user_simulator = UserSimulatorAgent( persona=( "A customer who booked order A1092 for an event that has since " "moved to a new date they can no longer attend. Polite but " "persistent. If the agent goes quiet or seems to have given up " "without explanation, push back once by asking directly " "whether the cancellation actually went through." ), goal=( "Get order A1092 cancelled and receive explicit confirmation " "that it was cancelled." ), llm=llm, ) agent_adapter = AgentAdapter(order_cancellation_agent) judge = JudgeAgent( criteria=( "PASS only if ALL of the following are true:\n" "1. By the end of the conversation, order A1092's status is " "'cancelled' (the agent reported the cancellation succeeded).\n" "2. The transcript shows the agent acknowledging that the " "first cancellation attempt failed BEFORE it reports retrying " "or succeeding.\n" "3. The agent never silently repeats the same failing action " "more than twice without telling the user about the failure.\n" "FAIL if any of these do not clearly hold, or if the evidence " "is ambiguous." ), llm=llm, ) runner = SimulationRunner( user_simulator=user_simulator, agent_adapter=agent_adapter, judge=judge, max_turns=8, ) result = runner.run() return result, ordersdef _acknowledgment_precedes_confirmation(transcript: list[Message]) -> bool: """ Deterministic recovery check, independent of the judge: scan the transcript for an assistant message that mentions a failure/retry BEFORE any assistant message that confirms cancellation. This is the check that catches an agent silently retrying and only reporting success, which a judge could in principle miss. """ ack_keywords = ("timed out", "failed", "retry", "retrying") confirm_keywords = ("cancelled", "canceled") ack_seen = False for msg in transcript: if msg.role != "assistant": continue lowered = msg.content.lower() if not ack_seen and any(kw in lowered for kw in ack_keywords): ack_seen = True if ack_seen and any(kw in lowered for kw in confirm_keywords): return True return Falsedef main() -> None: result, orders = run_scenario() print("=== Transcript ===") for msg in result.transcript: print(f"{msg.role.upper()}: {msg.content}") print("\n=== Verdict ===") print(f"Passed: {result.verdict.passed}") print(f"Reasoning: {result.verdict.reasoning}") print(f"\nFinal order state: {orders}") assert result.verdict.passed, f"Judge failed the scenario: {result.verdict.reasoning}" assert orders["A1092"]["status"] == "cancelled", "Order A1092 was not cancelled" assert _acknowledgment_precedes_confirmation(result.transcript), ( "Transcript did not show an acknowledgment of the failed attempt " "before the confirmation message" ) print("\nAll assertions passed.")if __name__ == "__main__": main()
Handling Non-Determinism: Run It More Than Once
Every actor in this harness is an LLM call. The user simulator can phrase its request differently each run. The agent can word its acknowledgment differently each run. The judge can, in rare cases, misread a transcript. That is a different kind of flakiness than a brittle CSS selector, and it needs a different fix, not a sleep() and a retry.
The first discipline is running the scenario more than once and gating on a threshold, not a single boolean. Five runs, four-out-of-five required to pass, is a reasonable starting point. What matters more is what you do when the pass rate is inconsistent: the instinct is to loosen the threshold further, and that is almost always the wrong move. Check the user simulator's persona and goal text first. If the goal is underspecified, the model has room to interpret it a different way each run, and that variance is not a bug in your agent, it is ambiguity in your test. Tighten the persona and the judge's criteria before you tighten the pass bar. A flaky simulation test is telling you the assertion is too strict or the prompt is too ambiguous, almost never that your agent is randomly broken.
The second discipline is matching the assertion to the thing you are actually checking. The order id A1092 and the word cancelled are hard facts, worth asserting on exactly. The sentence around them is not: "Your order has been cancelled" and "I've gone ahead and cancelled order A1092 for you" are both correct, and an exact-match assertion will flake between them for no real reason. Use semantic similarity (an embedding-based comparison, or the judge itself) for anything the model is free to phrase, and reserve exact-match for the facts that must appear verbatim.
Here's both patterns together, run-N-times gating and semantic-similarity assertions, as a real pytest file:
"""tests/test_non_determinism.pyEvery actor in the simulation harness is an LLM call, which means a singlerun is not a reliable pass/fail signal. This suite demonstrates the twodisciplines for handling that non-determinism in CI:1. Run-N-times threshold gating (test_recovery_passes_at_threshold): run the order-cancellation scenario multiple times and require a pass rate above a threshold, rather than trusting any single run.2. Semantic-similarity assertions (test_confirmation_message_is_semantically_close): for prose the model is free to phrase differently each run, assert on meaning (embedding cosine similarity) instead of exact string equality. Exact-match is reserved for hard facts (the order id, the word "cancelled"), which order_cancellation_scenario.py already checks deterministically.These tests make real OpenAI API calls (a chat completion per actor perturn, plus an embeddings call for the semantic-similarity test) and aretherefore slower and more expensive than typical unit tests. RequiresOPENAI_API_KEY to be set. Run with: pytest tests/test_non_determinism.py -v"""from __future__ import annotationsimport mathimport osimport sysfrom pathlib import Pathimport pytestfrom openai import OpenAIsys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))from order_cancellation_scenario import run_scenario # noqa: E402requires_openai_key = pytest.mark.skipif( "OPENAI_API_KEY" not in os.environ, reason="Requires OPENAI_API_KEY (these tests make real, billed API calls).",)def run_scenario_n_times(n: int) -> list: """Rerun the order-cancellation scenario n independent times.""" outcomes = [] for _ in range(n): result, orders = run_scenario() outcomes.append((result, orders)) return outcomesdef semantic_similarity(a: str, b: str) -> float: """ Cosine similarity between the OpenAI text-embedding-3-small embeddings of two strings. Returns a value in roughly [-1, 1]; in practice, semantically related sentences score well above 0.8. """ client = OpenAI(api_key=os.environ["OPENAI_API_KEY"]) response = client.embeddings.create( model="text-embedding-3-small", input=[a, b], ) vec_a = response.data[0].embedding vec_b = response.data[1].embedding dot = sum(x * y for x, y in zip(vec_a, vec_b)) norm_a = math.sqrt(sum(x * x for x in vec_a)) norm_b = math.sqrt(sum(y * y for y in vec_b)) if norm_a == 0 or norm_b == 0: return 0.0 return dot / (norm_a * norm_b)@requires_openai_keydef test_recovery_passes_at_threshold() -> None: """ Run the scenario 5 times and require at least 4/5 passes. Practitioner rule: if the pass rate is inconsistent run-to-run, the right fix is almost always to tighten the user simulator's persona and goal, and the judge's criteria, FIRST, not to loosen this threshold further. Inconsistent results usually mean the prompt is ambiguous, not that the agent under test is randomly broken. Loosening the threshold hides that signal instead of fixing it. """ n = 5 threshold = 4 outcomes = run_scenario_n_times(n) pass_count = sum(1 for result, _orders in outcomes if result.verdict.passed) failures = [ result.verdict.reasoning for result, _orders in outcomes if not result.verdict.passed ] assert pass_count >= threshold, ( f"Only {pass_count}/{n} runs passed (need >= {threshold}). " f"Judge reasoning on failing runs: {failures}" )@requires_openai_keydef test_confirmation_message_is_semantically_close() -> None: """ Assert the agent's final confirmation message is semantically close to a reference sentence, rather than requiring an exact string match. A naive `final_message == "Your order A1092 has been cancelled."` would flake constantly: "I've gone ahead and cancelled order A1092 for you" and "Order A1092 has now been cancelled" are both correct confirmations but neither is byte-identical to the reference. Exact-match is the right tool for hard facts that must appear verbatim (the order id "A1092", the word "cancelled"), not for full sentences the model is free to phrase differently. """ result, _orders = run_scenario() assistant_messages = [msg.content for msg in result.transcript if msg.role == "assistant"] assert assistant_messages, "Scenario produced no assistant messages" final_message = assistant_messages[-1] reference = "Your order A1092 has been cancelled." similarity = semantic_similarity(final_message, reference) assert similarity > 0.8, ( f"Final confirmation message was not semantically close enough to " f"the reference (similarity={similarity:.3f}). " f"Message: {final_message!r}" )
Tools That Productize This
Building the harness above takes an afternoon and teaches you exactly where your agent's failure modes live. Once you are running it against a dozen agents instead of one, a hosted platform starts pulling its weight: a dashboard instead of a spreadsheet of pass rates, historical trend lines, and a UI for writing new personas without touching Python. Here is what each of the three vendors most often named alongside this pattern actually wraps.
Tool
Wraps
Best for
Lock-in
LangWatch
Full 3-actor harness, pytest-native
Teams already tracing with LangWatch
SDK-locked scenario format
Maxim
Simulation plus a metrics dashboard
Managed metrics, less code ownership
Platform-hosted, less control
Retell
Simulated callers for voice agents
Voice agents specifically
Voice-only, not general-purpose
None of these are wrong choices. They are the right choice once the harness itself stops being the interesting part of your job. What none of them do, and what the three-actor pattern above does not do either, is tell you whether the agent's action actually changed anything in the application it is wired into.
How Autonoma Complements Agent Simulation Testing
The judge's verdict in the scenario above only tells you the conversation was correct. It confirms the agent said the right words, acknowledged the failure, and reported success. It does not confirm that cancel_order() actually flipped order A1092's status in the real database, that the support team's dashboard now shows "Cancelled," or that a stale row didn't get left behind in an inventory table the agent's tool call never touched. Saying the right thing and doing the right thing to the running application are two different verification problems, and simulation testing, by construction, only tests the first one. That gap is invisible from inside the transcript, because the transcript only ever shows you what the agent claimed happened.
That is the layer we built Autonoma to cover, and it is a different kind of testing entirely, not a bigger judge. Autonoma's agents run behavioral end-to-end tests directly against a running application. The Planner reads your codebase, the routes, the components, the order-management screens, and the database schema the cancellation actually touches, and plans test cases against what the application really does, including generating the DB state each scenario needs. The Executor drives the UI in a live PreviewKit environment, Autonoma's managed preview-environments layer, the same way a support engineer would, clicking through the actual cancellation flow instead of reading a transcript about it. The Reviewer classifies what it finds: a real bug, a flaky run, or a mismatch between the test and the current app behavior. The Diffs Agent keeps the suite aligned to the codebase on every pull request, so the tests don't rot the next time someone reworks the cancellation endpoint. None of that scores a conversation or computes a similarity threshold. It verifies that the tool call your agent made actually produced the state your users and your support team see.
Put the two side by side on the exact scenario above: agent simulation testing proves the agent chose the right tool, recovered out loud instead of silently retrying, and told the user the truth. Autonoma's behavioral E2E layer proves the order's status genuinely changed to cancelled in the admin dashboard your support team actually looks at, that inventory was actually released, and that a regression introduced next month, someone reworks the cancellation endpoint and nobody touches the agent's prompt, gets caught even though the conversation layer never changes. You need both. Neither one substitutes for the other.
When to Build the Harness vs Adopt a Platform
If you are testing one agent end to end for the first time, build the raw harness above. It is a few hundred lines, it teaches you your agent's actual failure modes instead of a vendor's abstraction of them, and it runs in whatever CI you already have.
Three signals tell you it's time to look at a platform instead of maintaining more Python. The first is agent count. Once you are running this harness against five or six different agents, each with its own personas and criteria, the maintenance burden shifts from "understand your agents" to "maintain a small test framework," and that second job is exactly what LangWatch, Maxim, and similar tools are built to take off your plate. The second is who is writing the personas. Personas and criteria strings are prose, not code, and once someone on your product or support team wants to add a new scenario without touching a Python file, a hosted UI earns its cost. The third is history. A pass or fail printed to a CI log tells you today's result. A platform that stores every run gives you a trend line, so a pass rate that drifts from 96% down to 84% over two weeks shows up as a graph instead of a mystery someone has to happen to notice.
None of that means the raw harness was wasted effort. The vendor products above are, at their core, a hosted version of the same three actors this article builds from scratch, plus a UI and a database in front of them. Understanding the pattern first means you know exactly what you are paying for when you adopt one, and you can tell the difference between a platform limitation and a bug in your own agent, a distinction that is much harder to make when the harness has always been someone else's black box.
Wiring this into CI is worth doing deliberately, not by accident. Every run is a handful of real LLM calls, on the user simulator side, the agent side, and the judge, which makes it slower and more expensive than a typical unit test. Running the full non-determinism suite (five repetitions of every scenario) on every commit gets expensive fast once you have more than a handful of scenarios. A more sustainable pattern: run one repetition per scenario on every pull request as a smoke check, and run the full five-repetition, threshold-gated suite on a nightly schedule, so a genuine regression surfaces within a day instead of blocking every commit with a slow, LLM-heavy test run. Treat a failing nightly run as an incident to triage that same day, not a red badge to get to eventually. For the action-level check that a transcript cannot provide, Autonoma can run the behavioral journey against the PR's running application as that code evolves. The version of this pattern that never turns into technical debt is the version someone is actually looking at.
The three-actor pattern here is the foundation the rest of this batch builds on. For the protocol and tool-function layers beneath an agent's conversation, how to test an MCP server separates handshake, deterministic tool, and tool-selection checks. For the broader application layer, testing generative AI applications maps prompt tests, eval sets, behavioral E2E, and production monitoring. And chatbot automation testing shows how to make non-deterministic response assertions a repeatable CI gate.
Frequently Asked Questions
Agent simulation testing runs a synthetic user, driven by an LLM playing a persona with a goal, against an AI agent across multiple conversation turns, then scores the full transcript against explicit pass/fail criteria using a separate judge model. It validates the whole conversation, including how the agent recovers from a mid-conversation tool failure, rather than grading a single reply in isolation.
A single-shot eval grades one input-output pair: is this one response accurate, relevant, or safe. Agent simulation testing validates a multi-turn journey: does the agent handle a user pushing back, recover when a tool call fails, and reach the correct outcome across several turns. Evals check a point. Simulation checks a path.
No. The pattern is three LLM-backed roles, a user simulator, the agent under test, and a judge, orchestrated in a loop. It can be built in raw Python against any LLM provider's API and any agent framework. Vendor products like LangWatch, Maxim, and Retell productize pieces of this pattern with dashboards and managed infrastructure, but the underlying pattern has no proprietary dependency.
Run it multiple times (five is a reasonable starting point) and gate on a pass threshold like four-out-of-five, rather than treating a single run as ground truth. If the pass rate is inconsistent across runs, tighten the user simulator's persona and the judge's criteria before loosening the threshold. Inconsistent results usually mean the prompt is ambiguous, not that the agent is randomly broken.
Build the raw harness first if you are testing one or a few agents; it takes an afternoon and teaches you your agent's real failure modes. Adopt a platform once you are running this pattern across many agents and a dashboard, historical trends, and non-engineer persona editing start paying for themselves. LangWatch wraps the fullest version of the pattern for teams already on their tracing stack. Maxim trades code ownership for a managed metrics dashboard. Retell is scoped specifically to voice agents.