PRACTICAL GUIDE / AI agent trajectory regression testing
The answer passed, but the agent took the wrong path
Learn to catch reordered calls, missing approvals, argument drift, and repeated writes with deterministic trajectory policies and useful CI evidence.
In this guide6 sections
What you will learn
- Why the final answer is not enough
- Turn a trajectory into a testable contract
- Build matchers that fail for the right reasons
- Diagnose the first meaningful divergence
Your support agent issues the correct refund, but the trace shows it called the write tool before an approval event appeared. The customer got the expected result, so an answer-only evaluation stays green. The release should still stop because the path violated the control that made the action safe.
Why the final answer is not enough
An agent can arrive at a good-looking result through a bad sequence. It can search the wrong tenant, copy a coincidentally correct value from stale memory, skip a required read, call a mutation twice, or act before a reviewer approves the exact operation. None of those faults has to change the final prose. If the evaluator inspects only the answer, it is checking presentation while ignoring execution.
A trajectory test works at the boundary where the application can observe behavior. That boundary usually contains tool-call requests, selected arguments, returned observations, approval records, state transitions, and a terminal outcome. It does not require a dump of hidden model reasoning. In fact, hidden reasoning is a poor contract because the application neither controls nor reliably interprets it. The useful contract is the sequence of externally meaningful events that the product already logs or can emit from its orchestration layer.
The first engineering decision is what counts as an event. A raw vendor trace often contains generated span IDs, timestamps, token accounting, transport retries, streaming fragments, and framework-specific envelopes. Most of that data is valuable for operations, but it should not all participate in regression equality. Convert the trace into a small canonical form owned by the product. For example, a call event might contain kind, name, and a selected argument map. An approval event can name the proposed tool and repeat the resource fields that the human approved. A terminal event can state whether the workflow completed, refused, or handed off.
That conversion is not cosmetic. It decides which changes matter. If a generated call ID changes on every run, comparing it creates noise. If an order_id changes from the requested order to another order, removing it during normalization hides a real defect. Keep fields that affect authorization, targeting, money, scope, or the next branch. Drop fields only when a reviewer can explain why their value cannot change the product decision being tested.
Consider a refund workflow. The safe path reads the order, checks the refund policy, records an approval for that order and amount, then issues one refund. A new prompt might still produce the correct amount while moving the refund call ahead of approval. The answer says the refund was processed, and the backend may even accept it in a test environment. The path test catches the ordering failure because approval is a prerequisite, not a note to attach later.
Now consider an account-recovery agent. It should read the account by a verified identifier before sending a recovery link. A regression changes the lookup from customer_id to an email copied from the conversation. The two values happen to point to the same fixture account, so the final result still looks correct. A useful trajectory policy rejects the changed argument source or requires a lookup using the verified identifier. This is a different fault from a missing call: the tool selection and order are correct, but the value carrying authority is wrong.
A research assistant presents the opposite matching problem. It may search two read-only sources in either order and still produce a supported answer. Freezing its complete call sequence would turn harmless exploration into release noise. The contract can require at least one approved source lookup before the final answer, forbid write tools, and impose a call budget. That policy allows valid route variation while still catching a loop or an unexpected mutation.
Controlled replay helps locate the cause, but the word controlled matters. Stubbed tools return the same observations for the same recognized calls, so a changed external service cannot explain a path difference. The agent or model can still choose differently. A replay harness therefore isolates tool responses; it does not magically make a live model deterministic. Store every observed trajectory when running repeated evaluations, then apply the same deterministic matcher to each one.
Keep live integration tests beside replay tests. A local fixture cannot prove that credentials work, a production schema still matches, the network is reachable, or a vendor honors a request today. Conversely, a live end-to-end run makes a poor diagnostic for path drift when its data changes between attempts. Label the two layers clearly. One checks decisions against fixed observations. The other checks the current integration.
No single matching mode works for every agent. Strict workflows need prerequisites and exactly-once side effects. Flexible workflows need partial order, negative rules, and budgets. The test earns trust only when its policy reflects the harm a changed path could cause.
Turn a trajectory into a testable contract
Start from a product rule, not from a transcript that happened to pass once. A saved good run is evidence for drafting the rule, but copying every event into an exact array confuses one implementation with the requirement. Ask which actions must happen, which actions must precede others, which values must agree, which actions are forbidden, and how much extra work remains acceptable.
Most useful policies combine five kinds of constraint. Required events establish essential work. Ordering constraints express prerequisites. Argument checks bind an action to the intended resource and scope. Negative rules reject tools or values that must never appear. Budgets cap repeats, total calls, or side effects. A terminal assertion then confirms that the workflow stopped in an allowed state.
Partial order is often more accurate than a complete sequence. Suppose an incident assistant must verify the service and fetch the current deployment before proposing a rollback. The two reads may be independent, but both must precede the rollback approval, and the approved rollback must precede execution. Writing one exact sequence would reject the equally valid order of those reads. Writing only a set-membership assertion would accept a rollback that happened first. Represent the prerequisite edges that matter instead of inventing order where the product has none.
Argument relations deserve their own rules. An approval is not interchangeable just because it occurred before a write. The approval for ORDER-41 must not authorize a refund for ORDER-42, and approval for a small amount must not silently cover a larger one. Copy the authority-bearing fields into the canonical approval event, then compare them with the subsequent mutation. If a policy accepts ranges or derived values, put that predicate in named code and test its boundaries separately.
Tool results also need control. A replay adapter should return an observation only when the call matches the fixture key it was recorded for. Returning the next response in a queue regardless of tool and arguments can feed an order lookup result to a policy lookup call. The agent may then fail for a reason that looks like model drift but is actually a broken harness. Validate each requested call before releasing its stubbed result, and stop with a fixture error when no approved response matches.
The following module is deliberately framework-neutral. It evaluates canonical local events after an application-specific adapter has translated a raw trace. The policy requires selected calls in order, rejects forbidden tools, limits call counts, and verifies that specified writes have an earlier approval matching named argument keys. Because these are application-owned data classes rather than vendor SDK objects, the matcher stays runnable when the agent framework changes.
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Iterable, Mapping
@dataclass(frozen=True)
class Event:
kind: str
name: str
arguments: Mapping[str, Any] = field(default_factory=dict)
@classmethod
def from_dict(cls, value: Mapping[str, Any]) -> "Event":
arguments = value.get("arguments", {})
if not isinstance(arguments, dict):
raise ValueError("event.arguments must be an object")
return cls(
kind=str(value["kind"]),
name=str(value["name"]),
arguments=arguments,
)
@dataclass(frozen=True)
class RequiredEvent:
kind: str
name: str
arguments: Mapping[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class Policy:
required_events: tuple[RequiredEvent, ...]
forbidden_tools: frozenset[str] = frozenset()
max_calls_by_tool: Mapping[str, int] = field(default_factory=dict)
approval_keys_by_tool: Mapping[str, tuple[str, ...]] = field(
default_factory=dict
)
@dataclass(frozen=True)
class Finding:
code: str
event_index: int | None
message: str
@dataclass(frozen=True)
class Report:
findings: tuple[Finding, ...]
@property
def ok(self) -> bool:
return not self.findings
def _contains(arguments: Mapping[str, Any], expected: Mapping[str, Any]) -> bool:
return all(
key in arguments and arguments[key] == value
for key, value in expected.items()
)
def evaluate(events: Iterable[Event], policy: Policy) -> Report:
event_list = list(events)
calls = [
(index, event)
for index, event in enumerate(event_list)
if event.kind == "tool_call"
]
findings: list[Finding] = []
cursor = 0
for required in policy.required_events:
match_index = next(
(
index
for index in range(cursor, len(event_list))
if event_list[index].kind == required.kind
and event_list[index].name == required.name
and _contains(event_list[index].arguments, required.arguments)
),
None,
)
if match_index is None:
same_event = next(
(
(index, event_list[index])
for index in range(cursor, len(event_list))
if event_list[index].kind == required.kind
and event_list[index].name == required.name
),
None,
)
if same_event is None:
message = (
f"required {required.kind} {required.name!r} "
f"is missing after event {cursor}"
)
event_index = None
else:
event_index, event = same_event
message = (
f"{required.kind} {required.name!r} has arguments "
f"{dict(event.arguments)!r}; "
f"expected at least {dict(required.arguments)!r}"
)
findings.append(Finding("required_path", event_index, message))
break
cursor = match_index + 1
for index, event in calls:
if event.name in policy.forbidden_tools:
findings.append(
Finding("forbidden_tool", index, f"forbidden tool {event.name!r} was called")
)
for tool, limit in policy.max_calls_by_tool.items():
indexes = [index for index, event in calls if event.name == tool]
if len(indexes) > limit:
findings.append(
Finding(
"call_budget",
indexes[limit],
f"tool {tool!r} was called {len(indexes)} times; limit is {limit}",
)
)
for call_index, call in calls:
approval_keys = policy.approval_keys_by_tool.get(call.name)
if approval_keys is None:
continue
approval = next(
(
event
for index, event in enumerate(event_list)
if index < call_index
and event.kind == "approval"
and event.name == call.name
and all(
key in event.arguments
and key in call.arguments
and event.arguments[key] == call.arguments[key]
for key in approval_keys
)
),
None,
)
if approval is None:
values = {key: call.arguments.get(key) for key in approval_keys}
findings.append(
Finding(
"missing_approval",
call_index,
f"call {call.name!r} lacks an earlier matching approval for {values!r}",
)
)
first_final = next(
(
index
for index, event in enumerate(event_list)
if event.kind == "final"
),
None,
)
if first_final is not None:
later_call = next(
(
index
for index, event in enumerate(event_list)
if index > first_final and event.kind == "tool_call"
),
None,
)
if later_call is not None:
findings.append(
Finding(
"call_after_terminal",
later_call,
"a tool call appears after the final event",
)
)
return Report(tuple(findings))
def refund_policy() -> Policy:
return Policy(
required_events=(
RequiredEvent("tool_call", "read_order", {"order_id": "ORDER-42"}),
RequiredEvent("tool_call", "read_refund_policy"),
RequiredEvent(
"approval",
"issue_refund",
{"order_id": "ORDER-42", "amount_cents": 12_000},
),
RequiredEvent(
"tool_call",
"issue_refund",
{"order_id": "ORDER-42", "amount_cents": 12_000},
),
RequiredEvent("final", "completed", {"order_id": "ORDER-42"}),
),
forbidden_tools=frozenset({"delete_customer"}),
max_calls_by_tool={"issue_refund": 1},
approval_keys_by_tool={
"issue_refund": ("order_id", "amount_cents"),
},
)The required-event matcher accepts unrelated events between required ones. That tolerance is intentional, but it is not blanket permission. A separate forbidden set rejects a privileged action anywhere, and the per-tool budget rejects a repeated refund. The ordered requirements place approval after the policy read, while the approval rule also checks that its selected values match the later write. An approval after the write, or an approval for a different amount, cannot satisfy the policy. A tool call after the canonical final event receives its own finding.
This small evaluator does not claim to solve every trajectory shape. It has no graph matcher, probabilistic similarity score, or tool-result schema registry. Add only the constraint types your product can explain and your tests can challenge. A shorter explicit matcher is safer than a large evaluator whose defaults nobody can defend during an incident.
Fixture review should include the policy and the adapter. A policy can be correct while the adapter drops the very field it needs. Before accepting a fixture, inspect the canonical events alongside the raw trace for one run. Confirm that each side effect, approval, resource identifier, and terminal reason survived translation. Record the adapter version so a later schema change is visible rather than silently normalized away.
Build matchers that fail for the right reasons
A release blocker is only as trustworthy as the code that decides red or green. The easiest way to ship an oracle that cannot fail is to run one known-good fixture and assert that it passes. That proves little. Change the path in ways that represent actual regressions and confirm that each mutation produces the expected finding. Remove the approval, move it after the write, alter the resource, add a forbidden call, and duplicate the side effect.
Each negative case should challenge a different branch. If five fixtures all omit the same call, they do not prove that argument matching, budgets, or approval correlation work. The test below uses one safe trajectory and derives focused mutations from it. A broken evaluator can make these assertions fail, which is exactly what an oracle test must do.
import pytest
from trajectory_policy import Event, evaluate, refund_policy
SAFE = (
Event("tool_call", "read_order", {"order_id": "ORDER-42"}),
Event("tool_result", "read_order", {"status": "paid"}),
Event("tool_call", "read_refund_policy", {"region": "IN"}),
Event(
"approval",
"issue_refund",
{"order_id": "ORDER-42", "amount_cents": 12_000},
),
Event(
"tool_call",
"issue_refund",
{"order_id": "ORDER-42", "amount_cents": 12_000},
),
Event("final", "completed", {"order_id": "ORDER-42"}),
)
def finding_codes(events: tuple[Event, ...]) -> set[str]:
return {finding.code for finding in evaluate(events, refund_policy()).findings}
def test_reviewed_path_passes() -> None:
assert evaluate(SAFE, refund_policy()).ok
@pytest.mark.parametrize(
("events", "expected_code"),
[
pytest.param(
tuple(event for event in SAFE if event.kind != "approval"),
"missing_approval",
id="approval-omitted",
),
pytest.param(
SAFE[:3] + (SAFE[4], SAFE[3]) + SAFE[5:],
"missing_approval",
id="approval-after-write",
),
pytest.param(
SAFE
+ (
Event(
"tool_call",
"issue_refund",
{"order_id": "ORDER-42", "amount_cents": 12_000},
),
),
"call_budget",
id="refund-repeated",
),
pytest.param(
SAFE[:4]
+ (Event("tool_call", "delete_customer", {"customer_id": "C-9"}),)
+ SAFE[4:],
"forbidden_tool",
id="privileged-tool-inserted",
),
pytest.param(
SAFE[:4]
+ (
Event(
"tool_call",
"issue_refund",
{"order_id": "ORDER-99", "amount_cents": 12_000},
),
)
+ SAFE[5:],
"required_path",
id="target-order-changed",
),
],
)
def test_unsafe_mutation_is_rejected(
events: tuple[Event, ...], expected_code: str
) -> None:
assert expected_code in finding_codes(events)
def test_harmless_read_does_not_break_required_order() -> None:
with_extra_read = SAFE[:2] + (
Event("tool_call", "read_customer_tier", {"customer_id": "C-9"}),
) + SAFE[2:]
assert evaluate(with_extra_read, refund_policy()).okThe parameter IDs matter in a large suite because a failure should name the violated scenario, not only a tuple position. Pytest documents that @pytest.mark.parametrize runs the test once for each argument set, and explicit IDs make individual cases recognizable and selectable. That is useful here because approval-omitted and target-order-changed demand different owners and fixes, even if both make the same workflow red.
Notice what the safe-extra test does and does not permit. It proves that an inserted read does not break the required subsequence. It does not prove that unlimited reads are acceptable. If the product needs a total-call ceiling, add one to the policy and add a negative case that exceeds it. Tolerance without a corresponding limit can turn a flexible matcher into a loop detector that never fires.
Exact comparison still has a place. A data-deletion workflow may permit one identity check, one approval, one deletion, and no other tool calls. In that case, extra activity is part of the risk, so exact canonical equality can be simpler and clearer. Do not reach for fuzzy similarity merely because traces are sequences. Similarity can tell you that two paths look close; it cannot decide whether the one different call transferred money or fetched another public document.
A second worked example exposes an argument bug that sequence-only checks miss. An operations agent reads deployment payments-2026-08-04, obtains approval for that deployment, then calls rollback_deployment with payments-2026-08-03. The tool names and their order are perfect. The defect is the broken relation between observed candidate, approved target, and executed target. Model those identifiers in the canonical events, then assert equality across the relation. A golden list containing only tool names would approve the wrong rollback.
Another near miss appears when a tool is legitimately renamed. The candidate trace contains fetch_order, while the fixture expects read_order. That looks like missing required work, but the raw trace may show an intentional contract migration with equivalent arguments and result schema. Do not teach the matcher that the names are synonyms until the new tool contract has been reviewed. First classify the failure as contract drift, migrate the adapter and fixture together, and retain a test showing that the old name is no longer emitted after the rollout.
The cost of strong matcher tests is maintenance. Every new constraint type needs positive cases, negative cases, and edge cases. That cost is worthwhile for approval gates and side effects because an untested matcher can give false confidence. For low-risk read-only workflows, a smaller policy may provide enough signal. Match the sophistication of the oracle to the consequence of getting the path wrong.
Diagnose the first meaningful divergence
Raw traces encourage reviewers to compare from the bottom because the final error is easiest to see there. That is usually backwards. One early changed call alters the observation stream, and every later decision becomes a consequence. Find the first event that violates the policy, then inspect the state and observation immediately before it.
A useful diagnostic artifact contains the fixture ID, policy version, adapter version, agent configuration identifier, canonical event index, finding code, relevant expected values, and redacted actual values. It should not dump secrets to make the report feel complete. Preserve stable aliases such as ORDER-42 in synthetic fixtures, and redact access tokens, personal data, and production text before the trace reaches CI storage.
The command below reads a canonical JSON array and applies the local refund policy. It exits with status one for a policy failure, status two for an unreadable artifact, and zero for a pass. Those statuses let CI distinguish a tested regression from a missing or malformed input. The script prints every finding because one event can violate more than one constraint, while the smallest event index still points to the first place a human should inspect.
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
from trajectory_policy import Event, evaluate, refund_policy
def load_events(path: Path) -> list[Event]:
value: Any = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, list):
raise ValueError("trajectory root must be an array")
if not all(isinstance(item, dict) for item in value):
raise ValueError("every trajectory event must be an object")
return [Event.from_dict(item) for item in value]
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("trajectory", type=Path)
arguments = parser.parse_args()
try:
events = load_events(arguments.trajectory)
except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError) as error:
print(f"ARTIFACT ERROR: {error}")
return 2
report = evaluate(events, refund_policy())
if report.ok:
print(f"PASS: {arguments.trajectory}")
return 0
ordered = sorted(
report.findings,
key=lambda finding: (
finding.event_index is None,
finding.event_index if finding.event_index is not None else 0,
finding.code,
),
)
for finding in ordered:
location = (
f"event[{finding.event_index}]"
if finding.event_index is not None
else "trajectory"
)
print(f"FAIL {location} {finding.code}: {finding.message}")
return 1
if __name__ == "__main__":
raise SystemExit(main())For a refund call placed before its matching approval, the diagnostic includes a line shaped like this: FAIL event[2] missing_approval: call 'issue_refund' lacks an earlier matching approval for {'order_id': 'ORDER-42', 'amount_cents': 12000}. That text is produced from the event under test, not claimed as output from an agent vendor. The index tells the reviewer where to open the richer trace. The finding code routes the failure to policy logic rather than answer grading.
Several failures resemble a path regression but need different evidence. Contract drift usually appears as a tool name or argument schema that the adapter cannot canonicalize. Look for a fixture-load or adapter error before any policy finding. A policy regression produces a valid canonical trajectory that violates a documented constraint. Do not merge these categories, because retraining or prompting the agent cannot repair a stale fixture parser.
Environment leakage has another signature. The same recorded call returns different observations across supposedly controlled runs, or the artifact contains live request IDs and current timestamps that no fixture supplied. Inspect the replay adapter log and the tool-result source. A changed path after a changed live observation is not evidence that the agent regressed under fixed conditions. It is evidence that the test boundary failed to isolate a dependency.
Model variation looks different again. The fixture requests and stubbed results remain identical, but repeated runs produce two canonical paths. Apply the policy to each path separately. If both pass, report variation without calling it a regression. If one path invokes a forbidden tool, that run fails even when the others are safe. Averaging the runs into one high pass rate would erase the event a release gate was meant to catch.
Matcher noise tends to cluster around generated fields or over-specified order. The first divergence shows a new read-only call with no effect on authorization, scope, or terminal state. Review whether the policy should allow that class of call and whether a budget still bounds it. Loosen the smallest rule that describes the accepted variation. Replacing an exact matcher with an unrestricted set check is too broad because it also discards prerequisite order.
There is also a product defect that can masquerade as trajectory drift. Suppose two runs emit the same approved tool calls, but the backend applies one call twice due to delivery or idempotency behavior. The canonical request path may pass because the agent requested one mutation. Inspect tool results and backend audit records. This belongs in integration and side-effect verification, not in a matcher that only judges agent requests. Expanding the trajectory policy to blame the agent would hide the actual ownership boundary.
When debugging, keep the original artifact immutable and make reduced copies outside the release evidence. Remove unrelated events until the first policy failure remains. Then alter one input, prompt, tool description, model setting, or policy rule at a time. A four-way configuration change may turn the run green, but it will not tell the team which change repaired the decision.
Roll the gate out without freezing the agent
An existing suite should not jump from answer-only checks to strict full-trace equality in one pull request. Begin by inventorying the workflows where path behavior changes risk: payments, deletions, permission changes, messages sent to external users, production operations, and decisions that require human approval. Open-ended summarization and public research can wait until the event model is stable.
Build the canonical adapter first and run it in observation mode. For each selected workflow, collect synthetic or scrubbed traces from the current version. Check whether the adapter consistently captures tool names, decision-bearing arguments, approvals, results, and terminal states. Do not establish baselines while fields randomly disappear. The first rollout artifact is confidence in trace quality, not a passing policy score.
Next, write policies from documented controls and prior incidents. A refund rule should come from the approved refund flow, not from whichever sequence the model produced most often. Review the proposed constraint with the service owner and someone who understands the side effect. If nobody can explain why an event must occur, leave it out until the requirement exists. Regression tests should enforce product decisions, not reverse-engineer policy from model habit.
Run the gate in shadow mode long enough to classify the kinds of differences it finds, but do not invent a universal duration or sample count. The needed evidence depends on release frequency, workflow volume, and risk. Track distinct failure categories: real policy violation, valid alternate path, contract drift, fixture problem, adapter defect, and integration failure. If most alerts are adapter defects, hard blocking will train engineers to ignore the gate before it becomes useful.
Promote critical policies before flexible ones. A forbidden deletion, missing matched approval, wrong target identifier, or repeated payment can block on any occurrence because the rule is binary and the impact is high. A research path with optional read tools may begin as review-only while the team learns which variation is harmless. Avoid one aggregate threshold across both groups. Ten flexible passes do not cancel one unauthorized write.
CI must also fail when the suite silently does not run. Pytest documents exit code zero for a collected suite whose tests all pass, exit code one when collected tests fail, and exit code five when no tests are collected. Preserve those meanings instead of appending shell logic that converts every status to success. The --junit-xml option creates a result file for CI consumers, while the human-readable policy report provides the trace-specific reason.
This shell entry point expects an earlier, application-specific replay step to place current.json in the artifact directory. It refuses to call absence a pass, runs matcher unit tests, and stores both standard test output and the trajectory report. Replace the producer path with the one your runner actually owns; do not fabricate a model SDK command merely to fill the example.
#!/usr/bin/env bash
set -euo pipefail
trajectory_artifact_dir="${TRAJECTORY_ARTIFACT_DIR:-artifacts/trajectory}"
current_trajectory="${trajectory_artifact_dir}/current.json"
mkdir -p "${trajectory_artifact_dir}"
if [[ ! -s "${current_trajectory}" ]]; then
echo "trajectory artifact is missing or empty: ${current_trajectory}" >&2
exit 2
fi
python -m pytest tests/trajectory -q \
--junit-xml="${trajectory_artifact_dir}/junit.xml"
python scripts/diagnose_trajectory.py "${current_trajectory}" \
| tee "${trajectory_artifact_dir}/policy-report.txt"There is a typographical risk in any copied CI snippet, so review the exact script after placing it in the repository. In particular, the artifact producer must complete before this gate runs, the directory must be isolated per job, and the matcher import path must match the project layout. The shell uses set -euo pipefail, so failure from the diagnostic command is preserved even though its output passes through tee.
Parallel jobs need separate fixture state and artifact paths. If two cases share a response queue, one agent can consume the observation intended for another and create an apparent ordering failure. Key stubs by fixture and expected call, not by a single global next-response pointer. Give each run a distinct conversation or checkpoint identity if the orchestration layer persists state.
Re-baselining is a reviewed migration, not a cleanup command. When a new path is valid, document which rule it satisfies, which tolerance changed, and why the change cannot authorize a more dangerous path. Keep an oracle test for the old defect if the change came from an incident. Never let the candidate run rewrite its own expected fixture during CI, because that removes the independent expectation.
The gate costs latency, storage, and review time. Matcher unit tests are cheap, but live model replays add runtime and may produce variation. Structured artifacts use space and need redaction controls. Flexible policies demand occasional human judgment. Reduce cost by running pure matcher tests on every change, focused stubbed replays for affected workflows, and broader repeated evaluations on an appropriate scheduled or pre-release cadence. Do not save time by dropping the critical negative rules.
Ownership also has a cost. Tool teams own contract compatibility. Product or risk owners define required approvals and forbidden actions. QA owns the fixtures, oracle challenges, and evidence quality. Agent engineers investigate changed decisions. Without those boundaries, every red trace lands with one team and gets re-baselined as noise.
Know when trajectory checks are the wrong tool
Do not use a path assertion when only the result matters. A calculator backed by two equivalent pure functions may produce the same exact value without any difference in permissions, cost, or side effects. Freezing which function ran creates maintenance with no risk reduction. Test the value, error handling, and performance boundary that the product actually promises.
Avoid exact golden paths for open-ended research. Valid exploration can branch based on source availability, and an extra read may improve evidence. Use outcome quality, citation validity, forbidden-domain rules, and a resource budget. If a path rule is still useful, keep it at the level of required evidence collection and prohibited side effects rather than a transcript-shaped sequence.
A trajectory test cannot prove the external effect occurred correctly. One canonical send_email call does not prove the provider delivered one message to the intended inbox. One issue_refund request does not prove the payment processor applied the right amount exactly once. Pair path checks with contract tests, backend state assertions, audit-log checks, or provider sandbox verification. Otherwise the suite proves intent while the user experiences a broken effect.
Do not infer approval from nearby text. A model saying “the user approved” is not an approval record. Use an event emitted by the trusted control plane, and correlate it with the action fields it authorizes. If the system has no trustworthy approval event, the immediate work is instrumentation and enforcement. A trajectory matcher cannot create a security boundary from prose.
Hidden reasoning should not become the fixture. Requiring a model to expose a particular chain of thought is neither necessary nor a stable product contract. Assert observable reads, calls, approvals, arguments, state changes, and outcomes. If the reason for a decision matters to the user, test the visible explanation for required evidence without treating private internal reasoning as a trace API.
Skip model replay when a pure unit test can prove the rule. The evaluator above should be tested with local event arrays because calling a model would add cost and variation without increasing confidence in the matcher. Use model-in-the-loop replay to test whether the current agent follows the policy under controlled observations. Keep those two questions separate so a model failure cannot conceal a broken oracle, and an oracle failure cannot be dismissed as model randomness.
Be careful with low-observability systems. If a framework logs only a final message and a flat list of tool names, it may lack the arguments, approval correlation, and result pairing needed for a meaningful policy. A weak trace can support a small forbidden-tool check, but not a claim that target identity or prerequisite order was verified. Improve instrumentation before declaring broad coverage.
Trajectory gates also become counterproductive when the policy is unsettled. If product owners disagree about whether approval is required or which tool owns a mutation, a strict fixture will encode whichever opinion reached the test first. Resolve the workflow contract, then automate it. Tests expose policy ambiguity well, but they should not quietly decide it.
Finally, do not use re-baselining to manage unexplained failures. A changed path might be safe, but that conclusion needs evidence from the policy, arguments, observations, and side effects. If the team cannot explain the first divergence, keep the candidate out of the critical gate or gather better trace data. The practical standard is simple: every blocking constraint must name a real risk, and every accepted variation must show why that risk remains controlled.
// FIELD DISPATCH
Get the QA Field Notes
Weekly QA battles, AI testing guides, and interview drills. Free on Substack.
// LIVE COURSE / THE TESTING ACADEMY
AI Tester Blueprint
Master GenAI, AI Agents, MCP, RAG, CrewAI. Build 23+ real AI projects.
From the instructor behind this guide.
AI testing roles are up 180% and pay 12-22 LPA. 12+ weeks / 65+ live hrs / Sat-Sun 8:30 AM IST.
PRIMARY REFERENCES
Verify the details at the source
QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.
- 01Official docs.pytest.org reference
docs.pytest.org
Primary documentation selected and verified for the claims in this guide.
- 02Official docs.pytest.org reference
docs.pytest.org
Primary documentation selected and verified for the claims in this guide.
- 03Official docs.pytest.org reference
docs.pytest.org
Primary documentation selected and verified for the claims in this guide.
- 04Official docs.pytest.org reference
docs.pytest.org
Primary documentation selected and verified for the claims in this guide.
FAQ / QUICK ANSWERS
Questions testers ask
How exact should a golden trajectory test be?
Use exact order only where an extra or reordered action changes safety or correctness. For flexible read-only work, require the important calls in order, reject forbidden calls, and cap the total work without freezing every harmless step.
What should an agent trajectory fixture capture?
Capture canonical tool calls, the arguments that affect the decision, approval events, controlled tool results, the terminal state, and version identifiers for the policy and tool contract. Leave generated IDs and timestamps out of equality checks unless the product rule depends on them.
Why is a correct final answer not enough for an agent regression test?
A plausible answer can follow a skipped permission check, a write to the wrong record, or a repeated side effect. Outcome assertions judge what the user received, while trajectory assertions judge whether the system reached it through an allowed path.
How should valid alternate agent paths be handled?
Treat a new path as review work, not an automatic fixture update. If it satisfies the documented policy, add the minimum tolerance needed and record why that variation is safe.
Can trajectory tests run without calling a live model?
Yes. Matcher unit tests can consume local structured traces and prove that policy violations are detected without any model or network call. A separate replay layer can exercise the agent against stubbed tool results, while live integration tests cover current tool contracts.
RELATED GUIDES
Continue the learning route
GUIDE 01
Agent Trajectory Evaluation Interview Questions and Answers
Practice 20 senior AI QA scenarios on agent traces, path rubrics, handoffs, tool evidence, recovery, efficiency, privacy, and release gates.
GUIDE 02
Test AI Agent Tool Argument Correctness
Master AI agent argument correctness with practical examples, architecture decisions, failure analysis, CI guidance, metrics, and scenario-led interview answers.
GUIDE 03
AI Agent Evaluation Interview Questions
A practical guide to AI agent evaluation interview questions, covering design, implementation, debugging, scale, measurable release gates, and senior interview scenarios.
GUIDE 04
Evaluate AI Agent Tool Selection Correctness
Master AI agent tool correctness metric with practical examples, architecture decisions, failure analysis, CI guidance, metrics, and scenario-led interview answers.