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::{
17 ConditionsRecord, DispatchMechanism, Harness, RunRecord, ToolInvocation, mechanism_for,
18};
19use crate::pipeline::error::PipelineError;
20use crate::pipeline::io::write_json;
21use crate::pipeline::slots::{run_key, run_slots};
22use crate::validation::{SchemaName, validate_against_schema};
23
24#[derive(Debug, Default, Clone, PartialEq, Eq)]
26pub struct FillTranscriptsResult {
27 pub filled: usize,
28 pub skipped: usize,
29 pub missing: usize,
30}
31
32#[derive(Debug, Deserialize)]
34struct DispatchEnvelope {
35 tasks: Option<Vec<DispatchRef>>,
36}
37
38#[derive(Debug, Deserialize)]
39struct DispatchRef {
40 eval_id: String,
41 condition: String,
42 #[serde(default)]
43 run_index: Option<u32>,
44 #[serde(default)]
45 agent_description: Option<String>,
46 #[serde(default)]
47 outputs_dir: Option<String>,
48}
49
50pub fn resolve_agent_description(
58 iteration_dir: &Path,
59 eval_id: &str,
60 condition: &str,
61 run_index: Option<u32>,
62) -> String {
63 let dispatch_path = iteration_dir.join("dispatch.json");
64 if let Ok(raw) = fs::read_to_string(&dispatch_path)
65 && let Ok(env) = serde_json::from_str::<DispatchEnvelope>(&raw)
66 && let Some(tasks) = env.tasks
67 && let Some(task) = tasks
68 .iter()
69 .find(|t| t.eval_id == eval_id && t.condition == condition && t.run_index == run_index)
70 && let Some(desc) = &task.agent_description
71 {
72 return desc.clone();
73 }
74 run_key(eval_id, condition, run_index)
75}
76
77pub fn fill_transcripts(
80 iteration_dir: &Path,
81 harness: Harness,
82 subagents_dir: Option<&Path>,
83 overwrite: bool,
84) -> Result<FillTranscriptsResult, PipelineError> {
85 let conditions_path = iteration_dir.join("conditions.json");
86 if !conditions_path.exists() {
87 return Err(PipelineError::Message(format!(
88 "missing: {}",
89 conditions_path.display()
90 )));
91 }
92 let conditions: ConditionsRecord =
93 serde_json::from_str(&fs::read_to_string(&conditions_path)?)?;
94 let condition_names: Vec<String> = conditions
95 .conditions
96 .iter()
97 .map(|c| c.name.clone())
98 .collect();
99
100 let outputs_by_key = outputs_dirs_by_key(iteration_dir);
101
102 let mut result = FillTranscriptsResult::default();
103 for entry in fs::read_dir(iteration_dir)? {
104 let entry = entry?;
105 let dir_name = entry.file_name().to_string_lossy().into_owned();
106 let Some(eval_id) = dir_name.strip_prefix("eval-") else {
107 continue;
108 };
109
110 for cond in &condition_names {
111 let cond_dir = iteration_dir.join(&dir_name).join(cond);
112 for slot in run_slots(&cond_dir) {
113 let run_path = slot.dir.join("run.json");
114 if !run_path.exists() {
115 continue;
116 }
117
118 let source = run_path.to_string_lossy();
119 let mut run: RunRecord = validate_against_schema(
120 SchemaName::RunRecord,
121 &serde_json::from_str(&fs::read_to_string(&run_path)?)?,
122 &source,
123 )?;
124
125 if !run.tool_invocations.is_empty() && !overwrite {
126 result.skipped += 1;
127 continue;
128 }
129
130 let outputs_dir = outputs_by_key
131 .get(&run_key(eval_id, cond, slot.run_index))
132 .cloned()
133 .unwrap_or_else(|| slot.dir.join("outputs").to_string_lossy().into_owned());
134
135 let Some(invocations) = invocations_for_run(
136 harness,
137 subagents_dir,
138 iteration_dir,
139 eval_id,
140 cond,
141 slot.run_index,
142 Path::new(&outputs_dir),
143 ) else {
144 result.missing += 1;
145 continue;
146 };
147
148 run.tool_invocations = invocations;
149 write_json(&run_path, &run)?;
150 result.filled += 1;
151 }
152 }
153 }
154
155 Ok(result)
156}
157
158fn outputs_dirs_by_key(iteration_dir: &Path) -> HashMap<String, String> {
162 let mut out = HashMap::new();
163 if let Ok(raw) = fs::read_to_string(iteration_dir.join("dispatch.json"))
164 && let Ok(env) = serde_json::from_str::<DispatchEnvelope>(&raw)
165 {
166 for t in env.tasks.unwrap_or_default() {
167 if let Some(dir) = t.outputs_dir {
168 out.insert(run_key(&t.eval_id, &t.condition, t.run_index), dir);
169 }
170 }
171 }
172 out
173}
174
175fn invocations_for_run(
180 harness: Harness,
181 subagents_dir: Option<&Path>,
182 iteration_dir: &Path,
183 eval_id: &str,
184 condition: &str,
185 run_index: Option<u32>,
186 outputs_dir: &Path,
187) -> Option<Vec<ToolInvocation>> {
188 match mechanism_for(harness) {
189 DispatchMechanism::Cli => {
190 let events_path = outputs_dir.join(adapter_for(harness).cli_events_filename()?);
191 if !events_path.exists() {
192 return None;
193 }
194 adapter_for(harness).parse_transcript(&events_path).ok()
195 }
196 DispatchMechanism::InSession => {
197 let description =
198 resolve_agent_description(iteration_dir, eval_id, condition, run_index);
199 let subagent =
200 find_by_description(subagents_dir.unwrap_or_else(|| Path::new("")), &description)?;
201 adapter_for(harness)
202 .parse_transcript(&subagent.jsonl_path)
203 .ok()
204 }
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211 use serde_json::{Value, json};
212 use std::path::PathBuf;
213 use tempfile::TempDir;
214
215 fn write_dispatch(iteration_dir: &Path, tasks: Value) {
216 fs::create_dir_all(iteration_dir).unwrap();
217 fs::write(
218 iteration_dir.join("dispatch.json"),
219 serde_json::to_string_pretty(&json!({"run_nonce": "abc123", "tasks": tasks})).unwrap(),
220 )
221 .unwrap();
222 }
223
224 fn jsonl(lines: &[Value]) -> String {
225 let body = lines
226 .iter()
227 .map(|l| l.to_string())
228 .collect::<Vec<_>>()
229 .join("\n");
230 format!("{body}\n")
231 }
232
233 fn write_run_record(path: &Path, tool_invocations: Value) {
234 let record = json!({
235 "eval_id": "crash",
236 "condition": "with_skill",
237 "skill_path": "/skill/SKILL.md",
238 "prompt": "Fix it",
239 "files": [],
240 "final_message": "Done.",
241 "tool_invocations": tool_invocations,
242 "total_tokens": Value::Null,
243 "duration_ms": Value::Null,
244 });
245 fs::write(path, serde_json::to_string_pretty(&record).unwrap()).unwrap();
246 }
247
248 #[test]
251 fn returns_the_namespaced_agent_description_from_dispatch() {
252 let root = TempDir::new().unwrap();
253 let dir = root.path().join("iter-canonical");
254 write_dispatch(
255 &dir,
256 json!([
257 {"eval_id": "crash", "condition": "with_skill", "agent_description": "crash:with_skill:i3-abc123"},
258 {"eval_id": "crash", "condition": "without_skill", "agent_description": "crash:without_skill:i3-abc123"}
259 ]),
260 );
261 assert_eq!(
262 resolve_agent_description(&dir, "crash", "with_skill", None),
263 "crash:with_skill:i3-abc123"
264 );
265 assert_eq!(
266 resolve_agent_description(&dir, "crash", "without_skill", None),
267 "crash:without_skill:i3-abc123"
268 );
269 }
270
271 #[test]
272 fn falls_back_to_legacy_reconstruction_when_dispatch_absent() {
273 let root = TempDir::new().unwrap();
274 let dir = root.path().join("iter-no-dispatch");
275 fs::create_dir_all(&dir).unwrap();
276 assert_eq!(
277 resolve_agent_description(&dir, "crash", "with_skill", None),
278 "crash:with_skill"
279 );
280 }
281
282 #[test]
283 fn falls_back_when_task_missing_from_dispatch() {
284 let root = TempDir::new().unwrap();
285 let dir = root.path().join("iter-partial");
286 write_dispatch(
287 &dir,
288 json!([{"eval_id": "other", "condition": "with_skill", "agent_description": "other:with_skill:i1-x"}]),
289 );
290 assert_eq!(
291 resolve_agent_description(&dir, "crash", "with_skill", None),
292 "crash:with_skill"
293 );
294 }
295
296 #[test]
297 fn falls_back_when_dispatch_malformed() {
298 let root = TempDir::new().unwrap();
299 let dir = root.path().join("iter-malformed");
300 fs::create_dir_all(&dir).unwrap();
301 fs::write(dir.join("dispatch.json"), "{ not valid json").unwrap();
302 assert_eq!(
303 resolve_agent_description(&dir, "crash", "with_skill", None),
304 "crash:with_skill"
305 );
306 }
307
308 #[test]
311 fn fills_a_codex_run_record_from_outputs_events() {
312 let root = TempDir::new().unwrap();
313 let iteration_dir: PathBuf = root.path().join("iter-codex-fill");
314 let cond_dir = iteration_dir.join("eval-crash").join("with_skill");
315 let outputs_dir = cond_dir.join("outputs");
316 fs::create_dir_all(&outputs_dir).unwrap();
317 let run_path = cond_dir.join("run.json");
318 write_run_record(&run_path, json!([]));
319 fs::write(
320 iteration_dir.join("conditions.json"),
321 json!({
322 "mode": "new-skill",
323 "conditions": [{"name": "with_skill", "skill_path": "/skill/SKILL.md"}],
324 "timestamp": "2026-06-07T00:00:00.000Z",
325 "harness": "codex"
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(
335 outputs_dir.join("codex-events.jsonl"),
336 jsonl(&[
337 json!({"type": "item.completed", "item": {"id": "item_1", "type": "command_execution", "command": "bun test", "output": "ok"}}),
338 ]),
339 )
340 .unwrap();
341
342 let result = fill_transcripts(&iteration_dir, Harness::Codex, None, false).unwrap();
343 assert_eq!(result.filled, 1);
344 assert_eq!(result.missing, 0);
345
346 let updated: RunRecord =
347 serde_json::from_str(&fs::read_to_string(&run_path).unwrap()).unwrap();
348 assert_eq!(
349 serde_json::to_value(&updated.tool_invocations).unwrap(),
350 json!([{"name": "command_execution", "ordinal": 0, "args": {"command": "bun test"}, "result": "ok"}])
351 );
352 }
353
354 #[test]
355 fn fills_codex_run_records_in_nested_run_dirs() {
356 let root = TempDir::new().unwrap();
357 let iteration_dir: PathBuf = root.path().join("iter-codex-multi");
358 let cond_dir = iteration_dir.join("eval-crash").join("with_skill");
359 fs::create_dir_all(&iteration_dir).unwrap();
360 fs::write(
361 iteration_dir.join("conditions.json"),
362 json!({
363 "mode": "new-skill",
364 "conditions": [{"name": "with_skill", "skill_path": "/skill/SKILL.md"}],
365 "timestamp": "2026-06-07T00:00:00.000Z",
366 "harness": "codex"
367 })
368 .to_string(),
369 )
370 .unwrap();
371 for (k, command) in [(1, "bun test"), (2, "bun lint")] {
372 let run_dir = cond_dir.join(format!("run-{k}"));
373 let outputs_dir = run_dir.join("outputs");
374 fs::create_dir_all(&outputs_dir).unwrap();
375 write_run_record(&run_dir.join("run.json"), json!([]));
376 fs::write(
377 outputs_dir.join("codex-events.jsonl"),
378 jsonl(&[
379 json!({"type": "item.completed", "item": {"id": "item_1", "type": "command_execution", "command": command, "output": "ok"}}),
380 ]),
381 )
382 .unwrap();
383 }
384
385 let result = fill_transcripts(&iteration_dir, Harness::Codex, None, false).unwrap();
386 assert_eq!(result.filled, 2);
387 assert_eq!(result.missing, 0);
388
389 for (k, command) in [(1, "bun test"), (2, "bun lint")] {
390 let updated: RunRecord = serde_json::from_str(
391 &fs::read_to_string(cond_dir.join(format!("run-{k}")).join("run.json")).unwrap(),
392 )
393 .unwrap();
394 assert_eq!(
395 serde_json::to_value(&updated.tool_invocations).unwrap(),
396 json!([{"name": "command_execution", "ordinal": 0, "args": {"command": command}, "result": "ok"}]),
397 "wrong invocations for run-{k}"
398 );
399 }
400 }
401}