1use 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#[derive(Debug, Deserialize)]
29struct DispatchFile {
30 tasks: Option<Vec<DispatchTask>>,
31}
32
33#[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#[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 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 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
108pub 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 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 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 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 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 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
207fn 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
243fn 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
259fn 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 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 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 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 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 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 final_message: Option<&'static str>,
415 }
416
417 struct TaskPaths {
419 outputs_dir: PathBuf,
420 run_record_path: PathBuf,
421 timing_path: PathBuf,
422 }
423
424 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 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 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 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 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 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 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 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}