mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
fix(codex): display renamed session titles (#4927)
* 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.
This commit is contained in:
@@ -6,6 +6,7 @@
|
|||||||
use crate::codex_config::{
|
use crate::codex_config::{
|
||||||
get_codex_config_dir, read_codex_config_text, CC_SWITCH_CODEX_MODEL_PROVIDER_ID,
|
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::config::{atomic_write, copy_file, get_app_config_dir};
|
||||||
use crate::database::{is_official_seed_id, Database};
|
use crate::database::{is_official_seed_id, Database};
|
||||||
use crate::error::AppError;
|
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_MIGRATION_NAME: &str = "codex-official-history-unify-v1";
|
||||||
/// 还原操作自身的备份目录(与迁移备份分开,保持迁移账本目录纯净)。
|
/// 还原操作自身的备份目录(与迁移备份分开,保持迁移账本目录纯净)。
|
||||||
const OFFICIAL_UNIFY_RESTORE_BACKUP_NAME: &str = "codex-official-history-unify-restore-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 列表按此分块。
|
/// SQLite 变量上限保守值,IN 列表按此分块。
|
||||||
const STATE_DB_ID_CHUNK: usize = 500;
|
const STATE_DB_ID_CHUNK: usize = 500;
|
||||||
|
|
||||||
@@ -1116,55 +1116,6 @@ fn migrate_codex_state_dbs(
|
|||||||
Ok(migrated)
|
Ok(migrated)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn codex_state_db_paths(codex_dir: &Path, config_text: &str) -> Vec<PathBuf> {
|
|
||||||
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<PathBuf>, path: PathBuf) {
|
|
||||||
if !paths.contains(&path) {
|
|
||||||
paths.push(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sqlite_home_from_codex_config(config_text: &str) -> Option<PathBuf> {
|
|
||||||
let doc = config_text.parse::<DocumentMut>().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<PathBuf> {
|
|
||||||
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(
|
fn migrate_codex_state_db_provider_bucket(
|
||||||
db_path: &Path,
|
db_path: &Path,
|
||||||
codex_dir: &Path,
|
codex_dir: &Path,
|
||||||
@@ -1329,6 +1280,7 @@ fn relative_backup_path(path: &Path, root: &Path) -> PathBuf {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::codex_state_db::CODEX_STATE_DB_FILENAME;
|
||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
use std::ffi::OsString;
|
use std::ffi::OsString;
|
||||||
|
|||||||
@@ -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<PathBuf> {
|
||||||
|
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<PathBuf>, path: PathBuf) {
|
||||||
|
if !paths.contains(&path) {
|
||||||
|
paths.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sqlite_home_from_codex_config(config_text: &str) -> Option<PathBuf> {
|
||||||
|
let doc = config_text.parse::<DocumentMut>().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<PathBuf> {
|
||||||
|
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),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ mod claude_mcp;
|
|||||||
mod claude_plugin;
|
mod claude_plugin;
|
||||||
mod codex_config;
|
mod codex_config;
|
||||||
mod codex_history_migration;
|
mod codex_history_migration;
|
||||||
|
mod codex_state_db;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod config;
|
mod config;
|
||||||
mod database;
|
mod database;
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::{BufRead, BufReader};
|
use std::io::{BufRead, BufReader};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
|
use rusqlite::Connection;
|
||||||
|
use serde::Deserialize;
|
||||||
use serde_json::Value;
|
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 crate::session_manager::{SessionMessage, SessionMeta};
|
||||||
|
|
||||||
use super::utils::{
|
use super::utils::{
|
||||||
@@ -15,6 +20,7 @@ use super::utils::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
const PROVIDER_ID: &str = "codex";
|
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 VSCODE_CONTEXT_PREFIX: &str = "# Context from my IDE setup:";
|
||||||
const CODEX_REQUEST_MARKER: &str = "my request for codex";
|
const CODEX_REQUEST_MARKER: &str = "my request for codex";
|
||||||
|
|
||||||
@@ -23,6 +29,12 @@ static UUID_RE: LazyLock<Regex> = LazyLock::new(|| {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SessionIndexEntry {
|
||||||
|
id: String,
|
||||||
|
thread_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn scan_sessions() -> Vec<SessionMeta> {
|
pub fn scan_sessions() -> Vec<SessionMeta> {
|
||||||
let roots = session_roots();
|
let roots = session_roots();
|
||||||
scan_sessions_in_roots(&roots)
|
scan_sessions_in_roots(&roots)
|
||||||
@@ -37,6 +49,14 @@ pub fn session_roots() -> Vec<PathBuf> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn scan_sessions_in_roots(roots: &[PathBuf]) -> Vec<SessionMeta> {
|
fn scan_sessions_in_roots(roots: &[PathBuf]) -> Vec<SessionMeta> {
|
||||||
|
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<String, String>,
|
||||||
|
) -> Vec<SessionMeta> {
|
||||||
let mut files = Vec::new();
|
let mut files = Vec::new();
|
||||||
for root in roots {
|
for root in roots {
|
||||||
collect_jsonl_files(root, &mut files);
|
collect_jsonl_files(root, &mut files);
|
||||||
@@ -44,7 +64,7 @@ fn scan_sessions_in_roots(roots: &[PathBuf]) -> Vec<SessionMeta> {
|
|||||||
|
|
||||||
let mut sessions = Vec::new();
|
let mut sessions = Vec::new();
|
||||||
for path in files {
|
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);
|
sessions.push(meta);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,6 +72,135 @@ fn scan_sessions_in_roots(roots: &[PathBuf]) -> Vec<SessionMeta> {
|
|||||||
sessions
|
sessions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn load_thread_titles() -> HashMap<String, String> {
|
||||||
|
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<String, String> {
|
||||||
|
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<String, String> {
|
||||||
|
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::<SessionIndexEntry>(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<String, String> {
|
||||||
|
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<Vec<SessionMessage>, String> {
|
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 file = File::open(path).map_err(|e| format!("Failed to open session file: {e}"))?;
|
||||||
let reader = BufReader::new(file);
|
let reader = BufReader::new(file);
|
||||||
@@ -141,6 +290,13 @@ pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result<boo
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn parse_session(path: &Path) -> Option<SessionMeta> {
|
fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||||
|
parse_session_with_titles(path, &HashMap::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_session_with_titles(
|
||||||
|
path: &Path,
|
||||||
|
thread_titles: &HashMap<String, String>,
|
||||||
|
) -> Option<SessionMeta> {
|
||||||
let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?;
|
let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?;
|
||||||
|
|
||||||
let mut session_id: Option<String> = None;
|
let mut session_id: Option<String> = None;
|
||||||
@@ -233,8 +389,10 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
|
|||||||
let session_id = session_id.or_else(|| infer_session_id_from_filename(path));
|
let session_id = session_id.or_else(|| infer_session_id_from_filename(path));
|
||||||
let session_id = session_id?;
|
let session_id = session_id?;
|
||||||
|
|
||||||
let title = first_user_message
|
let title = thread_titles
|
||||||
.map(|t| truncate_summary(&t, TITLE_MAX_CHARS))
|
.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(|| {
|
.or_else(|| {
|
||||||
project_dir
|
project_dir
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -366,6 +524,7 @@ fn collect_jsonl_files(root: &Path, files: &mut Vec<PathBuf>) {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::codex_state_db::CODEX_STATE_DB_FILENAME;
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
fn write_codex_session(path: &Path, session_id: &str, message: &str) {
|
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?"));
|
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<String>);
|
||||||
|
// 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]
|
#[test]
|
||||||
fn parse_session_skips_agents_md_injection() {
|
fn parse_session_skips_agents_md_injection() {
|
||||||
let temp = tempdir().expect("tempdir");
|
let temp = tempdir().expect("tempdir");
|
||||||
|
|||||||
Reference in New Issue
Block a user