Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3959,9 +3959,12 @@ impl ThreadRequestProcessor {
.await
&& let Some(title) = stored_thread.name.as_deref().map(str::trim)
&& !title.is_empty()
&& stored_thread.preview.trim() != title
{
set_thread_name_from_title(thread, title.to_string());
if stored_thread.history_mode == ThreadHistoryMode::Paginated {
thread.name = Some(title.to_string());
} else {
set_thread_name_from_title(thread, title.to_string());
}
}
}

Expand Down
14 changes: 7 additions & 7 deletions codex-rs/app-server/tests/suite/v2/thread_read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1220,18 +1220,16 @@ async fn thread_read_loaded_thread_returns_precomputed_path_before_materializati
}

#[tokio::test]
async fn thread_name_set_is_reflected_in_read_list_and_resume() -> Result<()> {
async fn paginated_thread_name_set_is_reflected_in_read_list_and_metadata_resume() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;

let preview = "Saved user message";
let conversation_id = create_fake_rollout_with_text_elements(
let conversation_id = create_fake_paginated_rollout(
codex_home.path(),
"2025-01-05T12-00-00",
"2025-01-05T12:00:00Z",
preview,
vec![],
"Saved user message",
Some("mock_provider"),
/*git_info*/ None,
)?;
Expand All @@ -1244,7 +1242,7 @@ async fn thread_name_set_is_reflected_in_read_list_and_resume() -> Result<()> {
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;

// Set a user-facing thread title.
let new_name = "My renamed thread";
let new_name = "Saved user message";
let set_id = mcp
.send_thread_set_name_request(ThreadSetNameParams {
thread_id: conversation_id.clone(),
Expand Down Expand Up @@ -1283,6 +1281,7 @@ async fn thread_name_set_is_reflected_in_read_list_and_resume() -> Result<()> {
let ThreadReadResponse { thread, .. } = to_response::<ThreadReadResponse>(read_resp)?;
assert_eq!(thread.id, conversation_id);
assert_eq!(thread.name.as_deref(), Some(new_name));
assert_eq!(thread.history_mode, ThreadHistoryMode::Paginated);
let thread_json = read_result
.get("thread")
.and_then(Value::as_object)
Expand All @@ -1309,7 +1308,7 @@ async fn thread_name_set_is_reflected_in_read_list_and_resume() -> Result<()> {
source_kinds: None,
archived: None,
cwd: None,
use_state_db_only: false,
use_state_db_only: true,
search_term: None,
parent_thread_id: None,
ancestor_thread_id: None,
Expand Down Expand Up @@ -1350,6 +1349,7 @@ async fn thread_name_set_is_reflected_in_read_list_and_resume() -> Result<()> {
let resume_id = mcp
.send_thread_resume_request(ThreadResumeParams {
thread_id: conversation_id.clone(),
exclude_turns: true,
..Default::default()
})
.await?;
Expand Down
1 change: 1 addition & 0 deletions codex-rs/state/migrations/0041_threads_name.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE threads ADD COLUMN name TEXT;
1 change: 1 addition & 0 deletions codex-rs/state/src/extract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,7 @@ mod tests {
cwd: PathBuf::from("/tmp"),
cli_version: "0.0.0".to_string(),
title: String::new(),
name: None,
preview: None,
sandbox_policy: "read-only".to_string(),
approval_mode: "on-request".to_string(),
Expand Down
14 changes: 13 additions & 1 deletion codex-rs/state/src/model/thread_metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ pub struct ExtractionOutcome {
pub parse_errors: usize,
}

/// Canonical thread metadata derived from rollout files.
/// Canonical persisted thread metadata.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ThreadMetadata {
/// The thread identifier.
Expand Down Expand Up @@ -110,6 +110,8 @@ pub struct ThreadMetadata {
pub cli_version: String,
/// A best-effort thread title.
pub title: String,
/// Explicit user-facing thread name, if one was set.
pub name: Option<String>,
/// Best available user-facing preview for discovery and list display.
pub preview: Option<String>,
/// The sandbox policy (stringified enum).
Expand Down Expand Up @@ -245,6 +247,7 @@ impl ThreadMetadataBuilder {
cwd: self.cwd.clone(),
cli_version: self.cli_version.clone().unwrap_or_default(),
title: String::new(),
name: None,
preview: None,
sandbox_policy,
approval_mode,
Expand Down Expand Up @@ -332,6 +335,9 @@ impl ThreadMetadata {
if self.title != other.title {
diffs.push("title");
}
if self.name != other.name {
diffs.push("name");
}
if self.preview != other.preview {
diffs.push("preview");
}
Expand Down Expand Up @@ -386,6 +392,7 @@ pub(crate) struct ThreadRow {
cwd: String,
cli_version: String,
title: String,
name: Option<String>,
preview: String,
sandbox_policy: String,
approval_mode: String,
Expand Down Expand Up @@ -417,6 +424,7 @@ impl ThreadRow {
cwd: row.try_get("cwd")?,
cli_version: row.try_get("cli_version")?,
title: row.try_get("title")?,
name: row.try_get("name")?,
preview: row.try_get("preview")?,
sandbox_policy: row.try_get("sandbox_policy")?,
approval_mode: row.try_get("approval_mode")?,
Expand Down Expand Up @@ -452,6 +460,7 @@ impl TryFrom<ThreadRow> for ThreadMetadata {
cwd,
cli_version,
title,
name,
preview,
sandbox_policy,
approval_mode,
Expand Down Expand Up @@ -486,6 +495,7 @@ impl TryFrom<ThreadRow> for ThreadMetadata {
cwd: PathBuf::from(cwd),
cli_version,
title,
name,
preview: (!preview.is_empty()).then_some(preview),
sandbox_policy,
approval_mode,
Expand Down Expand Up @@ -583,6 +593,7 @@ mod tests {
cwd: "/tmp/workspace".to_string(),
cli_version: "0.0.0".to_string(),
title: String::new(),
name: None,
preview: String::new(),
sandbox_policy: "read-only".to_string(),
approval_mode: "on-request".to_string(),
Expand Down Expand Up @@ -615,6 +626,7 @@ mod tests {
cwd: PathBuf::from("/tmp/workspace"),
cli_version: "0.0.0".to_string(),
title: String::new(),
name: None,
preview: None,
sandbox_policy: "read-only".to_string(),
approval_mode: "on-request".to_string(),
Expand Down
2 changes: 2 additions & 0 deletions codex-rs/state/src/runtime/memories.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ SELECT
threads.cwd,
threads.cli_version,
threads.title,
threads.name,
threads.preview,
threads.sandbox_policy,
threads.approval_mode,
Expand Down Expand Up @@ -562,6 +563,7 @@ SELECT
threads.cwd,
threads.cli_version,
threads.title,
threads.name,
threads.preview,
threads.sandbox_policy,
threads.approval_mode,
Expand Down
1 change: 1 addition & 0 deletions codex-rs/state/src/runtime/test_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ pub(super) fn test_thread_metadata(
cwd,
cli_version: "0.0.0".to_string(),
title: String::new(),
name: None,
preview: Some("hello".to_string()),
sandbox_policy: crate::extract::enum_to_string(&SandboxPolicy::new_read_only_policy()),
approval_mode: crate::extract::enum_to_string(&AskForApproval::OnRequest),
Expand Down
27 changes: 24 additions & 3 deletions codex-rs/state/src/runtime/threads.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ SELECT
threads.cwd,
threads.cli_version,
threads.title,
threads.name,
threads.preview,
threads.sandbox_policy,
threads.approval_mode,
Expand Down Expand Up @@ -556,6 +557,7 @@ INSERT INTO threads (
cwd,
cli_version,
title,
name,
preview,
sandbox_policy,
approval_mode,
Expand All @@ -567,7 +569,7 @@ INSERT INTO threads (
git_branch,
git_origin_url,
memory_mode
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO NOTHING
"#,
)
Expand Down Expand Up @@ -601,6 +603,7 @@ ON CONFLICT(id) DO NOTHING
.bind(metadata.cwd.display().to_string())
.bind(metadata.cli_version.as_str())
.bind(metadata.title.as_str())
.bind(metadata.name.as_deref())
.bind(preview)
.bind(metadata.sandbox_policy.as_str())
.bind(metadata.approval_mode.as_str())
Expand Down Expand Up @@ -645,6 +648,19 @@ ON CONFLICT(id) DO NOTHING
Ok(result.rows_affected() > 0)
}

pub async fn update_thread_name(
&self,
thread_id: ThreadId,
name: Option<&str>,
) -> anyhow::Result<bool> {
let result = sqlx::query("UPDATE threads SET name = ? WHERE id = ?")
.bind(name)
.bind(thread_id.to_string())
.execute(self.pool.as_ref())
.await?;
Ok(result.rows_affected() > 0)
}

pub async fn touch_thread_updated_at(
&self,
thread_id: ThreadId,
Expand Down Expand Up @@ -810,6 +826,7 @@ INSERT INTO threads (
cwd,
cli_version,
title,
name,
preview,
sandbox_policy,
approval_mode,
Expand All @@ -821,7 +838,7 @@ INSERT INTO threads (
git_branch,
git_origin_url,
memory_mode
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
rollout_path = excluded.rollout_path,
created_at = excluded.created_at,
Expand Down Expand Up @@ -884,6 +901,7 @@ ON CONFLICT(id) DO UPDATE SET
.bind(metadata.cwd.display().to_string())
.bind(metadata.cli_version.as_str())
.bind(metadata.title.as_str())
.bind(metadata.name.as_deref())
.bind(preview)
.bind(metadata.sandbox_policy.as_str())
.bind(metadata.approval_mode.as_str())
Expand Down Expand Up @@ -1223,6 +1241,7 @@ SELECT
threads.cwd,
threads.cli_version,
threads.title,
threads.name,
threads.preview,
threads.sandbox_policy,
threads.approval_mode,
Expand Down Expand Up @@ -1322,7 +1341,9 @@ pub(super) fn push_thread_filters<'a>(
None => {}
}
if let Some(search_term) = search_term {
builder.push(" AND (instr(threads.title, ");
builder.push(" AND (instr(COALESCE(threads.name, ''), ");
builder.push_bind(search_term);
builder.push(") > 0 OR instr(threads.title, ");
builder.push_bind(search_term);
builder.push(") > 0 OR instr(threads.preview, ");
builder.push_bind(search_term);
Expand Down
58 changes: 54 additions & 4 deletions codex-rs/thread-store/src/local/helpers.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::ffi::OsStr;
use std::fs::FileTimes;
use std::fs::OpenOptions;
Expand All @@ -15,10 +17,13 @@ use codex_protocol::protocol::GitInfo;
use codex_protocol::protocol::NetworkAccess;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::ThreadHistoryMode;
use codex_rollout::ARCHIVED_SESSIONS_SUBDIR;
use codex_rollout::ThreadItem;
use codex_rollout::find_thread_names_by_ids;
use codex_state::ThreadMetadata;

use super::LocalThreadStore;
use crate::StoredThread;
use crate::ThreadStoreError;
use crate::ThreadStoreResult;
Expand Down Expand Up @@ -177,6 +182,52 @@ pub(super) fn permission_profile_to_metadata_value(
}
}

pub(super) fn sqlite_thread_name(metadata: &ThreadMetadata) -> Option<String> {
metadata
.name
.as_deref()
.map(str::trim)
.filter(|name| !name.is_empty())
.map(str::to_string)
}

pub(super) async fn resolve_thread_names(
store: &LocalThreadStore,
thread_history_modes: &HashMap<ThreadId, ThreadHistoryMode>,
) -> HashMap<ThreadId, String> {
let mut names = HashMap::<ThreadId, String>::with_capacity(thread_history_modes.len());
let legacy_thread_ids = thread_history_modes
.iter()
.filter_map(|(&thread_id, &history_mode)| {
(history_mode == ThreadHistoryMode::Legacy).then_some(thread_id)
})
.collect::<HashSet<_>>();
if let Some(state_db_ctx) = store.state_db().await {
for (&thread_id, &history_mode) in thread_history_modes {
let Ok(Some(metadata)) = state_db_ctx.get_thread(thread_id).await else {
continue;
};
let name = match history_mode {
ThreadHistoryMode::Legacy => distinct_thread_metadata_title(&metadata),
ThreadHistoryMode::Paginated => sqlite_thread_name(&metadata),
};
if let Some(name) = name {
names.insert(thread_id, name);
}
}
}
if let Ok(legacy_names) =
find_thread_names_by_ids(store.config.codex_home.as_path(), &legacy_thread_ids).await
{
// Legacy titles remain authoritative when present; the index only fills
// names for threads whose SQLite title is still derived from the preview.
for (thread_id, name) in legacy_names {
names.entry(thread_id).or_insert(name);
}
}
names
}

pub(super) fn distinct_thread_metadata_title(metadata: &ThreadMetadata) -> Option<String> {
let title = metadata.title.trim();
if title.is_empty() || metadata.first_user_message.as_deref().map(str::trim) == Some(title) {
Expand All @@ -186,11 +237,10 @@ pub(super) fn distinct_thread_metadata_title(metadata: &ThreadMetadata) -> Optio
}
}

pub(super) fn set_thread_name_from_title(thread: &mut StoredThread, title: String) {
if title.trim().is_empty() || thread.preview.trim() == title.trim() {
return;
pub(super) fn set_thread_name(thread: &mut StoredThread, name: String) {
if thread.history_mode == ThreadHistoryMode::Paginated || thread.preview.trim() != name.trim() {
thread.name = Some(name);
}
thread.name = Some(title);
}

fn parse_rfc3339(value: Option<&str>) -> Option<DateTime<Utc>> {
Expand Down
Loading
Loading