1use crate::timeline::{
2 self, Step, Usage, assistant_text_step, attach_usage_to_first, compute_durations, parse_iso_ms,
3 pretty_json, tool_result_step, tool_use_step, user_text_step,
4};
5use anyhow::{Context, Result};
6use serde::Deserialize;
7use std::collections::HashMap;
8use std::path::Path;
9
10#[derive(Debug, Deserialize)]
11struct Entry {
12 #[serde(default)]
13 timestamp: Option<String>,
14 #[serde(rename = "type")]
15 kind: String,
16 #[serde(default)]
17 payload: serde_json::Value,
18}
19
20pub fn load(path: &Path) -> Result<Vec<Step>> {
21 use std::fs::File;
26 use std::io::{BufRead, BufReader};
27 let file = File::open(path)
28 .with_context(|| format!("opening codex session file: {}", path.display()))?;
29 let reader = BufReader::new(file);
30 let mut entries: Vec<Entry> = Vec::with_capacity(1024);
31 for (i, line) in reader.lines().enumerate() {
32 let line = line.with_context(|| format!("reading line {} of codex session", i + 1))?;
33 if line.trim().is_empty() {
34 continue;
35 }
36 let entry = serde_json::from_str::<Entry>(&line)
37 .with_context(|| format!("parsing line {} of codex session", i + 1))?;
38 entries.push(entry);
39 }
40
41 let tool_meta = collect_tool_meta(&entries);
42 let mut steps = Vec::new();
43 for entry in &entries {
44 if entry.kind != "response_item" {
45 continue;
46 }
47 let payload_type = entry.payload.get("type").and_then(|t| t.as_str());
48 let ts = entry.timestamp.as_deref().and_then(parse_iso_ms);
49 let mut maybe_step: Option<Step> = None;
50 match payload_type {
51 Some("message") => {
52 let role = entry
53 .payload
54 .get("role")
55 .and_then(|v| v.as_str())
56 .unwrap_or("");
57 let text = extract_message_text(&entry.payload);
58 if !text.trim().is_empty() {
59 maybe_step = match role {
60 "user" => Some(user_text_step(&text)),
61 "assistant" => Some(assistant_text_step(&text)),
62 _ => None,
63 };
64 }
65 }
66 Some("function_call") => {
67 let call_id = entry
68 .payload
69 .get("call_id")
70 .and_then(|v| v.as_str())
71 .unwrap_or("");
72 let name = entry
73 .payload
74 .get("name")
75 .and_then(|v| v.as_str())
76 .unwrap_or("(unknown)");
77 let input_pretty = prettify_codex_arguments(&entry.payload);
78 maybe_step = Some(tool_use_step(call_id, name, &input_pretty));
79 }
80 Some("function_call_output") => {
81 let call_id = entry
82 .payload
83 .get("call_id")
84 .and_then(|v| v.as_str())
85 .unwrap_or("");
86 let output = entry
87 .payload
88 .get("output")
89 .and_then(|v| v.as_str())
90 .map_or_else(|| pretty_json(&entry.payload.get("output")), String::from);
91 let meta = tool_meta.get(call_id);
92 maybe_step = Some(tool_result_step(
93 call_id,
94 &output,
95 meta.map(|m| m.name.as_str()),
96 meta.map(|m| m.input_pretty.as_str()),
97 ));
98 }
99 _ => {}
100 }
101 if let Some(mut step) = maybe_step {
102 step.timestamp_ms = ts;
103 let is_assistant_message = payload_type == Some("message")
104 && entry.payload.get("role").and_then(|v| v.as_str()) == Some("assistant");
105 steps.push(step);
106 if is_assistant_message {
107 let idx = steps.len() - 1;
108 let model = entry.payload.get("model").and_then(|v| v.as_str());
109 let usage = extract_codex_usage(&entry.payload);
110 attach_usage_to_first(&mut steps, idx, model, &usage);
111 }
112 }
113 }
114 compute_durations(&mut steps);
115 Ok(steps)
116}
117
118fn extract_codex_usage(payload: &serde_json::Value) -> Usage {
122 let Some(usage_obj) = payload.get("usage") else {
123 return Usage::default();
124 };
125 let get_u64 = |keys: &[&str]| -> Option<u64> {
126 for k in keys {
127 if let Some(v) = usage_obj.get(*k).and_then(|v| v.as_u64()) {
128 return Some(v);
129 }
130 }
131 None
132 };
133 Usage {
134 tokens_in: get_u64(&["input_tokens", "prompt_tokens", "promptTokens"]),
135 tokens_out: get_u64(&["output_tokens", "completion_tokens", "completionTokens"]),
136 cache_read: get_u64(&["cached_tokens", "prompt_cache_read_tokens"]),
137 cache_create: None,
138 }
139}
140
141#[derive(Debug, Clone)]
142struct ToolMeta {
143 name: String,
144 input_pretty: String,
145}
146
147fn collect_tool_meta(entries: &[Entry]) -> HashMap<String, ToolMeta> {
148 let mut map = HashMap::new();
149 for entry in entries {
150 if entry.kind != "response_item" {
151 continue;
152 }
153 if entry.payload.get("type").and_then(|t| t.as_str()) != Some("function_call") {
154 continue;
155 }
156 let Some(call_id) = entry.payload.get("call_id").and_then(|v| v.as_str()) else {
157 continue;
158 };
159 let name = entry
160 .payload
161 .get("name")
162 .and_then(|v| v.as_str())
163 .unwrap_or("(unknown)")
164 .to_string();
165 let input_pretty = prettify_codex_arguments(&entry.payload);
166 map.insert(call_id.to_string(), ToolMeta { name, input_pretty });
167 }
168 map
169}
170
171fn prettify_codex_arguments(payload: &serde_json::Value) -> String {
175 let raw = payload
176 .get("arguments")
177 .and_then(|v| v.as_str())
178 .unwrap_or("");
179 if raw.is_empty() {
180 return String::new();
181 }
182 match serde_json::from_str::<serde_json::Value>(raw) {
183 Ok(v) => timeline::pretty_json(&v),
184 Err(_) => raw.to_string(),
185 }
186}
187
188fn extract_message_text(payload: &serde_json::Value) -> String {
191 let Some(items) = payload.get("content").and_then(|c| c.as_array()) else {
192 return String::new();
193 };
194 items
195 .iter()
196 .filter_map(|item| item.get("text").and_then(|t| t.as_str()))
197 .collect::<Vec<_>>()
198 .join("\n")
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204 use crate::timeline::StepKind;
205 use std::io::Write;
206 use tempfile::NamedTempFile;
207
208 fn write_file(content: &str) -> NamedTempFile {
209 let mut f = NamedTempFile::new().unwrap();
210 f.write_all(content.as_bytes()).unwrap();
211 f
212 }
213
214 #[test]
215 fn parses_user_and_assistant_messages() {
216 let jsonl = r#"{"timestamp":"2024-01-01T00:00:00Z","type":"session_meta","payload":{"id":"s1","cwd":"/tmp"}}
217{"timestamp":"2024-01-01T00:00:01Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}}
218{"timestamp":"2024-01-01T00:00:02Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hi there"}]}}
219"#;
220 let f = write_file(jsonl);
221 let steps = load(f.path()).unwrap();
222 assert_eq!(steps.len(), 2);
223 assert_eq!(steps[0].kind, StepKind::UserText);
224 assert!(steps[0].detail.contains("hello"));
225 assert_eq!(steps[1].kind, StepKind::AssistantText);
226 assert!(steps[1].detail.contains("hi there"));
227 }
228
229 #[test]
230 fn pairs_function_call_with_function_call_output() {
231 let jsonl = r#"{"timestamp":"2024-01-01T00:00:00Z","type":"response_item","payload":{"type":"function_call","call_id":"call_abc","name":"exec_command","arguments":"{\"cmd\":\"ls\"}"}}
232{"timestamp":"2024-01-01T00:00:01Z","type":"response_item","payload":{"type":"function_call_output","call_id":"call_abc","output":"file1\nfile2"}}
233"#;
234 let f = write_file(jsonl);
235 let steps = load(f.path()).unwrap();
236 assert_eq!(steps.len(), 2);
237 assert_eq!(steps[0].kind, StepKind::ToolUse);
238 assert!(steps[0].detail.contains("exec_command"));
239 assert!(steps[0].detail.contains("\"cmd\""));
240 assert!(steps[0].detail.contains("\"ls\""));
241 assert_eq!(steps[1].kind, StepKind::ToolResult);
242 assert!(steps[1].label.contains("exec_command"));
243 assert!(steps[1].detail.contains("Tool: exec_command"));
244 assert!(steps[1].detail.contains("Input:"));
245 assert!(steps[1].detail.contains("Result:"));
246 assert!(steps[1].detail.contains("file1"));
247 }
248
249 #[test]
250 fn skips_developer_role_messages() {
251 let jsonl = r#"{"timestamp":"2024-01-01T00:00:00Z","type":"response_item","payload":{"type":"message","role":"developer","content":[{"type":"input_text","text":"system policies..."}]}}
252{"timestamp":"2024-01-01T00:00:01Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"real question"}]}}
253"#;
254 let f = write_file(jsonl);
255 let steps = load(f.path()).unwrap();
256 assert_eq!(steps.len(), 1);
257 assert_eq!(steps[0].kind, StepKind::UserText);
258 assert!(steps[0].detail.contains("real question"));
259 }
260
261 #[test]
262 fn skips_reasoning_entries() {
263 let jsonl = r#"{"timestamp":"2024-01-01T00:00:00Z","type":"response_item","payload":{"type":"reasoning","summary":[],"content":null}}
264{"timestamp":"2024-01-01T00:00:01Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}]}}
265"#;
266 let f = write_file(jsonl);
267 let steps = load(f.path()).unwrap();
268 assert_eq!(steps.len(), 1);
269 assert_eq!(steps[0].kind, StepKind::AssistantText);
270 }
271
272 #[test]
273 fn parses_usage_and_model_on_assistant_message() {
274 let jsonl = r#"{"timestamp":"2024-01-01T00:00:00Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}],"model":"gpt-5","usage":{"input_tokens":120,"output_tokens":60,"cached_tokens":40}}}
275"#;
276 let f = write_file(jsonl);
277 let steps = load(f.path()).unwrap();
278 assert_eq!(steps.len(), 1);
279 assert_eq!(steps[0].model.as_deref(), Some("gpt-5"));
280 assert_eq!(steps[0].tokens_in, Some(120));
281 assert_eq!(steps[0].tokens_out, Some(60));
282 assert_eq!(steps[0].cache_read, Some(40));
283 }
284
285 #[test]
286 fn legacy_camelcase_usage_fields_parse() {
287 let jsonl = r#"{"timestamp":"2024-01-01T00:00:00Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"x"}],"usage":{"promptTokens":10,"completionTokens":20}}}
290"#;
291 let f = write_file(jsonl);
292 let steps = load(f.path()).unwrap();
293 assert_eq!(steps[0].tokens_in, Some(10));
294 assert_eq!(steps[0].tokens_out, Some(20));
295 }
296
297 #[test]
298 fn user_message_does_not_get_usage() {
299 let jsonl = r#"{"timestamp":"2024-01-01T00:00:00Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}],"usage":{"input_tokens":5}}}
302"#;
303 let f = write_file(jsonl);
304 let steps = load(f.path()).unwrap();
305 assert_eq!(steps[0].tokens_in, None);
306 }
307
308 #[test]
309 fn skips_non_response_item_entries() {
310 let jsonl = r#"{"timestamp":"2024-01-01T00:00:00Z","type":"session_meta","payload":{"id":"s1"}}
311{"timestamp":"2024-01-01T00:00:01Z","type":"event_msg","payload":{"type":"task_started"}}
312{"timestamp":"2024-01-01T00:00:02Z","type":"turn_context","payload":{}}
313{"timestamp":"2024-01-01T00:00:03Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}}
314"#;
315 let f = write_file(jsonl);
316 let steps = load(f.path()).unwrap();
317 assert_eq!(steps.len(), 1);
318 assert_eq!(steps[0].kind, StepKind::UserText);
319 }
320}