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//! the persisted transcript (Claude Code subagent JSONL or Codex
9//! `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, find_by_description};
22use crate::core::{
23    DispatchMechanism, Harness, RunRecord, TimingRecord, TimingSource, mechanism_for,
24};
25use crate::pipeline::error::PipelineError;
26use crate::pipeline::io::write_json;
27use crate::validation::{SchemaName, validate_against_schema};
28
29/// The `dispatch.json` envelope record-runs reads.
30#[derive(Debug, Deserialize)]
31struct DispatchFile {
32    tasks: Option<Vec<DispatchTask>>,
33}
34
35/// The subset of a dispatch task record-runs consumes. `dispatch.json` carries
36/// more fields (e.g. `staged_skill_slug`, `dispatch_prompt_path`); serde ignores
37/// the extras.
38#[derive(Debug, Deserialize)]
39struct DispatchTask {
40    eval_id: String,
41    condition: String,
42    #[serde(default)]
43    run_index: Option<u32>,
44    skill_path: Option<String>,
45    user_prompt: String,
46    fixtures: Vec<String>,
47    outputs_dir: String,
48    run_record_path: String,
49    timing_path: String,
50    agent_description: String,
51}
52
53/// Tally of what record-runs did across the dispatch's tasks.
54#[derive(Debug, Default, Clone, PartialEq, Eq)]
55pub struct RecordRunsResult {
56    pub recorded: usize,
57    pub skipped_existing: usize,
58    pub skipped_no_final_message: usize,
59    pub missing_transcript: usize,
60}
61
62impl RecordRunsResult {
63    /// A loud, actionable warning when runs were recorded from `final-message.md`
64    /// but their transcripts didn't link — leaving `tool_invocations`/tokens/
65    /// duration empty so `transcript_check` assertions silently grade
66    /// unverifiable. `None` when every run matched its transcript. The hint is
67    /// tailored to how the harness correlates transcripts (description match vs.
68    /// the Codex events file).
69    pub fn transcript_warning(&self, harness: Harness) -> Option<String> {
70        if self.missing_transcript == 0 {
71            return None;
72        }
73        let n = self.missing_transcript;
74        let plural = if n == 1 { "" } else { "s" };
75        let all = self.recorded > 0 && self.missing_transcript >= self.recorded;
76        let lead = if all {
77            format!("⚠ {n} run{plural} recorded but NONE matched a transcript")
78        } else {
79            format!("⚠ {n} run{plural} missing a transcript")
80        };
81        let cause = match harness {
82            Harness::Codex => "expected `outputs/codex-events.jsonl` was not found".to_string(),
83            Harness::ClaudeCode | Harness::OpenCode => {
84                "did you pass each task's `agent_description` verbatim as the subagent \
85                 description? If so, confirm `--subagents-dir` points at the parent session's \
86                 subagents dir"
87                    .to_string()
88            }
89        };
90        Some(format!(
91            "{lead} — {cause}; tool_invocations/tokens/duration are empty, so transcript_check \
92             assertions will grade unverifiable."
93        ))
94    }
95}
96
97/// Assemble `run.json` + `timing.json` for every task in
98/// `<iteration_dir>/dispatch.json`. See the module docs for the field sources and
99/// the existing-record precedence rules.
100pub fn record_runs(
101    iteration_dir: &Path,
102    harness: Harness,
103    subagents_dir: Option<&Path>,
104    overwrite: bool,
105) -> Result<RecordRunsResult, PipelineError> {
106    let dispatch_path = iteration_dir.join("dispatch.json");
107    if !dispatch_path.exists() {
108        return Err(PipelineError::Message(format!(
109            "{} not found — record-runs assembles records from dispatch.json and only \
110             supports runner-built iterations. For hand-authored runs, write run.json + \
111             timing.json manually (see schema/run-record.schema.json).",
112            dispatch_path.display()
113        )));
114    }
115    let dispatch: DispatchFile = serde_json::from_str(&fs::read_to_string(&dispatch_path)?)?;
116    let tasks = dispatch.tasks.unwrap_or_default();
117
118    let mut result = RecordRunsResult::default();
119    for task in &tasks {
120        let summary = transcript_summary_for_task(harness, subagents_dir, task);
121        if summary.is_none() {
122            result.missing_transcript += 1;
123        }
124
125        let run_record_path = Path::new(&task.run_record_path);
126        if run_record_path.exists() && !overwrite {
127            // An agent/operator already wrote this run.json — leave it untouched.
128            result.skipped_existing += 1;
129        } else {
130            let final_message_path = Path::new(&task.outputs_dir).join("final-message.md");
131            let final_message = if final_message_path.exists() {
132                Some(fs::read_to_string(&final_message_path)?.trim().to_string())
133            } else {
134                summary.as_ref().and_then(|s| s.final_text.clone())
135            };
136            let Some(final_message) = final_message else {
137                // No final-message.md and no transcript text — don't write a blank record.
138                result.skipped_no_final_message += 1;
139                continue;
140            };
141
142            let record = RunRecord {
143                eval_id: task.eval_id.clone(),
144                condition: task.condition.clone(),
145                skill_path: task.skill_path.clone(),
146                prompt: task.user_prompt.clone(),
147                files: task.fixtures.clone(),
148                final_message,
149                tool_invocations: summary
150                    .as_ref()
151                    .map(|s| s.tool_invocations.clone())
152                    .unwrap_or_default(),
153                // Timing lives in timing.json; run.json never carries it.
154                total_tokens: None,
155                duration_ms: None,
156                run_index: task.run_index,
157            };
158            validate_against_schema::<RunRecord>(
159                SchemaName::RunRecord,
160                &serde_json::to_value(&record)?,
161                &task.run_record_path,
162            )?;
163            write_json(run_record_path, &record)?;
164            result.recorded += 1;
165        }
166
167        // timing.json — backfill only; completion-event numbers always win.
168        let timing_path = Path::new(&task.timing_path);
169        if let Some(summary) = &summary
170            && (!timing_path.exists() || overwrite)
171        {
172            let timing = TimingRecord {
173                total_tokens: Some(summary.total_tokens),
174                duration_ms: Some(summary.duration_ms),
175                source: Some(TimingSource::Transcript),
176            };
177            write_json(timing_path, &timing)?;
178        }
179    }
180
181    Ok(result)
182}
183
184/// Resolve a task's transcript summary, keyed on the dispatch mechanism: a
185/// `Cli`-mechanism harness reads the events file its CLI wrote under the task's
186/// outputs dir (e.g. Codex's `codex-events.jsonl`); an `InSession` harness reads
187/// the subagent transcript matched by the task's `agent_description`. Returns
188/// `None` when no transcript is found.
189fn transcript_summary_for_task(
190    harness: Harness,
191    subagents_dir: Option<&Path>,
192    task: &DispatchTask,
193) -> Option<TranscriptSummary> {
194    match mechanism_for(harness) {
195        DispatchMechanism::Cli => {
196            let events_path =
197                Path::new(&task.outputs_dir).join(adapter_for(harness).cli_events_filename()?);
198            if !events_path.exists() {
199                return None;
200            }
201            adapter_for(harness)
202                .parse_transcript_full(&events_path)
203                .ok()
204        }
205        DispatchMechanism::InSession => {
206            let subagent = find_by_description(
207                subagents_dir.unwrap_or_else(|| Path::new("")),
208                &task.agent_description,
209            )?;
210            adapter_for(harness)
211                .parse_transcript_full(&subagent.jsonl_path)
212                .ok()
213        }
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use serde_json::{Value, json};
221    use std::path::PathBuf;
222    use tempfile::TempDir;
223
224    /// Token math for `transcript_lines`: msg_1 (100+20+30+50) + msg_2
225    /// (200+40+0+60) = 500.
226    const TRANSCRIPT_TOKENS: i64 = 500;
227    /// 10:00:00.000 → 10:01:00.000.
228    const TRANSCRIPT_DURATION_MS: i64 = 60_000;
229
230    fn jsonl(lines: &[Value]) -> String {
231        let body = lines
232            .iter()
233            .map(|l| l.to_string())
234            .collect::<Vec<_>>()
235            .join("\n");
236        format!("{body}\n")
237    }
238
239    /// A minimal transcript with usage, timestamps, one tool call, and final text.
240    fn transcript_lines(final_text: &str) -> Vec<Value> {
241        vec![
242            json!({"type": "user", "timestamp": "2026-06-04T10:00:00.000Z", "message": {"role": "user", "content": "go"}}),
243            json!({"type": "assistant", "timestamp": "2026-06-04T10:00:10.000Z", "message": {
244                "id": "msg_1", "role": "assistant",
245                "usage": {"input_tokens": 100, "output_tokens": 20, "cache_creation_input_tokens": 30, "cache_read_input_tokens": 50},
246                "content": [{"type": "tool_use", "id": "toolu_1", "name": "Bash", "input": {"command": "ls"}}]
247            }}),
248            json!({"type": "user", "timestamp": "2026-06-04T10:00:12.000Z", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_1", "content": "ok"}]}}),
249            json!({"type": "assistant", "timestamp": "2026-06-04T10:01:00.000Z", "message": {
250                "id": "msg_2", "role": "assistant",
251                "usage": {"input_tokens": 200, "output_tokens": 40, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 60},
252                "content": [{"type": "text", "text": final_text}]
253            }}),
254        ]
255    }
256
257    fn write_subagent(subagents_dir: &Path, name: &str, description: &str, lines: &[Value]) {
258        fs::write(
259            subagents_dir.join(format!("{name}.meta.json")),
260            json!({"agentType": "general-purpose", "description": description}).to_string(),
261        )
262        .unwrap();
263        fs::write(subagents_dir.join(format!("{name}.jsonl")), jsonl(lines)).unwrap();
264    }
265
266    fn write_codex_events(outputs_dir: &Path, final_text: &str) {
267        let lines = vec![
268            json!({"type": "thread.started", "timestamp": "2026-06-04T10:00:00.000Z"}),
269            json!({"type": "item.completed", "timestamp": "2026-06-04T10:00:10.000Z", "item": {"id": "item_1", "type": "command_execution", "command": "bun test", "output": "ok"}}),
270            json!({"type": "item.completed", "timestamp": "2026-06-04T10:00:20.000Z", "item": {"id": "item_2", "type": "agent_message", "text": final_text}}),
271            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}}),
272        ];
273        fs::write(outputs_dir.join("codex-events.jsonl"), jsonl(&lines)).unwrap();
274    }
275
276    struct FixtureTask {
277        eval_id: &'static str,
278        condition: &'static str,
279        /// Written to `outputs/final-message.md` when `Some`.
280        final_message: Option<&'static str>,
281    }
282
283    /// Paths the tests reach into after building the iteration.
284    struct TaskPaths {
285        outputs_dir: PathBuf,
286        run_record_path: PathBuf,
287        timing_path: PathBuf,
288    }
289
290    /// Build an iteration dir + `dispatch.json` shaped like `run.ts` serializes it.
291    fn write_iteration(iteration_dir: &Path, tasks: &[FixtureTask]) -> Vec<TaskPaths> {
292        let mut serialized = Vec::new();
293        let mut paths = Vec::new();
294        for t in tasks {
295            let cond_dir = iteration_dir
296                .join(format!("eval-{}", t.eval_id))
297                .join(t.condition);
298            let outputs_dir = cond_dir.join("outputs");
299            fs::create_dir_all(&outputs_dir).unwrap();
300            if let Some(msg) = t.final_message {
301                fs::write(outputs_dir.join("final-message.md"), msg).unwrap();
302            }
303            let run_record_path = cond_dir.join("run.json");
304            let timing_path = cond_dir.join("timing.json");
305            let without = t.condition == "without_skill";
306            serialized.push(json!({
307                "eval_id": t.eval_id,
308                "condition": t.condition,
309                "skill_path": if without { Value::Null } else { json!("/staged/skill/SKILL.md") },
310                "staged_skill_slug": if without { Value::Null } else { json!("test-slug") },
311                "user_prompt": format!("Do the {} task", t.eval_id),
312                "fixtures": [cond_dir.join("inputs").join("fixture.txt").to_string_lossy()],
313                "outputs_dir": outputs_dir.to_string_lossy(),
314                "run_record_path": run_record_path.to_string_lossy(),
315                "timing_path": timing_path.to_string_lossy(),
316                "agent_description": format!("{}:{}:i1-nonce1", t.eval_id, t.condition),
317                "dispatch_prompt_path": cond_dir.join("dispatch-prompt.txt").to_string_lossy(),
318            }));
319            paths.push(TaskPaths {
320                outputs_dir,
321                run_record_path,
322                timing_path,
323            });
324        }
325        fs::write(
326            iteration_dir.join("dispatch.json"),
327            serde_json::to_string_pretty(&json!({"run_nonce": "nonce1", "tasks": serialized}))
328                .unwrap(),
329        )
330        .unwrap();
331        paths
332    }
333
334    fn read_run(iteration_dir: &Path, eval_id: &str, condition: &str) -> RunRecord {
335        let raw = fs::read_to_string(
336            iteration_dir
337                .join(format!("eval-{eval_id}"))
338                .join(condition)
339                .join("run.json"),
340        )
341        .unwrap();
342        serde_json::from_str(&raw).unwrap()
343    }
344
345    fn read_timing_value(iteration_dir: &Path, eval_id: &str, condition: &str) -> Value {
346        let raw = fs::read_to_string(
347            iteration_dir
348                .join(format!("eval-{eval_id}"))
349                .join(condition)
350                .join("timing.json"),
351        )
352        .unwrap();
353        serde_json::from_str(&raw).unwrap()
354    }
355
356    fn timing_exists(iteration_dir: &Path, eval_id: &str, condition: &str) -> bool {
357        iteration_dir
358            .join(format!("eval-{eval_id}"))
359            .join(condition)
360            .join("timing.json")
361            .exists()
362    }
363
364    fn run_exists(iteration_dir: &Path, eval_id: &str, condition: &str) -> bool {
365        iteration_dir
366            .join(format!("eval-{eval_id}"))
367            .join(condition)
368            .join("run.json")
369            .exists()
370    }
371
372    /// `(iteration_dir, subagents_dir)` under a fresh temp root.
373    fn dirs(root: &TempDir) -> (PathBuf, PathBuf) {
374        let iteration_dir = root.path().join("iter");
375        let subagents_dir = root.path().join("sub");
376        fs::create_dir_all(&iteration_dir).unwrap();
377        fs::create_dir_all(&subagents_dir).unwrap();
378        (iteration_dir, subagents_dir)
379    }
380
381    #[test]
382    fn assembles_run_and_timing_for_every_task_from_disk() {
383        let root = TempDir::new().unwrap();
384        let (iter, sub) = dirs(&root);
385        write_iteration(
386            &iter,
387            &[
388                FixtureTask {
389                    eval_id: "crash",
390                    condition: "with_skill",
391                    final_message: Some("Fixed it."),
392                },
393                FixtureTask {
394                    eval_id: "crash",
395                    condition: "without_skill",
396                    final_message: Some("Done, I think."),
397                },
398            ],
399        );
400        write_subagent(
401            &sub,
402            "agent-a",
403            "crash:with_skill:i1-nonce1",
404            &transcript_lines("unused"),
405        );
406        write_subagent(
407            &sub,
408            "agent-b",
409            "crash:without_skill:i1-nonce1",
410            &transcript_lines("unused"),
411        );
412
413        let result = record_runs(&iter, Harness::ClaudeCode, Some(&sub), false).unwrap();
414        assert_eq!(result.recorded, 2);
415        assert_eq!(result.missing_transcript, 0);
416
417        let run = read_run(&iter, "crash", "with_skill");
418        assert_eq!(run.eval_id, "crash");
419        assert_eq!(run.condition, "with_skill");
420        assert_eq!(run.skill_path.as_deref(), Some("/staged/skill/SKILL.md"));
421        assert_eq!(run.prompt, "Do the crash task");
422        assert_eq!(run.files.len(), 1);
423        assert_eq!(run.final_message, "Fixed it.");
424        assert_eq!(run.tool_invocations.len(), 1);
425        assert_eq!(run.tool_invocations[0].name, "Bash");
426        assert_eq!(run.tool_invocations[0].ordinal, 0);
427
428        assert!(
429            read_run(&iter, "crash", "without_skill")
430                .skill_path
431                .is_none()
432        );
433
434        let timing = read_timing_value(&iter, "crash", "with_skill");
435        assert_eq!(timing["total_tokens"], json!(TRANSCRIPT_TOKENS));
436        assert_eq!(timing["duration_ms"], json!(TRANSCRIPT_DURATION_MS));
437        assert_eq!(timing["source"], json!("transcript"));
438    }
439
440    #[test]
441    fn carries_run_index_from_dispatch_task_into_each_run_record() {
442        let root = TempDir::new().unwrap();
443        let (iter, _sub) = dirs(&root);
444        let cond_dir = iter.join("eval-crash").join("with_skill");
445        let mut serialized = Vec::new();
446        for k in [1u32, 2] {
447            let run_dir = cond_dir.join(format!("run-{k}"));
448            let outputs_dir = run_dir.join("outputs");
449            fs::create_dir_all(&outputs_dir).unwrap();
450            fs::write(
451                outputs_dir.join("final-message.md"),
452                format!("Fixed it in run {k}."),
453            )
454            .unwrap();
455            write_codex_events(&outputs_dir, "unused");
456            serialized.push(json!({
457                "eval_id": "crash",
458                "condition": "with_skill",
459                "run_index": k,
460                "skill_path": "/staged/skill/SKILL.md",
461                "user_prompt": "Do the crash task",
462                "fixtures": [],
463                "outputs_dir": outputs_dir.to_string_lossy(),
464                "run_record_path": run_dir.join("run.json").to_string_lossy(),
465                "timing_path": run_dir.join("timing.json").to_string_lossy(),
466                "agent_description": format!("crash:with_skill:r{k}:i1-nonce1"),
467            }));
468        }
469        fs::write(
470            iter.join("dispatch.json"),
471            serde_json::to_string_pretty(&json!({"run_nonce": "nonce1", "tasks": serialized}))
472                .unwrap(),
473        )
474        .unwrap();
475
476        let result = record_runs(&iter, Harness::Codex, None, false).unwrap();
477        assert_eq!(result.recorded, 2);
478
479        for k in [1u32, 2] {
480            let raw =
481                fs::read_to_string(cond_dir.join(format!("run-{k}")).join("run.json")).unwrap();
482            let run: RunRecord = serde_json::from_str(&raw).unwrap();
483            assert_eq!(run.run_index, Some(k), "wrong run_index for run-{k}");
484            assert_eq!(run.final_message, format!("Fixed it in run {k}."));
485        }
486    }
487
488    #[test]
489    fn assembles_codex_records_from_each_tasks_events() {
490        let root = TempDir::new().unwrap();
491        let (iter, _sub) = dirs(&root);
492        let paths = write_iteration(
493            &iter,
494            &[FixtureTask {
495                eval_id: "crash",
496                condition: "with_skill",
497                final_message: Some("Fixed it."),
498            }],
499        );
500        write_codex_events(&paths[0].outputs_dir, "Codex final.");
501
502        let result = record_runs(&iter, Harness::Codex, None, false).unwrap();
503        assert_eq!(result.recorded, 1);
504        assert_eq!(result.missing_transcript, 0);
505
506        let run = read_run(&iter, "crash", "with_skill");
507        assert_eq!(run.final_message, "Fixed it.");
508        assert_eq!(
509            serde_json::to_value(&run.tool_invocations).unwrap(),
510            json!([{"name": "command_execution", "ordinal": 0, "args": {"command": "bun test"}, "result": "ok"}])
511        );
512
513        let timing = read_timing_value(&iter, "crash", "with_skill");
514        assert_eq!(
515            timing,
516            json!({"total_tokens": 125, "duration_ms": 30_000, "source": "transcript"})
517        );
518    }
519
520    #[test]
521    fn falls_back_to_codex_final_agent_message_when_final_message_md_missing() {
522        let root = TempDir::new().unwrap();
523        let (iter, _sub) = dirs(&root);
524        let paths = write_iteration(
525            &iter,
526            &[FixtureTask {
527                eval_id: "crash",
528                condition: "with_skill",
529                final_message: None,
530            }],
531        );
532        write_codex_events(&paths[0].outputs_dir, "Closing summary from Codex.");
533
534        let result = record_runs(&iter, Harness::Codex, None, false).unwrap();
535        assert_eq!(result.recorded, 1);
536        assert_eq!(
537            read_run(&iter, "crash", "with_skill").final_message,
538            "Closing summary from Codex."
539        );
540    }
541
542    #[test]
543    fn skips_existing_run_without_overwrite_then_replaces_with_it() {
544        let root = TempDir::new().unwrap();
545        let (iter, sub) = dirs(&root);
546        let paths = write_iteration(
547            &iter,
548            &[FixtureTask {
549                eval_id: "crash",
550                condition: "with_skill",
551                final_message: Some("New."),
552            }],
553        );
554        write_subagent(
555            &sub,
556            "agent-a",
557            "crash:with_skill:i1-nonce1",
558            &transcript_lines("unused"),
559        );
560        let hand_written = json!({
561            "eval_id": "crash", "condition": "with_skill",
562            "skill_path": "/staged/skill/SKILL.md", "prompt": "Do the crash task",
563            "files": [], "final_message": "Agent-authored.", "tool_invocations": []
564        });
565        fs::write(&paths[0].run_record_path, hand_written.to_string()).unwrap();
566
567        let skipped = record_runs(&iter, Harness::ClaudeCode, Some(&sub), false).unwrap();
568        assert_eq!(skipped.recorded, 0);
569        assert_eq!(skipped.skipped_existing, 1);
570        assert_eq!(
571            read_run(&iter, "crash", "with_skill").final_message,
572            "Agent-authored."
573        );
574
575        let replaced = record_runs(&iter, Harness::ClaudeCode, Some(&sub), true).unwrap();
576        assert_eq!(replaced.recorded, 1);
577        assert_eq!(read_run(&iter, "crash", "with_skill").final_message, "New.");
578    }
579
580    #[test]
581    fn backfills_timing_only_when_absent() {
582        let root = TempDir::new().unwrap();
583        let (iter, sub) = dirs(&root);
584        let paths = write_iteration(
585            &iter,
586            &[FixtureTask {
587                eval_id: "crash",
588                condition: "with_skill",
589                final_message: Some("Done."),
590            }],
591        );
592        write_subagent(
593            &sub,
594            "agent-a",
595            "crash:with_skill:i1-nonce1",
596            &transcript_lines("unused"),
597        );
598        fs::write(
599            &paths[0].timing_path,
600            json!({"total_tokens": 12345, "duration_ms": 9000}).to_string(),
601        )
602        .unwrap();
603
604        record_runs(&iter, Harness::ClaudeCode, Some(&sub), false).unwrap();
605
606        // Agent-captured completion-event timing wins; not overwritten.
607        let timing = read_timing_value(&iter, "crash", "with_skill");
608        assert_eq!(timing["total_tokens"], json!(12345));
609        assert_eq!(timing["duration_ms"], json!(9000));
610        assert!(timing.get("source").is_none());
611    }
612
613    #[test]
614    fn falls_back_to_transcript_final_assistant_text_when_final_message_md_missing() {
615        let root = TempDir::new().unwrap();
616        let (iter, sub) = dirs(&root);
617        write_iteration(
618            &iter,
619            &[FixtureTask {
620                eval_id: "crash",
621                condition: "with_skill",
622                final_message: None,
623            }],
624        );
625        write_subagent(
626            &sub,
627            "agent-a",
628            "crash:with_skill:i1-nonce1",
629            &transcript_lines("Closing summary from transcript."),
630        );
631
632        let result = record_runs(&iter, Harness::ClaudeCode, Some(&sub), false).unwrap();
633        assert_eq!(result.recorded, 1);
634        assert_eq!(
635            read_run(&iter, "crash", "with_skill").final_message,
636            "Closing summary from transcript."
637        );
638    }
639
640    #[test]
641    fn skips_the_slot_entirely_when_no_final_message_source_exists() {
642        let root = TempDir::new().unwrap();
643        let (iter, sub) = dirs(&root);
644        write_iteration(
645            &iter,
646            &[FixtureTask {
647                eval_id: "crash",
648                condition: "with_skill",
649                final_message: None,
650            }],
651        );
652        // No final-message.md, no transcript.
653
654        let result = record_runs(&iter, Harness::ClaudeCode, Some(&sub), false).unwrap();
655        assert_eq!(result.recorded, 0);
656        assert_eq!(result.skipped_no_final_message, 1);
657        assert!(!run_exists(&iter, "crash", "with_skill"));
658        assert!(!timing_exists(&iter, "crash", "with_skill"));
659    }
660
661    #[test]
662    fn writes_empty_invocations_and_no_timing_when_transcript_missing() {
663        let root = TempDir::new().unwrap();
664        let (iter, sub) = dirs(&root);
665        write_iteration(
666            &iter,
667            &[FixtureTask {
668                eval_id: "crash",
669                condition: "with_skill",
670                final_message: Some("Done."),
671            }],
672        );
673        // final-message.md exists but no subagent transcript matches.
674
675        let result = record_runs(&iter, Harness::ClaudeCode, Some(&sub), false).unwrap();
676        assert_eq!(result.recorded, 1);
677        assert_eq!(result.missing_transcript, 1);
678
679        let run = read_run(&iter, "crash", "with_skill");
680        assert_eq!(run.final_message, "Done.");
681        assert!(run.tool_invocations.is_empty());
682        assert!(!timing_exists(&iter, "crash", "with_skill"));
683    }
684
685    #[test]
686    fn errors_when_dispatch_json_is_absent() {
687        let root = TempDir::new().unwrap();
688        let (iter, sub) = dirs(&root);
689        // Hand-authored/operator runs have no dispatch.json — the manual path owns them.
690        let err = record_runs(&iter, Harness::ClaudeCode, Some(&sub), false).unwrap_err();
691        assert!(
692            err.to_string().contains("dispatch.json"),
693            "error was: {err}"
694        );
695    }
696
697    #[test]
698    fn no_transcript_warning_when_all_transcripts_matched() {
699        let result = RecordRunsResult {
700            recorded: 4,
701            missing_transcript: 0,
702            ..Default::default()
703        };
704        assert!(result.transcript_warning(Harness::ClaudeCode).is_none());
705    }
706
707    #[test]
708    fn claude_code_warning_names_agent_description_when_all_runs_miss() {
709        let result = RecordRunsResult {
710            recorded: 8,
711            missing_transcript: 8,
712            ..Default::default()
713        };
714        let warning = result.transcript_warning(Harness::ClaudeCode).unwrap();
715        assert!(warning.contains("8"), "names the count: {warning}");
716        assert!(
717            warning.contains("agent_description"),
718            "points at the load-bearing key: {warning}"
719        );
720        assert!(
721            warning.to_lowercase().contains("verbatim"),
722            "says pass it verbatim: {warning}"
723        );
724        assert!(
725            warning.contains("--subagents-dir"),
726            "offers the other likely cause: {warning}"
727        );
728    }
729
730    #[test]
731    fn claude_code_warning_fires_on_partial_miss() {
732        let result = RecordRunsResult {
733            recorded: 4,
734            missing_transcript: 1,
735            ..Default::default()
736        };
737        let warning = result.transcript_warning(Harness::ClaudeCode).unwrap();
738        assert!(warning.contains('1'), "names the count: {warning}");
739    }
740
741    #[test]
742    fn codex_warning_points_at_events_file() {
743        let result = RecordRunsResult {
744            recorded: 2,
745            missing_transcript: 2,
746            ..Default::default()
747        };
748        let warning = result.transcript_warning(Harness::Codex).unwrap();
749        assert!(
750            warning.contains("codex-events.jsonl"),
751            "names the Codex source: {warning}"
752        );
753        assert!(
754            !warning.contains("agent_description"),
755            "Codex doesn't use agent_description: {warning}"
756        );
757    }
758}