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