Skip to main content

eval_magic/pipeline/
record_runs.rs

1//! Stage 1 — `record-runs`.
2//!
3//! Assembles a schema-valid `run.json` (and
4//! backfills `timing.json`) for every task in the iteration's `dispatch.json`,
5//! from sources already on disk: carry-over fields from the dispatch task, the
6//! `final_message` (from `<outputs_dir>/final-message.md`, falling back to the
7//! transcript's last assistant text), and `tool_invocations`/tokens/duration from
8//! each task's events file (`outputs/<harness>-events.jsonl` — Claude Code's
9//! `claude -p` stream-json or Codex's `codex-events.jsonl`).
10//!
11//! Existing records always win: an agent/operator-written `run.json` is skipped
12//! without `overwrite`, and `timing.json` is backfill-only — completion-event
13//! numbers captured at dispatch time are never replaced by transcript-derived
14//! ones (which include cache accounting and are not comparable 1:1).
15
16use std::fs;
17use std::path::Path;
18
19use serde::Deserialize;
20
21use crate::adapters::{TranscriptSummary, adapter_for};
22use crate::core::{Harness, RunRecord, TimingRecord, TimingSource};
23use crate::pipeline::error::PipelineError;
24use crate::pipeline::io::write_json;
25use crate::validation::{SchemaName, validate_against_schema};
26
27/// The `dispatch.json` envelope record-runs reads.
28#[derive(Debug, Deserialize)]
29struct DispatchFile {
30    tasks: Option<Vec<DispatchTask>>,
31}
32
33/// The subset of a dispatch task record-runs consumes. `dispatch.json` carries
34/// more fields (e.g. `staged_skill_slug`); serde ignores the extras.
35#[derive(Debug, Deserialize)]
36struct DispatchTask {
37    eval_id: String,
38    condition: String,
39    #[serde(default)]
40    run_index: Option<u32>,
41    skill_path: Option<String>,
42    user_prompt: String,
43    fixtures: Vec<String>,
44    outputs_dir: String,
45    run_record_path: String,
46    timing_path: String,
47    #[serde(default)]
48    dispatch_prompt_path: String,
49}
50
51/// Tally of what record-runs did across the dispatch's tasks.
52#[derive(Debug, Default, Clone, PartialEq, Eq)]
53pub struct RecordRunsResult {
54    pub recorded: usize,
55    pub skipped_existing: usize,
56    pub skipped_no_final_message: usize,
57    pub missing_transcript: usize,
58    pub skipped_prompt_unread: usize,
59}
60
61impl RecordRunsResult {
62    /// A loud, actionable warning when runs were recorded from `final-message.md`
63    /// but their transcripts didn't link — leaving `tool_invocations`/tokens/
64    /// duration empty so `transcript_check` assertions silently grade
65    /// unverifiable. `None` when every run matched its transcript. The hint names
66    /// the per-task events file the harness CLI was expected to write.
67    pub fn transcript_warning(&self, harness: Harness) -> Option<String> {
68        if self.missing_transcript == 0 {
69            return None;
70        }
71        let n = self.missing_transcript;
72        let plural = if n == 1 { "" } else { "s" };
73        let all = self.recorded > 0 && self.missing_transcript >= self.recorded;
74        let lead = if all {
75            format!("⚠ {n} run{plural} recorded but NONE matched a transcript")
76        } else {
77            format!("⚠ {n} run{plural} missing a transcript")
78        };
79        let file = adapter_for(harness)
80            .cli_events_filename()
81            .unwrap_or_else(|| "the events file".to_string());
82        let cause = format!("expected `outputs/{file}` was not found");
83        Some(format!(
84            "{lead} — {cause}; tool_invocations/tokens/duration are empty, so transcript_check \
85             assertions will grade unverifiable."
86        ))
87    }
88
89    /// A loud, actionable warning when one or more dispatches were excluded
90    /// because their transcript shows a failed read of the dispatch prompt — the
91    /// agent never received its instructions, so the result is a no-op, not data.
92    /// `None` when none were flagged.
93    pub fn prompt_unread_warning(&self) -> Option<String> {
94        if self.skipped_prompt_unread == 0 {
95            return None;
96        }
97        let n = self.skipped_prompt_unread;
98        let plural = if n == 1 { "" } else { "es" };
99        Some(format!(
100            "⚠ {n} dispatch{plural} skipped — the transcript shows a failed read of the dispatch \
101             prompt (the agent never received its instructions). These are NOT recorded, so they \
102             cannot be graded as data. Check the env/sandbox can reach each task's \
103             `dispatch_prompt_path`, then re-dispatch."
104        ))
105    }
106}
107
108/// Assemble `run.json` + `timing.json` for every task in
109/// `<iteration_dir>/dispatch.json`. See the module docs for the field sources and
110/// the existing-record precedence rules.
111pub fn record_runs(
112    iteration_dir: &Path,
113    harness: Harness,
114    overwrite: bool,
115) -> Result<RecordRunsResult, PipelineError> {
116    let dispatch_path = iteration_dir.join("dispatch.json");
117    if !dispatch_path.exists() {
118        return Err(PipelineError::Message(format!(
119            "{} not found — record-runs assembles records from dispatch.json and only \
120             supports runner-built iterations. For hand-authored runs, write run.json + \
121             timing.json manually (see schema/run-record.schema.json).",
122            dispatch_path.display()
123        )));
124    }
125    let dispatch: DispatchFile = serde_json::from_str(&fs::read_to_string(&dispatch_path)?)?;
126    let tasks = dispatch.tasks.unwrap_or_default();
127
128    let mut result = RecordRunsResult::default();
129    for task in &tasks {
130        let summary = transcript_summary_for_task(harness, task);
131        if summary.is_none() {
132            result.missing_transcript += 1;
133        }
134
135        let run_record_path = Path::new(&task.run_record_path);
136        if run_record_path.exists() && !overwrite {
137            // An agent/operator already wrote this run.json — leave it untouched.
138            result.skipped_existing += 1;
139        } else if let Some(summary) = &summary
140            && prompt_read_failed(
141                summary,
142                &task.dispatch_prompt_path,
143                &prompt_sentinel(&task.dispatch_prompt_path),
144            )
145        {
146            // The transcript shows the agent tried to read its prompt and the
147            // read returned an error, not the prompt — it never received its
148            // instructions. Skip both run.json and timing so the no-op can't be
149            // graded as data.
150            result.skipped_prompt_unread += 1;
151            continue;
152        } else {
153            let final_message_path = Path::new(&task.outputs_dir).join("final-message.md");
154            let final_message = if final_message_path.exists() {
155                Some(fs::read_to_string(&final_message_path)?.trim().to_string())
156            } else {
157                summary.as_ref().and_then(|s| s.final_text.clone())
158            };
159            let Some(final_message) = final_message else {
160                // No final-message.md and no transcript text — don't write a blank record.
161                result.skipped_no_final_message += 1;
162                continue;
163            };
164
165            let record = RunRecord {
166                eval_id: task.eval_id.clone(),
167                condition: task.condition.clone(),
168                skill_path: task.skill_path.clone(),
169                prompt: task.user_prompt.clone(),
170                files: task.fixtures.clone(),
171                final_message,
172                tool_invocations: summary
173                    .as_ref()
174                    .map(|s| s.tool_invocations.clone())
175                    .unwrap_or_default(),
176                // Timing lives in timing.json; run.json never carries it.
177                total_tokens: None,
178                duration_ms: None,
179                run_index: task.run_index,
180            };
181            validate_against_schema::<RunRecord>(
182                SchemaName::RunRecord,
183                &serde_json::to_value(&record)?,
184                &task.run_record_path,
185            )?;
186            write_json(run_record_path, &record)?;
187            result.recorded += 1;
188        }
189
190        // timing.json — backfill only; completion-event numbers always win.
191        let timing_path = Path::new(&task.timing_path);
192        if let Some(summary) = &summary
193            && (!timing_path.exists() || overwrite)
194        {
195            let timing = TimingRecord {
196                total_tokens: Some(summary.total_tokens),
197                duration_ms: Some(summary.duration_ms),
198                source: Some(TimingSource::Transcript),
199            };
200            write_json(timing_path, &timing)?;
201        }
202    }
203
204    Ok(result)
205}
206
207/// Positive evidence that the agent tried to read its dispatch prompt and
208/// failed: the transcript has a tool call referencing `prompt_path`, yet no such
209/// call returned the prompt's content (its distinctive first-line `sentinel`).
210///
211/// A run that never references the prompt path is NOT flagged — absence is not
212/// proof of failure (the agent can receive the prompt another way),
213/// and requiring positive evidence keeps the check free of false positives.
214/// Returns `false` when `sentinel` is empty (the prompt file was missing or
215/// unreadable, so the read cannot be judged).
216fn prompt_read_failed(summary: &TranscriptSummary, prompt_path: &str, sentinel: &str) -> bool {
217    if sentinel.is_empty() {
218        return false;
219    }
220    let mut referenced = false;
221    let mut delivered = false;
222    for inv in &summary.tool_invocations {
223        let mentions_prompt = inv
224            .args
225            .as_ref()
226            .is_some_and(|a| a.to_string().contains(prompt_path));
227        if !mentions_prompt {
228            continue;
229        }
230        referenced = true;
231        if inv
232            .result
233            .as_ref()
234            .and_then(serde_json::Value::as_str)
235            .is_some_and(|r| r.contains(sentinel))
236        {
237            delivered = true;
238        }
239    }
240    referenced && !delivered
241}
242
243/// The dispatch prompt's distinctive first non-empty line, used as the sentinel
244/// for [`prompt_read_failed`]. Empty when the prompt file is missing/unreadable.
245fn prompt_sentinel(prompt_path: &str) -> String {
246    if prompt_path.is_empty() {
247        return String::new();
248    }
249    fs::read_to_string(prompt_path)
250        .ok()
251        .and_then(|p| {
252            p.lines()
253                .find(|l| !l.trim().is_empty())
254                .map(|l| l.trim().to_string())
255        })
256        .unwrap_or_default()
257}
258
259/// Resolve a task's transcript summary: read the events file the harness CLI
260/// wrote under the task's outputs dir (e.g. Codex's `codex-events.jsonl`, Claude
261/// Code's `claude-events.jsonl`). Returns `None` when no transcript is found.
262fn transcript_summary_for_task(harness: Harness, task: &DispatchTask) -> Option<TranscriptSummary> {
263    let events_path =
264        Path::new(&task.outputs_dir).join(adapter_for(harness).cli_events_filename()?);
265    if !events_path.exists() {
266        return None;
267    }
268    adapter_for(harness)
269        .parse_cli_events_full(&events_path)
270        .ok()
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use serde_json::{Value, json};
277    use std::path::PathBuf;
278    use tempfile::TempDir;
279
280    fn jsonl(lines: &[Value]) -> String {
281        let body = lines
282            .iter()
283            .map(|l| l.to_string())
284            .collect::<Vec<_>>()
285            .join("\n");
286        format!("{body}\n")
287    }
288
289    fn write_codex_events(outputs_dir: &Path, final_text: &str) {
290        let lines = vec![
291            json!({"type": "thread.started", "timestamp": "2026-06-04T10:00:00.000Z"}),
292            json!({"type": "item.completed", "timestamp": "2026-06-04T10:00:10.000Z", "item": {"id": "item_1", "type": "command_execution", "command": "bun test", "output": "ok"}}),
293            json!({"type": "item.completed", "timestamp": "2026-06-04T10:00:20.000Z", "item": {"id": "item_2", "type": "agent_message", "text": final_text}}),
294            json!({"type": "turn.completed", "timestamp": "2026-06-04T10:00:30.000Z", "usage": {"input_tokens": 100, "cached_input_tokens": 80, "output_tokens": 20, "reasoning_output_tokens": 5}}),
295        ];
296        fs::write(outputs_dir.join("codex-events.jsonl"), jsonl(&lines)).unwrap();
297    }
298
299    /// A `claude -p --output-format stream-json` events fixture: a `system/init`
300    /// line, one tool call, and a terminal `result` event carrying the final
301    /// text + duration + usage (there are no per-line timestamps). Tokens sum to
302    /// 125 (100 + 20 + 0 + 5).
303    fn write_claude_events(outputs_dir: &Path, final_text: &str) {
304        let lines = vec![
305            json!({"type": "system", "subtype": "init", "cwd": "/env"}),
306            json!({"type": "assistant", "message": {"id": "msg_1", "role": "assistant", "content": [{"type": "tool_use", "id": "toolu_1", "name": "Bash", "input": {"command": "bun test"}}]}}),
307            json!({"type": "user", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_1", "content": "ok"}]}}),
308            json!({"type": "result", "subtype": "success", "is_error": false, "result": final_text, "duration_ms": 30_000, "usage": {"input_tokens": 100, "output_tokens": 20, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 5}}),
309        ];
310        fs::write(outputs_dir.join("claude-events.jsonl"), jsonl(&lines)).unwrap();
311    }
312
313    /// A `claude -p` events fixture where the agent reads its dispatch prompt:
314    /// a `Read` tool call whose `input.file_path` is `prompt_path`, a
315    /// `tool_result` carrying `read_result` (the file content on success, an
316    /// error string on a denied/out-of-cwd read), and a terminal `result` event.
317    fn write_claude_events_prompt_read(
318        outputs_dir: &Path,
319        prompt_path: &str,
320        read_result: &str,
321        final_text: &str,
322    ) {
323        let lines = vec![
324            json!({"type": "system", "subtype": "init", "cwd": "/env"}),
325            json!({"type": "assistant", "message": {"id": "msg_1", "role": "assistant", "content": [{"type": "tool_use", "id": "toolu_1", "name": "Read", "input": {"file_path": prompt_path}}]}}),
326            json!({"type": "user", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_1", "content": read_result}]}}),
327            json!({"type": "result", "subtype": "success", "is_error": false, "result": final_text, "duration_ms": 30_000, "usage": {"input_tokens": 100, "output_tokens": 20, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 5}}),
328        ];
329        fs::write(outputs_dir.join("claude-events.jsonl"), jsonl(&lines)).unwrap();
330    }
331
332    const PROMPT_SENTINEL: &str =
333        "You are executing a single test case for a skill evaluation framework.";
334
335    #[test]
336    fn flags_dispatch_whose_prompt_read_failed() {
337        // A dispatch that couldn't read its prompt still exits 0 and emits a
338        // final message — but the run is a silent no-op, not data (issue #109).
339        let tmp = TempDir::new().unwrap();
340        let iter = tmp.path();
341        let paths = write_iteration(
342            iter,
343            &[FixtureTask {
344                eval_id: "e1",
345                condition: "with_skill",
346                final_message: Some("I could not read the prompt file."),
347            }],
348        );
349        let prompt_path = iter
350            .join("eval-e1")
351            .join("with_skill")
352            .join("dispatch-prompt.txt");
353        fs::write(
354            &prompt_path,
355            format!("{PROMPT_SENTINEL}\n\nUser request:\ndo it"),
356        )
357        .unwrap();
358        // The transcript shows a Read of the prompt path that ERRORED — the
359        // result is a denial, not the prompt content.
360        write_claude_events_prompt_read(
361            &paths[0].outputs_dir,
362            &prompt_path.to_string_lossy(),
363            "<tool_use_error>File is outside the allowed working directory.</tool_use_error>",
364            "I could not read the prompt file.",
365        );
366
367        let result = record_runs(iter, Harness::resolve("claude-code").unwrap(), false).unwrap();
368
369        assert_eq!(result.skipped_prompt_unread, 1);
370        assert_eq!(result.recorded, 0);
371        assert!(!paths[0].run_record_path.exists());
372    }
373
374    #[test]
375    fn records_dispatch_when_prompt_read_succeeded() {
376        // The same shape, but the Read returned the prompt content (Read echoes
377        // it with a line-number prefix) — a legitimate run, recorded as data.
378        let tmp = TempDir::new().unwrap();
379        let iter = tmp.path();
380        let paths = write_iteration(
381            iter,
382            &[FixtureTask {
383                eval_id: "e1",
384                condition: "with_skill",
385                final_message: Some("Done."),
386            }],
387        );
388        let prompt_path = iter
389            .join("eval-e1")
390            .join("with_skill")
391            .join("dispatch-prompt.txt");
392        fs::write(
393            &prompt_path,
394            format!("{PROMPT_SENTINEL}\n\nUser request:\ndo it"),
395        )
396        .unwrap();
397        write_claude_events_prompt_read(
398            &paths[0].outputs_dir,
399            &prompt_path.to_string_lossy(),
400            &format!("     1→{PROMPT_SENTINEL}\n     2→\n     3→User request:"),
401            "Done.",
402        );
403
404        let result = record_runs(iter, Harness::resolve("claude-code").unwrap(), false).unwrap();
405
406        assert_eq!(result.recorded, 1);
407        assert_eq!(result.skipped_prompt_unread, 0);
408    }
409
410    struct FixtureTask {
411        eval_id: &'static str,
412        condition: &'static str,
413        /// Written to `outputs/final-message.md` when `Some`.
414        final_message: Option<&'static str>,
415    }
416
417    /// Paths the tests reach into after building the iteration.
418    struct TaskPaths {
419        outputs_dir: PathBuf,
420        run_record_path: PathBuf,
421        timing_path: PathBuf,
422    }
423
424    /// Build an iteration dir + `dispatch.json` shaped like `run.ts` serializes it.
425    fn write_iteration(iteration_dir: &Path, tasks: &[FixtureTask]) -> Vec<TaskPaths> {
426        let mut serialized = Vec::new();
427        let mut paths = Vec::new();
428        for t in tasks {
429            let cond_dir = iteration_dir
430                .join(format!("eval-{}", t.eval_id))
431                .join(t.condition);
432            let outputs_dir = cond_dir.join("outputs");
433            fs::create_dir_all(&outputs_dir).unwrap();
434            if let Some(msg) = t.final_message {
435                fs::write(outputs_dir.join("final-message.md"), msg).unwrap();
436            }
437            let run_record_path = cond_dir.join("run.json");
438            let timing_path = cond_dir.join("timing.json");
439            let without = t.condition == "without_skill";
440            serialized.push(json!({
441                "eval_id": t.eval_id,
442                "condition": t.condition,
443                "skill_path": if without { Value::Null } else { json!("/staged/skill/SKILL.md") },
444                "staged_skill_slug": if without { Value::Null } else { json!("test-slug") },
445                "user_prompt": format!("Do the {} task", t.eval_id),
446                "fixtures": [cond_dir.join("inputs").join("fixture.txt").to_string_lossy()],
447                "outputs_dir": outputs_dir.to_string_lossy(),
448                "run_record_path": run_record_path.to_string_lossy(),
449                "timing_path": timing_path.to_string_lossy(),
450                "agent_description": format!("{}:{}:i1-nonce1", t.eval_id, t.condition),
451                "dispatch_prompt_path": cond_dir.join("dispatch-prompt.txt").to_string_lossy(),
452            }));
453            paths.push(TaskPaths {
454                outputs_dir,
455                run_record_path,
456                timing_path,
457            });
458        }
459        fs::write(
460            iteration_dir.join("dispatch.json"),
461            serde_json::to_string_pretty(&json!({"run_nonce": "nonce1", "tasks": serialized}))
462                .unwrap(),
463        )
464        .unwrap();
465        paths
466    }
467
468    fn read_run(iteration_dir: &Path, eval_id: &str, condition: &str) -> RunRecord {
469        let raw = fs::read_to_string(
470            iteration_dir
471                .join(format!("eval-{eval_id}"))
472                .join(condition)
473                .join("run.json"),
474        )
475        .unwrap();
476        serde_json::from_str(&raw).unwrap()
477    }
478
479    fn read_timing_value(iteration_dir: &Path, eval_id: &str, condition: &str) -> Value {
480        let raw = fs::read_to_string(
481            iteration_dir
482                .join(format!("eval-{eval_id}"))
483                .join(condition)
484                .join("timing.json"),
485        )
486        .unwrap();
487        serde_json::from_str(&raw).unwrap()
488    }
489
490    fn timing_exists(iteration_dir: &Path, eval_id: &str, condition: &str) -> bool {
491        iteration_dir
492            .join(format!("eval-{eval_id}"))
493            .join(condition)
494            .join("timing.json")
495            .exists()
496    }
497
498    fn run_exists(iteration_dir: &Path, eval_id: &str, condition: &str) -> bool {
499        iteration_dir
500            .join(format!("eval-{eval_id}"))
501            .join(condition)
502            .join("run.json")
503            .exists()
504    }
505
506    /// The iteration dir under a fresh temp root.
507    fn dirs(root: &TempDir) -> PathBuf {
508        let iteration_dir = root.path().join("iter");
509        fs::create_dir_all(&iteration_dir).unwrap();
510        iteration_dir
511    }
512
513    #[test]
514    fn assembles_run_and_timing_for_every_task_from_disk() {
515        let root = TempDir::new().unwrap();
516        let iter = dirs(&root);
517        let paths = write_iteration(
518            &iter,
519            &[
520                FixtureTask {
521                    eval_id: "crash",
522                    condition: "with_skill",
523                    final_message: Some("Fixed it."),
524                },
525                FixtureTask {
526                    eval_id: "crash",
527                    condition: "without_skill",
528                    final_message: Some("Done, I think."),
529                },
530            ],
531        );
532        write_claude_events(&paths[0].outputs_dir, "unused");
533        write_claude_events(&paths[1].outputs_dir, "unused");
534
535        let result = record_runs(&iter, Harness::resolve("claude-code").unwrap(), false).unwrap();
536        assert_eq!(result.recorded, 2);
537        assert_eq!(result.missing_transcript, 0);
538
539        let run = read_run(&iter, "crash", "with_skill");
540        assert_eq!(run.eval_id, "crash");
541        assert_eq!(run.condition, "with_skill");
542        assert_eq!(run.skill_path.as_deref(), Some("/staged/skill/SKILL.md"));
543        assert_eq!(run.prompt, "Do the crash task");
544        assert_eq!(run.files.len(), 1);
545        assert_eq!(run.final_message, "Fixed it.");
546        assert_eq!(run.tool_invocations.len(), 1);
547        assert_eq!(run.tool_invocations[0].name, "Bash");
548        assert_eq!(run.tool_invocations[0].ordinal, 0);
549
550        assert!(
551            read_run(&iter, "crash", "without_skill")
552                .skill_path
553                .is_none()
554        );
555
556        let timing = read_timing_value(&iter, "crash", "with_skill");
557        assert_eq!(timing["total_tokens"], json!(125));
558        assert_eq!(timing["duration_ms"], json!(30_000));
559        assert_eq!(timing["source"], json!("transcript"));
560    }
561
562    #[test]
563    fn carries_run_index_from_dispatch_task_into_each_run_record() {
564        let root = TempDir::new().unwrap();
565        let iter = dirs(&root);
566        let cond_dir = iter.join("eval-crash").join("with_skill");
567        let mut serialized = Vec::new();
568        for k in [1u32, 2] {
569            let run_dir = cond_dir.join(format!("run-{k}"));
570            let outputs_dir = run_dir.join("outputs");
571            fs::create_dir_all(&outputs_dir).unwrap();
572            fs::write(
573                outputs_dir.join("final-message.md"),
574                format!("Fixed it in run {k}."),
575            )
576            .unwrap();
577            write_codex_events(&outputs_dir, "unused");
578            serialized.push(json!({
579                "eval_id": "crash",
580                "condition": "with_skill",
581                "run_index": k,
582                "skill_path": "/staged/skill/SKILL.md",
583                "user_prompt": "Do the crash task",
584                "fixtures": [],
585                "outputs_dir": outputs_dir.to_string_lossy(),
586                "run_record_path": run_dir.join("run.json").to_string_lossy(),
587                "timing_path": run_dir.join("timing.json").to_string_lossy(),
588                "agent_description": format!("crash:with_skill:r{k}:i1-nonce1"),
589            }));
590        }
591        fs::write(
592            iter.join("dispatch.json"),
593            serde_json::to_string_pretty(&json!({"run_nonce": "nonce1", "tasks": serialized}))
594                .unwrap(),
595        )
596        .unwrap();
597
598        let result = record_runs(&iter, Harness::resolve("codex").unwrap(), false).unwrap();
599        assert_eq!(result.recorded, 2);
600
601        for k in [1u32, 2] {
602            let raw =
603                fs::read_to_string(cond_dir.join(format!("run-{k}")).join("run.json")).unwrap();
604            let run: RunRecord = serde_json::from_str(&raw).unwrap();
605            assert_eq!(run.run_index, Some(k), "wrong run_index for run-{k}");
606            assert_eq!(run.final_message, format!("Fixed it in run {k}."));
607        }
608    }
609
610    #[test]
611    fn assembles_codex_records_from_each_tasks_events() {
612        let root = TempDir::new().unwrap();
613        let iter = dirs(&root);
614        let paths = write_iteration(
615            &iter,
616            &[FixtureTask {
617                eval_id: "crash",
618                condition: "with_skill",
619                final_message: Some("Fixed it."),
620            }],
621        );
622        write_codex_events(&paths[0].outputs_dir, "Codex final.");
623
624        let result = record_runs(&iter, Harness::resolve("codex").unwrap(), false).unwrap();
625        assert_eq!(result.recorded, 1);
626        assert_eq!(result.missing_transcript, 0);
627
628        let run = read_run(&iter, "crash", "with_skill");
629        assert_eq!(run.final_message, "Fixed it.");
630        assert_eq!(
631            serde_json::to_value(&run.tool_invocations).unwrap(),
632            json!([{"name": "command_execution", "ordinal": 0, "args": {"command": "bun test"}, "result": "ok"}])
633        );
634
635        let timing = read_timing_value(&iter, "crash", "with_skill");
636        assert_eq!(
637            timing,
638            json!({"total_tokens": 125, "duration_ms": 30_000, "source": "transcript"})
639        );
640    }
641
642    #[test]
643    fn falls_back_to_codex_final_agent_message_when_final_message_md_missing() {
644        let root = TempDir::new().unwrap();
645        let iter = dirs(&root);
646        let paths = write_iteration(
647            &iter,
648            &[FixtureTask {
649                eval_id: "crash",
650                condition: "with_skill",
651                final_message: None,
652            }],
653        );
654        write_codex_events(&paths[0].outputs_dir, "Closing summary from Codex.");
655
656        let result = record_runs(&iter, Harness::resolve("codex").unwrap(), false).unwrap();
657        assert_eq!(result.recorded, 1);
658        assert_eq!(
659            read_run(&iter, "crash", "with_skill").final_message,
660            "Closing summary from Codex."
661        );
662    }
663
664    #[test]
665    fn skips_existing_run_without_overwrite_then_replaces_with_it() {
666        let root = TempDir::new().unwrap();
667        let iter = dirs(&root);
668        let paths = write_iteration(
669            &iter,
670            &[FixtureTask {
671                eval_id: "crash",
672                condition: "with_skill",
673                final_message: Some("New."),
674            }],
675        );
676        write_claude_events(&paths[0].outputs_dir, "unused");
677        let hand_written = json!({
678            "eval_id": "crash", "condition": "with_skill",
679            "skill_path": "/staged/skill/SKILL.md", "prompt": "Do the crash task",
680            "files": [], "final_message": "Agent-authored.", "tool_invocations": []
681        });
682        fs::write(&paths[0].run_record_path, hand_written.to_string()).unwrap();
683
684        let skipped = record_runs(&iter, Harness::resolve("claude-code").unwrap(), false).unwrap();
685        assert_eq!(skipped.recorded, 0);
686        assert_eq!(skipped.skipped_existing, 1);
687        assert_eq!(
688            read_run(&iter, "crash", "with_skill").final_message,
689            "Agent-authored."
690        );
691
692        let replaced = record_runs(&iter, Harness::resolve("claude-code").unwrap(), true).unwrap();
693        assert_eq!(replaced.recorded, 1);
694        assert_eq!(read_run(&iter, "crash", "with_skill").final_message, "New.");
695    }
696
697    #[test]
698    fn backfills_timing_only_when_absent() {
699        let root = TempDir::new().unwrap();
700        let iter = dirs(&root);
701        let paths = write_iteration(
702            &iter,
703            &[FixtureTask {
704                eval_id: "crash",
705                condition: "with_skill",
706                final_message: Some("Done."),
707            }],
708        );
709        write_claude_events(&paths[0].outputs_dir, "unused");
710        fs::write(
711            &paths[0].timing_path,
712            json!({"total_tokens": 12345, "duration_ms": 9000}).to_string(),
713        )
714        .unwrap();
715
716        record_runs(&iter, Harness::resolve("claude-code").unwrap(), false).unwrap();
717
718        // Agent-captured completion-event timing wins; not overwritten.
719        let timing = read_timing_value(&iter, "crash", "with_skill");
720        assert_eq!(timing["total_tokens"], json!(12345));
721        assert_eq!(timing["duration_ms"], json!(9000));
722        assert!(timing.get("source").is_none());
723    }
724
725    #[test]
726    fn skips_the_slot_entirely_when_no_final_message_source_exists() {
727        let root = TempDir::new().unwrap();
728        let iter = dirs(&root);
729        write_iteration(
730            &iter,
731            &[FixtureTask {
732                eval_id: "crash",
733                condition: "with_skill",
734                final_message: None,
735            }],
736        );
737        // No final-message.md, no transcript.
738
739        let result = record_runs(&iter, Harness::resolve("claude-code").unwrap(), false).unwrap();
740        assert_eq!(result.recorded, 0);
741        assert_eq!(result.skipped_no_final_message, 1);
742        assert!(!run_exists(&iter, "crash", "with_skill"));
743        assert!(!timing_exists(&iter, "crash", "with_skill"));
744    }
745
746    #[test]
747    fn writes_empty_invocations_and_no_timing_when_transcript_missing() {
748        let root = TempDir::new().unwrap();
749        let iter = dirs(&root);
750        write_iteration(
751            &iter,
752            &[FixtureTask {
753                eval_id: "crash",
754                condition: "with_skill",
755                final_message: Some("Done."),
756            }],
757        );
758        // final-message.md exists but no events file is present.
759
760        let result = record_runs(&iter, Harness::resolve("claude-code").unwrap(), false).unwrap();
761        assert_eq!(result.recorded, 1);
762        assert_eq!(result.missing_transcript, 1);
763
764        let run = read_run(&iter, "crash", "with_skill");
765        assert_eq!(run.final_message, "Done.");
766        assert!(run.tool_invocations.is_empty());
767        assert!(!timing_exists(&iter, "crash", "with_skill"));
768    }
769
770    #[test]
771    fn errors_when_dispatch_json_is_absent() {
772        let root = TempDir::new().unwrap();
773        let iter = dirs(&root);
774        // Hand-authored/operator runs have no dispatch.json — the manual path owns them.
775        let err = record_runs(&iter, Harness::resolve("claude-code").unwrap(), false).unwrap_err();
776        assert!(
777            err.to_string().contains("dispatch.json"),
778            "error was: {err}"
779        );
780    }
781
782    #[test]
783    fn no_transcript_warning_when_all_transcripts_matched() {
784        let result = RecordRunsResult {
785            recorded: 4,
786            missing_transcript: 0,
787            ..Default::default()
788        };
789        assert!(
790            result
791                .transcript_warning(Harness::resolve("claude-code").unwrap())
792                .is_none()
793        );
794    }
795
796    #[test]
797    fn claude_code_warning_fires_on_partial_miss() {
798        let result = RecordRunsResult {
799            recorded: 4,
800            missing_transcript: 1,
801            ..Default::default()
802        };
803        let warning = result
804            .transcript_warning(Harness::resolve("claude-code").unwrap())
805            .unwrap();
806        assert!(warning.contains('1'), "names the count: {warning}");
807    }
808
809    #[test]
810    fn codex_warning_points_at_events_file() {
811        let result = RecordRunsResult {
812            recorded: 2,
813            missing_transcript: 2,
814            ..Default::default()
815        };
816        let warning = result
817            .transcript_warning(Harness::resolve("codex").unwrap())
818            .unwrap();
819        assert!(
820            warning.contains("codex-events.jsonl"),
821            "names the Codex source: {warning}"
822        );
823        assert!(
824            !warning.contains("agent_description"),
825            "Codex doesn't use agent_description: {warning}"
826        );
827    }
828
829    #[test]
830    fn assembles_claude_records_from_each_tasks_events() {
831        let root = TempDir::new().unwrap();
832        let iter = dirs(&root);
833        let paths = write_iteration(
834            &iter,
835            &[FixtureTask {
836                eval_id: "crash",
837                condition: "with_skill",
838                final_message: Some("Fixed it."),
839            }],
840        );
841        write_claude_events(&paths[0].outputs_dir, "Closing summary.");
842
843        let result = record_runs(&iter, Harness::resolve("claude-code").unwrap(), false).unwrap();
844        assert_eq!(result.recorded, 1);
845        assert_eq!(result.missing_transcript, 0);
846
847        let run = read_run(&iter, "crash", "with_skill");
848        // final-message.md wins when present.
849        assert_eq!(run.final_message, "Fixed it.");
850        assert_eq!(
851            serde_json::to_value(&run.tool_invocations).unwrap(),
852            json!([{"name": "Bash", "ordinal": 0, "args": {"command": "bun test"}, "result": "ok"}])
853        );
854        let timing = read_timing_value(&iter, "crash", "with_skill");
855        assert_eq!(
856            timing,
857            json!({"total_tokens": 125, "duration_ms": 30_000, "source": "transcript"})
858        );
859    }
860
861    #[test]
862    fn falls_back_to_claude_result_final_text_when_final_message_md_missing() {
863        // Claude `-p` has no --output-last-message, so the result event's text is
864        // the primary final-message source.
865        let root = TempDir::new().unwrap();
866        let iter = dirs(&root);
867        let paths = write_iteration(
868            &iter,
869            &[FixtureTask {
870                eval_id: "crash",
871                condition: "with_skill",
872                final_message: None,
873            }],
874        );
875        write_claude_events(&paths[0].outputs_dir, "Closing summary from claude -p.");
876
877        let result = record_runs(&iter, Harness::resolve("claude-code").unwrap(), false).unwrap();
878        assert_eq!(result.recorded, 1);
879        assert_eq!(
880            read_run(&iter, "crash", "with_skill").final_message,
881            "Closing summary from claude -p."
882        );
883    }
884
885    #[test]
886    fn claude_warning_points_at_events_file() {
887        let result = RecordRunsResult {
888            recorded: 2,
889            missing_transcript: 2,
890            ..Default::default()
891        };
892        let warning = result
893            .transcript_warning(Harness::resolve("claude-code").unwrap())
894            .unwrap();
895        assert!(
896            warning.contains("claude-events.jsonl"),
897            "names the Claude CLI events source: {warning}"
898        );
899        assert!(
900            !warning.contains("agent_description"),
901            "CLI dispatch doesn't use agent_description: {warning}"
902        );
903    }
904}