Skip to main content

eval_magic/adapters/
extract.rs

1//! Declarative transcript extraction for flat event streams.
2//!
3//! [`ExtractSpec`] is the data half of a descriptor's `[transcript.extract]`
4//! block: five primitives — an equality event filter, a final-text field pick,
5//! a flat tool-item mapping, a token sum, and a duration rule — interpreted by
6//! this one generic engine. A stream that needs more (keyed cross-event joins,
7//! content coercion) is a named code capability
8//! ([`super::capabilities::TranscriptParser`]), not a bigger spec.
9//!
10//! Fixed rules the spec cannot change: `final_text` and `duration.field` take
11//! the last match; `timestamp_spread` needs at least two parseable RFC 3339
12//! timestamps; `result_coalesce` takes the first *present* field
13//! (present-but-null counts), strings verbatim and everything else as compact
14//! JSON; args preserve the item's key order; dotted paths descend objects
15//! only, so keys containing literal dots are unaddressable.
16
17use crate::adapters::TranscriptSummary;
18use crate::adapters::transcript::read_jsonl;
19use crate::core::ToolInvocation;
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22use std::collections::BTreeMap;
23use std::io;
24use std::path::Path;
25
26/// Dotted-path → expected-string equality filter; empty matches every record.
27type Where = BTreeMap<String, String>;
28
29/// The `[transcript.extract]` block: which of the four outputs to produce and
30/// how. Validation requires at least one sub-table.
31#[derive(Debug, Clone, Deserialize, Serialize)]
32pub struct ExtractSpec {
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub tools: Option<ToolsExtract>,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub final_text: Option<FieldPick>,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub tokens: Option<TokensExtract>,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub duration: Option<DurationExtract>,
41}
42
43/// Flat tool-item mapping: each matching record's item object becomes one
44/// [`ToolInvocation`].
45#[derive(Debug, Clone, Deserialize, Serialize)]
46pub struct ToolsExtract {
47    #[serde(default, rename = "where", skip_serializing_if = "BTreeMap::is_empty")]
48    pub r#where: Where,
49    /// Dotted path to the tool object within the record; omit when the record
50    /// itself is the item.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub item: Option<String>,
53    /// Field of the item whose string value is the invocation name; records
54    /// without it are skipped.
55    pub name_field: String,
56    /// Names that are not tools (e.g. message/reasoning items).
57    #[serde(default, skip_serializing_if = "Vec::is_empty")]
58    pub skip_names: Vec<String>,
59    /// Structural keys excluded from args; whatever remains is the args
60    /// object, `null` when empty.
61    #[serde(default, skip_serializing_if = "Vec::is_empty")]
62    pub args_omit: Vec<String>,
63    /// Result = first present of these item fields.
64    #[serde(default, skip_serializing_if = "Vec::is_empty")]
65    pub result_coalesce: Vec<String>,
66}
67
68/// Field pick over matching records; the last match wins.
69#[derive(Debug, Clone, Deserialize, Serialize)]
70pub struct FieldPick {
71    #[serde(default, rename = "where", skip_serializing_if = "BTreeMap::is_empty")]
72    pub r#where: Where,
73    pub field: String,
74}
75
76/// Token reduction: over matching records, sum the listed integer fields. A
77/// record where no listed path resolves leaves the total untouched (it never
78/// turns an absent total into zero).
79#[derive(Debug, Clone, Deserialize, Serialize)]
80pub struct TokensExtract {
81    #[serde(default, rename = "where", skip_serializing_if = "BTreeMap::is_empty")]
82    pub r#where: Where,
83    pub sum: Vec<String>,
84}
85
86/// Duration: either a direct millisecond field pick (last match wins) or the
87/// spread between the first and last RFC 3339 timestamps. Validation requires
88/// exactly one variant.
89#[derive(Debug, Clone, Deserialize, Serialize)]
90pub struct DurationExtract {
91    #[serde(default, rename = "where", skip_serializing_if = "BTreeMap::is_empty")]
92    pub r#where: Where,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub field: Option<String>,
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub timestamp_spread: Option<String>,
97}
98
99/// Extract ordered tool invocations from a JSONL events file.
100pub(crate) fn parse(spec: &ExtractSpec, path: &Path) -> io::Result<Vec<ToolInvocation>> {
101    let records = read_jsonl::<Value>(path)?;
102    Ok(extract_tools(spec, &records))
103}
104
105/// Extract a full [`TranscriptSummary`] from a JSONL events file.
106pub(crate) fn parse_full(spec: &ExtractSpec, path: &Path) -> io::Result<TranscriptSummary> {
107    let records = read_jsonl::<Value>(path)?;
108    Ok(TranscriptSummary {
109        tool_invocations: extract_tools(spec, &records),
110        total_tokens: spec
111            .tokens
112            .as_ref()
113            .and_then(|t| extract_tokens(t, &records)),
114        duration_ms: spec
115            .duration
116            .as_ref()
117            .and_then(|d| extract_duration(d, &records)),
118        final_text: spec
119            .final_text
120            .as_ref()
121            .and_then(|f| extract_final_text(f, &records)),
122    })
123}
124
125/// Follow a dotted path through nested objects.
126fn resolve<'a>(record: &'a Value, path: &str) -> Option<&'a Value> {
127    path.split('.')
128        .try_fold(record, |value, segment| value.get(segment))
129}
130
131fn matches(record: &Value, filter: &Where) -> bool {
132    filter
133        .iter()
134        .all(|(path, expected)| resolve(record, path).and_then(Value::as_str) == Some(expected))
135}
136
137fn stringify_value(v: &Value) -> String {
138    match v {
139        Value::String(s) => s.clone(),
140        other => serde_json::to_string(other).unwrap_or_default(),
141    }
142}
143
144fn extract_tools(spec: &ExtractSpec, records: &[Value]) -> Vec<ToolInvocation> {
145    let Some(tools) = &spec.tools else {
146        return Vec::new();
147    };
148    let mut invocations = Vec::new();
149    for record in records {
150        if !matches(record, &tools.r#where) {
151            continue;
152        }
153        let item_value = match &tools.item {
154            Some(path) => match resolve(record, path) {
155                Some(v) => v,
156                None => continue,
157            },
158            None => record,
159        };
160        let Some(item) = item_value.as_object() else {
161            continue;
162        };
163        let Some(name) = item.get(&tools.name_field).and_then(Value::as_str) else {
164            continue;
165        };
166        if tools.skip_names.iter().any(|skip| skip == name) {
167            continue;
168        }
169        let mut args = serde_json::Map::new();
170        for (key, value) in item {
171            if tools.args_omit.iter().any(|omit| omit == key) {
172                continue;
173            }
174            args.insert(key.clone(), value.clone());
175        }
176        let result = tools
177            .result_coalesce
178            .iter()
179            .find_map(|key| item.get(key).map(stringify_value));
180        let ordinal = invocations.len() as u32;
181        invocations.push(ToolInvocation {
182            name: name.to_string(),
183            args: (!args.is_empty()).then_some(Value::Object(args)),
184            result: result.map(Value::String),
185            ordinal,
186        });
187    }
188    invocations
189}
190
191fn extract_final_text(pick: &FieldPick, records: &[Value]) -> Option<String> {
192    records
193        .iter()
194        .filter(|record| matches(record, &pick.r#where))
195        .filter_map(|record| resolve(record, &pick.field).and_then(Value::as_str))
196        .next_back()
197        .map(str::to_string)
198}
199
200fn extract_tokens(tokens: &TokensExtract, records: &[Value]) -> Option<i64> {
201    let mut total: Option<i64> = None;
202    for record in records {
203        if !matches(record, &tokens.r#where) {
204            continue;
205        }
206        let resolved: Vec<&Value> = tokens
207            .sum
208            .iter()
209            .filter_map(|path| resolve(record, path))
210            .collect();
211        // No listed path resolves: the record carries no token report, and
212        // must not turn an absent total into zero.
213        if resolved.is_empty() {
214            continue;
215        }
216        let sum: i64 = resolved.iter().filter_map(|v| v.as_i64()).sum();
217        total = Some(total.unwrap_or(0) + sum);
218    }
219    total
220}
221
222fn parse_millis(s: &str) -> Option<i64> {
223    chrono::DateTime::parse_from_rfc3339(s)
224        .ok()
225        .map(|dt| dt.timestamp_millis())
226}
227
228fn extract_duration(duration: &DurationExtract, records: &[Value]) -> Option<i64> {
229    let matching = records
230        .iter()
231        .filter(|record| matches(record, &duration.r#where));
232    if let Some(field) = &duration.field {
233        return matching
234            .filter_map(|record| resolve(record, field).and_then(Value::as_i64))
235            .next_back();
236    }
237    let ts_field = duration.timestamp_spread.as_ref()?;
238    let mut first: Option<i64> = None;
239    let mut last: Option<i64> = None;
240    let mut count = 0usize;
241    for record in matching {
242        let Some(ts) = resolve(record, ts_field)
243            .and_then(Value::as_str)
244            .and_then(parse_millis)
245        else {
246            continue;
247        };
248        if first.is_none() {
249            first = Some(ts);
250        }
251        last = Some(ts);
252        count += 1;
253    }
254    match (first, last) {
255        (Some(f), Some(l)) if count >= 2 => Some(l - f),
256        _ => None,
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use serde_json::json;
264    use std::fs;
265    use tempfile::TempDir;
266
267    fn write_jsonl(path: &Path, lines: &[Value]) {
268        let body = lines
269            .iter()
270            .map(|l| l.to_string())
271            .collect::<Vec<_>>()
272            .join("\n");
273        fs::write(path, format!("{body}\n")).unwrap();
274    }
275
276    /// The Codex stream expressed in the extractor language — the worked
277    /// example from the BYOH guide, kept equivalent to the `codex-items` code
278    /// parser by the differential test below.
279    fn codex_spec() -> ExtractSpec {
280        toml::from_str(
281            r#"
282            [tools]
283            where = { type = "item.completed" }
284            item = "item"
285            name_field = "type"
286            skip_names = ["agent_message", "reasoning", "plan_update"]
287            args_omit = ["id", "type", "status", "output", "result", "error"]
288            result_coalesce = ["output", "result", "error"]
289
290            [final_text]
291            where = { type = "item.completed", "item.type" = "agent_message" }
292            field = "item.text"
293
294            [tokens]
295            where = { type = "turn.completed" }
296            sum = ["usage.input_tokens", "usage.output_tokens", "usage.reasoning_output_tokens"]
297
298            [duration]
299            timestamp_spread = "timestamp"
300            "#,
301        )
302        .unwrap()
303    }
304
305    #[test]
306    fn extracts_completed_tool_items_with_ordinals_args_and_results() {
307        let dir = TempDir::new().unwrap();
308        let path = dir.path().join("items.jsonl");
309        write_jsonl(
310            &path,
311            &[
312                json!({"type": "item.started", "timestamp": "2026-06-07T10:00:00.000Z", "item": {"id": "item_1", "type": "command_execution", "command": "bash -lc 'bun test'", "status": "in_progress"}}),
313                json!({"type": "item.completed", "timestamp": "2026-06-07T10:00:02.000Z", "item": {"id": "item_1", "type": "command_execution", "command": "bash -lc 'bun test'", "output": "2 pass\n0 fail", "status": "completed"}}),
314                json!({"type": "item.completed", "item": {"id": "item_2", "type": "file_change", "path": "src/app.ts", "status": "completed"}}),
315                json!({"type": "item.completed", "item": {"id": "item_3", "type": "agent_message", "text": "Done."}}),
316            ],
317        );
318
319        let result = parse(&codex_spec(), &path).unwrap();
320        assert_eq!(result.len(), 2);
321        assert_eq!(
322            result[0],
323            ToolInvocation {
324                name: "command_execution".into(),
325                args: Some(json!({"command": "bash -lc 'bun test'"})),
326                result: Some(Value::String("2 pass\n0 fail".into())),
327                ordinal: 0,
328            }
329        );
330        assert_eq!(
331            result[1],
332            ToolInvocation {
333                name: "file_change".into(),
334                args: Some(json!({"path": "src/app.ts"})),
335                result: None,
336                ordinal: 1,
337            }
338        );
339    }
340
341    #[test]
342    fn skips_malformed_jsonl_lines() {
343        let dir = TempDir::new().unwrap();
344        let path = dir.path().join("malformed.jsonl");
345        let good = json!({"type": "item.completed", "item": {"id": "item_1", "type": "web_search", "query": "codex exec json"}});
346        fs::write(&path, format!("{good}\nnot valid json\n")).unwrap();
347        assert_eq!(
348            parse(&codex_spec(), &path).unwrap(),
349            vec![ToolInvocation {
350                name: "web_search".into(),
351                args: Some(json!({"query": "codex exec json"})),
352                result: None,
353                ordinal: 0,
354            }]
355        );
356    }
357
358    #[test]
359    fn preserves_text_fields_on_non_message_tool_items() {
360        let dir = TempDir::new().unwrap();
361        let path = dir.path().join("tool-text.jsonl");
362        write_jsonl(
363            &path,
364            &[
365                json!({"type": "item.completed", "item": {"id": "item_1", "type": "web_search", "query": "codex events", "text": "search summary"}}),
366            ],
367        );
368        assert_eq!(
369            parse(&codex_spec(), &path).unwrap(),
370            vec![ToolInvocation {
371                name: "web_search".into(),
372                args: Some(json!({"query": "codex events", "text": "search summary"})),
373                result: None,
374                ordinal: 0,
375            }]
376        );
377    }
378
379    #[test]
380    fn does_not_treat_skip_named_items_as_tools() {
381        let dir = TempDir::new().unwrap();
382        let path = dir.path().join("non-tools.jsonl");
383        write_jsonl(
384            &path,
385            &[
386                json!({"type": "item.completed", "item": {"id": "a", "type": "agent_message"}}),
387                json!({"type": "item.completed", "item": {"id": "b", "type": "reasoning"}}),
388                json!({"type": "item.completed", "item": {"id": "c", "type": "plan_update"}}),
389            ],
390        );
391        assert_eq!(parse(&codex_spec(), &path).unwrap(), vec![]);
392    }
393
394    #[test]
395    fn extracts_invocations_last_agent_text_usage_and_duration() {
396        let dir = TempDir::new().unwrap();
397        let path = dir.path().join("full.jsonl");
398        write_jsonl(
399            &path,
400            &[
401                json!({"type": "thread.started", "timestamp": "2026-06-07T10:00:00.000Z"}),
402                json!({"type": "item.completed", "timestamp": "2026-06-07T10:00:03.000Z", "item": {"id": "item_1", "type": "command_execution", "command": "ls", "output": "README.md"}}),
403                json!({"type": "item.completed", "timestamp": "2026-06-07T10:00:04.000Z", "item": {"id": "item_2", "type": "agent_message", "text": "First."}}),
404                json!({"type": "item.completed", "timestamp": "2026-06-07T10:00:05.000Z", "item": {"id": "item_3", "type": "agent_message", "text": "Final."}}),
405                json!({"type": "turn.completed", "timestamp": "2026-06-07T10:00:10.000Z", "usage": {"input_tokens": 100, "cached_input_tokens": 75, "output_tokens": 20, "reasoning_output_tokens": 5}}),
406            ],
407        );
408
409        let full = parse_full(&codex_spec(), &path).unwrap();
410        assert_eq!(
411            full.tool_invocations,
412            vec![ToolInvocation {
413                name: "command_execution".into(),
414                args: Some(json!({"command": "ls"})),
415                result: Some(Value::String("README.md".into())),
416                ordinal: 0,
417            }]
418        );
419        assert_eq!(full.final_text, Some("Final.".into()));
420        assert_eq!(full.total_tokens, Some(125)); // fields not listed in `sum` excluded
421        assert_eq!(full.duration_ms, Some(10_000));
422    }
423
424    #[test]
425    fn returns_null_usage_and_duration_when_sparse() {
426        let dir = TempDir::new().unwrap();
427        let path = dir.path().join("sparse.jsonl");
428        write_jsonl(
429            &path,
430            &[
431                json!({"type": "item.completed", "timestamp": "2026-06-07T10:00:00.000Z", "item": {"id": "item_1", "type": "agent_message", "text": "Done."}}),
432            ],
433        );
434        let full = parse_full(&codex_spec(), &path).unwrap();
435        assert_eq!(full.final_text, Some("Done.".into()));
436        assert_eq!(full.total_tokens, None);
437        assert_eq!(full.duration_ms, None);
438    }
439
440    #[test]
441    fn duration_field_variant_picks_the_last_match() {
442        let spec: ExtractSpec = toml::from_str(
443            r#"
444            [duration]
445            where = { type = "result" }
446            field = "elapsed_ms"
447            "#,
448        )
449        .unwrap();
450        let dir = TempDir::new().unwrap();
451        let path = dir.path().join("durations.jsonl");
452        write_jsonl(
453            &path,
454            &[
455                json!({"type": "result", "elapsed_ms": 1_000}),
456                json!({"type": "progress", "elapsed_ms": 9_999}),
457                json!({"type": "result", "elapsed_ms": 2_500}),
458            ],
459        );
460        assert_eq!(parse_full(&spec, &path).unwrap().duration_ms, Some(2_500));
461    }
462
463    #[test]
464    fn matching_event_without_any_listed_token_field_leaves_tokens_null() {
465        let dir = TempDir::new().unwrap();
466        let path = dir.path().join("bare-turn.jsonl");
467        write_jsonl(&path, &[json!({"type": "turn.completed"})]);
468        assert_eq!(parse_full(&codex_spec(), &path).unwrap().total_tokens, None);
469    }
470
471    #[test]
472    fn partial_token_fields_count_and_missing_ones_add_zero() {
473        let dir = TempDir::new().unwrap();
474        let path = dir.path().join("partial-usage.jsonl");
475        write_jsonl(
476            &path,
477            &[
478                json!({"type": "turn.completed", "usage": {"input_tokens": 40}}),
479                json!({"type": "turn.completed", "usage": {"output_tokens": 2}}),
480            ],
481        );
482        assert_eq!(
483            parse_full(&codex_spec(), &path).unwrap().total_tokens,
484            Some(42)
485        );
486    }
487
488    #[test]
489    fn flat_records_map_without_an_item_root() {
490        let spec: ExtractSpec = toml::from_str(
491            r#"
492            [tools]
493            where = { event = "tool" }
494            name_field = "name"
495            args_omit = ["event", "name", "output"]
496            result_coalesce = ["output"]
497            "#,
498        )
499        .unwrap();
500        let dir = TempDir::new().unwrap();
501        let path = dir.path().join("flat.jsonl");
502        write_jsonl(
503            &path,
504            &[
505                json!({"event": "tool", "name": "grep", "pattern": "todo", "output": "3 matches"}),
506                json!({"event": "text", "content": "done"}),
507            ],
508        );
509        assert_eq!(
510            parse(&spec, &path).unwrap(),
511            vec![ToolInvocation {
512                name: "grep".into(),
513                args: Some(json!({"pattern": "todo"})),
514                result: Some(Value::String("3 matches".into())),
515                ordinal: 0,
516            }]
517        );
518    }
519
520    #[test]
521    fn present_but_null_result_field_still_coalesces() {
522        let dir = TempDir::new().unwrap();
523        let path = dir.path().join("null-result.jsonl");
524        write_jsonl(
525            &path,
526            &[
527                json!({"type": "item.completed", "item": {"id": "i", "type": "command_execution", "command": "true", "output": null, "error": "boom"}}),
528            ],
529        );
530        let result = parse(&codex_spec(), &path).unwrap();
531        assert_eq!(result[0].result, Some(Value::String("null".into())));
532    }
533
534    #[test]
535    fn non_string_results_are_compact_json() {
536        let dir = TempDir::new().unwrap();
537        let path = dir.path().join("object-result.jsonl");
538        write_jsonl(
539            &path,
540            &[
541                json!({"type": "item.completed", "item": {"id": "i", "type": "web_search", "result": {"hits": 2}}}),
542            ],
543        );
544        let result = parse(&codex_spec(), &path).unwrap();
545        assert_eq!(result[0].result, Some(Value::String("{\"hits\":2}".into())));
546    }
547
548    #[test]
549    fn items_with_only_omitted_keys_get_null_args() {
550        let dir = TempDir::new().unwrap();
551        let path = dir.path().join("bare-item.jsonl");
552        write_jsonl(
553            &path,
554            &[
555                json!({"type": "item.completed", "item": {"id": "i", "type": "file_change", "status": "completed"}}),
556            ],
557        );
558        let result = parse(&codex_spec(), &path).unwrap();
559        assert_eq!(result[0].args, None);
560    }
561
562    #[test]
563    fn records_missing_the_item_root_or_name_field_are_skipped() {
564        let dir = TempDir::new().unwrap();
565        let path = dir.path().join("shapeless.jsonl");
566        write_jsonl(
567            &path,
568            &[
569                json!({"type": "item.completed"}),
570                json!({"type": "item.completed", "item": "not an object"}),
571                json!({"type": "item.completed", "item": {"id": "i", "type": 7}}),
572                json!({"type": "item.completed", "item": {"id": "i"}}),
573            ],
574        );
575        assert_eq!(parse(&codex_spec(), &path).unwrap(), vec![]);
576    }
577
578    #[test]
579    fn spec_without_a_tools_mapping_yields_no_invocations() {
580        let spec: ExtractSpec = toml::from_str(
581            r#"
582            [final_text]
583            field = "text"
584            "#,
585        )
586        .unwrap();
587        let dir = TempDir::new().unwrap();
588        let path = dir.path().join("no-tools.jsonl");
589        write_jsonl(&path, &[json!({"text": "hello"})]);
590        assert_eq!(parse(&spec, &path).unwrap(), vec![]);
591        let full = parse_full(&spec, &path).unwrap();
592        assert_eq!(full.tool_invocations, vec![]);
593        assert_eq!(full.final_text, Some("hello".into()));
594    }
595
596    /// Acceptance benchmark for the extractor tier: the worked-example spec
597    /// must parse every corpus in this module identically to the `codex-items`
598    /// reference implementation.
599    #[test]
600    fn codex_spec_is_equivalent_to_the_codex_items_reference_parser() {
601        use crate::adapters::codex::transcript::{parse_codex_events, parse_codex_events_full};
602
603        let corpora: Vec<Vec<Value>> = vec![
604            vec![
605                json!({"type": "item.started", "timestamp": "2026-06-07T10:00:00.000Z", "item": {"id": "item_1", "type": "command_execution", "command": "bash -lc 'bun test'", "status": "in_progress"}}),
606                json!({"type": "item.completed", "timestamp": "2026-06-07T10:00:02.000Z", "item": {"id": "item_1", "type": "command_execution", "command": "bash -lc 'bun test'", "output": "2 pass\n0 fail", "status": "completed"}}),
607                json!({"type": "item.completed", "item": {"id": "item_2", "type": "file_change", "path": "src/app.ts", "status": "completed"}}),
608                json!({"type": "item.completed", "item": {"id": "item_3", "type": "agent_message", "text": "Done."}}),
609            ],
610            vec![
611                json!({"type": "item.completed", "item": {"id": "item_1", "type": "web_search", "query": "codex events", "text": "search summary"}}),
612            ],
613            vec![
614                json!({"type": "item.completed", "item": {"id": "a", "type": "agent_message"}}),
615                json!({"type": "item.completed", "item": {"id": "b", "type": "reasoning"}}),
616                json!({"type": "item.completed", "item": {"id": "c", "type": "plan_update"}}),
617            ],
618            vec![
619                json!({"type": "thread.started", "timestamp": "2026-06-07T10:00:00.000Z"}),
620                json!({"type": "item.completed", "timestamp": "2026-06-07T10:00:03.000Z", "item": {"id": "item_1", "type": "command_execution", "command": "ls", "output": "README.md"}}),
621                json!({"type": "item.completed", "timestamp": "2026-06-07T10:00:04.000Z", "item": {"id": "item_2", "type": "agent_message", "text": "First."}}),
622                json!({"type": "item.completed", "timestamp": "2026-06-07T10:00:05.000Z", "item": {"id": "item_3", "type": "agent_message", "text": "Final."}}),
623                json!({"type": "turn.completed", "timestamp": "2026-06-07T10:00:10.000Z", "usage": {"input_tokens": 100, "cached_input_tokens": 75, "output_tokens": 20, "reasoning_output_tokens": 5}}),
624            ],
625            vec![
626                json!({"type": "item.completed", "timestamp": "2026-06-07T10:00:00.000Z", "item": {"id": "item_1", "type": "agent_message", "text": "Done."}}),
627            ],
628            vec![
629                json!({"type": "item.completed", "item": {"id": "i", "type": "command_execution", "command": "true", "output": null, "error": "boom"}}),
630                json!({"type": "item.completed", "item": {"id": "i", "type": "web_search", "result": {"hits": 2}}}),
631                json!({"type": "item.completed", "item": {"id": "i", "type": "file_change", "status": "completed"}}),
632                json!({"type": "turn.completed", "usage": {"input_tokens": 40}}),
633            ],
634        ];
635
636        let spec = codex_spec();
637        let dir = TempDir::new().unwrap();
638        for (i, corpus) in corpora.iter().enumerate() {
639            let path = dir.path().join(format!("corpus-{i}.jsonl"));
640            write_jsonl(&path, corpus);
641            assert_eq!(
642                parse(&spec, &path).unwrap(),
643                parse_codex_events(&path).unwrap(),
644                "invocations diverge on corpus {i}"
645            );
646            assert_eq!(
647                parse_full(&spec, &path).unwrap(),
648                parse_codex_events_full(&path).unwrap(),
649                "summaries diverge on corpus {i}"
650            );
651        }
652
653        // The malformed-line corpus can't round-trip through `json!`; write it raw.
654        let path = dir.path().join("corpus-malformed.jsonl");
655        let good = json!({"type": "item.completed", "item": {"id": "item_1", "type": "web_search", "query": "codex exec json"}});
656        fs::write(&path, format!("{good}\nnot valid json\n")).unwrap();
657        assert_eq!(
658            parse(&spec, &path).unwrap(),
659            parse_codex_events(&path).unwrap()
660        );
661        assert_eq!(
662            parse_full(&spec, &path).unwrap(),
663            parse_codex_events_full(&path).unwrap()
664        );
665    }
666}