Skip to main content

eval_magic/pipeline/
fill_transcripts.rs

1//! Stage 2 — `fill-transcripts`.
2//!
3//! Walks the iteration's `eval-*`
4//! directories and, for each `(eval, condition)` `run.json`, populates
5//! `tool_invocations` from the events file the harness CLI wrote under the task's
6//! `outputs_dir` (e.g. Codex's `codex-events.jsonl`, Claude Code's
7//! `claude-events.jsonl`). Records that already carry invocations are skipped
8//! unless `overwrite`.
9
10use std::collections::HashMap;
11use std::fs;
12use std::path::Path;
13
14use serde::Deserialize;
15
16use crate::adapters::adapter_for;
17use crate::core::{ConditionsRecord, Harness, RunRecord, ToolInvocation};
18use crate::pipeline::error::PipelineError;
19use crate::pipeline::io::write_json;
20use crate::pipeline::slots::{run_key, run_slots};
21use crate::validation::{SchemaName, validate_against_schema};
22
23/// Tally of what fill-transcripts did across the iteration's runs.
24#[derive(Debug, Default, Clone, PartialEq, Eq)]
25pub struct FillTranscriptsResult {
26    pub filled: usize,
27    pub skipped: usize,
28    pub missing: usize,
29}
30
31/// The `dispatch.json` fields fill-transcripts reads back.
32#[derive(Debug, Deserialize)]
33struct DispatchEnvelope {
34    tasks: Option<Vec<DispatchRef>>,
35}
36
37#[derive(Debug, Deserialize)]
38struct DispatchRef {
39    eval_id: String,
40    condition: String,
41    #[serde(default)]
42    run_index: Option<u32>,
43    #[serde(default)]
44    outputs_dir: Option<String>,
45}
46
47/// Populate `tool_invocations` for every `run.json` under `iteration_dir`. See
48/// the module docs for the transcript sources and overwrite semantics.
49pub fn fill_transcripts(
50    iteration_dir: &Path,
51    harness: Harness,
52    overwrite: bool,
53) -> Result<FillTranscriptsResult, PipelineError> {
54    let conditions_path = iteration_dir.join("conditions.json");
55    if !conditions_path.exists() {
56        return Err(PipelineError::Message(format!(
57            "missing: {}",
58            conditions_path.display()
59        )));
60    }
61    let conditions: ConditionsRecord =
62        serde_json::from_str(&fs::read_to_string(&conditions_path)?)?;
63    let condition_names: Vec<String> = conditions
64        .conditions
65        .iter()
66        .map(|c| c.name.clone())
67        .collect();
68
69    let outputs_by_key = outputs_dirs_by_key(iteration_dir);
70
71    let mut result = FillTranscriptsResult::default();
72    for entry in fs::read_dir(iteration_dir)? {
73        let entry = entry?;
74        let dir_name = entry.file_name().to_string_lossy().into_owned();
75        let Some(eval_id) = dir_name.strip_prefix("eval-") else {
76            continue;
77        };
78
79        for cond in &condition_names {
80            let cond_dir = iteration_dir.join(&dir_name).join(cond);
81            for slot in run_slots(&cond_dir) {
82                let run_path = slot.dir.join("run.json");
83                if !run_path.exists() {
84                    continue;
85                }
86
87                let source = run_path.to_string_lossy();
88                let mut run: RunRecord = validate_against_schema(
89                    SchemaName::RunRecord,
90                    &serde_json::from_str(&fs::read_to_string(&run_path)?)?,
91                    &source,
92                )?;
93
94                if run.conversation.is_some() {
95                    // record-runs already populated the authoritative global
96                    // tool order from conversation.json. An empty list is a
97                    // legitimate tool-free conversation, not a missing fill.
98                    result.skipped += 1;
99                    continue;
100                }
101
102                if !run.tool_invocations.is_empty() && !overwrite {
103                    result.skipped += 1;
104                    continue;
105                }
106
107                let outputs_dir = outputs_by_key
108                    .get(&run_key(eval_id, cond, slot.run_index))
109                    .cloned()
110                    .unwrap_or_else(|| slot.dir.join("outputs").to_string_lossy().into_owned());
111
112                let Some(invocations) = invocations_for_run(harness, Path::new(&outputs_dir))
113                else {
114                    result.missing += 1;
115                    continue;
116                };
117
118                run.tool_invocations = invocations;
119                write_json(&run_path, &run)?;
120                result.filled += 1;
121            }
122        }
123    }
124
125    Ok(result)
126}
127
128/// Map `"<eval_id>:<condition>[:r<k>]"` → the task's `outputs_dir` from
129/// `dispatch.json`. Empty when the file is absent or malformed (callers fall
130/// back to convention).
131fn outputs_dirs_by_key(iteration_dir: &Path) -> HashMap<String, String> {
132    let mut out = HashMap::new();
133    if let Ok(raw) = fs::read_to_string(iteration_dir.join("dispatch.json"))
134        && let Ok(env) = serde_json::from_str::<DispatchEnvelope>(&raw)
135    {
136        for t in env.tasks.unwrap_or_default() {
137            if let Some(dir) = t.outputs_dir {
138                out.insert(run_key(&t.eval_id, &t.condition, t.run_index), dir);
139            }
140        }
141    }
142    out
143}
144
145/// Parse the invocations for one run: read the events file the harness CLI wrote
146/// under `outputs_dir` (e.g. Codex's `codex-events.jsonl`, Claude Code's
147/// `claude-events.jsonl`). Returns `None` when no events file is found.
148fn invocations_for_run(harness: Harness, outputs_dir: &Path) -> Option<Vec<ToolInvocation>> {
149    let events_path = outputs_dir.join(adapter_for(harness).cli_events_filename()?);
150    if !events_path.exists() {
151        return None;
152    }
153    adapter_for(harness).parse_cli_events(&events_path).ok()
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use serde_json::{Value, json};
160    use std::path::PathBuf;
161    use tempfile::TempDir;
162
163    fn write_dispatch(iteration_dir: &Path, tasks: Value) {
164        fs::create_dir_all(iteration_dir).unwrap();
165        fs::write(
166            iteration_dir.join("dispatch.json"),
167            serde_json::to_string_pretty(&json!({"run_nonce": "abc123", "tasks": tasks})).unwrap(),
168        )
169        .unwrap();
170    }
171
172    fn jsonl(lines: &[Value]) -> String {
173        let body = lines
174            .iter()
175            .map(|l| l.to_string())
176            .collect::<Vec<_>>()
177            .join("\n");
178        format!("{body}\n")
179    }
180
181    fn write_run_record(path: &Path, tool_invocations: Value) {
182        let record = json!({
183            "eval_id": "crash",
184            "condition": "with_skill",
185            "skill_path": "/skill/SKILL.md",
186            "prompt": "Fix it",
187            "files": [],
188            "final_message": "Done.",
189            "tool_invocations": tool_invocations,
190            "total_tokens": Value::Null,
191            "duration_ms": Value::Null,
192        });
193        fs::write(path, serde_json::to_string_pretty(&record).unwrap()).unwrap();
194    }
195
196    // --- fillTranscripts ---
197
198    #[test]
199    fn fills_a_claude_run_record_from_outputs_events() {
200        let root = TempDir::new().unwrap();
201        let iteration_dir: PathBuf = root.path().join("iter-claude-fill");
202        let cond_dir = iteration_dir.join("eval-crash").join("with_skill");
203        let outputs_dir = cond_dir.join("outputs");
204        fs::create_dir_all(&outputs_dir).unwrap();
205        let run_path = cond_dir.join("run.json");
206        write_run_record(&run_path, json!([]));
207        fs::write(
208            iteration_dir.join("conditions.json"),
209            json!({
210                "mode": "new-skill",
211                "conditions": [{"name": "with_skill", "skill_path": "/skill/SKILL.md"}],
212                "timestamp": "2026-06-07T00:00:00.000Z",
213                "harness": "claude-code"
214            })
215            .to_string(),
216        )
217        .unwrap();
218        write_dispatch(
219            &iteration_dir,
220            json!([{"eval_id": "crash", "condition": "with_skill", "outputs_dir": outputs_dir.to_string_lossy()}]),
221        );
222        // `claude -p` stream-json: assistant tool_use + user tool_result + result.
223        fs::write(
224            outputs_dir.join("claude-events.jsonl"),
225            jsonl(&[
226                json!({"type": "assistant", "message": {"id": "msg_1", "role": "assistant", "content": [{"type": "tool_use", "id": "toolu_1", "name": "Bash", "input": {"command": "bun test"}}]}}),
227                json!({"type": "user", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_1", "content": "ok"}]}}),
228                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}}),
229            ]),
230        )
231        .unwrap();
232
233        let result = fill_transcripts(
234            &iteration_dir,
235            Harness::resolve("claude-code").unwrap(),
236            false,
237        )
238        .unwrap();
239        assert_eq!(result.filled, 1);
240        assert_eq!(result.missing, 0);
241
242        let updated: RunRecord =
243            serde_json::from_str(&fs::read_to_string(&run_path).unwrap()).unwrap();
244        assert_eq!(
245            serde_json::to_value(&updated.tool_invocations).unwrap(),
246            json!([{"name": "Bash", "ordinal": 0, "args": {"command": "bun test"}, "result": "ok"}])
247        );
248    }
249
250    #[test]
251    fn fills_a_codex_run_record_from_outputs_events() {
252        let root = TempDir::new().unwrap();
253        let iteration_dir: PathBuf = root.path().join("iter-codex-fill");
254        let cond_dir = iteration_dir.join("eval-crash").join("with_skill");
255        let outputs_dir = cond_dir.join("outputs");
256        fs::create_dir_all(&outputs_dir).unwrap();
257        let run_path = cond_dir.join("run.json");
258        write_run_record(&run_path, json!([]));
259        fs::write(
260            iteration_dir.join("conditions.json"),
261            json!({
262                "mode": "new-skill",
263                "conditions": [{"name": "with_skill", "skill_path": "/skill/SKILL.md"}],
264                "timestamp": "2026-06-07T00:00:00.000Z",
265                "harness": "codex"
266            })
267            .to_string(),
268        )
269        .unwrap();
270        write_dispatch(
271            &iteration_dir,
272            json!([{"eval_id": "crash", "condition": "with_skill", "outputs_dir": outputs_dir.to_string_lossy()}]),
273        );
274        fs::write(
275            outputs_dir.join("codex-events.jsonl"),
276            jsonl(&[
277                json!({"type": "item.completed", "item": {"id": "item_1", "type": "command_execution", "command": "bun test", "output": "ok"}}),
278            ]),
279        )
280        .unwrap();
281
282        let result =
283            fill_transcripts(&iteration_dir, Harness::resolve("codex").unwrap(), false).unwrap();
284        assert_eq!(result.filled, 1);
285        assert_eq!(result.missing, 0);
286
287        let updated: RunRecord =
288            serde_json::from_str(&fs::read_to_string(&run_path).unwrap()).unwrap();
289        assert_eq!(
290            serde_json::to_value(&updated.tool_invocations).unwrap(),
291            json!([{"name": "command_execution", "ordinal": 0, "args": {"command": "bun test"}, "result": "ok"}])
292        );
293    }
294
295    #[test]
296    fn fills_codex_run_records_in_nested_run_dirs() {
297        let root = TempDir::new().unwrap();
298        let iteration_dir: PathBuf = root.path().join("iter-codex-multi");
299        let cond_dir = iteration_dir.join("eval-crash").join("with_skill");
300        fs::create_dir_all(&iteration_dir).unwrap();
301        fs::write(
302            iteration_dir.join("conditions.json"),
303            json!({
304                "mode": "new-skill",
305                "conditions": [{"name": "with_skill", "skill_path": "/skill/SKILL.md"}],
306                "timestamp": "2026-06-07T00:00:00.000Z",
307                "harness": "codex"
308            })
309            .to_string(),
310        )
311        .unwrap();
312        for (k, command) in [(1, "bun test"), (2, "bun lint")] {
313            let run_dir = cond_dir.join(format!("run-{k}"));
314            let outputs_dir = run_dir.join("outputs");
315            fs::create_dir_all(&outputs_dir).unwrap();
316            write_run_record(&run_dir.join("run.json"), json!([]));
317            fs::write(
318                outputs_dir.join("codex-events.jsonl"),
319                jsonl(&[
320                    json!({"type": "item.completed", "item": {"id": "item_1", "type": "command_execution", "command": command, "output": "ok"}}),
321                ]),
322            )
323            .unwrap();
324        }
325
326        let result =
327            fill_transcripts(&iteration_dir, Harness::resolve("codex").unwrap(), false).unwrap();
328        assert_eq!(result.filled, 2);
329        assert_eq!(result.missing, 0);
330
331        for (k, command) in [(1, "bun test"), (2, "bun lint")] {
332            let updated: RunRecord = serde_json::from_str(
333                &fs::read_to_string(cond_dir.join(format!("run-{k}")).join("run.json")).unwrap(),
334            )
335            .unwrap();
336            assert_eq!(
337                serde_json::to_value(&updated.tool_invocations).unwrap(),
338                json!([{"name": "command_execution", "ordinal": 0, "args": {"command": command}, "result": "ok"}]),
339                "wrong invocations for run-{k}"
340            );
341        }
342    }
343}