Files
CC-Switch/src-tauri/src/session_manager/providers/codex.rs
T
nanmen2 e9d84af500 fix(session): include Codex archived sessions (#2861)
Scan Codex archived_sessions alongside active sessions so archived conversations appear in the session manager.

Allow deletion from any validated Codex session root while keeping path safety checks in place.
2026-05-27 11:16:50 +08:00

499 lines
19 KiB
Rust

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";
static UUID_RE: LazyLock<Regex> = 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<SessionMeta> {
let roots = session_roots();
scan_sessions_in_roots(&roots)
}
pub fn session_roots() -> Vec<PathBuf> {
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<SessionMeta> {
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<Vec<SessionMessage>, 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<bool, String> {
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<SessionMeta> {
let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?;
let mut session_id: Option<String> = None;
let mut project_dir: Option<String> = None;
let mut created_at: Option<i64> = None;
let mut first_user_message: Option<String> = 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();
let trimmed = text.trim();
if !trimmed.is_empty()
&& !trimmed.starts_with("# AGENTS.md")
&& !trimmed.starts_with("<environment_context>")
{
first_user_message = Some(trimmed.to_string());
}
}
}
}
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<i64> = None;
let mut summary: Option<String> = 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 infer_session_id_from_filename(path: &Path) -> Option<String> {
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<PathBuf>) {
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::<Vec<_>>();
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\":\"<permissions>\"}}\n",
"{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"# AGENTS.md instructions for /tmp/project\\n<INSTRUCTIONS>Do stuff</INSTRUCTIONS>\"}}\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\":\"<environment_context>\\n <cwd>/tmp/project</cwd>\\n</environment_context>\"}}\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_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.");
}
}