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