Files
optimclaw/tests/fixtures/llm_traces/README.md
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

523 lines
21 KiB
Markdown

# 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.
```json
{
"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()`.
```json
{
"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](#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
```json
{
"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
```json
{
"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)
```json
{
"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):
```json
{
"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):
```json
{
"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:
```json
{
"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:
```json
{
"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:
```rust
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:
```rust
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
```bash
# 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
```json
{
"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.