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