mirror of
https://github.com/openai/codex.git
synced 2026-03-24 15:43:53 +00:00
Compare commits
1 Commits
dev/cc/mul
...
codex-auto
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8be1e1f30b |
@@ -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<String> {
|
||||
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<String, ThreadNameUpdateError> {
|
||||
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<Session>) {
|
||||
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<Session>, 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<Session>, 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<Session>) {
|
||||
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;
|
||||
|
||||
@@ -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<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
let text = input
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
UserInput::Text { text, .. } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
auto_thread_name_from_text(&text)
|
||||
}
|
||||
|
||||
fn auto_thread_name_from_text(text: &str) -> Option<String> {
|
||||
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<String> {
|
||||
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<ThreadId>) -> Option<String> {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user