1use 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
26type Where = BTreeMap<String, String>;
28
29#[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#[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 #[serde(skip_serializing_if = "Option::is_none")]
56 pub item: Option<String>,
57 pub name_field: String,
60 #[serde(default, skip_serializing_if = "Vec::is_empty")]
62 pub skip_names: Vec<String>,
63 #[serde(default, skip_serializing_if = "Vec::is_empty")]
66 pub args_omit: Vec<String>,
67 #[serde(default, skip_serializing_if = "Vec::is_empty")]
69 pub result_coalesce: Vec<String>,
70}
71
72#[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#[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#[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
103pub(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
109pub(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
134fn 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 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 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)); 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 #[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 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}