Files
optimclaw/tests/fixtures/llm_traces
b4b19738a8 Trajectory benchmarks and e2e trace test rig (#553)
* refactor: extract shared assertion helpers to support/assertions.rs

Move 5 assertion helpers from e2e_spot_checks.rs to a shared module.
Add assert_all_tools_succeeded and assert_tool_succeeded for eliminating
false positives in E2E tests.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: add tool output capture via tool_results() accessor

Extract (name, preview) from ToolResult status events in TestChannel
and TestRig, enabling content assertions on tool outputs.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: correct tool parameters in 3 broken trace fixtures

- tool_time.json: add missing "operation": "now" for time tool
- robust_correct_tool.json: same fix
- memory_full_cycle.json: change "path" to "target" for memory_write

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: add tool success and output assertions to eliminate false positives

Every E2E test that exercises tools now calls assert_all_tools_succeeded.
Added tool output content assertions where tool results are predictable
(time year, read_file content, memory_read content).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: capture per-tool timing from ToolStarted/ToolCompleted events

Record Instant on ToolStarted and compute elapsed duration on
ToolCompleted, wiring real timing data into collect_metrics() instead
of hardcoded zeros.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* refactor: add RAII CleanupGuard for temp file/dir cleanup in tests

Replace manual cleanup_test_dir() calls and inline remove_file() with
Drop-based CleanupGuard that ensures cleanup even if a test panics.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: add Drop impl and graceful shutdown for TestRig

Wrap agent_handle in Option so Drop can abort leaked tasks. Signal
the channel shutdown before aborting for future cooperative shutdown.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: replace agent startup sleep with oneshot ready signal

Use a oneshot channel fired in Channel::start() instead of a fixed
100ms sleep, eliminating the race condition on slow systems.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: replace fragile string-matching iteration limit with count-based detection

Use tool completion count vs max_tool_iterations instead of scanning
status messages for "iteration"/"limit" substrings.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: use assert_all_tools_succeeded for memory_full_cycle test

Remove incorrect comment about memory_tree failing with empty path
(it actually succeeds). Omit empty path from fixture and use the
standard assert_all_tools_succeeded instead of per-tool assertions.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* refactor: promote benchmark metrics types to library code

Move TraceMetrics, ScenarioResult, RunResult, MetricDelta, and
compare_runs() from tests/support/metrics.rs to src/benchmark/metrics.rs.
Existing tests use re-export for backward compatibility.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: add Scenario and Criterion types for agent benchmarking

Scenario defines a task with input, success criteria, and resource
limits. Criterion is an enum of programmatic checks (tool_used,
response_contains, etc.) evaluated without LLM judgment.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: add initial benchmark scenario suite (12 scenarios across 5 categories)

Scenarios cover tool_selection, tool_chaining, error_recovery,
efficiency, and memory_operations. All loaded from JSON with
deserialization validation test.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: add benchmark runner with BenchChannel and InstrumentedLlm

BenchChannel is a minimal Channel implementation for benchmarks.
InstrumentedLlm wraps any LlmProvider to capture per-call metrics.
Runner creates a fresh agent per scenario, evaluates success criteria,
and produces RunResult with timing, token, and cost metrics.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: add baseline management, reports, and benchmark entry point

- baseline.rs: load/save/promote benchmark results
- report.rs: format comparison reports with regression detection
- benchmark_runner.rs: integration test with real LLM (feature-gated)
- Add benchmark feature flag to Cargo.toml

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* style: apply cargo fmt to benchmark module

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat(benchmark): add multi-turn scenario types with setup, judge, ResponseNotContains

Add BenchScenario, Turn, TurnAssertions, JudgeConfig, ScenarioSetup,
WorkspaceSetup, SeedDocument types for multi-turn benchmark scenarios.
Add ResponseNotContains criterion variant. Add TurnAssertions::to_criteria()
converter for backward compat with existing evaluation engine.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat(benchmark): add JSON scenario loader with recursive discovery and tag filter

Add load_bench_scenarios() for the new BenchScenario format with recursive
directory traversal and tag-based filtering. Create 4 initial trajectory
scenarios across tool-selection, multi-turn, and efficiency categories.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat(benchmark): multi-turn runner with workspace seeding and per-turn metrics

Add run_bench_scenario() that loops over BenchScenario turns, seeds workspace
documents, collects per-turn metrics (tokens, tool calls, wall time), and
evaluates per-turn assertions. Add TurnMetrics to metrics.rs and
clear_for_next_turn() to BenchChannel.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat(benchmark): add LLM-as-judge scoring with prompt formatting and score parsing

Create judge.rs with format_judge_prompt, parse_judge_score, and judge_turn.
Wire into run_bench_scenario for turns with judge config -- scores below
min_score fail the turn.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat(benchmark): add CLI subcommand (ironclaw benchmark)

Add BenchmarkCommand with --tags, --scenario, --no-judge, --timeout,
--update-baseline flags. Wire into Command enum and main.rs dispatch.
Feature-gated behind benchmark flag.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat(benchmark): per-scenario JSON output with full trajectory

Add save_scenario_results() that writes per-scenario JSON files alongside
the run summary. Each scenario gets its own file with turn_metrics trajectory.
Update CLI to use new output format.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat(benchmark): add ToolRegistry::retain_only and wire tool filtering in scenarios

Add a retain_only() method to ToolRegistry that filters tools down to a
given allowlist. Wire this into run_bench_scenario() so that when a
scenario specifies a tools list in its setup, only those tools are
available during the benchmark run. Includes two tests for the new
method: one verifying filtering works and one verifying empty input
is a no-op.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat(benchmark): wire identity overrides into workspace before agent start

Add seed_identity() helper that writes identity files (IDENTITY.md,
USER.md, etc.) into the workspace before the agent starts, so that
workspace.system_prompt() picks them up. Wire it into
run_bench_scenario() after workspace seeding. Include a test that
verifies identity files are written and readable.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat(benchmark): add --parallel and --max-cost CLI flags

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(benchmark): use feature-conditional snapshot names for CLI help tests

Prevents snapshot conflicts between default (no benchmark) and
all-features (with benchmark) builds by using separate snapshot names
per feature set.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat(benchmark): parallel execution with JoinSet and budget cap enforcement

Replace sequential loop in run_all_bench() with parallel execution using
JoinSet + semaphore when config.parallel > 1. Add budget cap enforcement
that skips remaining scenarios when max_total_cost_usd is exceeded.
Track skipped count in RunResult.skipped_scenarios and display it in
format_report().

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat(benchmark): add tool restriction and identity override test scenarios

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* chore: fix formatting for Phase 3

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat(benchmark): add SkillRegistry::retain_only and wire skill filtering in scenarios

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat(benchmark): add --json flag for machine-readable output

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* ci: add GitHub Actions benchmark workflow (manual trigger)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* refactor(benchmark): remove in-tree benchmark harness, keep retain_only utilities

Move benchmark-specific code out of ironclaw in preparation for the
nearai/benchmarks trajectory adapter. This removes:

- src/benchmark/ (runner, scenarios, metrics, judge, report, etc.)
- src/cli/benchmark.rs and the Benchmark CLI subcommand
- benchmarks/ data directory (scenarios + trajectories)
- .github/workflows/benchmark.yml
- The "benchmark" Cargo feature flag

What remains:
- ToolRegistry::retain_only() and SkillRegistry::retain_only()
- Test support types (TraceMetrics, InstrumentedLlm) inlined into
  tests/support/ instead of re-exporting from the deleted module

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* docs: add README for LLM trace fixture format

Documents the trajectory JSON format, response types, request hints,
directory structure, and how to write new traces.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat(test): unify trace format around turns, add multi-turn support

Introduce TraceTurn type that groups user_input with LLM response steps,
making traces self-contained conversation trajectories. Add run_trace()
to TestRig for automatic multi-turn replay. Backward-compatible: flat
"steps" JSON is deserialized as a single turn transparently.

Includes all trace fixtures (spot, coverage, advanced), plan docs, and
new e2e tests for steering, error recovery, long chains, memory, and
prompt injection resilience.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(test): fix CI failures after merging main

- Fix tool_json fixture: use "data" parameter (not "input") to match
  JsonTool schema
- Fix status_events test: remove assertion for "time" tool that isn't
  in the fixture (only "echo" calls are used)
- Allow dead_code in test support metrics/instrumented_llm modules
  (utilities for future benchmark tests)

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* Working on recording traces and testing them

* feat(test): add declarative expects to trace fixtures, split infra tests

Add TraceExpects struct with 9 optional assertion fields (response_contains,
tools_used, all_tools_succeeded, etc.) that can be declared in fixture JSON
instead of hand-written Rust. Add verify_expects() and run_recorded_trace()
so recorded trace tests become one-liners.

Split trace infra tests (deserialization, backward compat) into
tests/trace_format.rs which doesn't require the libsql feature gate.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* refactor(test): add expects to all trace fixtures, simplify e2e tests

Add declarative expects blocks to all 19 trace fixture JSONs across
spot/, coverage/, advanced/, and root directories. Update all 8 e2e
test files to use verify_trace_expects() / run_and_verify_trace(),
replacing ~270 lines of hand-written assertions with fixture-driven
verification.

Tests that check things beyond expects (file content on disk, metrics,
event ordering) keep those extra assertions alongside the declarative
ones.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(test): adapt tests to AppBuilder refactor, fix formatting

Update test files to work with refactored TestRigBuilder that uses
AppBuilder::build_all() (removing with_tools/with_workspace methods).
Update telegram_check fixture to use tool_list instead of echo.
Fix cargo fmt issues in src/llm/mod.rs and src/llm/recording.rs.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* refactor(test): deduplicate support unit tests into single binary

Support modules (assertions, cleanup, test_channel, test_rig, trace_llm)
had #[cfg(test)] mod tests blocks that were compiled and run 12 times —
once per e2e test binary that declares `mod support;`. Extracted all 29
support unit tests into a dedicated `tests/support_unit_tests.rs` so they
run exactly once.

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* style: fix trailing newlines in support files

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* refactor(test): unify trace types and fix recorded multi-turn replay

Import shared types (TraceStep, TraceResponse, TraceToolCall, RequestHint,
ExpectedToolResult, MemorySnapshotEntry, HttpExchange*) from
ironclaw::llm::recording instead of redefining them in trace_llm.rs.

Fix the flat-steps deserializer to split at UserInput boundaries into
multiple turns, instead of filtering them out and wrapping everything
into a single turn. This enables recorded multi-turn traces to be
replayed as proper multi-turn conversations via run_trace().

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(test): fix CI failures - unused imports and missing struct fields

- Add #[allow(unused_imports)] on pub use re-exports in trace_llm.rs
  (types are re-exported for downstream test files, not used locally)
- Add `..` to ToolCompleted pattern in test_channel.rs to match new
  `error` and `parameters` fields

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(test): fix CI failures after merging main

- Add missing `error` and `parameters` fields to ToolCompleted
  constructors in support_unit_tests.rs
- Add `..` to ToolCompleted pattern match in support_unit_tests.rs
- Add #[allow(dead_code)] to CleanupGuard, LlmTrace impl, and
  TraceLlm impl (only used behind #[cfg(feature = "libsql")])

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* Adding coverage running script

* fix(test): address review feedback on E2E test infrastructure

- Increase wait_for_responses polling to exponential backoff (50ms-500ms)
  and raise default timeout from 15s to 30s to reduce CI flakiness (#1)
- Strengthen prompt_injection_resilience test with positive safety layer
  assertion via has_safety_warnings(), enable injection_check (#2)
- Add assert_tool_order() helper and tools_order field in TraceExpects
  for verifying tool execution ordering in multi-step traces (#3)
- Document TraceLlm sequential-call assumption for concurrency (#6)
- Clean up CleanupGuard with PathKind enum instead of shotgun
  remove_file + remove_dir_all on every path (#8)
- Fix coverage.sh: default to --lib only, fix multi-filter syntax,
  add COV_ALL_TARGETS option
- Add coverage/ to .gitignore
- Remove planning docs from PR

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address PR review - use HashSet in retain_only, improve skill test

- Use HashSet for O(N+M) lookup in SkillRegistry::retain_only and
  ToolRegistry::retain_only instead of linear scan
- Strengthen test_retain_only_empty_is_noop in SkillRegistry to
  pre-populate with a skill before asserting the no-op behavior

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(test): revert incorrect safety layer assertion in injection test

The safety layer sanitizes tool output, not user input. The injection
test sends a malicious user message with no tools called, so the safety
layer never fires. Reverted to the original test which correctly
validates the LLM refuses via trace expects. Also fixed case-sensitive
request hint ("ignore" -> "Ignore") to suppress noisy warning.

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: clean stale profdata before coverage run

Adds `cargo llvm-cov clean` before each run to prevent
"mismatched data" warnings from stale instrumentation profiles.

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* style: fix formatting in retain_only test

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: Illia Polosukhin <[email protected]>
2026-03-05 09:13:09 +00:00
..

LLM Trace Fixtures

Trace fixtures are JSON files that script LLM behavior for deterministic E2E testing. The TraceLlm provider (tests/support/trace_llm.rs) replays these canned responses in order, allowing tests to exercise the full agent loop -- tool dispatch, safety layer, context accumulation -- without calling a real LLM.

Traces can be hand-written or recorded from a live session using the RecordingLlm wrapper (src/llm/recording.rs). Recorded traces include additional fields (memory snapshots, HTTP exchanges, expected tool results) that enable fully deterministic replay.

Trace Format

A trace is a model name and a list of turns. Each turn pairs a user message with the LLM response steps that follow it.

{
  "model_name": "descriptive-name",
  "turns": [
    {
      "user_input": "Write hello to /tmp/test.txt",
      "steps": [
        {
          "response": {
            "type": "tool_calls",
            "tool_calls": [{ "id": "c1", "name": "write_file", "arguments": {"path": "/tmp/test.txt", "content": "hello"} }],
            "input_tokens": 60, "output_tokens": 20
          }
        },
        {
          "response": {
            "type": "text",
            "content": "Done, wrote hello to the file.",
            "input_tokens": 80, "output_tokens": 15
          }
        }
      ]
    },
    {
      "user_input": "Actually, change it to goodbye instead",
      "steps": [
        {
          "response": {
            "type": "tool_calls",
            "tool_calls": [{ "id": "c2", "name": "write_file", "arguments": {"path": "/tmp/test.txt", "content": "goodbye"} }],
            "input_tokens": 100, "output_tokens": 20
          }
        },
        {
          "response": {
            "type": "text",
            "content": "Updated the file to say goodbye.",
            "input_tokens": 120, "output_tokens": 15
          }
        }
      ]
    }
  ]
}

TestRig::run_trace() drives the entire conversation automatically -- no test code needed to send user messages.

Legacy flat format

For backward compatibility, traces with a top-level "steps" array (no "turns") are accepted. They are deserialized as a single turn with a placeholder user message. Existing fixtures work unchanged; test code provides the user message via rig.send_message().

{
  "model_name": "descriptive-name",
  "memory_snapshot": [
    { "path": "context/vision.md", "content": "..." }
  ],
  "http_exchanges": [
    {
      "request": { "method": "GET", "url": "https://api.example.com/data", "headers": [], "body": null },
      "response": { "status": 200, "headers": [], "body": "{\"result\": 42}" }
    }
  ],
  "steps": [
    { "response": { "type": "text", "content": "Hello", "input_tokens": 10, "output_tokens": 5 } },
    {
      "response": { "type": "user_input", "content": "What time is it?" }
    },
    {
      "request_hint": {
        "last_user_message_contains": "optional substring",
        "min_message_count": 1
      },
      "expected_tool_results": [
        { "tool_call_id": "call_time_1", "name": "time", "content": "14:30:00" }
      ],
      "response": { "..." }
    }
  ]
}

Top-level fields

Field Type Required Description
model_name string yes Identifier returned by LlmProvider::model_name(). Convention: {category}-{scenario} (e.g. spot-smoke-greeting, advanced-tool-error-recovery).
turns array yes* List of turns. Each turn has user_input (string) and steps (array of response steps).
memory_snapshot array no Workspace memory documents captured before the recording session. Replay should restore these before running the trace. Each entry has path (string) and content (string).
http_exchanges array no HTTP request/response pairs recorded during the session, in order. During replay, the ReplayingHttpInterceptor returns these instead of making real HTTP requests.
expects object no Declarative expectations verified after replay. See Expects fields.

*Or steps for the legacy flat format (deserialized as a single turn with a placeholder user message). Legacy steps are ordered: each complete() or complete_with_tools() call consumes the next text/tool_calls step. user_input steps are metadata markers and must be skipped during replay. If LLM calls exceed the number of playable steps, TraceLlm returns an error.

Turn fields

Field Type Required Description
user_input string yes The user message that starts this turn.
steps array yes Ordered list of LLM response steps for this turn.
expects object no Per-turn expectations. Same schema as top-level expects.

Step fields

Field Type Required Description
request_hint object no Soft validation against the incoming request. Mismatches log a warning but do not fail the call.
response object yes The canned response for this step.
expected_tool_results array no Tool results that appeared in the message context since the previous step. During replay, the test harness can compare actual Role::Tool messages against these to verify tool output hasn't changed (regression detection). Each entry has tool_call_id, name, and content.

Request hints

Field Type Description
last_user_message_contains string Asserts the last Role::User message contains this substring.
min_message_count integer Asserts the message list has at least this many entries.

Hints are intentionally soft -- they help catch wiring mistakes during test development without making traces brittle.

Determinism requirement

Trace fixtures must produce deterministic results across runs. Do not use tools whose output varies by time or environment state. Specifically:

Avoid:

  • time -- output changes every run
  • list_dir on directories not created by the trace itself
  • shell with commands that depend on system state (e.g. date, ps, ls /var)
  • http -- external endpoints may change or be unavailable
  • memory_search unless the trace writes the memory entry first

Prefer:

  • echo -- always returns its input
  • json -- deterministic parsing/formatting
  • write_file + read_file -- self-contained if the trace writes first
  • memory_write + memory_read -- deterministic if the trace writes first
  • shell with deterministic commands (e.g. echo "hello", printf)

When a trace needs to exercise a stateful tool (like list_dir), have an earlier step create the expected state (e.g. write_file to create the directory contents first).

Response types

Responses are tagged via the type field.

text -- plain text completion

{
  "type": "text",
  "content": "The capital of France is Paris.",
  "input_tokens": 40,
  "output_tokens": 10
}

Returns a CompletionResponse / ToolCompletionResponse with no tool calls and FinishReason::Stop. If complete() is called (not complete_with_tools()), this is the only valid response type.

tool_calls -- one or more tool invocations

{
  "type": "tool_calls",
  "tool_calls": [
    {
      "id": "call_write_1",
      "name": "write_file",
      "arguments": { "path": "/tmp/test.txt", "content": "hello" }
    }
  ],
  "input_tokens": 80,
  "output_tokens": 25
}

Returns a ToolCompletionResponse with FinishReason::ToolUse. The agent loop executes the tool calls against real tool implementations, feeds the results back as tool-result messages, then calls the LLM again (consuming the next step).

Important: tool_calls steps cause real tool execution. The tools run against the actual tool registry, so side effects (file writes, memory operations) happen for real. This is what makes these E2E tests -- the only mock is the LLM itself.

Field Type Description
id string Unique call ID. Convention: call_{tool}_{n}.
name string Must match a registered tool name (e.g. echo, write_file, read_file, memory_write, shell).
arguments object Tool parameters as JSON. Must conform to the tool's parameters_schema().

user_input -- user message marker (recording only)

{
  "type": "user_input",
  "content": "What time is it?"
}

A metadata marker recording what the user said. This does not correspond to an LLM call. During replay, TraceLlm must skip user_input steps and only consume text/tool_calls steps. These steps are emitted by RecordingLlm when it detects new Role::User messages between LLM calls.

Token counts

Every text and tool_calls response includes input_tokens and output_tokens. These are synthetic values for cost tracking -- set them to reasonable estimates for your scenario. user_input steps do not have token counts.

Expected tool results

When present on a step, expected_tool_results lists the tool output that appeared in the message context before this LLM call. Each entry has:

Field Type Description
tool_call_id string The id of the tool call that produced this result.
name string The tool name.
content string The full tool result content as it appeared in the message context.

During replay, after tools execute and before returning the canned LLM response, the test harness should compare actual tool results against these entries. A content mismatch indicates a tool behavior change (regression).

Expects fields

The expects object can appear at the top level (whole trace) or per-turn. All fields are optional; traces without expects work unchanged.

Field Type Description
response_contains string[] Each must appear in response (case-insensitive).
response_not_contains string[] None may appear in response.
response_matches string Regex that must match response.
tools_used string[] Each tool name must appear in started calls.
tools_not_used string[] None of these may appear.
all_tools_succeeded bool If true, all tools must succeed.
max_tool_calls usize Upper bound on tool call count.
min_responses usize Minimum response count.
tool_results_contain map<string,string> Tool result preview must contain substring.

Example (top-level):

{
  "model_name": "recorded-telegram-check",
  "expects": {
    "response_contains": ["Telegram", "connected"],
    "tools_used": ["echo"],
    "all_tools_succeeded": true,
    "tool_results_contain": { "echo": "Checking telegram" },
    "min_responses": 1
  },
  "steps": [ ... ]
}

Example (per-turn):

{
  "model_name": "multi-turn-example",
  "turns": [
    {
      "user_input": "say hello",
      "expects": { "response_contains": ["hello"], "tools_not_used": ["shell"] },
      "steps": [ ... ]
    }
  ]
}

run_recorded_trace("filename.json") in test code loads the fixture, builds a rig, replays, verifies all expects, and shuts down -- turning recorded trace tests into one-liners.

What gets mocked vs. what runs for real

Component Mocked? Notes
LLM responses Yes TraceLlm replays canned responses from the trace
Tool execution No Real tools run: file I/O, memory ops, shell commands all execute
Outgoing HTTP (from tools) Depends Mocked when http_exchanges present and ReplayingHttpInterceptor is wired; real otherwise
Memory/workspace Depends Pre-seeded from memory_snapshot if present; real workspace operations otherwise
Safety layer No Sanitizer, validator, policy, leak detector all run
Context/message accumulation No Messages accumulate naturally across turns
Token counting Partial Uses synthetic counts from the trace

Directory structure

llm_traces/
  simple_text.json          # Minimal single-turn text response
  file_write_read.json      # Write then read a file
  memory_write_read.json    # Memory write then text confirmation
  error_path.json           # Tool call with missing params, then recovery
  spot/                     # Quick smoke tests (1-3 steps each)
    smoke_greeting.json     # Simple greeting, no tools
    smoke_math.json         # Math question, no tools
    robust_no_tool.json     # Factual question, no tools
    tool_echo.json          # Single echo tool call + confirmation
    tool_json.json          # JSON parse tool call + confirmation
    chain_write_read.json   # Write file -> read file -> confirm
    memory_save_recall.json # Memory write -> memory search -> confirm
    robust_correct_tool.json
  coverage/                 # Broader tool and feature coverage
    shell_echo.json         # Shell command execution
    list_dir.json           # Directory listing
    apply_patch_chain.json  # File patching workflow
    json_operations.json    # JSON tool usage
    injection_in_echo.json  # Prompt injection in tool output
    memory_full_cycle.json  # Full memory write/search/read cycle
    status_events_tool_chain.json
  advanced/                 # Multi-step and edge-case scenarios
    long_tool_chain.json    # Many sequential tool calls
    tool_error_recovery.json # Failed tool call -> retry with valid path
    multi_turn_memory.json  # Memory across multiple turns
    steering.json           # User steering: correct agent mid-conversation
    workspace_search.json   # Workspace search workflows
    prompt_injection_resilience.json
    iteration_limit.json    # Tests agent loop iteration bounds

Writing a new trace

  1. Pick a category: spot/ for quick smoke tests, coverage/ for tool/feature coverage, advanced/ for complex multi-step scenarios.

  2. Name the model: Use {category}-{scenario} (e.g. spot-tool-echo, coverage-shell-echo).

  3. Script the conversation: Think through the turn sequence. Each LLM call is one step. After a tool_calls step, the agent executes the tools and calls the LLM again with the results -- that's the next step.

  4. Add request hints on the first step of each turn (at minimum) to catch wiring issues. Later steps often omit hints since the message content depends on tool output.

  5. End each turn with a text step so the agent has a final response to return.

Example -- single-turn trace:

{
  "model_name": "spot-tool-echo",
  "turns": [
    {
      "user_input": "Please echo hello for me",
      "steps": [
        {
          "request_hint": { "last_user_message_contains": "echo" },
          "response": {
            "type": "tool_calls",
            "tool_calls": [{ "id": "call_echo_1", "name": "echo", "arguments": { "message": "hello" } }],
            "input_tokens": 60, "output_tokens": 20
          }
        },
        {
          "response": {
            "type": "text",
            "content": "The echo tool returned: hello",
            "input_tokens": 80, "output_tokens": 15
          }
        }
      ]
    }
  ]
}

Example -- multi-turn steering:

{
  "model_name": "advanced-steering",
  "turns": [
    {
      "user_input": "Write hello to /tmp/test.txt",
      "steps": [
        {
          "response": {
            "type": "tool_calls",
            "tool_calls": [{ "id": "c1", "name": "write_file", "arguments": {"path": "/tmp/test.txt", "content": "hello"} }],
            "input_tokens": 60, "output_tokens": 20
          }
        },
        { "response": { "type": "text", "content": "Done.", "input_tokens": 80, "output_tokens": 5 } }
      ]
    },
    {
      "user_input": "Actually, change it to goodbye",
      "steps": [
        {
          "response": {
            "type": "tool_calls",
            "tool_calls": [{ "id": "c2", "name": "write_file", "arguments": {"path": "/tmp/test.txt", "content": "goodbye"} }],
            "input_tokens": 100, "output_tokens": 20
          }
        },
        { "response": { "type": "text", "content": "Updated.", "input_tokens": 120, "output_tokens": 5 } }
      ]
    }
  ]
}

TraceLlm API

The provider exposes inspection methods for test assertions:

let llm = TraceLlm::from_file("tests/fixtures/llm_traces/spot/tool_echo.json")?;

// ... run agent loop ...

assert_eq!(llm.calls(), 2);              // Total LLM calls made
assert_eq!(llm.hint_mismatches(), 0);     // Request hint failures
let reqs = llm.captured_requests();       // Vec<Vec<ChatMessage>> of all requests

TestRig::run_trace()

For traces with multiple turns, run_trace() drives the entire conversation automatically:

let trace = LlmTrace::from_file("tests/fixtures/llm_traces/advanced/steering.json")?;
let rig = TestRigBuilder::new()
    .with_trace(trace.clone())
    .with_tools(tools_with_file_support())
    .build()
    .await;

// Sends each turn's user_input, waits for response, accumulates results.
let all_responses = rig.run_trace(&trace, Duration::from_secs(15)).await;

assert!(!all_responses[0].is_empty(), "Turn 1: no response");
assert!(!all_responses[1].is_empty(), "Turn 2: no response");

For legacy flat traces or when you need fine-grained control, use send_message() + wait_for_responses() directly.

Recording traces from live sessions

Instead of hand-writing traces, you can record them from a real LLM session using the RecordingLlm wrapper (src/llm/recording.rs). This captures everything needed for deterministic replay: user inputs, LLM responses, memory state, HTTP exchanges, and tool results.

Environment variables

Variable Required Default Description
IRONCLAW_RECORD_TRACE yes Set to any non-empty value to enable recording.
IRONCLAW_TRACE_OUTPUT no ./trace_{timestamp}.json Output file path for the recorded trace.
IRONCLAW_TRACE_MODEL_NAME no recorded-{model} The model_name field in the trace JSON.

Usage

# Record a trace (writes to ./trace_20260304T120000.json)
IRONCLAW_RECORD_TRACE=1 cargo run

# Custom output path
IRONCLAW_RECORD_TRACE=1 IRONCLAW_TRACE_OUTPUT=my_trace.json cargo run

# Custom model name
IRONCLAW_RECORD_TRACE=1 IRONCLAW_TRACE_MODEL_NAME=regression-auth-flow cargo run

Run the agent normally, interact with it, then quit. The trace file is written on shutdown.

What gets recorded

  1. Memory snapshot -- all workspace documents are captured before the agent starts, saved in memory_snapshot.
  2. User inputs -- new Role::User messages detected between LLM calls are emitted as user_input steps.
  3. LLM responses -- every complete()/complete_with_tools() response is saved as a text or tool_calls step with request_hint.
  4. Tool results -- new Role::Tool messages between LLM calls are captured in expected_tool_results on the next step.
  5. HTTP exchanges -- all outgoing HTTP requests from tools are recorded via the HttpInterceptor and saved in http_exchanges.

Using a recorded trace for replay

A recorded trace is a superset of the hand-written format. To use it:

  1. The replay provider (TraceLlm) must skip user_input steps -- they are metadata markers, not LLM responses.
  2. If memory_snapshot is present, restore workspace documents before running the trace.
  3. If http_exchanges is present, wire a ReplayingHttpInterceptor into JobContext.http_interceptor so tools get pre-recorded HTTP responses instead of making real requests.
  4. If expected_tool_results is present on a step, compare actual tool output against recorded values before returning the canned LLM response.

Example recorded trace

{
  "model_name": "recorded-claude-3-5-sonnet",
  "memory_snapshot": [
    { "path": "context/vision.md", "content": "# Vision\nBuild a secure AI assistant." }
  ],
  "http_exchanges": [
    {
      "request": { "method": "GET", "url": "https://api.example.com/time" },
      "response": { "status": 200, "body": "{\"time\": \"14:30\"}" }
    }
  ],
  "steps": [
    {
      "response": { "type": "user_input", "content": "What time is it?" }
    },
    {
      "request_hint": { "last_user_message_contains": "What time is it?", "min_message_count": 2 },
      "response": {
        "type": "tool_calls",
        "tool_calls": [
          { "id": "call_http_1", "name": "http", "arguments": { "url": "https://api.example.com/time" } }
        ],
        "input_tokens": 60,
        "output_tokens": 20
      }
    },
    {
      "request_hint": { "min_message_count": 4 },
      "expected_tool_results": [
        { "tool_call_id": "call_http_1", "name": "http", "content": "{\"status\":200,\"body\":{\"time\":\"14:30\"}}" }
      ],
      "response": {
        "type": "text",
        "content": "The current time is 2:30 PM.",
        "input_tokens": 80,
        "output_tokens": 15
      }
    }
  ]
}

Backward compatibility

Recorded traces are backward-compatible with hand-written traces. All new fields (memory_snapshot, http_exchanges, expected_tool_results, user_input steps) are optional and default to empty. Existing hand-written traces work unchanged.