1use std::collections::HashMap;
10use std::fs;
11use std::path::Path;
12
13use serde::Deserialize;
14
15use crate::adapters::{adapter_for, find_by_description};
16use crate::core::{ConditionsRecord, DispatchMechanism, Harness, RunRecord, ToolInvocation};
17use crate::pipeline::error::PipelineError;
18use crate::pipeline::io::write_json;
19use crate::pipeline::slots::{run_key, run_slots};
20use crate::validation::{SchemaName, validate_against_schema};
21
22#[derive(Debug, Default, Clone, PartialEq, Eq)]
24pub struct FillTranscriptsResult {
25 pub filled: usize,
26 pub skipped: usize,
27 pub missing: usize,
28}
29
30#[derive(Debug, Deserialize)]
32struct DispatchEnvelope {
33 tasks: Option<Vec<DispatchRef>>,
34}
35
36#[derive(Debug, Deserialize)]
37struct DispatchRef {
38 eval_id: String,
39 condition: String,
40 #[serde(default)]
41 run_index: Option<u32>,
42 #[serde(default)]
43 agent_description: Option<String>,
44 #[serde(default)]
45 outputs_dir: Option<String>,
46}
47
48pub fn resolve_agent_description(
56 iteration_dir: &Path,
57 eval_id: &str,
58 condition: &str,
59 run_index: Option<u32>,
60) -> String {
61 let dispatch_path = iteration_dir.join("dispatch.json");
62 if let Ok(raw) = fs::read_to_string(&dispatch_path)
63 && let Ok(env) = serde_json::from_str::<DispatchEnvelope>(&raw)
64 && let Some(tasks) = env.tasks
65 && let Some(task) = tasks
66 .iter()
67 .find(|t| t.eval_id == eval_id && t.condition == condition && t.run_index == run_index)
68 && let Some(desc) = &task.agent_description
69 {
70 return desc.clone();
71 }
72 run_key(eval_id, condition, run_index)
73}
74
75pub fn fill_transcripts(
78 iteration_dir: &Path,
79 harness: Harness,
80 mechanism: DispatchMechanism,
81 subagents_dir: Option<&Path>,
82 overwrite: bool,
83) -> Result<FillTranscriptsResult, PipelineError> {
84 let conditions_path = iteration_dir.join("conditions.json");
85 if !conditions_path.exists() {
86 return Err(PipelineError::Message(format!(
87 "missing: {}",
88 conditions_path.display()
89 )));
90 }
91 let conditions: ConditionsRecord =
92 serde_json::from_str(&fs::read_to_string(&conditions_path)?)?;
93 let condition_names: Vec<String> = conditions
94 .conditions
95 .iter()
96 .map(|c| c.name.clone())
97 .collect();
98
99 let outputs_by_key = outputs_dirs_by_key(iteration_dir);
100
101 let mut result = FillTranscriptsResult::default();
102 for entry in fs::read_dir(iteration_dir)? {
103 let entry = entry?;
104 let dir_name = entry.file_name().to_string_lossy().into_owned();
105 let Some(eval_id) = dir_name.strip_prefix("eval-") else {
106 continue;
107 };
108
109 for cond in &condition_names {
110 let cond_dir = iteration_dir.join(&dir_name).join(cond);
111 for slot in run_slots(&cond_dir) {
112 let run_path = slot.dir.join("run.json");
113 if !run_path.exists() {
114 continue;
115 }
116
117 let source = run_path.to_string_lossy();
118 let mut run: RunRecord = validate_against_schema(
119 SchemaName::RunRecord,
120 &serde_json::from_str(&fs::read_to_string(&run_path)?)?,
121 &source,
122 )?;
123
124 if !run.tool_invocations.is_empty() && !overwrite {
125 result.skipped += 1;
126 continue;
127 }
128
129 let outputs_dir = outputs_by_key
130 .get(&run_key(eval_id, cond, slot.run_index))
131 .cloned()
132 .unwrap_or_else(|| slot.dir.join("outputs").to_string_lossy().into_owned());
133
134 let description = (mechanism == DispatchMechanism::InSession).then(|| {
137 resolve_agent_description(iteration_dir, eval_id, cond, slot.run_index)
138 });
139 let Some(invocations) = invocations_for_run(
140 harness,
141 mechanism,
142 subagents_dir,
143 description.as_deref(),
144 Path::new(&outputs_dir),
145 ) else {
146 result.missing += 1;
147 continue;
148 };
149
150 run.tool_invocations = invocations;
151 write_json(&run_path, &run)?;
152 result.filled += 1;
153 }
154 }
155 }
156
157 Ok(result)
158}
159
160fn outputs_dirs_by_key(iteration_dir: &Path) -> HashMap<String, String> {
164 let mut out = HashMap::new();
165 if let Ok(raw) = fs::read_to_string(iteration_dir.join("dispatch.json"))
166 && let Ok(env) = serde_json::from_str::<DispatchEnvelope>(&raw)
167 {
168 for t in env.tasks.unwrap_or_default() {
169 if let Some(dir) = t.outputs_dir {
170 out.insert(run_key(&t.eval_id, &t.condition, t.run_index), dir);
171 }
172 }
173 }
174 out
175}
176
177fn invocations_for_run(
183 harness: Harness,
184 mechanism: DispatchMechanism,
185 subagents_dir: Option<&Path>,
186 description: Option<&str>,
187 outputs_dir: &Path,
188) -> Option<Vec<ToolInvocation>> {
189 match mechanism {
190 DispatchMechanism::Cli => {
191 let events_path = outputs_dir.join(adapter_for(harness).cli_events_filename()?);
192 if !events_path.exists() {
193 return None;
194 }
195 adapter_for(harness).parse_cli_events(&events_path).ok()
196 }
197 DispatchMechanism::InSession => {
198 let subagent =
199 find_by_description(subagents_dir.unwrap_or_else(|| Path::new("")), description?)?;
200 adapter_for(harness)
201 .parse_transcript(&subagent.jsonl_path)
202 .ok()
203 }
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210 use serde_json::{Value, json};
211 use std::path::PathBuf;
212 use tempfile::TempDir;
213
214 fn write_dispatch(iteration_dir: &Path, tasks: Value) {
215 fs::create_dir_all(iteration_dir).unwrap();
216 fs::write(
217 iteration_dir.join("dispatch.json"),
218 serde_json::to_string_pretty(&json!({"run_nonce": "abc123", "tasks": tasks})).unwrap(),
219 )
220 .unwrap();
221 }
222
223 fn jsonl(lines: &[Value]) -> String {
224 let body = lines
225 .iter()
226 .map(|l| l.to_string())
227 .collect::<Vec<_>>()
228 .join("\n");
229 format!("{body}\n")
230 }
231
232 fn write_run_record(path: &Path, tool_invocations: Value) {
233 let record = json!({
234 "eval_id": "crash",
235 "condition": "with_skill",
236 "skill_path": "/skill/SKILL.md",
237 "prompt": "Fix it",
238 "files": [],
239 "final_message": "Done.",
240 "tool_invocations": tool_invocations,
241 "total_tokens": Value::Null,
242 "duration_ms": Value::Null,
243 });
244 fs::write(path, serde_json::to_string_pretty(&record).unwrap()).unwrap();
245 }
246
247 #[test]
250 fn returns_the_namespaced_agent_description_from_dispatch() {
251 let root = TempDir::new().unwrap();
252 let dir = root.path().join("iter-canonical");
253 write_dispatch(
254 &dir,
255 json!([
256 {"eval_id": "crash", "condition": "with_skill", "agent_description": "crash:with_skill:i3-abc123"},
257 {"eval_id": "crash", "condition": "without_skill", "agent_description": "crash:without_skill:i3-abc123"}
258 ]),
259 );
260 assert_eq!(
261 resolve_agent_description(&dir, "crash", "with_skill", None),
262 "crash:with_skill:i3-abc123"
263 );
264 assert_eq!(
265 resolve_agent_description(&dir, "crash", "without_skill", None),
266 "crash:without_skill:i3-abc123"
267 );
268 }
269
270 #[test]
271 fn falls_back_to_legacy_reconstruction_when_dispatch_absent() {
272 let root = TempDir::new().unwrap();
273 let dir = root.path().join("iter-no-dispatch");
274 fs::create_dir_all(&dir).unwrap();
275 assert_eq!(
276 resolve_agent_description(&dir, "crash", "with_skill", None),
277 "crash:with_skill"
278 );
279 }
280
281 #[test]
282 fn falls_back_when_task_missing_from_dispatch() {
283 let root = TempDir::new().unwrap();
284 let dir = root.path().join("iter-partial");
285 write_dispatch(
286 &dir,
287 json!([{"eval_id": "other", "condition": "with_skill", "agent_description": "other:with_skill:i1-x"}]),
288 );
289 assert_eq!(
290 resolve_agent_description(&dir, "crash", "with_skill", None),
291 "crash:with_skill"
292 );
293 }
294
295 #[test]
296 fn falls_back_when_dispatch_malformed() {
297 let root = TempDir::new().unwrap();
298 let dir = root.path().join("iter-malformed");
299 fs::create_dir_all(&dir).unwrap();
300 fs::write(dir.join("dispatch.json"), "{ not valid json").unwrap();
301 assert_eq!(
302 resolve_agent_description(&dir, "crash", "with_skill", None),
303 "crash:with_skill"
304 );
305 }
306
307 #[test]
310 fn fills_a_claude_hybrid_run_record_from_outputs_events() {
311 let root = TempDir::new().unwrap();
312 let iteration_dir: PathBuf = root.path().join("iter-claude-fill");
313 let cond_dir = iteration_dir.join("eval-crash").join("with_skill");
314 let outputs_dir = cond_dir.join("outputs");
315 fs::create_dir_all(&outputs_dir).unwrap();
316 let run_path = cond_dir.join("run.json");
317 write_run_record(&run_path, json!([]));
318 fs::write(
319 iteration_dir.join("conditions.json"),
320 json!({
321 "mode": "new-skill",
322 "conditions": [{"name": "with_skill", "skill_path": "/skill/SKILL.md"}],
323 "timestamp": "2026-06-07T00:00:00.000Z",
324 "harness": "claude-code",
325 "run_mode": "hybrid"
326 })
327 .to_string(),
328 )
329 .unwrap();
330 write_dispatch(
331 &iteration_dir,
332 json!([{"eval_id": "crash", "condition": "with_skill", "outputs_dir": outputs_dir.to_string_lossy()}]),
333 );
334 fs::write(
336 outputs_dir.join("claude-events.jsonl"),
337 jsonl(&[
338 json!({"type": "assistant", "message": {"id": "msg_1", "role": "assistant", "content": [{"type": "tool_use", "id": "toolu_1", "name": "Bash", "input": {"command": "bun test"}}]}}),
339 json!({"type": "user", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_1", "content": "ok"}]}}),
340 json!({"type": "result", "subtype": "success", "is_error": false, "result": "Done", "duration_ms": 10, "usage": {"input_tokens": 1, "output_tokens": 1, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}}),
341 ]),
342 )
343 .unwrap();
344
345 let result = fill_transcripts(
346 &iteration_dir,
347 Harness::ClaudeCode,
348 DispatchMechanism::Cli,
349 None,
350 false,
351 )
352 .unwrap();
353 assert_eq!(result.filled, 1);
354 assert_eq!(result.missing, 0);
355
356 let updated: RunRecord =
357 serde_json::from_str(&fs::read_to_string(&run_path).unwrap()).unwrap();
358 assert_eq!(
359 serde_json::to_value(&updated.tool_invocations).unwrap(),
360 json!([{"name": "Bash", "ordinal": 0, "args": {"command": "bun test"}, "result": "ok"}])
361 );
362 }
363
364 #[test]
365 fn fills_a_codex_run_record_from_outputs_events() {
366 let root = TempDir::new().unwrap();
367 let iteration_dir: PathBuf = root.path().join("iter-codex-fill");
368 let cond_dir = iteration_dir.join("eval-crash").join("with_skill");
369 let outputs_dir = cond_dir.join("outputs");
370 fs::create_dir_all(&outputs_dir).unwrap();
371 let run_path = cond_dir.join("run.json");
372 write_run_record(&run_path, json!([]));
373 fs::write(
374 iteration_dir.join("conditions.json"),
375 json!({
376 "mode": "new-skill",
377 "conditions": [{"name": "with_skill", "skill_path": "/skill/SKILL.md"}],
378 "timestamp": "2026-06-07T00:00:00.000Z",
379 "harness": "codex"
380 })
381 .to_string(),
382 )
383 .unwrap();
384 write_dispatch(
385 &iteration_dir,
386 json!([{"eval_id": "crash", "condition": "with_skill", "outputs_dir": outputs_dir.to_string_lossy()}]),
387 );
388 fs::write(
389 outputs_dir.join("codex-events.jsonl"),
390 jsonl(&[
391 json!({"type": "item.completed", "item": {"id": "item_1", "type": "command_execution", "command": "bun test", "output": "ok"}}),
392 ]),
393 )
394 .unwrap();
395
396 let result = fill_transcripts(
397 &iteration_dir,
398 Harness::Codex,
399 DispatchMechanism::Cli,
400 None,
401 false,
402 )
403 .unwrap();
404 assert_eq!(result.filled, 1);
405 assert_eq!(result.missing, 0);
406
407 let updated: RunRecord =
408 serde_json::from_str(&fs::read_to_string(&run_path).unwrap()).unwrap();
409 assert_eq!(
410 serde_json::to_value(&updated.tool_invocations).unwrap(),
411 json!([{"name": "command_execution", "ordinal": 0, "args": {"command": "bun test"}, "result": "ok"}])
412 );
413 }
414
415 #[test]
416 fn fills_codex_run_records_in_nested_run_dirs() {
417 let root = TempDir::new().unwrap();
418 let iteration_dir: PathBuf = root.path().join("iter-codex-multi");
419 let cond_dir = iteration_dir.join("eval-crash").join("with_skill");
420 fs::create_dir_all(&iteration_dir).unwrap();
421 fs::write(
422 iteration_dir.join("conditions.json"),
423 json!({
424 "mode": "new-skill",
425 "conditions": [{"name": "with_skill", "skill_path": "/skill/SKILL.md"}],
426 "timestamp": "2026-06-07T00:00:00.000Z",
427 "harness": "codex"
428 })
429 .to_string(),
430 )
431 .unwrap();
432 for (k, command) in [(1, "bun test"), (2, "bun lint")] {
433 let run_dir = cond_dir.join(format!("run-{k}"));
434 let outputs_dir = run_dir.join("outputs");
435 fs::create_dir_all(&outputs_dir).unwrap();
436 write_run_record(&run_dir.join("run.json"), json!([]));
437 fs::write(
438 outputs_dir.join("codex-events.jsonl"),
439 jsonl(&[
440 json!({"type": "item.completed", "item": {"id": "item_1", "type": "command_execution", "command": command, "output": "ok"}}),
441 ]),
442 )
443 .unwrap();
444 }
445
446 let result = fill_transcripts(
447 &iteration_dir,
448 Harness::Codex,
449 DispatchMechanism::Cli,
450 None,
451 false,
452 )
453 .unwrap();
454 assert_eq!(result.filled, 2);
455 assert_eq!(result.missing, 0);
456
457 for (k, command) in [(1, "bun test"), (2, "bun lint")] {
458 let updated: RunRecord = serde_json::from_str(
459 &fs::read_to_string(cond_dir.join(format!("run-{k}")).join("run.json")).unwrap(),
460 )
461 .unwrap();
462 assert_eq!(
463 serde_json::to_value(&updated.tool_invocations).unwrap(),
464 json!([{"name": "command_execution", "ordinal": 0, "args": {"command": command}, "result": "ok"}]),
465 "wrong invocations for run-{k}"
466 );
467 }
468 }
469}