From e606adfae7328acf836292701ad25cb1afe523f6 Mon Sep 17 00:00:00 2001 From: makoMakoGo <48956204+makoMakoGo@users.noreply.github.com> Date: Mon, 6 Jul 2026 09:11:55 +0800 Subject: [PATCH] fix(codex): display renamed session titles (#4927) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(codex): display renamed session titles * fix(codex): resolve custom state db for titles * refactor(codex): dedupe state-db resolution and harden title lookup - Extract a shared `codex_state_db` module for `state_5.sqlite` path resolution (config `sqlite_home` / `CODEX_SQLITE_HOME`), previously copy-pasted verbatim between codex_history_migration and the codex session provider. `state_5.sqlite` is now a single source of truth. - Add `busy_timeout` to the read-only title query; without it a read during a concurrent Codex write fails immediately (SQLITE_BUSY) and renamed titles silently fall back to the first user message. - Push the `title == first_user_message` comparison into the SQL WHERE clause (NULL-safe, aligning with Codex's `distinct_thread_metadata_title` semantics) and stop SELECTing `first_user_message`, which can hold large values (openai/codex#29007) — the comparison belongs in SQL anyway, so the column no longer needs to cross into Rust. --- src-tauri/src/codex_history_migration.rs | 52 +-- src-tauri/src/codex_state_db.rs | 98 ++++++ src-tauri/src/lib.rs | 1 + .../src/session_manager/providers/codex.rs | 327 +++++++++++++++++- 4 files changed, 424 insertions(+), 54 deletions(-) create mode 100644 src-tauri/src/codex_state_db.rs diff --git a/src-tauri/src/codex_history_migration.rs b/src-tauri/src/codex_history_migration.rs index 648af4d81..4d99bf2a6 100644 --- a/src-tauri/src/codex_history_migration.rs +++ b/src-tauri/src/codex_history_migration.rs @@ -6,6 +6,7 @@ use crate::codex_config::{ get_codex_config_dir, read_codex_config_text, CC_SWITCH_CODEX_MODEL_PROVIDER_ID, }; +use crate::codex_state_db::codex_state_db_paths; use crate::config::{atomic_write, copy_file, get_app_config_dir}; use crate::database::{is_official_seed_id, Database}; use crate::error::AppError; @@ -28,7 +29,6 @@ const MIGRATION_NAME: &str = "codex-history-provider-migration-v1"; const OFFICIAL_UNIFY_MIGRATION_NAME: &str = "codex-official-history-unify-v1"; /// 还原操作自身的备份目录(与迁移备份分开,保持迁移账本目录纯净)。 const OFFICIAL_UNIFY_RESTORE_BACKUP_NAME: &str = "codex-official-history-unify-restore-v1"; -const CODEX_STATE_DB_FILENAME: &str = "state_5.sqlite"; /// SQLite 变量上限保守值,IN 列表按此分块。 const STATE_DB_ID_CHUNK: usize = 500; @@ -1116,55 +1116,6 @@ fn migrate_codex_state_dbs( Ok(migrated) } -fn codex_state_db_paths(codex_dir: &Path, config_text: &str) -> Vec { - let mut paths = Vec::new(); - push_unique_path(&mut paths, codex_dir.join(CODEX_STATE_DB_FILENAME)); - // Codex lets SQLite state move away from CODEX_HOME; config takes precedence. - if let Some(sqlite_home) = sqlite_home_from_codex_config(config_text) { - push_unique_path(&mut paths, sqlite_home.join(CODEX_STATE_DB_FILENAME)); - } else if let Some(sqlite_home) = sqlite_home_from_env() { - push_unique_path(&mut paths, sqlite_home.join(CODEX_STATE_DB_FILENAME)); - } - paths -} - -fn push_unique_path(paths: &mut Vec, path: PathBuf) { - if !paths.contains(&path) { - paths.push(path); - } -} - -fn sqlite_home_from_codex_config(config_text: &str) -> Option { - let doc = config_text.parse::().ok()?; - let raw = doc.get("sqlite_home")?.as_str()?.trim(); - if raw.is_empty() { - return None; - } - Some(resolve_user_path(raw)) -} - -fn sqlite_home_from_env() -> Option { - let raw = std::env::var("CODEX_SQLITE_HOME").ok()?; - let raw = raw.trim(); - if raw.is_empty() { - return None; - } - Some(resolve_user_path(raw)) -} - -fn resolve_user_path(raw: &str) -> PathBuf { - if raw == "~" { - return crate::config::get_home_dir(); - } - if let Some(rest) = raw.strip_prefix("~/") { - return crate::config::get_home_dir().join(rest); - } - if let Some(rest) = raw.strip_prefix("~\\") { - return crate::config::get_home_dir().join(rest); - } - PathBuf::from(raw) -} - fn migrate_codex_state_db_provider_bucket( db_path: &Path, codex_dir: &Path, @@ -1329,6 +1280,7 @@ fn relative_backup_path(path: &Path, root: &Path) -> PathBuf { #[cfg(test)] mod tests { use super::*; + use crate::codex_state_db::CODEX_STATE_DB_FILENAME; use crate::provider::Provider; use serial_test::serial; use std::ffi::OsString; diff --git a/src-tauri/src/codex_state_db.rs b/src-tauri/src/codex_state_db.rs new file mode 100644 index 000000000..ba5ee4e28 --- /dev/null +++ b/src-tauri/src/codex_state_db.rs @@ -0,0 +1,98 @@ +//! Locating Codex's per-thread state SQLite databases. +//! +//! Codex stores thread metadata in `state_5.sqlite`, normally inside the Codex +//! config dir (`CODEX_HOME` / `~/.codex`). The SQLite location can be moved with +//! the `sqlite_home` key in `config.toml` or the `CODEX_SQLITE_HOME` env var; +//! when set, a second DB lives there. Both history migration and the session +//! list's title lookup need the same resolution, so it lives here once. + +use std::path::{Path, PathBuf}; + +use toml_edit::DocumentMut; + +use crate::config::get_home_dir; + +/// Filename of Codex's per-thread state database. Codex bumps the version +/// number across releases; update this single source of truth when a new state +/// DB version ships. +pub(crate) const CODEX_STATE_DB_FILENAME: &str = "state_5.sqlite"; + +/// Env var that overrides the Codex SQLite state directory. +const CODEX_SQLITE_HOME_ENV: &str = "CODEX_SQLITE_HOME"; + +/// Resolve every candidate `state_5.sqlite` path: the config-dir DB plus, when +/// Codex is configured to keep its SQLite state elsewhere, that DB too. +/// +/// `config_dir` is the Codex config dir (`~/.codex`); `config_text` is the raw +/// `config.toml` contents, used to detect a `sqlite_home` override. +pub(crate) fn codex_state_db_paths(config_dir: &Path, config_text: &str) -> Vec { + let mut paths = Vec::new(); + push_unique_path(&mut paths, config_dir.join(CODEX_STATE_DB_FILENAME)); + // Codex lets SQLite state move away from CODEX_HOME; config takes precedence. + if let Some(sqlite_home) = sqlite_home_from_codex_config(config_text) { + push_unique_path(&mut paths, sqlite_home.join(CODEX_STATE_DB_FILENAME)); + } else if let Some(sqlite_home) = sqlite_home_from_env() { + push_unique_path(&mut paths, sqlite_home.join(CODEX_STATE_DB_FILENAME)); + } + paths +} + +fn push_unique_path(paths: &mut Vec, path: PathBuf) { + if !paths.contains(&path) { + paths.push(path); + } +} + +fn sqlite_home_from_codex_config(config_text: &str) -> Option { + let doc = config_text.parse::().ok()?; + let raw = doc.get("sqlite_home")?.as_str()?.trim(); + if raw.is_empty() { + return None; + } + Some(resolve_user_path(raw)) +} + +fn sqlite_home_from_env() -> Option { + let raw = std::env::var(CODEX_SQLITE_HOME_ENV).ok()?; + let raw = raw.trim(); + if raw.is_empty() { + return None; + } + Some(resolve_user_path(raw)) +} + +fn resolve_user_path(raw: &str) -> PathBuf { + if raw == "~" { + return get_home_dir(); + } + if let Some(rest) = raw.strip_prefix("~/") { + return get_home_dir().join(rest); + } + if let Some(rest) = raw.strip_prefix("~\\") { + return get_home_dir().join(rest); + } + PathBuf::from(raw) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn includes_config_sqlite_home() { + let temp = tempdir().expect("tempdir"); + let sqlite_home = temp.path().join("sqlite-home"); + let config_text = format!("sqlite_home = \"{}\"\n", sqlite_home.display()); + + let paths = codex_state_db_paths(temp.path(), &config_text); + + assert_eq!( + paths, + vec![ + temp.path().join(CODEX_STATE_DB_FILENAME), + sqlite_home.join(CODEX_STATE_DB_FILENAME), + ] + ); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b6e0deee5..d5432f077 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ mod claude_mcp; mod claude_plugin; mod codex_config; mod codex_history_migration; +mod codex_state_db; mod commands; mod config; mod database; diff --git a/src-tauri/src/session_manager/providers/codex.rs b/src-tauri/src/session_manager/providers/codex.rs index 616a30458..e2383fe6c 100644 --- a/src-tauri/src/session_manager/providers/codex.rs +++ b/src-tauri/src/session_manager/providers/codex.rs @@ -1,12 +1,17 @@ +use std::collections::HashMap; use std::fs::File; use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; use std::sync::LazyLock; +use std::time::Duration; use regex::Regex; +use rusqlite::Connection; +use serde::Deserialize; use serde_json::Value; -use crate::codex_config::get_codex_config_dir; +use crate::codex_config::{get_codex_config_dir, read_codex_config_text}; +use crate::codex_state_db::codex_state_db_paths; use crate::session_manager::{SessionMessage, SessionMeta}; use super::utils::{ @@ -15,6 +20,7 @@ use super::utils::{ }; const PROVIDER_ID: &str = "codex"; +const CODEX_SESSION_INDEX_FILENAME: &str = "session_index.jsonl"; const VSCODE_CONTEXT_PREFIX: &str = "# Context from my IDE setup:"; const CODEX_REQUEST_MARKER: &str = "my request for codex"; @@ -23,6 +29,12 @@ static UUID_RE: LazyLock = LazyLock::new(|| { .unwrap() }); +#[derive(Deserialize)] +struct SessionIndexEntry { + id: String, + thread_name: String, +} + pub fn scan_sessions() -> Vec { let roots = session_roots(); scan_sessions_in_roots(&roots) @@ -37,6 +49,14 @@ pub fn session_roots() -> Vec { } fn scan_sessions_in_roots(roots: &[PathBuf]) -> Vec { + let thread_titles = load_thread_titles(); + scan_sessions_in_roots_with_titles(roots, &thread_titles) +} + +fn scan_sessions_in_roots_with_titles( + roots: &[PathBuf], + thread_titles: &HashMap, +) -> Vec { let mut files = Vec::new(); for root in roots { collect_jsonl_files(root, &mut files); @@ -44,7 +64,7 @@ fn scan_sessions_in_roots(roots: &[PathBuf]) -> Vec { let mut sessions = Vec::new(); for path in files { - if let Some(meta) = parse_session(&path) { + if let Some(meta) = parse_session_with_titles(&path, thread_titles) { sessions.push(meta); } } @@ -52,6 +72,135 @@ fn scan_sessions_in_roots(roots: &[PathBuf]) -> Vec { sessions } +fn load_thread_titles() -> HashMap { + let config_dir = get_codex_config_dir(); + let config_text = read_codex_config_text().unwrap_or_default(); + let db_paths = codex_state_db_paths(&config_dir, &config_text); + load_thread_titles_from_paths(&config_dir.join(CODEX_SESSION_INDEX_FILENAME), &db_paths) +} + +fn load_thread_titles_from_paths( + session_index_path: &Path, + db_paths: &[PathBuf], +) -> HashMap { + let mut titles = load_thread_titles_from_session_index(session_index_path); + for db_path in db_paths { + titles.extend(load_thread_titles_from_db(db_path)); + } + titles +} + +fn load_thread_titles_from_session_index(index_path: &Path) -> HashMap { + if !index_path.exists() { + return HashMap::new(); + } + + let file = match File::open(index_path) { + Ok(file) => file, + Err(err) => { + log::warn!( + "Failed to open Codex session index {}: {err}", + index_path.display() + ); + return HashMap::new(); + } + }; + + let reader = BufReader::new(file); + let mut titles = HashMap::new(); + for line in reader.lines() { + let line = match line { + Ok(line) => line, + Err(_) => continue, + }; + let Ok(entry) = serde_json::from_str::(line.trim()) else { + continue; + }; + let id = entry.id.trim(); + let title = entry.thread_name.trim(); + if !id.is_empty() && !title.is_empty() { + titles.insert(id.to_string(), title.to_string()); + } + } + + titles +} + +fn load_thread_titles_from_db(db_path: &Path) -> HashMap { + if !db_path.exists() { + return HashMap::new(); + } + + let conn = match Connection::open_with_flags( + db_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) { + Ok(conn) => conn, + Err(err) => { + log::warn!( + "Failed to open Codex state database {}: {err}", + db_path.display() + ); + return HashMap::new(); + } + }; + // Codex keeps this DB open and write-locked while running; without a busy + // timeout a read during a write fails immediately and titles silently drop. + if let Err(err) = conn.busy_timeout(Duration::from_secs(2)) { + log::warn!( + "Failed to set Codex state database busy timeout for {}: {err}", + db_path.display() + ); + return HashMap::new(); + } + + // Mirror Codex's own `distinct_thread_metadata_title`: keep a title only + // when it differs from the first user message. Push the comparison into SQL + // (NULL-safe) so we never SELECT the unbounded `first_user_message` blob — + // it can grow large enough to OOM (openai/codex#29007). + let mut stmt = match conn.prepare( + "SELECT id, title FROM threads \ + WHERE title <> '' \ + AND (first_user_message IS NULL OR TRIM(title) <> TRIM(first_user_message))", + ) { + Ok(stmt) => stmt, + Err(err) => { + log::warn!( + "Failed to prepare Codex thread title query for {}: {err}", + db_path.display() + ); + return HashMap::new(); + } + }; + + let rows = match stmt.query_map([], |row| { + let id: String = row.get(0)?; + let title: String = row.get(1)?; + Ok((id, title)) + }) { + Ok(rows) => rows, + Err(err) => { + log::warn!( + "Failed to query Codex thread titles from {}: {err}", + db_path.display() + ); + return HashMap::new(); + } + }; + + rows.flatten() + .filter_map(|(id, title)| { + let id = id.trim(); + let title = title.trim(); + if id.is_empty() || title.is_empty() { + None + } else { + Some((id.to_string(), title.to_string())) + } + }) + .collect() +} + 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); @@ -141,6 +290,13 @@ pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result Option { + parse_session_with_titles(path, &HashMap::new()) +} + +fn parse_session_with_titles( + path: &Path, + thread_titles: &HashMap, +) -> Option { let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?; let mut session_id: Option = None; @@ -233,8 +389,10 @@ fn parse_session(path: &Path) -> Option { 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)) + let title = thread_titles + .get(&session_id) + .map(|t| truncate_summary(t, TITLE_MAX_CHARS)) + .or_else(|| first_user_message.map(|t| truncate_summary(&t, TITLE_MAX_CHARS))) .or_else(|| { project_dir .as_deref() @@ -366,6 +524,7 @@ fn collect_jsonl_files(root: &Path, files: &mut Vec) { #[cfg(test)] mod tests { use super::*; + use crate::codex_state_db::CODEX_STATE_DB_FILENAME; use tempfile::tempdir; fn write_codex_session(path: &Path, session_id: &str, message: &str) { @@ -443,6 +602,166 @@ mod tests { assert_eq!(meta.title.as_deref(), Some("How do I deploy?")); } + #[test] + fn parse_session_prefers_thread_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" + ), + ) + .expect("write"); + + let mut thread_titles = HashMap::new(); + thread_titles.insert( + "test-id".to_string(), + "Renamed deployment thread".to_string(), + ); + + let meta = parse_session_with_titles(&path, &thread_titles).unwrap(); + assert_eq!(meta.title.as_deref(), Some("Renamed deployment thread")); + } + + #[test] + fn load_thread_titles_from_state_db_trims_and_filters_titles() { + let temp = tempdir().expect("tempdir"); + let db_path = temp.path().join(CODEX_STATE_DB_FILENAME); + let conn = Connection::open(&db_path).expect("open sqlite db"); + conn.execute( + "CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT NOT NULL, first_user_message TEXT NOT NULL)", + [], + ) + .expect("create threads table"); + conn.execute( + "INSERT INTO threads (id, title, first_user_message) VALUES (?1, ?2, ?3)", + ("thread-1", " Renamed Codex thread ", "First prompt"), + ) + .expect("insert renamed thread"); + conn.execute( + "INSERT INTO threads (id, title, first_user_message) VALUES (?1, ?2, ?3)", + ("thread-2", " ", "First prompt"), + ) + .expect("insert blank thread"); + conn.execute( + "INSERT INTO threads (id, title, first_user_message) VALUES (?1, ?2, ?3)", + ("thread-3", " First prompt ", "First prompt"), + ) + .expect("insert first-message title"); + drop(conn); + + let titles = load_thread_titles_from_db(&db_path); + + assert_eq!( + titles.get("thread-1").map(String::as_str), + Some("Renamed Codex thread") + ); + assert!(!titles.contains_key("thread-2")); + assert!(!titles.contains_key("thread-3")); + } + + #[test] + fn load_thread_titles_from_state_db_keeps_title_when_first_user_message_null() { + let temp = tempdir().expect("tempdir"); + let db_path = temp.path().join(CODEX_STATE_DB_FILENAME); + let conn = Connection::open(&db_path).expect("open sqlite db"); + // Codex stores first_user_message as a nullable column (Option); + // a renamed thread can have a title before any first message is synced. + conn.execute( + "CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT NOT NULL, first_user_message TEXT)", + [], + ) + .expect("create threads table"); + conn.execute( + "INSERT INTO threads (id, title, first_user_message) VALUES (?1, ?2, NULL)", + ("thread-1", "Renamed thread"), + ) + .expect("insert renamed thread without first message"); + conn.execute( + "INSERT INTO threads (id, title, first_user_message) VALUES (?1, ?2, ?3)", + ("thread-2", "First prompt", "First prompt"), + ) + .expect("insert first-message title"); + drop(conn); + + let titles = load_thread_titles_from_db(&db_path); + + // Kept: title present and no first message to compare against. + assert_eq!( + titles.get("thread-1").map(String::as_str), + Some("Renamed thread") + ); + // Filtered: title equals the first user message. + assert!(!titles.contains_key("thread-2")); + } + + #[test] + fn load_thread_titles_from_session_index_uses_latest_name() { + let temp = tempdir().expect("tempdir"); + let index_path = temp.path().join(CODEX_SESSION_INDEX_FILENAME); + std::fs::write( + &index_path, + concat!( + "{\"id\":\"thread-1\",\"thread_name\":\"Old name\",\"updated_at\":\"2026-07-01T00:00:00Z\"}\n", + "{\"id\":\"thread-2\",\"thread_name\":\" \",\"updated_at\":\"2026-07-01T00:00:00Z\"}\n", + "not json\n", + "{\"id\":\"thread-1\",\"thread_name\":\" New name \",\"updated_at\":\"2026-07-02T00:00:00Z\"}\n" + ), + ) + .expect("write session index"); + + let titles = load_thread_titles_from_session_index(&index_path); + + assert_eq!(titles.get("thread-1").map(String::as_str), Some("New name")); + assert!(!titles.contains_key("thread-2")); + } + + #[test] + fn load_thread_titles_prefers_state_db_explicit_title_over_session_index() { + let temp = tempdir().expect("tempdir"); + let index_path = temp.path().join(CODEX_SESSION_INDEX_FILENAME); + std::fs::write( + &index_path, + concat!( + "{\"id\":\"thread-1\",\"thread_name\":\"Legacy name\",\"updated_at\":\"2026-07-01T00:00:00Z\"}\n", + "{\"id\":\"thread-2\",\"thread_name\":\"Legacy fallback\",\"updated_at\":\"2026-07-01T00:00:00Z\"}\n" + ), + ) + .expect("write session index"); + + let db_path = temp.path().join(CODEX_STATE_DB_FILENAME); + let conn = Connection::open(&db_path).expect("open sqlite db"); + conn.execute( + "CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT NOT NULL, first_user_message TEXT NOT NULL)", + [], + ) + .expect("create threads table"); + conn.execute( + "INSERT INTO threads (id, title, first_user_message) VALUES (?1, ?2, ?3)", + ("thread-1", "SQLite name", "First prompt"), + ) + .expect("insert sqlite title"); + conn.execute( + "INSERT INTO threads (id, title, first_user_message) VALUES (?1, ?2, ?3)", + ("thread-2", "First prompt", "First prompt"), + ) + .expect("insert first-message sqlite title"); + drop(conn); + + let titles = load_thread_titles_from_paths(&index_path, &[db_path]); + + assert_eq!( + titles.get("thread-1").map(String::as_str), + Some("SQLite name") + ); + assert_eq!( + titles.get("thread-2").map(String::as_str), + Some("Legacy fallback") + ); + } + #[test] fn parse_session_skips_agents_md_injection() { let temp = tempdir().expect("tempdir");