mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
e9d84af500
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.
499 lines
19 KiB
Rust
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.");
|
|
}
|
|
}
|