* test(replay): promote engine replay traces to insta-backed snapshot gate Adds a ReplayOutcome snapshot type, a replay-gate CI workflow, and a developer script wrapper for cargo-insta. Replaces unreviewable 3,000-line JSON diffs on engine changes with a YAML snapshot of the observable run shape (tool sequence, final state, retrospective analyzer issues). Why: engine v2 live-fixture traces had grown past reviewability. A single prompt-wording change could move the whole fixture, and reviewers had no way to see which behaviour actually changed. Splitting the fixture into a "replay driver" (JSON stays in tests/fixtures/) and a "regression snapshot" (YAML in tests/snapshots/) gives reviewers a narrow, stable diff to approve, while keeping the full recorded context for deterministic replay. Changes: - `tests/support/replay_outcome.rs` — ReplayOutcome + assert_replay_snapshot! macro; snapshots include retrospective analyzer output (TraceIssue severity/category) via a new `ironclaw::bridge::engine_retrospectives_for_test()` helper that runs `build_trace()` over engine threads - `tests/e2e_engine_v2.rs` — three POC snapshot tests (single_tool_echo, tool_error_recovery, zizmor_scan_v2) - `tests/e2e_bug_bash_snapshots.rs` + `tests/fixtures/llm_traces/bug_bash/` — bug-regression fixture template, mapped to open issues in the README - `.github/workflows/replay-gate.yml` — cargo insta test --check on engine/agent/LLM/tools/bridge path changes; rejects committed .snap.new - `scripts/replay-snap.sh` — review/accept/test/record wrappers around cargo-insta and IRONCLAW_RECORD_TRACE - `scripts/trace-coverage.sh` — reports EventKind variants with snapshot coverage; `--strict` mode for future CI promotion - `tests/e2e_live.rs` — `#[ignore]` swapped for `cfg_attr(not(feature="replay"), ignore)` so the replay CI job can run the scenarios without `-- --ignored` - `Cargo.toml` — new `replay = ["libsql"]` feature; insta gains the `yaml` feature - `tests/fixtures/llm_traces/README.md` — documents the two-role driver/snapshot split Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(replay): address PR #2621 review + swap cargo-insta installer Review fixes: - Replay gate was missing the bug-bash snapshot suite. Adds `tests/e2e_bug_bash_snapshots.rs` to the workflow paths trigger and the `cargo insta test --check` invocation so bug-regression snapshots are actually gated. (copilot-pull-request-reviewer) - `cargo install cargo-insta --locked` added ~40s of cold-cache compile to the gate. Swapped for `taiki-e/install-action@v2`, which downloads a precompiled binary in a few seconds. Also updated `scripts/replay-snap.sh` to *fail closed* when cargo-insta is missing instead of silently auto-installing it. (gemini-code-assist) - `engine_retrospectives_for_test` was `pub` and re-exported under the default-enabled `libsql` feature, contradicting its "not part of any public API" doc. Split the re-export, kept `reset_engine_state` as a plain `pub use`, and hid `engine_retrospectives_for_test` behind `#[doc(hidden)]` — it still needs to cross the crate boundary for integration tests (which live in a separate crate, so `#[cfg(test)]` doesn't reach them), but no longer appears in published docs. (copilot-pull-request-reviewer) - Added an explicit "caller must serialize" note on `engine_retrospectives_for_test` explaining the `ENGINE_STATE` singleton and pointing new callers at `engine_v2_test_lock()` / `reset_engine_state()`. Matches what the existing snapshot tests already do. (gemini-code-assist) Doc corrections: - `snapshot_zizmor_scan_v2` doc claimed the snapshot pinned `ApprovalNeeded` events and response wording — it doesn't. Rewrote to describe what the snapshot actually asserts (tool order, step count, retrospective issues, final state). (copilot-pull-request-reviewer) - `llm_call_count` was documented as "bucketed" but passed through verbatim. Updated the field doc to reflect the raw value. Bucketing wasn't needed because fixtures are deterministic. (copilot-pull-request-reviewer) - `src/bridge/router.rs` doc referenced a non-existent `ReplayOutcome.trace_issues` field — the struct uses `engine_threads`. Fixed the reference. (copilot-pull-request-reviewer) - `scripts/trace-coverage.sh` header claimed CI runs it with `--strict`; the workflow runs it in advisory mode. Rewrote the header to match, with a pointer for when to promote to strict. (copilot-pull-request-reviewer) No-change replies (rationale commented in the code): - `event_kind_name` uses an exhaustive `match` on `EventKind` rather than `Debug` or a `strum` derive. The compile-time exhaustiveness check is the point — adding a new engine event should force a conscious decision about how the snapshot represents it, not a silent fallthrough. Added a comment making that intent explicit. - `trace-coverage.sh` awk parser of `event.rs` is fragile — agreed, but the script is advisory and its failure mode is false negatives (uncovered variants simply aren't gated). Documented the tradeoff and the rewrite-in-Rust escape hatch in the script header. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci(replay-gate): prime cache on staging, restrict PR runs to read-only The second run on PR #2621 missed the cache ("No cache found" in the rust-cache restore step) even though the workflow is wired correctly. Root cause: the repo sits close to GitHub's 10 GB per-repo cache quota (~59 entries, many >500 MB), and the LRU policy evicts PR-scoped caches before they get reused. Fix: - Add `push: [staging, main]` so the gate runs (and saves a ~1.2 GB cache under the `replay-gate` key) on every merge to the branches PRs actually target. Subsequent PRs restore from that base-branch cache — GitHub Actions permits cross-ref restore when the restoring ref's base matches the saved ref. - Set `save-if: ${{ github.event_name == 'push' }}` so PR runs only *read* the cache. Without this gate, each PR push would save its own copy and crowd out the primed base-branch cache, putting us right back in the eviction loop. Expected effect: cold-cache 9m → warm ~2-3m once staging has a run with the new workflow. Base-branch prime run still pays 9m (no regression). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(replay): drop bug-bash fixture scaffolding Replay fixtures can't reproduce the Phase 3 target bugs because the fixture *is* the LLM's output — handwriting a trace where the LLM emits a tool call doesn't test whether the real LLM would have emitted that call, only that the harness dispatches a scripted one. What `summarization_uses_tools.json` actually pinned was the happy path, not the #2541 bug. Of the 7 open bug-bash issues, only #2544 ("plans and delegates but never executes") is catchable by replay, and only via a live-recorded fixture. The other six are LLM-behavior or infra-timing bugs outside replay's reach. Rather than ship regression theater, tear out the scaffolding. Removed: - tests/e2e_bug_bash_snapshots.rs - tests/fixtures/llm_traces/bug_bash/ - tests/snapshots/replay__bug_bash_summarization_uses_tools.snap Unwired: - Replay-gate workflow paths + test list no longer mention bug_bash - scripts/replay-snap.sh test command drops the extra --test flag Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci: switch to cargo-nextest with per-test timeouts Nextest runs each integration test in its own process and runs test binaries in parallel, which is a big unlock for this repo: - Engine v2 tests share a process-global `ENGINE_STATE` singleton (OnceLock), which the current test lock serialises inside a single test binary. Nextest's process-per-test model gives each test a clean state automatically, so the 16 engine_v2 tests stop running one-by-one. - Cross-binary parallelism: `cargo test --test A --test B` runs binaries in sequence; nextest runs them concurrently. Measured locally: the replay-gate test set (3 binaries, 21 tests) went from ~30s sequential to **2.7s parallel**. Adds `.config/nextest.toml` with: - `slow-timeout = 60s / terminate-after 3` in the default profile so a hung test fails fast instead of blocking the workflow-level 25- minute cap. - A `ci` profile with `fail-fast = false` (one flake shouldn't mask other failures), `failure-output = immediate-final`, `success-output = never` for readable Actions logs. - Per-test 300s override for the handful of genuinely slow scenarios (zizmor scan, e2e_thread_scheduling). Workflows updated: - `replay-gate.yml`: installs cargo-nextest via taiki-e/install-action alongside cargo-insta (one step), runs `cargo insta test --test-runner nextest` with `NEXTEST_PROFILE=ci`. - `test.yml`: all five `cargo test` invocations swapped for `cargo nextest run --profile ci`. Nextest doesn't execute doctests, so every nextest step is paired with a `cargo test --doc` follow-up to preserve coverage. Local dev is unchanged — `cargo test` still works; nextest is only required in CI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci: re-trigger replay-gate workflow after nextest migration Previous push only modified workflow files and `.config/nextest.toml`; GitHub skipped the `pull_request` workflow events for that sync, so the nextest migration didn't actually get exercised in CI. Empty commit forces re-evaluation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(replay): note nextest wiring in the fixtures README Also forces a CI re-run: the previous empty commit had no matching paths, so the `pull_request.paths` filters skipped every workflow including replay-gate. Touching a file under `tests/fixtures/llm_traces/**` re-matches the filter and runs the nextest-based gate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci(test): defer test.yml nextest migration Staging restructured test.yml significantly while this PR was open (matrix-config dynamic matrix, `changes` code-detection job, composite install-cargo-component action, save-if restricted to base-branch pushes). The merge into staging had heavy conflicts for every nextest-swap hunk. Rather than force a re-layering of the new staging structure on top of the nextest migration in this PR, revert test.yml to staging's current version. This PR now scopes the nextest change to just the replay-gate workflow (where it cleanly demonstrates the value) plus the shared `.config/nextest.toml` profile. Migrating the rest of test.yml to nextest is a follow-up that can rebase on the new structure without the heavy conflict surface. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Henry Park <henrypark133@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
23 KiB
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.
Two files per scenario: driver + regression snapshot
A trace plays two distinct roles, owned by two different files:
| Role | File | Owner | When it changes |
|---|---|---|---|
| Replay driver | .json under this directory |
RecordingLlm / hand-written |
Only when you re-record from a new live session |
| Regression snapshot | tests/snapshots/replay__<name>.snap |
insta via ReplayOutcome |
Every time engine dispatch, tools, or safety changes observable output |
The JSON encodes what the LLM would have said. It's a stub, not a contract, and it lives in this directory. The .snap encodes what the agent actually did with that response (tool order, final state, retrospective issues) — it's the reviewable contract, lives in tests/snapshots/, and is what reviewers diff on every PR touching engine code.
Editing the JSON without re-generating the snapshot is a code smell: it means the regression the snapshot was pinning moved. Run cargo insta review to inspect and accept the drift.
When to touch which
- Prompt-wording change in the engine: usually no JSON change, often a snapshot drift — normal, review and accept.
- Tool sequencing change: JSON stays the same; snapshot drifts to reflect the new order.
- LLM provider switch (new model, new backend): re-record fixtures; snapshots may or may not drift depending on how the new model routes.
- Recording a fresh fixture:
scripts/replay-snap.sh record <name>drives both.
Developer ergonomics
scripts/replay-snap.sh review # interactive diff review (cargo insta review)
scripts/replay-snap.sh accept # accept all pending snapshots
scripts/replay-snap.sh test # run the replay gate locally (cargo insta test --check)
scripts/replay-snap.sh record <name> # record a fresh fixture against a real LLM
scripts/trace-coverage.sh # report which EventKind variants have snapshot coverage
CI runs the gate via cargo insta test --test-runner nextest with
NEXTEST_PROFILE=ci, so test binaries execute in parallel processes
and the per-test timeouts defined in .config/nextest.toml apply.
Local dev does not require nextest — cargo test still works.
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 runlist_diron directories not created by the trace itselfshellwith commands that depend on system state (e.g.date,ps,ls /var)http-- external endpoints may change or be unavailablememory_searchunless the trace writes the memory entry first
Prefer:
echo-- always returns its inputjson-- deterministic parsing/formattingwrite_file+read_file-- self-contained if the trace writes firstmemory_write+memory_read-- deterministic if the trace writes firstshellwith 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
-
Pick a category:
spot/for quick smoke tests,coverage/for tool/feature coverage,advanced/for complex multi-step scenarios. -
Name the model: Use
{category}-{scenario}(e.g.spot-tool-echo,coverage-shell-echo). -
Script the conversation: Think through the turn sequence. Each LLM call is one step. After a
tool_callsstep, the agent executes the tools and calls the LLM again with the results -- that's the next step. -
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.
-
End each turn with a
textstep 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
- Memory snapshot -- all workspace documents are captured before the agent starts, saved in
memory_snapshot. - User inputs -- new
Role::Usermessages detected between LLM calls are emitted asuser_inputsteps. - LLM responses -- every
complete()/complete_with_tools()response is saved as atextortool_callsstep withrequest_hint. - Tool results -- new
Role::Toolmessages between LLM calls are captured inexpected_tool_resultson the next step. - HTTP exchanges -- all outgoing HTTP requests from tools are recorded via the
HttpInterceptorand saved inhttp_exchanges.
Using a recorded trace for replay
A recorded trace is a superset of the hand-written format. To use it:
- The replay provider (
TraceLlm) must skipuser_inputsteps -- they are metadata markers, not LLM responses. - If
memory_snapshotis present, restore workspace documents before running the trace. - If
http_exchangesis present, wire aReplayingHttpInterceptorintoJobContext.http_interceptorso tools get pre-recorded HTTP responses instead of making real requests. - If
expected_tool_resultsis 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.