diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 7b540aa4d8..f014f639df 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3345,6 +3345,7 @@ impl Session { input: &[UserInput], response_item: ResponseItem, ) { + let auto_thread_name = self.auto_thread_name_candidate(input).await; // Persist the user message to history, but emit the turn item from `UserInput` so // UI-only `text_elements` are preserved. `ResponseItem::Message` does not carry // those spans, and `record_response_item_and_emit_turn_item` would drop them. @@ -3354,6 +3355,30 @@ impl Session { self.emit_turn_item_started(turn_context, &turn_item).await; self.emit_turn_item_completed(turn_context, turn_item).await; self.ensure_rollout_materialized().await; + if let Some(name) = auto_thread_name { + handlers::maybe_set_auto_thread_name(self, turn_context.sub_id.clone(), name).await; + } + } + + async fn auto_thread_name_candidate(&self, input: &[UserInput]) -> Option { + if self.services.rollout.lock().await.is_none() { + return None; + } + + let state = self.state.lock().await; + if state.session_configuration.thread_name.is_some() { + return None; + } + if state + .history + .raw_items() + .iter() + .any(|item| matches!(item, ResponseItem::Message { role, .. } if role == "user")) + { + return None; + } + + crate::util::auto_thread_name_from_user_input(input) } pub(crate) async fn notify_background_event( @@ -3911,6 +3936,57 @@ mod handlers { use tracing::info; use tracing::warn; + enum ThreadNameUpdateError { + BadRequest(String), + Other(String), + } + + async fn update_thread_name( + sess: &Session, + name: String, + ) -> std::result::Result { + let Some(name) = crate::util::normalize_thread_name(&name) else { + return Err(ThreadNameUpdateError::BadRequest( + "Thread name cannot be empty.".to_string(), + )); + }; + + let persistence_enabled = { + let rollout = sess.services.rollout.lock().await; + rollout.is_some() + }; + if !persistence_enabled { + return Err(ThreadNameUpdateError::Other( + "Session persistence is disabled; cannot rename thread.".to_string(), + )); + } + + let codex_home = sess.codex_home().await; + session_index::append_thread_name(&codex_home, sess.conversation_id, &name) + .await + .map_err(|err| { + ThreadNameUpdateError::Other(format!("Failed to set thread name: {err}")) + })?; + + { + let mut state = sess.state.lock().await; + state.session_configuration.thread_name = Some(name.clone()); + } + + Ok(name) + } + + async fn emit_thread_name_updated(sess: &Session, sub_id: String, name: String) { + sess.send_event_raw(Event { + id: sub_id, + msg: EventMsg::ThreadNameUpdated(ThreadNameUpdatedEvent { + thread_id: sess.conversation_id, + thread_name: Some(name), + }), + }) + .await; + } + pub async fn interrupt(sess: &Arc) { sess.interrupt_task().await; } @@ -4496,62 +4572,36 @@ mod handlers { /// /// Returns an error event if the name is empty or session persistence is disabled. pub async fn set_thread_name(sess: &Arc, sub_id: String, name: String) { - let Some(name) = crate::util::normalize_thread_name(&name) else { - let event = Event { - id: sub_id, - msg: EventMsg::Error(ErrorEvent { - message: "Thread name cannot be empty.".to_string(), - codex_error_info: Some(CodexErrorInfo::BadRequest), - }), - }; - sess.send_event_raw(event).await; - return; - }; - - let persistence_enabled = { - let rollout = sess.services.rollout.lock().await; - rollout.is_some() - }; - if !persistence_enabled { - let event = Event { - id: sub_id, - msg: EventMsg::Error(ErrorEvent { - message: "Session persistence is disabled; cannot rename thread.".to_string(), - codex_error_info: Some(CodexErrorInfo::Other), - }), - }; - sess.send_event_raw(event).await; - return; - }; - - let codex_home = sess.codex_home().await; - if let Err(e) = - session_index::append_thread_name(&codex_home, sess.conversation_id, &name).await - { - let event = Event { - id: sub_id, - msg: EventMsg::Error(ErrorEvent { - message: format!("Failed to set thread name: {e}"), - codex_error_info: Some(CodexErrorInfo::Other), - }), - }; - sess.send_event_raw(event).await; - return; + match update_thread_name(sess.as_ref(), name).await { + Ok(name) => emit_thread_name_updated(sess.as_ref(), sub_id, name).await, + Err(ThreadNameUpdateError::BadRequest(message)) => { + sess.send_event_raw(Event { + id: sub_id, + msg: EventMsg::Error(ErrorEvent { + message, + codex_error_info: Some(CodexErrorInfo::BadRequest), + }), + }) + .await; + } + Err(ThreadNameUpdateError::Other(message)) => { + sess.send_event_raw(Event { + id: sub_id, + msg: EventMsg::Error(ErrorEvent { + message, + codex_error_info: Some(CodexErrorInfo::Other), + }), + }) + .await; + } } + } - { - let mut state = sess.state.lock().await; - state.session_configuration.thread_name = Some(name.clone()); - } - - sess.send_event_raw(Event { - id: sub_id, - msg: EventMsg::ThreadNameUpdated(ThreadNameUpdatedEvent { - thread_id: sess.conversation_id, - thread_name: Some(name), - }), - }) - .await; + pub(super) async fn maybe_set_auto_thread_name(sess: &Session, sub_id: String, name: String) { + let Ok(name) = update_thread_name(sess, name).await else { + return; + }; + emit_thread_name_updated(sess, sub_id, name).await; } pub async fn shutdown(sess: &Arc, sub_id: String) -> bool { @@ -8600,6 +8650,111 @@ mod tests { make_session_and_context_with_dynamic_tools_and_rx(Vec::new()).await } + async fn enable_rollout_persistence(sess: &Arc) { + let session_configuration = { sess.state.lock().await.session_configuration.clone() }; + let config = Arc::clone(&session_configuration.original_config_do_not_use); + let event_persistence_mode = if session_configuration.persist_extended_history { + EventPersistenceMode::Extended + } else { + EventPersistenceMode::Limited + }; + let recorder = RolloutRecorder::new( + config.as_ref(), + RolloutRecorderParams::new( + sess.conversation_id, + None, + session_configuration.session_source.clone(), + BaseInstructions { + text: session_configuration.base_instructions.clone(), + }, + session_configuration.dynamic_tools.clone(), + event_persistence_mode, + ), + None, + None, + ) + .await + .expect("create rollout recorder"); + *sess.services.rollout.lock().await = Some(recorder); + } + + #[tokio::test] + async fn record_user_prompt_auto_names_new_thread() { + let (session, turn_context, _rx) = make_session_and_context_with_rx().await; + enable_rollout_persistence(&session).await; + + let input = vec![UserInput::Text { + text: "Fix CI on Android app".to_string(), + text_elements: Vec::new(), + }]; + let response_item: ResponseItem = ResponseInputItem::from(input.clone()).into(); + + session + .record_user_prompt_and_emit_turn_item(turn_context.as_ref(), &input, response_item) + .await; + + let thread_name = { + session + .state + .lock() + .await + .session_configuration + .thread_name + .clone() + }; + assert_eq!(thread_name.as_deref(), Some("Fix CI Android app")); + + let persisted = session_index::find_thread_name_by_id( + turn_context.config.codex_home.as_path(), + &session.conversation_id, + ) + .await + .expect("read persisted thread name"); + assert_eq!(persisted.as_deref(), Some("Fix CI Android app")); + } + + #[tokio::test] + async fn record_user_prompt_does_not_auto_name_thread_with_existing_user_history() { + let (session, turn_context, _rx) = make_session_and_context_with_rx().await; + enable_rollout_persistence(&session).await; + + session + .record_into_history( + &[user_message("existing thread seed")], + turn_context.as_ref(), + ) + .await; + + let input = vec![UserInput::Text { + text: "Fix CI on Android app".to_string(), + text_elements: Vec::new(), + }]; + let response_item: ResponseItem = ResponseInputItem::from(input.clone()).into(); + + session + .record_user_prompt_and_emit_turn_item(turn_context.as_ref(), &input, response_item) + .await; + + let thread_name = { + session + .state + .lock() + .await + .session_configuration + .thread_name + .clone() + }; + assert_eq!(thread_name, None); + + let persisted = session_index::find_thread_name_by_id( + turn_context.config.codex_home.as_path(), + &session.conversation_id, + ) + .await + .expect("read persisted thread name"); + assert_eq!(persisted, None); + } + #[tokio::test] async fn refresh_mcp_servers_is_deferred_until_next_turn() { let (session, turn_context) = make_session_and_context().await; diff --git a/codex-rs/core/src/util.rs b/codex-rs/core/src/util.rs index 59fecb0a9e..ef9d565750 100644 --- a/codex-rs/core/src/util.rs +++ b/codex-rs/core/src/util.rs @@ -3,14 +3,24 @@ use std::path::PathBuf; use std::time::Duration; use codex_protocol::ThreadId; +use codex_protocol::protocol::USER_MESSAGE_BEGIN; +use codex_protocol::user_input::UserInput; use rand::Rng; use tracing::debug; use tracing::error; use crate::parse_command::shlex_join; +const AUTO_THREAD_NAME_MAX_WORDS: usize = 4; +const AUTO_THREAD_NAME_MAX_CHARS: usize = 40; const INITIAL_DELAY_MS: u64 = 200; const BACKOFF_FACTOR: f64 = 2.0; +const AUTO_THREAD_NAME_STOP_WORDS: &[&str] = &[ + "a", "an", "and", "are", "as", "at", "be", "by", "can", "could", "for", "from", "help", "how", + "i", "in", "into", "is", "it", "me", "my", "need", "of", "on", "or", "please", "should", + "tell", "that", "the", "this", "to", "us", "want", "we", "what", "why", "with", "would", "you", + "your", +]; /// Emit structured feedback metadata as key/value pairs. /// @@ -85,6 +95,105 @@ pub fn normalize_thread_name(name: &str) -> Option { } } +/// Mirror the desktop app's preference for very short task titles while keeping +/// CLI naming deterministic and local. +pub fn auto_thread_name_from_user_input(input: &[UserInput]) -> Option { + let text = input + .iter() + .filter_map(|item| match item { + UserInput::Text { text, .. } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join(" "); + auto_thread_name_from_text(&text) +} + +fn auto_thread_name_from_text(text: &str) -> Option { + let excerpt = strip_user_message_prefix(text) + .split(['\n', '\r']) + .next() + .unwrap_or(text) + .split(['.', '?', '!']) + .next() + .unwrap_or(text) + .trim(); + if excerpt.is_empty() { + return None; + } + + let preferred = collect_title_words(excerpt, true); + let words = if preferred.len() >= 2 { + preferred + } else { + collect_title_words(excerpt, false) + }; + if words.is_empty() { + return None; + } + + let mut title = words.join(" "); + uppercase_first_letter(&mut title); + normalize_thread_name(&title) +} + +fn collect_title_words(text: &str, drop_stop_words: bool) -> Vec { + let mut words = Vec::new(); + let mut total_len = 0usize; + + for raw_word in text.split_whitespace() { + let word = clean_title_word(raw_word); + if word.is_empty() { + continue; + } + if drop_stop_words && is_auto_thread_name_stop_word(&word) { + continue; + } + + let next_len = if words.is_empty() { + word.len() + } else { + total_len + 1 + word.len() + }; + if words.len() >= AUTO_THREAD_NAME_MAX_WORDS + || (!words.is_empty() && next_len > AUTO_THREAD_NAME_MAX_CHARS) + { + break; + } + + total_len = next_len; + words.push(word); + } + + words +} + +fn clean_title_word(word: &str) -> String { + word.trim_matches(|ch: char| !ch.is_alphanumeric()) + .to_string() +} + +fn is_auto_thread_name_stop_word(word: &str) -> bool { + AUTO_THREAD_NAME_STOP_WORDS + .iter() + .any(|stop_word| word.eq_ignore_ascii_case(stop_word)) +} + +fn uppercase_first_letter(text: &mut String) { + let Some((idx, ch)) = text.char_indices().find(|(_, ch)| ch.is_alphabetic()) else { + return; + }; + let upper = ch.to_uppercase().to_string(); + text.replace_range(idx..idx + ch.len_utf8(), &upper); +} + +fn strip_user_message_prefix(text: &str) -> &str { + match text.find(USER_MESSAGE_BEGIN) { + Some(idx) => text[idx + USER_MESSAGE_BEGIN.len()..].trim(), + None => text.trim(), + } +} + pub fn resume_command(thread_name: Option<&str>, thread_id: Option) -> Option { let resume_target = thread_name .filter(|name| !name.is_empty()) @@ -146,6 +255,56 @@ mod tests { ); } + #[test] + fn auto_thread_name_prefers_short_content_words() { + let input = vec![UserInput::Text { + text: "Fix CI on Android app".to_string(), + text_elements: Vec::new(), + }]; + + assert_eq!( + auto_thread_name_from_user_input(&input), + Some("Fix CI Android app".to_string()) + ); + } + + #[test] + fn auto_thread_name_strips_user_message_prefix() { + let text = format!("{USER_MESSAGE_BEGIN} can you explain MCP OAuth login flow?"); + assert_eq!( + auto_thread_name_from_text(&text), + Some("Explain MCP OAuth login".to_string()) + ); + } + + #[test] + fn auto_thread_name_preserves_word_boundaries_between_text_items() { + let input = vec![ + UserInput::Text { + text: "fix CI".to_string(), + text_elements: Vec::new(), + }, + UserInput::Text { + text: "on Android".to_string(), + text_elements: Vec::new(), + }, + ]; + + assert_eq!( + auto_thread_name_from_user_input(&input), + Some("Fix CI Android".to_string()) + ); + } + + #[test] + fn auto_thread_name_ignores_non_text_input() { + let input = vec![UserInput::Image { + image_url: "https://example.com/image.png".to_string(), + }]; + + assert_eq!(auto_thread_name_from_user_input(&input), None); + } + #[test] fn resume_command_prefers_name_over_id() { let thread_id = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap();