1use 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#[derive(Debug, Deserialize)]
31struct DispatchFile {
32 tasks: Option<Vec<DispatchTask>>,
33}
34
35#[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#[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 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
97pub 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 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 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 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 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
184fn 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 const TRANSCRIPT_TOKENS: i64 = 500;
227 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 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 final_message: Option<&'static str>,
281 }
282
283 struct TaskPaths {
285 outputs_dir: PathBuf,
286 run_record_path: PathBuf,
287 timing_path: PathBuf,
288 }
289
290 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 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 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 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 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 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}