use std::fs::File; use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; use std::sync::LazyLock; use regex::Regex; use serde_json::Value; use crate::codex_config::get_codex_config_dir; use crate::session_manager::{SessionMessage, SessionMeta}; use super::utils::{ extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary, }; const PROVIDER_ID: &str = "codex"; static UUID_RE: LazyLock = LazyLock::new(|| { Regex::new(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") .unwrap() }); pub fn scan_sessions() -> Vec { let root = get_codex_config_dir().join("sessions"); let mut files = Vec::new(); collect_jsonl_files(&root, &mut files); let mut sessions = Vec::new(); for path in files { if let Some(meta) = parse_session(&path) { sessions.push(meta); } } sessions } pub fn load_messages(path: &Path) -> Result, String> { let file = File::open(path).map_err(|e| format!("Failed to open session file: {e}"))?; let reader = BufReader::new(file); let mut messages = Vec::new(); for line in reader.lines() { let line = match line { Ok(value) => value, Err(_) => continue, }; let value: Value = match serde_json::from_str(&line) { Ok(parsed) => parsed, Err(_) => continue, }; if value.get("type").and_then(Value::as_str) != Some("response_item") { continue; } let payload = match value.get("payload") { Some(payload) => payload, None => continue, }; let payload_type = payload.get("type").and_then(Value::as_str).unwrap_or(""); // Codex uses separate payload types for tool interactions let (role, content) = match payload_type { "message" => { let role = payload .get("role") .and_then(Value::as_str) .unwrap_or("unknown") .to_string(); let content = payload.get("content").map(extract_text).unwrap_or_default(); (role, content) } "function_call" => { let name = payload .get("name") .and_then(Value::as_str) .unwrap_or("unknown"); ("assistant".to_string(), format!("[Tool: {name}]")) } "function_call_output" => { let output = payload .get("output") .and_then(Value::as_str) .unwrap_or("") .to_string(); ("tool".to_string(), output) } _ => continue, }; if content.trim().is_empty() { continue; } let ts = value.get("timestamp").and_then(parse_timestamp_to_ms); messages.push(SessionMessage { role, content, ts }); } Ok(messages) } pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result { let meta = parse_session(path) .ok_or_else(|| format!("Failed to parse Codex session metadata: {}", path.display()))?; if meta.session_id != session_id { return Err(format!( "Codex session ID mismatch: expected {session_id}, found {}", meta.session_id )); } std::fs::remove_file(path).map_err(|e| { format!( "Failed to delete Codex session file {}: {e}", path.display() ) })?; Ok(true) } fn parse_session(path: &Path) -> Option { let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?; let mut session_id: Option = None; let mut project_dir: Option = None; let mut created_at: Option = None; // Extract metadata from head lines for line in &head { let value: Value = match serde_json::from_str(line) { Ok(parsed) => parsed, Err(_) => continue, }; if created_at.is_none() { created_at = value.get("timestamp").and_then(parse_timestamp_to_ms); } if value.get("type").and_then(Value::as_str) == Some("session_meta") { if let Some(payload) = value.get("payload") { if session_id.is_none() { session_id = payload .get("id") .and_then(Value::as_str) .map(|s| s.to_string()); } if project_dir.is_none() { project_dir = payload .get("cwd") .and_then(Value::as_str) .map(|s| s.to_string()); } if let Some(ts) = payload.get("timestamp").and_then(parse_timestamp_to_ms) { created_at.get_or_insert(ts); } } } } // Extract last_active_at and summary from tail lines (reverse order) let mut last_active_at: Option = None; let mut summary: Option = None; for line in tail.iter().rev() { let value: Value = match serde_json::from_str(line) { Ok(parsed) => parsed, Err(_) => continue, }; if last_active_at.is_none() { last_active_at = value.get("timestamp").and_then(parse_timestamp_to_ms); } if summary.is_none() && value.get("type").and_then(Value::as_str) == Some("response_item") { if let Some(payload) = value.get("payload") { if payload.get("type").and_then(Value::as_str) == Some("message") { let text = payload.get("content").map(extract_text).unwrap_or_default(); if !text.trim().is_empty() { summary = Some(text); } } } } if last_active_at.is_some() && summary.is_some() { break; } } let session_id = session_id.or_else(|| infer_session_id_from_filename(path)); let session_id = session_id?; let title = project_dir .as_deref() .and_then(path_basename) .map(|value| value.to_string()); let summary = summary.map(|text| truncate_summary(&text, 160)); Some(SessionMeta { provider_id: PROVIDER_ID.to_string(), session_id: session_id.clone(), title, summary, project_dir, created_at, last_active_at, source_path: Some(path.to_string_lossy().to_string()), resume_command: Some(format!("codex resume {session_id}")), }) } fn infer_session_id_from_filename(path: &Path) -> Option { let file_name = path.file_name()?.to_string_lossy(); UUID_RE.find(&file_name).map(|mat| mat.as_str().to_string()) } fn collect_jsonl_files(root: &Path, files: &mut Vec) { if !root.exists() { return; } let entries = match std::fs::read_dir(root) { Ok(entries) => entries, Err(_) => return, }; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { collect_jsonl_files(&path, files); } else if path.extension().and_then(|ext| ext.to_str()) == Some("jsonl") { files.push(path); } } } #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; #[test] fn delete_session_removes_jsonl_file() { let temp = tempdir().expect("tempdir"); let path = temp .path() .join("rollout-2026-03-06T21-50-12-019cc369-bd7c-7891-b371-7b20b4fe0b18.jsonl"); std::fs::write( &path, concat!( "{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"019cc369-bd7c-7891-b371-7b20b4fe0b18\",\"cwd\":\"/tmp/project\"}}\n", "{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"hello\"}}\n" ), ) .expect("write session"); delete_session(temp.path(), &path, "019cc369-bd7c-7891-b371-7b20b4fe0b18") .expect("delete session"); assert!(!path.exists()); } #[test] fn load_messages_includes_function_call_and_output() { let temp = tempdir().expect("tempdir"); let path = temp.path().join("session.jsonl"); std::fs::write( &path, concat!( "{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"test-id\",\"cwd\":\"/tmp\"}}\n", "{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"list files\"}}\n", "{\"timestamp\":\"2026-03-06T21:50:14Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"function_call\",\"name\":\"shell\",\"arguments\":\"{\\\"cmd\\\":[\\\"ls\\\"]}\",\"call_id\":\"call_1\"}}\n", "{\"timestamp\":\"2026-03-06T21:50:15Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"function_call_output\",\"call_id\":\"call_1\",\"output\":\"file1.txt\\nfile2.txt\"}}\n", "{\"timestamp\":\"2026-03-06T21:50:16Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Done.\"}]}}\n", ), ) .expect("write"); let msgs = load_messages(&path).expect("load"); assert_eq!(msgs.len(), 4); assert_eq!(msgs[0].role, "user"); assert_eq!(msgs[0].content, "list files"); assert_eq!(msgs[1].role, "assistant"); assert!(msgs[1].content.contains("[Tool: shell]")); assert_eq!(msgs[2].role, "tool"); assert!(msgs[2].content.contains("file1.txt")); assert_eq!(msgs[3].role, "assistant"); assert_eq!(msgs[3].content, "Done."); } }