1use 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
25type Where = BTreeMap<String, String>;
27
28#[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#[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 #[serde(skip_serializing_if = "Option::is_none")]
55 pub item: Option<String>,
56 pub name_field: String,
59 #[serde(default, skip_serializing_if = "Vec::is_empty")]
61 pub skip_names: Vec<String>,
62 #[serde(default, skip_serializing_if = "Vec::is_empty")]
65 pub args_omit: Vec<String>,
66 #[serde(default, skip_serializing_if = "Vec::is_empty")]
68 pub result_coalesce: Vec<String>,
69}
70
71#[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#[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#[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
105pub(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
111pub(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
136fn 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 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 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)); 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 #[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 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}