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