ProductHow it worksPricingBlogDocsLoginFind Your First Bug
A user-simulator terminal and an agent terminal exchanging messages, with a judge's gavel and scale above them, illustrating the three-actor pattern behind agent simulation testing
TestingAIAI Agent Testing

What Is Agent Simulation Testing? A 3-Actor Harness

Tom Piaggio
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.

Diagram of the three-actor simulation harness: UserSimulatorAgent and AgentAdapter exchange user turns and agent replies for N turns, orchestrated by a SimulationRunner, then the full transcript is sent to a JudgeAgent which returns a pass or fail verdict with reasoning
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.py

A reusable, provider-agnostic three-actor simulation harness for testing
conversational 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 simulator
signals it is done (or `max_turns` is reached), then always hands the full
transcript 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 module
has no __main__ block; it is imported by `order_cancellation_scenario.py`
and the pytest suite in `tests/test_non_determinism.py`.
"""

from __future__ import annotations

import os
from dataclasses import dataclass
from typing import Any, Callable, Optional

from openai import OpenAI


@dataclass
class Message:
    """A single turn in a conversation."""

    role: str  # "user" | "assistant" | "system" | "tool"
    content: str


@dataclass
class JudgeVerdict:
    """The outcome of a JudgeAgent evaluating a transcript."""

    passed: bool
    reasoning: str


@dataclass
class SimulationResult:
    """The full output of a SimulationRunner.run() call."""

    transcript: list[Message]
    verdict: JudgeVerdict


class 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 reply


AgentFn = 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.

Timeline of the order-cancellation scenario across five turns: the user asks to cancel order A1092, the agent calls cancel_order, the tool call fails on the first attempt, the agent acknowledges the failure and retries, the retry succeeds, and a recovery assertion checks that the failure was acknowledged before the retry, not silently swallowed
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.py

A worked simulation-testing scenario built on top of harness.py: a customer
wants order A1092 cancelled because the event it was for moved to a date
they can no longer attend. The `cancel_order` tool times out on its first
call and succeeds on the second (retry), and the toy agent under test has
to notice the failure, tell the user, retry, and confirm success naming
the order id and the word "cancelled".

Run standalone:

    python src/order_cancellation_scenario.py

Requires the OPENAI_API_KEY environment variable to be set.
"""

from __future__ import annotations

import sys
from pathlib import Path
from 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_agent


def 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, orders


def _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 False


def 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.py

Every actor in the simulation harness is an LLM call, which means a single
run is not a reliable pass/fail signal. This suite demonstrates the two
disciplines 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 per
turn, plus an embeddings call for the semantic-similarity test) and are
therefore slower and more expensive than typical unit tests. Requires
OPENAI_API_KEY to be set. Run with:

    pytest tests/test_non_determinism.py -v
"""

from __future__ import annotations

import math
import os
import sys
from pathlib import Path

import pytest
from openai import OpenAI

sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))

from order_cancellation_scenario import run_scenario  # noqa: E402

requires_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 outcomes


def 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_key
def 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_key
def 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.

ToolWrapsBest forLock-in
LangWatchFull 3-actor harness, pytest-nativeTeams already tracing with LangWatchSDK-locked scenario format
MaximSimulation plus a metrics dashboardManaged metrics, less code ownershipPlatform-hosted, less control
RetellSimulated callers for voice agentsVoice agents specificallyVoice-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.

Related articles

A chatbot test pipeline moving from manual QA through scripted and semantic assertions into an automated CI gate that samples the model N times before allowing a merge

Chatbot Automation Testing: Why Assertions Fail

Chatbot automation testing that survives non-deterministic replies: the migration to a CI gate, n-run sampling, threshold gating, and real GitHub Actions YAML.

Ghost Inspector alternative concept: Quara the frog beside a cracked recorded-test snapshot next to a regenerating test path

Ghost Inspector Alternative: Recorder, Framework, or AI?

Looking for a Ghost Inspector alternative? Compare record-and-playback SaaS, code frameworks, and AI-agent-generated testing by approach, not just by tool.

Diagram showing AI-generated auth code without a baseline: an agent writes login code on one side, while expected auth behavior (valid login, rejected password, protected route redirect) must be defined explicitly on the other

How to Test the Auth Code an AI Agent Wrote

When an AI agent writes your authentication, there is no baseline for correct behavior. Here is how to test AI-generated code for the auth bugs that compile, pass review, and lock users out.

Split diagram showing code that compiles cleanly on the left and a broken login flow at runtime on the right, illustrating what AI code review cannot see

Why AI Code Review Misses Auth Bugs

AI code review catches structure and style. It cannot catch a dropped auth wrapper or broken login flow. Here is what code review misses and why E2E testing fills the gap.