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, TITLE_MAX_CHARS, }; const PROVIDER_ID: &str = "codex"; const VSCODE_CONTEXT_PREFIX: &str = "# Context from my IDE setup:"; const CODEX_REQUEST_MARKER: &str = "my request for 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 roots = session_roots(); scan_sessions_in_roots(&roots) } pub fn session_roots() -> Vec { let config_dir = get_codex_config_dir(); vec![ config_dir.join("sessions"), config_dir.join("archived_sessions"), ] } fn scan_sessions_in_roots(roots: &[PathBuf]) -> Vec { let mut files = Vec::new(); for root in roots { 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; let mut first_user_message: Option = None; // Extract metadata and first user message 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 is_subagent_source(payload.get("source")) { return None; } 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 first user message as title candidate if first_user_message.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") && payload.get("role").and_then(Value::as_str) == Some("user") { let text = payload.get("content").map(extract_text).unwrap_or_default(); if let Some(title) = title_candidate_from_user_message(&text) { first_user_message = Some(title); } } } } if session_id.is_some() && project_dir.is_some() && created_at.is_some() && first_user_message.is_some() { break; } } // 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 = first_user_message .map(|t| truncate_summary(&t, TITLE_MAX_CHARS)) .or_else(|| { project_dir .as_deref() .and_then(path_basename) .map(|v| v.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 is_subagent_source(source: Option<&Value>) -> bool { source .and_then(|value| value.as_object()) .map(|source| source.contains_key("subagent")) .unwrap_or(false) } fn title_candidate_from_user_message(text: &str) -> Option { let trimmed = text.trim(); if trimmed.is_empty() || trimmed.starts_with("# AGENTS.md") || trimmed.starts_with("") { return None; } if trimmed.starts_with(VSCODE_CONTEXT_PREFIX) { return extract_codex_prompt_from_ide_context(trimmed); } Some(trimmed.to_string()) } fn extract_codex_prompt_from_ide_context(text: &str) -> Option { let normalized = text.replace("\r\n", "\n"); let lines = normalized.lines().collect::>(); // VS Code injects the real prompt as the LAST "## My request for Codex:" // section, so keep the final matching heading. Earlier matches can be // headings that live inside the active selection / open file content. // Trade-off: if the request body itself repeats the heading, the title // truncates to its trailing part (rare; covered by tests below). let mut prompt: Option = None; for (index, line) in lines.iter().enumerate() { let Some(inline_prompt) = codex_request_heading_payload(line) else { continue; }; if !inline_prompt.is_empty() { prompt = Some(inline_prompt.to_string()); continue; } let following_prompt = lines[index + 1..].join("\n").trim().to_string(); prompt = (!following_prompt.is_empty()).then_some(following_prompt); } prompt } fn codex_request_heading_payload(line: &str) -> Option<&str> { let trimmed = line.trim(); if !trimmed.starts_with('#') { return None; } let heading = trimmed.trim_start_matches('#').trim_start(); let lowered = heading.to_ascii_lowercase(); if !lowered.starts_with(CODEX_REQUEST_MARKER) { return None; } let suffix = heading[CODEX_REQUEST_MARKER.len()..].trim_start(); if suffix.is_empty() { return Some(""); } let Some(separator) = suffix.chars().next() else { return Some(""); }; if !matches!(separator, ':' | ':' | '-' | '—') { return None; } Some( suffix .trim_start_matches(|c: char| c.is_whitespace() || matches!(c, ':' | ':' | '-' | '—')) .trim(), ) } 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; fn write_codex_session(path: &Path, session_id: &str, message: &str) { std::fs::write( path, format!( "{{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{{\"id\":\"{session_id}\",\"cwd\":\"/tmp/project\"}}}}\n\ {{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{{\"type\":\"message\",\"role\":\"user\",\"content\":\"{message}\"}}}}\n", ), ) .expect("write session"); } #[test] fn scan_sessions_in_roots_includes_active_and_archived_files() { let temp = tempdir().expect("tempdir"); let active = temp.path().join("sessions"); let archived = temp.path().join("archived_sessions"); std::fs::create_dir_all(&active).expect("active dir"); std::fs::create_dir_all(&archived).expect("archived dir"); write_codex_session(&active.join("active.jsonl"), "active-id", "Active session"); write_codex_session( &archived.join("archived.jsonl"), "archived-id", "Archived session", ); let sessions = scan_sessions_in_roots(&[active, archived]); let ids = sessions .into_iter() .map(|session| session.session_id) .collect::>(); assert!(ids.contains(&"active-id".to_string())); assert!(ids.contains(&"archived-id".to_string())); } #[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 parse_session_uses_first_user_message_as_title() { 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/project\"}}\n", "{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"How do I deploy?\"}}\n", "{\"timestamp\":\"2026-03-06T21:50:14Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"Here is how...\"}}\n" ), ) .expect("write"); let meta = parse_session(&path).unwrap(); assert_eq!(meta.title.as_deref(), Some("How do I deploy?")); } #[test] fn parse_session_skips_agents_md_injection() { 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/project\"}}\n", "{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"developer\",\"content\":\"\"}}\n", "{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"# AGENTS.md instructions for /tmp/project\\nDo stuff\"}}\n", "{\"timestamp\":\"2026-03-06T21:50:14Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"Fix the login bug\"}}\n" ), ) .expect("write"); let meta = parse_session(&path).unwrap(); // Should skip AGENTS.md injection and use the real user message assert_eq!(meta.title.as_deref(), Some("Fix the login bug")); } #[test] fn parse_session_skips_subagent_sessions() { let temp = tempdir().expect("tempdir"); let path = temp.path().join("session.jsonl"); std::fs::write( &path, concat!( "{\"timestamp\":\"2026-04-28T10:00:00Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"subagent-id\",\"cwd\":\"/tmp/project\",\"originator\":\"codex-tui\",\"source\":{\"subagent\":{\"thread_spawn\":{\"parent_thread_id\":\"parent-id\",\"depth\":1,\"agent_role\":\"explorer\"}}}}}\n", "{\"timestamp\":\"2026-04-28T10:00:01Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"Inspect the project\"}}\n" ), ) .expect("write"); assert!(parse_session(&path).is_none()); } #[test] fn parse_session_skips_environment_context_injection() { 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/project\"}}\n", "{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"\\n /tmp/project\\n\"}}\n", "{\"timestamp\":\"2026-03-06T21:50:14Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"Fix the login bug\"}}\n" ), ) .expect("write"); let meta = parse_session(&path).unwrap(); // Should skip environment_context injection and use the real user message assert_eq!(meta.title.as_deref(), Some("Fix the login bug")); } #[test] fn parse_session_extracts_vscode_ide_request_as_title() { 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/project\"}}\n", "{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"# Context from my IDE setup:\\n\\n## Active file: src/main.ts\\n\\n## My request for Codex:\\nFix the session title preview\"}}\n" ), ) .expect("write"); let meta = parse_session(&path).unwrap(); assert_eq!(meta.title.as_deref(), Some("Fix the session title preview")); } #[test] fn parse_session_extracts_inline_vscode_ide_request_as_title() { 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/project\"}}\n", "{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"# Context from my IDE setup:\\n\\n## My request for Codex: Fix the TOC preview\"}}\n" ), ) .expect("write"); let meta = parse_session(&path).unwrap(); assert_eq!(meta.title.as_deref(), Some("Fix the TOC preview")); } #[test] fn parse_session_ignores_marker_mentions_before_request_heading() { 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/project\"}}\n", "{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"# Context from my IDE setup:\\n\\n## Active selection:\\nMy request for Codex: not the prompt\\n\\n## My request for Codex:\\nUse the real request heading\"}}\n" ), ) .expect("write"); let meta = parse_session(&path).unwrap(); assert_eq!(meta.title.as_deref(), Some("Use the real request heading")); } #[test] fn parse_session_uses_last_request_heading_when_selection_has_one() { 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/project\"}}\n", "{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"# Context from my IDE setup:\\n\\n## Active selection: docs/codex-format.md\\n## My request for Codex:\\nselected document content, not the real request\\n\\n## My request for Codex:\\nUse the last request heading\"}}\n" ), ) .expect("write"); let meta = parse_session(&path).unwrap(); assert_eq!(meta.title.as_deref(), Some("Use the last request heading")); } // Known limitation: the IDE marker is matched purely by text, so a // "## My request for Codex:" line inside the real request body is treated as // a new boundary and only the trailing part is kept. This pins the // best-effort behavior; fully fixing it needs structured IDE section data // that the Codex VS Code context does not provide. #[test] fn parse_session_keeps_trailing_part_when_request_body_repeats_heading() { 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/project\"}}\n", "{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"# Context from my IDE setup:\\n\\n## Active file: foo.ts\\n\\n## My request for Codex:\\nDocument the format, for example:\\n## My request for Codex:\\nand the rest follows.\"}}\n" ), ) .expect("write"); let meta = parse_session(&path).unwrap(); assert_eq!(meta.title.as_deref(), Some("and the rest follows.")); } #[test] fn parse_session_skips_vscode_ide_context_without_request() { 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/project\"}}\n", "{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"# Context from my IDE setup:\\n\\n## Active file: src/main.ts\"}}\n", "{\"timestamp\":\"2026-03-06T21:50:14Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"Fix the login bug\"}}\n" ), ) .expect("write"); let meta = parse_session(&path).unwrap(); assert_eq!(meta.title.as_deref(), Some("Fix the login bug")); } #[test] fn parse_session_falls_back_to_dir_basename() { 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/my-project\"}}\n", "{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"Hello\"}}\n" ), ) .expect("write"); let meta = parse_session(&path).unwrap(); // No user message → falls back to dir basename assert_eq!(meta.title.as_deref(), Some("my-project")); } #[test] fn parse_session_truncates_long_title() { let temp = tempdir().expect("tempdir"); let path = temp.path().join("session.jsonl"); let long_msg = "a".repeat(200); std::fs::write( &path, format!( "{{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{{\"id\":\"test-id\",\"cwd\":\"/tmp/p\"}}}}\n\ {{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{{\"type\":\"message\",\"role\":\"user\",\"content\":\"{long_msg}\"}}}}\n", ), ) .expect("write"); let meta = parse_session(&path).unwrap(); let title = meta.title.unwrap(); assert!(title.len() <= TITLE_MAX_CHARS + 3); // +3 for "..." assert!(title.ends_with("...")); } #[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."); } }