From 90cc2156020121b3daf5a5aad912bf126ccdbb2b Mon Sep 17 00:00:00 2001 From: richardopenai Date: Thu, 2 Jul 2026 13:54:29 -0700 Subject: [PATCH] Refresh derived thread titles over time --- codex-rs/thread-store/src/in_memory.rs | 23 +++++-- codex-rs/thread-store/src/local/mod.rs | 51 +++++++++++++++ .../src/local/update_thread_metadata.rs | 65 +++++++++++++++++++ .../thread-store/src/thread_metadata_sync.rs | 53 +++++++++++---- codex-rs/thread-store/src/types.rs | 11 ++++ 5 files changed, 186 insertions(+), 17 deletions(-) diff --git a/codex-rs/thread-store/src/in_memory.rs b/codex-rs/thread-store/src/in_memory.rs index cb3f198ff3..94266c1f1b 100644 --- a/codex-rs/thread-store/src/in_memory.rs +++ b/codex-rs/thread-store/src/in_memory.rs @@ -744,8 +744,25 @@ fn stored_thread_from_state( thread_id, items: history_items.clone(), }); - let name = state.names.get(&thread_id).cloned().flatten(); let metadata = state.metadata_updates.get(&thread_id); + let preview = metadata + .and_then(|metadata| metadata.preview.clone()) + .unwrap_or_default(); + let name = state + .names + .get(&thread_id) + .cloned() + .flatten() + .or_else(|| { + let title = metadata + .and_then(|metadata| { + metadata + .title + .clone() + .or_else(|| metadata.derived_title.clone()) + })?; + (title.trim() != preview.trim()).then_some(title) + }); let rollout_path = state .rollout_paths .iter() @@ -761,9 +778,7 @@ fn stored_thread_from_state( .or(rollout_path), forked_from_id: created.forked_from_id, parent_thread_id: created.parent_thread_id, - preview: metadata - .and_then(|metadata| metadata.preview.clone()) - .unwrap_or_default(), + preview, name, model_provider: metadata .and_then(|metadata| metadata.model_provider.clone()) diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index 5939ea606e..d1ffb252a0 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -470,6 +470,56 @@ mod tests { assert_eq!(metadata.title, "observed append"); } + #[tokio::test] + async fn live_thread_refreshes_title_from_later_user_messages() { + let home = TempDir::new().expect("temp dir"); + let config = test_config(home.path()); + let runtime = codex_state::StateRuntime::init( + config.sqlite_home.clone(), + config.default_model_provider_id.clone(), + ) + .await + .expect("state db should initialize"); + let store = Arc::new(LocalThreadStore::new(config, Some(runtime.clone()))); + let thread_id = ThreadId::default(); + let live_thread = LiveThread::create(store.clone(), create_thread_params(thread_id)) + .await + .expect("create live thread"); + + live_thread + .append_items(&[user_message_item("initial setup task")]) + .await + .expect("append first user message"); + live_thread + .append_items(&[user_message_item("switch to release notes")]) + .await + .expect("append later user message"); + live_thread.flush().await.expect("flush thread"); + + let metadata = runtime + .get_thread(thread_id) + .await + .expect("sqlite metadata read") + .expect("sqlite metadata"); + assert_eq!( + metadata.first_user_message.as_deref(), + Some("initial setup task") + ); + assert_eq!(metadata.preview.as_deref(), Some("initial setup task")); + assert_eq!(metadata.title, "switch to release notes"); + + let thread = store + .read_thread(ReadThreadParams { + thread_id, + include_archived: false, + include_history: false, + }) + .await + .expect("read thread"); + assert_eq!(thread.preview, "initial setup task"); + assert_eq!(thread.name.as_deref(), Some("switch to release notes")); + } + #[tokio::test] async fn live_thread_output_advances_updated_at_but_not_recency_at() { let home = TempDir::new().expect("temp dir"); @@ -728,6 +778,7 @@ mod tests { metadata.first_user_message.as_deref(), Some("Hello from user") ); + assert_eq!(metadata.title, "new live append"); } #[tokio::test] diff --git a/codex-rs/thread-store/src/local/update_thread_metadata.rs b/codex-rs/thread-store/src/local/update_thread_metadata.rs index 580df1dc61..de875ee433 100644 --- a/codex-rs/thread-store/src/local/update_thread_metadata.rs +++ b/codex-rs/thread-store/src/local/update_thread_metadata.rs @@ -12,6 +12,7 @@ use codex_rollout::ARCHIVED_SESSIONS_SUBDIR; use codex_rollout::append_rollout_item_to_path; use codex_rollout::append_thread_name; use codex_rollout::find_archived_thread_path_by_id_str; +use codex_rollout::find_thread_name_by_id; use codex_rollout::find_thread_path_by_id_str; use codex_rollout::read_session_meta_line; use codex_state::ThreadMetadataBuilder; @@ -253,6 +254,11 @@ async fn apply_metadata_update( if let Some(preview) = patch.preview { metadata.preview = Some(preview); } + if let Some(derived_title) = patch.derived_title + && !has_explicit_thread_name(store, thread_id).await + { + metadata.title = derived_title; + } if let Some(name) = patch.name { metadata.title = name.unwrap_or_default(); } @@ -482,6 +488,7 @@ fn has_observed_metadata_facts(patch: &ThreadMetadataPatch) -> bool { patch.rollout_path.is_some() || patch.preview.is_some() || patch.title.is_some() + || patch.derived_title.is_some() || patch.model_provider.is_some() || patch.model.is_some() || patch.reasoning_effort.is_some() @@ -499,6 +506,14 @@ fn has_observed_metadata_facts(patch: &ThreadMetadataPatch) -> bool { || patch.first_user_message.is_some() } +async fn has_explicit_thread_name(store: &LocalThreadStore, thread_id: ThreadId) -> bool { + find_thread_name_by_id(store.config.codex_home.as_path(), &thread_id) + .await + .ok() + .flatten() + .is_some_and(|name| !name.trim().is_empty()) +} + fn enum_to_string(value: &T) -> String { match serde_json::to_value(value) { Ok(serde_json::Value::String(value)) => value, @@ -1444,6 +1459,56 @@ mod tests { assert_eq!(thread.name.as_deref(), Some("Derived first message")); } + #[tokio::test] + async fn metadata_patch_does_not_apply_derived_title_over_existing_name() { + let home = TempDir::new().expect("temp dir"); + let config = test_config(home.path()); + let runtime = codex_state::StateRuntime::init( + home.path().to_path_buf(), + config.default_model_provider_id.clone(), + ) + .await + .expect("state db should initialize"); + let store = LocalThreadStore::new(config, Some(runtime.clone())); + let uuid = Uuid::from_u128(307); + let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); + write_session_file(home.path(), "2025-01-03T16-15-00", uuid).expect("session file"); + + store + .update_thread_metadata(UpdateThreadMetadataParams { + thread_id, + patch: ThreadMetadataPatch { + name: Some(Some("User chosen name".to_string())), + ..Default::default() + }, + include_archived: false, + }) + .await + .expect("set explicit name"); + + let thread = store + .update_thread_metadata(UpdateThreadMetadataParams { + thread_id, + patch: ThreadMetadataPatch { + derived_title: Some("Later live prompt".to_string()), + preview: Some("Original prompt".to_string()), + first_user_message: Some("Original prompt".to_string()), + ..Default::default() + }, + include_archived: false, + }) + .await + .expect("apply observed metadata"); + + assert_eq!(thread.name.as_deref(), Some("User chosen name")); + let metadata = runtime + .get_thread(thread_id) + .await + .expect("sqlite metadata read") + .expect("sqlite metadata"); + assert_eq!(metadata.title, "User chosen name"); + } + #[tokio::test] async fn metadata_patch_applies_latest_preview_and_first_user_message() { let home = TempDir::new().expect("temp dir"); diff --git a/codex-rs/thread-store/src/thread_metadata_sync.rs b/codex-rs/thread-store/src/thread_metadata_sync.rs index 1057715a4a..c3d4aa2f2f 100644 --- a/codex-rs/thread-store/src/thread_metadata_sync.rs +++ b/codex-rs/thread-store/src/thread_metadata_sync.rs @@ -37,7 +37,6 @@ pub(crate) struct ThreadMetadataSync { cwd_seen: bool, preview_seen: bool, first_user_message_seen: bool, - title_seen: bool, pending_update: Option, pending_update_generation: u64, last_touch_persisted_at: Option, @@ -83,7 +82,6 @@ impl ThreadMetadataSync { cwd_seen: !cwd.as_os_str().is_empty(), preview_seen: false, first_user_message_seen: false, - title_seen: false, pending_update: Some(update), pending_update_generation: 1, last_touch_persisted_at: None, @@ -102,7 +100,6 @@ impl ThreadMetadataSync { .is_some_and(|cwd| !cwd.as_os_str().is_empty()), preview_seen: false, first_user_message_seen: false, - title_seen: false, pending_update: None, pending_update_generation: 0, last_touch_persisted_at: None, @@ -259,12 +256,9 @@ impl ThreadMetadataSync { update.preview = Some(preview); } } - if !self.title_seen { - let title = strip_user_message_prefix(user.message.as_str()); - if !title.is_empty() { - self.title_seen = true; - update.title = Some(title.to_string()); - } + let title = strip_user_message_prefix(user.message.as_str()); + if !title.is_empty() { + update.derived_title = Some(title.to_string()); } } RolloutItem::EventMsg(EventMsg::TokenCount(token_count)) => { @@ -357,6 +351,7 @@ fn update_has_metadata_facts(update: &ThreadMetadataPatch) -> bool { update.rollout_path.is_some() || update.preview.is_some() || update.title.is_some() + || update.derived_title.is_some() || update.model_provider.is_some() || update.model.is_some() || update.reasoning_effort.is_some() @@ -422,7 +417,10 @@ mod tests { "2025-01-03T12:00:00+00:00" ); assert_eq!(update.patch.preview.as_deref(), Some("hello metadata")); - assert_eq!(update.patch.title.as_deref(), Some("hello metadata")); + assert_eq!( + update.patch.derived_title.as_deref(), + Some("hello metadata") + ); assert_eq!( update.patch.first_user_message.as_deref(), Some("hello metadata") @@ -457,11 +455,14 @@ mod tests { update.patch.first_user_message.as_deref(), Some("first user text") ); - assert_eq!(update.patch.title.as_deref(), Some("first user text")); + assert_eq!( + update.patch.derived_title.as_deref(), + Some("first user text") + ); } #[test] - fn later_user_messages_do_not_emit_existing_preview_fields() { + fn later_user_messages_update_title_without_existing_preview_fields() { let thread_id = ThreadId::new(); let mut sync = ThreadMetadataSync::for_resume(&resume_params( thread_id, @@ -479,11 +480,37 @@ mod tests { .expect("updated_at touch"); assert_eq!(update.patch.preview, None); - assert_eq!(update.patch.title, None); + assert_eq!( + update.patch.derived_title.as_deref(), + Some("later user text") + ); assert_eq!(update.patch.first_user_message, None); assert!(update.patch.updated_at.is_some()); } + #[test] + fn resume_history_derives_title_from_latest_user_message() { + let thread_id = ThreadId::new(); + let sync = ThreadMetadataSync::for_resume(&resume_params( + thread_id, + vec![ + RolloutItem::EventMsg(EventMsg::UserMessage(user_message("first user text"))), + RolloutItem::EventMsg(EventMsg::UserMessage(user_message("latest user text"))), + ], + )); + + let update = sync.take_pending_update().expect("pending metadata update"); + assert_eq!(update.patch.preview.as_deref(), Some("first user text")); + assert_eq!( + update.patch.first_user_message.as_deref(), + Some("first user text") + ); + assert_eq!( + update.patch.derived_title.as_deref(), + Some("latest user text") + ); + } + #[test] fn metadata_irrelevant_items_coalesce_updated_at_touches() { let thread_id = ThreadId::new(); diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index 4e688eca6f..4e443a9e59 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -538,6 +538,11 @@ pub struct ThreadMetadataPatch { pub preview: Option, /// Best-effort title derived from history. pub title: Option, + /// Best-effort live title derived from appended history. + /// + /// Unlike `title`, stores may ignore this when a user-facing name has + /// already been set explicitly. + pub derived_title: Option, /// Model provider associated with the thread. pub model_provider: Option, /// Latest observed model. @@ -617,6 +622,9 @@ impl ThreadMetadataPatch { if next.title.is_some() { self.title = next.title; } + if next.derived_title.is_some() { + self.derived_title = next.derived_title; + } if next.model_provider.is_some() { self.model_provider = next.model_provider; } @@ -683,6 +691,7 @@ impl ThreadMetadataPatch { && self.rollout_path.is_none() && self.preview.is_none() && self.title.is_none() + && self.derived_title.is_none() && self.model_provider.is_none() && self.model.is_none() && self.reasoning_effort.is_none() @@ -832,6 +841,7 @@ mod tests { name: Some(None), preview: None, title: Some("new title".to_string()), + derived_title: Some("derived title".to_string()), git_info: Some(GitInfoPatch { sha: None, branch: Some(Some("feature".to_string())), @@ -843,6 +853,7 @@ mod tests { assert_eq!(current.name, Some(None)); assert_eq!(current.preview.as_deref(), Some("old preview")); assert_eq!(current.title.as_deref(), Some("new title")); + assert_eq!(current.derived_title.as_deref(), Some("derived title")); assert_eq!( current.git_info, Some(GitInfoPatch {