eval_magic/adapters/codex/
transcript.rs1use crate::adapters::TranscriptSummary;
10use crate::adapters::transcript::read_jsonl;
11use crate::core::ToolInvocation;
12use serde_json::{Map, Value};
13use std::io;
14use std::path::Path;
15
16const NON_TOOL_ITEMS: [&str; 3] = ["agent_message", "reasoning", "plan_update"];
17const ARG_OMIT_KEYS: [&str; 6] = ["id", "type", "status", "output", "result", "error"];
18
19fn stringify_value(v: &Value) -> String {
20 match v {
21 Value::String(s) => s.clone(),
22 other => serde_json::to_string(other).unwrap_or_default(),
23 }
24}
25
26fn maybe_result(item: &Map<String, Value>) -> Option<String> {
29 ["output", "result", "error"]
30 .into_iter()
31 .find_map(|k| item.get(k).map(stringify_value))
32}
33
34fn item_args(item: &Map<String, Value>) -> Option<Value> {
37 let mut args = Map::new();
38 for (key, value) in item {
39 if ARG_OMIT_KEYS.contains(&key.as_str()) {
40 continue;
41 }
42 args.insert(key.clone(), value.clone());
43 }
44 if args.is_empty() {
45 None
46 } else {
47 Some(Value::Object(args))
48 }
49}
50
51fn extract_invocations(records: &[Value]) -> Vec<ToolInvocation> {
52 let mut invocations = Vec::new();
53 for record in records {
54 if record.get("type").and_then(Value::as_str) != Some("item.completed") {
55 continue;
56 }
57 let Some(item) = record.get("item").and_then(Value::as_object) else {
58 continue;
59 };
60 let Some(item_type) = item.get("type").and_then(Value::as_str) else {
61 continue;
62 };
63 if NON_TOOL_ITEMS.contains(&item_type) {
64 continue;
65 }
66 let ordinal = invocations.len() as u32;
67 invocations.push(ToolInvocation {
68 name: item_type.to_string(),
69 args: item_args(item),
70 result: maybe_result(item).map(Value::String),
71 ordinal,
72 });
73 }
74 invocations
75}
76
77pub fn parse_codex_events(jsonl_path: &Path) -> io::Result<Vec<ToolInvocation>> {
79 Ok(extract_invocations(&read_jsonl::<Value>(jsonl_path)?))
80}
81
82fn parse_millis(s: &str) -> Option<i64> {
83 chrono::DateTime::parse_from_rfc3339(s)
84 .ok()
85 .map(|dt| dt.timestamp_millis())
86}
87
88pub fn parse_codex_events_full(jsonl_path: &Path) -> io::Result<TranscriptSummary> {
90 let records = read_jsonl::<Value>(jsonl_path)?;
91
92 let mut first_ts: Option<i64> = None;
93 let mut last_ts: Option<i64> = None;
94 let mut timestamp_count = 0usize;
95 let mut final_text: Option<String> = None;
96 let mut total_tokens: Option<i64> = None;
97
98 for record in &records {
99 if let Some(ts_str) = record.get("timestamp").and_then(Value::as_str)
100 && let Some(ts) = parse_millis(ts_str)
101 {
102 if first_ts.is_none() {
103 first_ts = Some(ts);
104 }
105 last_ts = Some(ts);
106 timestamp_count += 1;
107 }
108
109 let rtype = record.get("type").and_then(Value::as_str);
110
111 if rtype == Some("item.completed")
112 && let Some(item) = record.get("item").and_then(Value::as_object)
113 && item.get("type").and_then(Value::as_str) == Some("agent_message")
114 && let Some(text) = item.get("text").and_then(Value::as_str)
115 {
116 final_text = Some(text.to_string());
117 }
118
119 if rtype == Some("turn.completed")
120 && let Some(usage) = record.get("usage").and_then(Value::as_object)
121 {
122 let get = |k: &str| usage.get(k).and_then(Value::as_i64).unwrap_or(0);
123 let sum = get("input_tokens") + get("output_tokens") + get("reasoning_output_tokens");
124 total_tokens = Some(total_tokens.unwrap_or(0) + sum);
125 }
126 }
127
128 let duration_ms = match (first_ts, last_ts) {
129 (Some(f), Some(l)) if timestamp_count >= 2 => Some(l - f),
130 _ => None,
131 };
132
133 Ok(TranscriptSummary {
134 tool_invocations: extract_invocations(&records),
135 total_tokens,
136 duration_ms,
137 final_text,
138 })
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144 use crate::core::ToolInvocation;
145 use serde_json::{Value, json};
146 use std::fs;
147 use std::path::Path;
148 use tempfile::TempDir;
149
150 fn write_jsonl(path: &Path, lines: &[Value]) {
151 let body = lines
152 .iter()
153 .map(|l| l.to_string())
154 .collect::<Vec<_>>()
155 .join("\n");
156 fs::write(path, format!("{body}\n")).unwrap();
157 }
158
159 #[test]
160 fn extracts_completed_tool_items_with_ordinals_args_and_results() {
161 let dir = TempDir::new().unwrap();
162 let path = dir.path().join("items.jsonl");
163 write_jsonl(
164 &path,
165 &[
166 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"}}),
167 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"}}),
168 json!({"type": "item.completed", "item": {"id": "item_2", "type": "file_change", "path": "src/app.ts", "status": "completed"}}),
169 json!({"type": "item.completed", "item": {"id": "item_3", "type": "agent_message", "text": "Done."}}),
170 ],
171 );
172
173 let result = parse_codex_events(&path).unwrap();
174 assert_eq!(result.len(), 2);
175 assert_eq!(
176 result[0],
177 ToolInvocation {
178 name: "command_execution".into(),
179 args: Some(json!({"command": "bash -lc 'bun test'"})),
180 result: Some(Value::String("2 pass\n0 fail".into())),
181 ordinal: 0,
182 }
183 );
184 assert_eq!(
185 result[1],
186 ToolInvocation {
187 name: "file_change".into(),
188 args: Some(json!({"path": "src/app.ts"})),
189 result: None,
190 ordinal: 1,
191 }
192 );
193 }
194
195 #[test]
196 fn skips_malformed_jsonl_lines() {
197 let dir = TempDir::new().unwrap();
198 let path = dir.path().join("malformed.jsonl");
199 let good = json!({"type": "item.completed", "item": {"id": "item_1", "type": "web_search", "query": "codex exec json"}});
200 fs::write(&path, format!("{good}\nnot valid json\n")).unwrap();
201 assert_eq!(
202 parse_codex_events(&path).unwrap(),
203 vec![ToolInvocation {
204 name: "web_search".into(),
205 args: Some(json!({"query": "codex exec json"})),
206 result: None,
207 ordinal: 0,
208 }]
209 );
210 }
211
212 #[test]
213 fn preserves_text_fields_on_non_message_tool_items() {
214 let dir = TempDir::new().unwrap();
215 let path = dir.path().join("tool-text.jsonl");
216 write_jsonl(
217 &path,
218 &[
219 json!({"type": "item.completed", "item": {"id": "item_1", "type": "web_search", "query": "codex events", "text": "search summary"}}),
220 ],
221 );
222 assert_eq!(
223 parse_codex_events(&path).unwrap(),
224 vec![ToolInvocation {
225 name: "web_search".into(),
226 args: Some(json!({"query": "codex events", "text": "search summary"})),
227 result: None,
228 ordinal: 0,
229 }]
230 );
231 }
232
233 #[test]
234 fn does_not_treat_agent_messages_reasoning_or_plan_updates_as_tools() {
235 let dir = TempDir::new().unwrap();
236 let path = dir.path().join("non-tools.jsonl");
237 write_jsonl(
238 &path,
239 &[
240 json!({"type": "item.completed", "item": {"id": "a", "type": "agent_message"}}),
241 json!({"type": "item.completed", "item": {"id": "b", "type": "reasoning"}}),
242 json!({"type": "item.completed", "item": {"id": "c", "type": "plan_update"}}),
243 ],
244 );
245 assert_eq!(parse_codex_events(&path).unwrap(), vec![]);
246 }
247
248 #[test]
249 fn extracts_invocations_last_agent_text_usage_and_duration() {
250 let dir = TempDir::new().unwrap();
251 let path = dir.path().join("full.jsonl");
252 write_jsonl(
253 &path,
254 &[
255 json!({"type": "thread.started", "timestamp": "2026-06-07T10:00:00.000Z"}),
256 json!({"type": "item.completed", "timestamp": "2026-06-07T10:00:03.000Z", "item": {"id": "item_1", "type": "command_execution", "command": "ls", "output": "README.md"}}),
257 json!({"type": "item.completed", "timestamp": "2026-06-07T10:00:04.000Z", "item": {"id": "item_2", "type": "agent_message", "text": "First."}}),
258 json!({"type": "item.completed", "timestamp": "2026-06-07T10:00:05.000Z", "item": {"id": "item_3", "type": "agent_message", "text": "Final."}}),
259 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}}),
260 ],
261 );
262
263 let full = parse_codex_events_full(&path).unwrap();
264 assert_eq!(
265 full.tool_invocations,
266 vec![ToolInvocation {
267 name: "command_execution".into(),
268 args: Some(json!({"command": "ls"})),
269 result: Some(Value::String("README.md".into())),
270 ordinal: 0,
271 }]
272 );
273 assert_eq!(full.final_text, Some("Final.".into()));
274 assert_eq!(full.total_tokens, Some(125)); assert_eq!(full.duration_ms, Some(10_000));
276 }
277
278 #[test]
279 fn returns_null_usage_and_duration_when_sparse() {
280 let dir = TempDir::new().unwrap();
281 let path = dir.path().join("sparse.jsonl");
282 write_jsonl(
283 &path,
284 &[
285 json!({"type": "item.completed", "timestamp": "2026-06-07T10:00:00.000Z", "item": {"id": "item_1", "type": "agent_message", "text": "Done."}}),
286 ],
287 );
288 let full = parse_codex_events_full(&path).unwrap();
289 assert_eq!(full.final_text, Some("Done.".into()));
290 assert_eq!(full.total_tokens, None);
291 assert_eq!(full.duration_ms, None);
292 }
293}