use crate::adapters::TranscriptSummary;
use crate::adapters::transcript::read_jsonl;
use crate::core::ToolInvocation;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
use std::io;
use std::path::Path;
type Where = BTreeMap<String, String>;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ExtractSpec {
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<ToolsExtract>,
#[serde(skip_serializing_if = "Option::is_none")]
pub final_text: Option<FieldPick>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tokens: Option<TokensExtract>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<DurationExtract>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ToolsExtract {
#[serde(default, rename = "where", skip_serializing_if = "BTreeMap::is_empty")]
pub r#where: Where,
#[serde(skip_serializing_if = "Option::is_none")]
pub item: Option<String>,
pub name_field: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skip_names: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub args_omit: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub result_coalesce: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct FieldPick {
#[serde(default, rename = "where", skip_serializing_if = "BTreeMap::is_empty")]
pub r#where: Where,
pub field: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct TokensExtract {
#[serde(default, rename = "where", skip_serializing_if = "BTreeMap::is_empty")]
pub r#where: Where,
pub sum: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DurationExtract {
#[serde(default, rename = "where", skip_serializing_if = "BTreeMap::is_empty")]
pub r#where: Where,
#[serde(skip_serializing_if = "Option::is_none")]
pub field: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp_spread: Option<String>,
}
pub(crate) fn parse(spec: &ExtractSpec, path: &Path) -> io::Result<Vec<ToolInvocation>> {
let records = read_jsonl::<Value>(path)?;
Ok(extract_tools(spec, &records))
}
pub(crate) fn parse_full(spec: &ExtractSpec, path: &Path) -> io::Result<TranscriptSummary> {
let records = read_jsonl::<Value>(path)?;
Ok(TranscriptSummary {
tool_invocations: extract_tools(spec, &records),
total_tokens: spec
.tokens
.as_ref()
.and_then(|t| extract_tokens(t, &records)),
duration_ms: spec
.duration
.as_ref()
.and_then(|d| extract_duration(d, &records)),
final_text: spec
.final_text
.as_ref()
.and_then(|f| extract_final_text(f, &records)),
})
}
fn resolve<'a>(record: &'a Value, path: &str) -> Option<&'a Value> {
path.split('.')
.try_fold(record, |value, segment| value.get(segment))
}
fn matches(record: &Value, filter: &Where) -> bool {
filter
.iter()
.all(|(path, expected)| resolve(record, path).and_then(Value::as_str) == Some(expected))
}
fn stringify_value(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
other => serde_json::to_string(other).unwrap_or_default(),
}
}
fn extract_tools(spec: &ExtractSpec, records: &[Value]) -> Vec<ToolInvocation> {
let Some(tools) = &spec.tools else {
return Vec::new();
};
let mut invocations = Vec::new();
for record in records {
if !matches(record, &tools.r#where) {
continue;
}
let item_value = match &tools.item {
Some(path) => match resolve(record, path) {
Some(v) => v,
None => continue,
},
None => record,
};
let Some(item) = item_value.as_object() else {
continue;
};
let Some(name) = item.get(&tools.name_field).and_then(Value::as_str) else {
continue;
};
if tools.skip_names.iter().any(|skip| skip == name) {
continue;
}
let mut args = serde_json::Map::new();
for (key, value) in item {
if tools.args_omit.iter().any(|omit| omit == key) {
continue;
}
args.insert(key.clone(), value.clone());
}
let result = tools
.result_coalesce
.iter()
.find_map(|key| item.get(key).map(stringify_value));
let ordinal = invocations.len() as u32;
invocations.push(ToolInvocation {
name: name.to_string(),
args: (!args.is_empty()).then_some(Value::Object(args)),
result: result.map(Value::String),
ordinal,
});
}
invocations
}
fn extract_final_text(pick: &FieldPick, records: &[Value]) -> Option<String> {
records
.iter()
.filter(|record| matches(record, &pick.r#where))
.filter_map(|record| resolve(record, &pick.field).and_then(Value::as_str))
.next_back()
.map(str::to_string)
}
fn extract_tokens(tokens: &TokensExtract, records: &[Value]) -> Option<i64> {
let mut total: Option<i64> = None;
for record in records {
if !matches(record, &tokens.r#where) {
continue;
}
let resolved: Vec<&Value> = tokens
.sum
.iter()
.filter_map(|path| resolve(record, path))
.collect();
if resolved.is_empty() {
continue;
}
let sum: i64 = resolved.iter().filter_map(|v| v.as_i64()).sum();
total = Some(total.unwrap_or(0) + sum);
}
total
}
fn parse_millis(s: &str) -> Option<i64> {
chrono::DateTime::parse_from_rfc3339(s)
.ok()
.map(|dt| dt.timestamp_millis())
}
fn extract_duration(duration: &DurationExtract, records: &[Value]) -> Option<i64> {
let matching = records
.iter()
.filter(|record| matches(record, &duration.r#where));
if let Some(field) = &duration.field {
return matching
.filter_map(|record| resolve(record, field).and_then(Value::as_i64))
.next_back();
}
let ts_field = duration.timestamp_spread.as_ref()?;
let mut first: Option<i64> = None;
let mut last: Option<i64> = None;
let mut count = 0usize;
for record in matching {
let Some(ts) = resolve(record, ts_field)
.and_then(Value::as_str)
.and_then(parse_millis)
else {
continue;
};
if first.is_none() {
first = Some(ts);
}
last = Some(ts);
count += 1;
}
match (first, last) {
(Some(f), Some(l)) if count >= 2 => Some(l - f),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::fs;
use tempfile::TempDir;
fn write_jsonl(path: &Path, lines: &[Value]) {
let body = lines
.iter()
.map(|l| l.to_string())
.collect::<Vec<_>>()
.join("\n");
fs::write(path, format!("{body}\n")).unwrap();
}
fn codex_spec() -> ExtractSpec {
toml::from_str(
r#"
[tools]
where = { type = "item.completed" }
item = "item"
name_field = "type"
skip_names = ["agent_message", "reasoning", "plan_update"]
args_omit = ["id", "type", "status", "output", "result", "error"]
result_coalesce = ["output", "result", "error"]
[final_text]
where = { type = "item.completed", "item.type" = "agent_message" }
field = "item.text"
[tokens]
where = { type = "turn.completed" }
sum = ["usage.input_tokens", "usage.output_tokens", "usage.reasoning_output_tokens"]
[duration]
timestamp_spread = "timestamp"
"#,
)
.unwrap()
}
#[test]
fn extracts_completed_tool_items_with_ordinals_args_and_results() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("items.jsonl");
write_jsonl(
&path,
&[
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"}}),
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"}}),
json!({"type": "item.completed", "item": {"id": "item_2", "type": "file_change", "path": "src/app.ts", "status": "completed"}}),
json!({"type": "item.completed", "item": {"id": "item_3", "type": "agent_message", "text": "Done."}}),
],
);
let result = parse(&codex_spec(), &path).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(
result[0],
ToolInvocation {
name: "command_execution".into(),
args: Some(json!({"command": "bash -lc 'bun test'"})),
result: Some(Value::String("2 pass\n0 fail".into())),
ordinal: 0,
}
);
assert_eq!(
result[1],
ToolInvocation {
name: "file_change".into(),
args: Some(json!({"path": "src/app.ts"})),
result: None,
ordinal: 1,
}
);
}
#[test]
fn skips_malformed_jsonl_lines() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("malformed.jsonl");
let good = json!({"type": "item.completed", "item": {"id": "item_1", "type": "web_search", "query": "codex exec json"}});
fs::write(&path, format!("{good}\nnot valid json\n")).unwrap();
assert_eq!(
parse(&codex_spec(), &path).unwrap(),
vec![ToolInvocation {
name: "web_search".into(),
args: Some(json!({"query": "codex exec json"})),
result: None,
ordinal: 0,
}]
);
}
#[test]
fn preserves_text_fields_on_non_message_tool_items() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("tool-text.jsonl");
write_jsonl(
&path,
&[
json!({"type": "item.completed", "item": {"id": "item_1", "type": "web_search", "query": "codex events", "text": "search summary"}}),
],
);
assert_eq!(
parse(&codex_spec(), &path).unwrap(),
vec![ToolInvocation {
name: "web_search".into(),
args: Some(json!({"query": "codex events", "text": "search summary"})),
result: None,
ordinal: 0,
}]
);
}
#[test]
fn does_not_treat_skip_named_items_as_tools() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("non-tools.jsonl");
write_jsonl(
&path,
&[
json!({"type": "item.completed", "item": {"id": "a", "type": "agent_message"}}),
json!({"type": "item.completed", "item": {"id": "b", "type": "reasoning"}}),
json!({"type": "item.completed", "item": {"id": "c", "type": "plan_update"}}),
],
);
assert_eq!(parse(&codex_spec(), &path).unwrap(), vec![]);
}
#[test]
fn extracts_invocations_last_agent_text_usage_and_duration() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("full.jsonl");
write_jsonl(
&path,
&[
json!({"type": "thread.started", "timestamp": "2026-06-07T10:00:00.000Z"}),
json!({"type": "item.completed", "timestamp": "2026-06-07T10:00:03.000Z", "item": {"id": "item_1", "type": "command_execution", "command": "ls", "output": "README.md"}}),
json!({"type": "item.completed", "timestamp": "2026-06-07T10:00:04.000Z", "item": {"id": "item_2", "type": "agent_message", "text": "First."}}),
json!({"type": "item.completed", "timestamp": "2026-06-07T10:00:05.000Z", "item": {"id": "item_3", "type": "agent_message", "text": "Final."}}),
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}}),
],
);
let full = parse_full(&codex_spec(), &path).unwrap();
assert_eq!(
full.tool_invocations,
vec![ToolInvocation {
name: "command_execution".into(),
args: Some(json!({"command": "ls"})),
result: Some(Value::String("README.md".into())),
ordinal: 0,
}]
);
assert_eq!(full.final_text, Some("Final.".into()));
assert_eq!(full.total_tokens, Some(125)); assert_eq!(full.duration_ms, Some(10_000));
}
#[test]
fn returns_null_usage_and_duration_when_sparse() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("sparse.jsonl");
write_jsonl(
&path,
&[
json!({"type": "item.completed", "timestamp": "2026-06-07T10:00:00.000Z", "item": {"id": "item_1", "type": "agent_message", "text": "Done."}}),
],
);
let full = parse_full(&codex_spec(), &path).unwrap();
assert_eq!(full.final_text, Some("Done.".into()));
assert_eq!(full.total_tokens, None);
assert_eq!(full.duration_ms, None);
}
#[test]
fn duration_field_variant_picks_the_last_match() {
let spec: ExtractSpec = toml::from_str(
r#"
[duration]
where = { type = "result" }
field = "elapsed_ms"
"#,
)
.unwrap();
let dir = TempDir::new().unwrap();
let path = dir.path().join("durations.jsonl");
write_jsonl(
&path,
&[
json!({"type": "result", "elapsed_ms": 1_000}),
json!({"type": "progress", "elapsed_ms": 9_999}),
json!({"type": "result", "elapsed_ms": 2_500}),
],
);
assert_eq!(parse_full(&spec, &path).unwrap().duration_ms, Some(2_500));
}
#[test]
fn matching_event_without_any_listed_token_field_leaves_tokens_null() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("bare-turn.jsonl");
write_jsonl(&path, &[json!({"type": "turn.completed"})]);
assert_eq!(parse_full(&codex_spec(), &path).unwrap().total_tokens, None);
}
#[test]
fn partial_token_fields_count_and_missing_ones_add_zero() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("partial-usage.jsonl");
write_jsonl(
&path,
&[
json!({"type": "turn.completed", "usage": {"input_tokens": 40}}),
json!({"type": "turn.completed", "usage": {"output_tokens": 2}}),
],
);
assert_eq!(
parse_full(&codex_spec(), &path).unwrap().total_tokens,
Some(42)
);
}
#[test]
fn flat_records_map_without_an_item_root() {
let spec: ExtractSpec = toml::from_str(
r#"
[tools]
where = { event = "tool" }
name_field = "name"
args_omit = ["event", "name", "output"]
result_coalesce = ["output"]
"#,
)
.unwrap();
let dir = TempDir::new().unwrap();
let path = dir.path().join("flat.jsonl");
write_jsonl(
&path,
&[
json!({"event": "tool", "name": "grep", "pattern": "todo", "output": "3 matches"}),
json!({"event": "text", "content": "done"}),
],
);
assert_eq!(
parse(&spec, &path).unwrap(),
vec![ToolInvocation {
name: "grep".into(),
args: Some(json!({"pattern": "todo"})),
result: Some(Value::String("3 matches".into())),
ordinal: 0,
}]
);
}
#[test]
fn present_but_null_result_field_still_coalesces() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("null-result.jsonl");
write_jsonl(
&path,
&[
json!({"type": "item.completed", "item": {"id": "i", "type": "command_execution", "command": "true", "output": null, "error": "boom"}}),
],
);
let result = parse(&codex_spec(), &path).unwrap();
assert_eq!(result[0].result, Some(Value::String("null".into())));
}
#[test]
fn non_string_results_are_compact_json() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("object-result.jsonl");
write_jsonl(
&path,
&[
json!({"type": "item.completed", "item": {"id": "i", "type": "web_search", "result": {"hits": 2}}}),
],
);
let result = parse(&codex_spec(), &path).unwrap();
assert_eq!(result[0].result, Some(Value::String("{\"hits\":2}".into())));
}
#[test]
fn items_with_only_omitted_keys_get_null_args() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("bare-item.jsonl");
write_jsonl(
&path,
&[
json!({"type": "item.completed", "item": {"id": "i", "type": "file_change", "status": "completed"}}),
],
);
let result = parse(&codex_spec(), &path).unwrap();
assert_eq!(result[0].args, None);
}
#[test]
fn records_missing_the_item_root_or_name_field_are_skipped() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("shapeless.jsonl");
write_jsonl(
&path,
&[
json!({"type": "item.completed"}),
json!({"type": "item.completed", "item": "not an object"}),
json!({"type": "item.completed", "item": {"id": "i", "type": 7}}),
json!({"type": "item.completed", "item": {"id": "i"}}),
],
);
assert_eq!(parse(&codex_spec(), &path).unwrap(), vec![]);
}
#[test]
fn spec_without_a_tools_mapping_yields_no_invocations() {
let spec: ExtractSpec = toml::from_str(
r#"
[final_text]
field = "text"
"#,
)
.unwrap();
let dir = TempDir::new().unwrap();
let path = dir.path().join("no-tools.jsonl");
write_jsonl(&path, &[json!({"text": "hello"})]);
assert_eq!(parse(&spec, &path).unwrap(), vec![]);
let full = parse_full(&spec, &path).unwrap();
assert_eq!(full.tool_invocations, vec![]);
assert_eq!(full.final_text, Some("hello".into()));
}
#[test]
fn codex_spec_is_equivalent_to_the_codex_items_reference_parser() {
use crate::adapters::codex::transcript::{parse_codex_events, parse_codex_events_full};
let corpora: Vec<Vec<Value>> = vec![
vec![
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"}}),
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"}}),
json!({"type": "item.completed", "item": {"id": "item_2", "type": "file_change", "path": "src/app.ts", "status": "completed"}}),
json!({"type": "item.completed", "item": {"id": "item_3", "type": "agent_message", "text": "Done."}}),
],
vec![
json!({"type": "item.completed", "item": {"id": "item_1", "type": "web_search", "query": "codex events", "text": "search summary"}}),
],
vec![
json!({"type": "item.completed", "item": {"id": "a", "type": "agent_message"}}),
json!({"type": "item.completed", "item": {"id": "b", "type": "reasoning"}}),
json!({"type": "item.completed", "item": {"id": "c", "type": "plan_update"}}),
],
vec![
json!({"type": "thread.started", "timestamp": "2026-06-07T10:00:00.000Z"}),
json!({"type": "item.completed", "timestamp": "2026-06-07T10:00:03.000Z", "item": {"id": "item_1", "type": "command_execution", "command": "ls", "output": "README.md"}}),
json!({"type": "item.completed", "timestamp": "2026-06-07T10:00:04.000Z", "item": {"id": "item_2", "type": "agent_message", "text": "First."}}),
json!({"type": "item.completed", "timestamp": "2026-06-07T10:00:05.000Z", "item": {"id": "item_3", "type": "agent_message", "text": "Final."}}),
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}}),
],
vec![
json!({"type": "item.completed", "timestamp": "2026-06-07T10:00:00.000Z", "item": {"id": "item_1", "type": "agent_message", "text": "Done."}}),
],
vec![
json!({"type": "item.completed", "item": {"id": "i", "type": "command_execution", "command": "true", "output": null, "error": "boom"}}),
json!({"type": "item.completed", "item": {"id": "i", "type": "web_search", "result": {"hits": 2}}}),
json!({"type": "item.completed", "item": {"id": "i", "type": "file_change", "status": "completed"}}),
json!({"type": "turn.completed", "usage": {"input_tokens": 40}}),
],
];
let spec = codex_spec();
let dir = TempDir::new().unwrap();
for (i, corpus) in corpora.iter().enumerate() {
let path = dir.path().join(format!("corpus-{i}.jsonl"));
write_jsonl(&path, corpus);
assert_eq!(
parse(&spec, &path).unwrap(),
parse_codex_events(&path).unwrap(),
"invocations diverge on corpus {i}"
);
assert_eq!(
parse_full(&spec, &path).unwrap(),
parse_codex_events_full(&path).unwrap(),
"summaries diverge on corpus {i}"
);
}
let path = dir.path().join("corpus-malformed.jsonl");
let good = json!({"type": "item.completed", "item": {"id": "item_1", "type": "web_search", "query": "codex exec json"}});
fs::write(&path, format!("{good}\nnot valid json\n")).unwrap();
assert_eq!(
parse(&spec, &path).unwrap(),
parse_codex_events(&path).unwrap()
);
assert_eq!(
parse_full(&spec, &path).unwrap(),
parse_codex_events_full(&path).unwrap()
);
}
}