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.tool_invocations.is_empty() && !overwrite {
95                    result.skipped += 1;
96                    continue;
97                }
98
99                let outputs_dir = outputs_by_key
100                    .get(&run_key(eval_id, cond, slot.run_index))
101                    .cloned()
102                    .unwrap_or_else(|| slot.dir.join("outputs").to_string_lossy().into_owned());
103
104                let Some(invocations) = invocations_for_run(harness, Path::new(&outputs_dir))
105                else {
106                    result.missing += 1;
107                    continue;
108                };
109
110                run.tool_invocations = invocations;
111                write_json(&run_path, &run)?;
112                result.filled += 1;
113            }
114        }
115    }
116
117    Ok(result)
118}
119
120/// Map `"<eval_id>:<condition>[:r<k>]"` → the task's `outputs_dir` from
121/// `dispatch.json`. Empty when the file is absent or malformed (callers fall
122/// back to convention).
123fn outputs_dirs_by_key(iteration_dir: &Path) -> HashMap<String, String> {
124    let mut out = HashMap::new();
125    if let Ok(raw) = fs::read_to_string(iteration_dir.join("dispatch.json"))
126        && let Ok(env) = serde_json::from_str::<DispatchEnvelope>(&raw)
127    {
128        for t in env.tasks.unwrap_or_default() {
129            if let Some(dir) = t.outputs_dir {
130                out.insert(run_key(&t.eval_id, &t.condition, t.run_index), dir);
131            }
132        }
133    }
134    out
135}
136
137/// Parse the invocations for one run: read the events file the harness CLI wrote
138/// under `outputs_dir` (e.g. Codex's `codex-events.jsonl`, Claude Code's
139/// `claude-events.jsonl`). Returns `None` when no events file is found.
140fn invocations_for_run(harness: Harness, outputs_dir: &Path) -> Option<Vec<ToolInvocation>> {
141    let events_path = outputs_dir.join(adapter_for(harness).cli_events_filename()?);
142    if !events_path.exists() {
143        return None;
144    }
145    adapter_for(harness).parse_cli_events(&events_path).ok()
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use serde_json::{Value, json};
152    use std::path::PathBuf;
153    use tempfile::TempDir;
154
155    fn write_dispatch(iteration_dir: &Path, tasks: Value) {
156        fs::create_dir_all(iteration_dir).unwrap();
157        fs::write(
158            iteration_dir.join("dispatch.json"),
159            serde_json::to_string_pretty(&json!({"run_nonce": "abc123", "tasks": tasks})).unwrap(),
160        )
161        .unwrap();
162    }
163
164    fn jsonl(lines: &[Value]) -> String {
165        let body = lines
166            .iter()
167            .map(|l| l.to_string())
168            .collect::<Vec<_>>()
169            .join("\n");
170        format!("{body}\n")
171    }
172
173    fn write_run_record(path: &Path, tool_invocations: Value) {
174        let record = json!({
175            "eval_id": "crash",
176            "condition": "with_skill",
177            "skill_path": "/skill/SKILL.md",
178            "prompt": "Fix it",
179            "files": [],
180            "final_message": "Done.",
181            "tool_invocations": tool_invocations,
182            "total_tokens": Value::Null,
183            "duration_ms": Value::Null,
184        });
185        fs::write(path, serde_json::to_string_pretty(&record).unwrap()).unwrap();
186    }
187
188    // --- fillTranscripts ---
189
190    #[test]
191    fn fills_a_claude_run_record_from_outputs_events() {
192        let root = TempDir::new().unwrap();
193        let iteration_dir: PathBuf = root.path().join("iter-claude-fill");
194        let cond_dir = iteration_dir.join("eval-crash").join("with_skill");
195        let outputs_dir = cond_dir.join("outputs");
196        fs::create_dir_all(&outputs_dir).unwrap();
197        let run_path = cond_dir.join("run.json");
198        write_run_record(&run_path, json!([]));
199        fs::write(
200            iteration_dir.join("conditions.json"),
201            json!({
202                "mode": "new-skill",
203                "conditions": [{"name": "with_skill", "skill_path": "/skill/SKILL.md"}],
204                "timestamp": "2026-06-07T00:00:00.000Z",
205                "harness": "claude-code"
206            })
207            .to_string(),
208        )
209        .unwrap();
210        write_dispatch(
211            &iteration_dir,
212            json!([{"eval_id": "crash", "condition": "with_skill", "outputs_dir": outputs_dir.to_string_lossy()}]),
213        );
214        // `claude -p` stream-json: assistant tool_use + user tool_result + result.
215        fs::write(
216            outputs_dir.join("claude-events.jsonl"),
217            jsonl(&[
218                json!({"type": "assistant", "message": {"id": "msg_1", "role": "assistant", "content": [{"type": "tool_use", "id": "toolu_1", "name": "Bash", "input": {"command": "bun test"}}]}}),
219                json!({"type": "user", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_1", "content": "ok"}]}}),
220                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}}),
221            ]),
222        )
223        .unwrap();
224
225        let result = fill_transcripts(
226            &iteration_dir,
227            Harness::resolve("claude-code").unwrap(),
228            false,
229        )
230        .unwrap();
231        assert_eq!(result.filled, 1);
232        assert_eq!(result.missing, 0);
233
234        let updated: RunRecord =
235            serde_json::from_str(&fs::read_to_string(&run_path).unwrap()).unwrap();
236        assert_eq!(
237            serde_json::to_value(&updated.tool_invocations).unwrap(),
238            json!([{"name": "Bash", "ordinal": 0, "args": {"command": "bun test"}, "result": "ok"}])
239        );
240    }
241
242    #[test]
243    fn fills_a_codex_run_record_from_outputs_events() {
244        let root = TempDir::new().unwrap();
245        let iteration_dir: PathBuf = root.path().join("iter-codex-fill");
246        let cond_dir = iteration_dir.join("eval-crash").join("with_skill");
247        let outputs_dir = cond_dir.join("outputs");
248        fs::create_dir_all(&outputs_dir).unwrap();
249        let run_path = cond_dir.join("run.json");
250        write_run_record(&run_path, json!([]));
251        fs::write(
252            iteration_dir.join("conditions.json"),
253            json!({
254                "mode": "new-skill",
255                "conditions": [{"name": "with_skill", "skill_path": "/skill/SKILL.md"}],
256                "timestamp": "2026-06-07T00:00:00.000Z",
257                "harness": "codex"
258            })
259            .to_string(),
260        )
261        .unwrap();
262        write_dispatch(
263            &iteration_dir,
264            json!([{"eval_id": "crash", "condition": "with_skill", "outputs_dir": outputs_dir.to_string_lossy()}]),
265        );
266        fs::write(
267            outputs_dir.join("codex-events.jsonl"),
268            jsonl(&[
269                json!({"type": "item.completed", "item": {"id": "item_1", "type": "command_execution", "command": "bun test", "output": "ok"}}),
270            ]),
271        )
272        .unwrap();
273
274        let result =
275            fill_transcripts(&iteration_dir, Harness::resolve("codex").unwrap(), false).unwrap();
276        assert_eq!(result.filled, 1);
277        assert_eq!(result.missing, 0);
278
279        let updated: RunRecord =
280            serde_json::from_str(&fs::read_to_string(&run_path).unwrap()).unwrap();
281        assert_eq!(
282            serde_json::to_value(&updated.tool_invocations).unwrap(),
283            json!([{"name": "command_execution", "ordinal": 0, "args": {"command": "bun test"}, "result": "ok"}])
284        );
285    }
286
287    #[test]
288    fn fills_codex_run_records_in_nested_run_dirs() {
289        let root = TempDir::new().unwrap();
290        let iteration_dir: PathBuf = root.path().join("iter-codex-multi");
291        let cond_dir = iteration_dir.join("eval-crash").join("with_skill");
292        fs::create_dir_all(&iteration_dir).unwrap();
293        fs::write(
294            iteration_dir.join("conditions.json"),
295            json!({
296                "mode": "new-skill",
297                "conditions": [{"name": "with_skill", "skill_path": "/skill/SKILL.md"}],
298                "timestamp": "2026-06-07T00:00:00.000Z",
299                "harness": "codex"
300            })
301            .to_string(),
302        )
303        .unwrap();
304        for (k, command) in [(1, "bun test"), (2, "bun lint")] {
305            let run_dir = cond_dir.join(format!("run-{k}"));
306            let outputs_dir = run_dir.join("outputs");
307            fs::create_dir_all(&outputs_dir).unwrap();
308            write_run_record(&run_dir.join("run.json"), json!([]));
309            fs::write(
310                outputs_dir.join("codex-events.jsonl"),
311                jsonl(&[
312                    json!({"type": "item.completed", "item": {"id": "item_1", "type": "command_execution", "command": command, "output": "ok"}}),
313                ]),
314            )
315            .unwrap();
316        }
317
318        let result =
319            fill_transcripts(&iteration_dir, Harness::resolve("codex").unwrap(), false).unwrap();
320        assert_eq!(result.filled, 2);
321        assert_eq!(result.missing, 0);
322
323        for (k, command) in [(1, "bun test"), (2, "bun lint")] {
324            let updated: RunRecord = serde_json::from_str(
325                &fs::read_to_string(cond_dir.join(format!("run-{k}")).join("run.json")).unwrap(),
326            )
327            .unwrap();
328            assert_eq!(
329                serde_json::to_value(&updated.tool_invocations).unwrap(),
330                json!([{"name": "command_execution", "ordinal": 0, "args": {"command": command}, "result": "ok"}]),
331                "wrong invocations for run-{k}"
332            );
333        }
334    }
335}