From 439dfc0233dfc10de58191fce25b06dceb42d7e2 Mon Sep 17 00:00:00 2001 From: stefanstokic-oai Date: Tue, 23 Jun 2026 19:25:08 +0000 Subject: [PATCH 1/5] Sanitize imported session fallback titles --- codex-rs/Cargo.lock | 1 + .../tests/suite/v2/external_agent_config.rs | 32 +++++-- codex-rs/external-agent-sessions/Cargo.toml | 1 + .../external-agent-sessions/src/export.rs | 40 +++++++- codex-rs/external-agent-sessions/src/lib.rs | 1 + .../external-agent-sessions/src/records.rs | 25 +++-- codex-rs/external-agent-sessions/src/title.rs | 87 +++++++++++++++++ .../src/title_tests.rs | 95 +++++++++++++++++++ 8 files changed, 263 insertions(+), 19 deletions(-) create mode 100644 codex-rs/external-agent-sessions/src/title.rs create mode 100644 codex-rs/external-agent-sessions/src/title_tests.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9e250998ff88..f96f896d47c7 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3048,6 +3048,7 @@ dependencies = [ "codex-app-server-protocol", "codex-protocol", "codex-utils-output-truncation", + "pretty_assertions", "serde", "serde_json", "sha2 0.10.9", diff --git a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs index 6bef968941ad..c26fa98db7c4 100644 --- a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs +++ b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs @@ -615,6 +615,7 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); let session_dir = external_agent_home(codex_home.path()).join("projects/repo"); let session_path = session_dir.join("session.jsonl"); + let first_request = "\ncontrol context\n\nFix auth flow"; std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&session_dir)?; std::fs::write( @@ -624,7 +625,7 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { "type": "user", "cwd": &project_root, "timestamp": &recent_timestamp, - "message": { "content": "first request" }, + "message": { "content": first_request }, }) .to_string(), serde_json::json!({ @@ -634,11 +635,6 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { "message": { "content": "first answer" }, }) .to_string(), - serde_json::json!({ - "type": "custom-title", - "customTitle": "source session title", - }) - .to_string(), ] .join("\n"), )?; @@ -667,6 +663,14 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { .await??; let detected: ExternalAgentConfigDetectResponse = to_response(response)?; assert_eq!(detected.items.len(), 1); + assert_eq!( + detected.items[0] + .details + .as_ref() + .and_then(|details| details.sessions.first()) + .and_then(|session| session.title.as_deref()), + Some("Fix auth flow") + ); let request_id = mcp .send_raw_request( @@ -743,8 +747,8 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { .expect("expected imported thread") .clone(); assert_eq!(imported_thread_id, thread.id.to_string()); - assert_eq!(thread.preview, "first request"); - assert_eq!(thread.name.as_deref(), Some("source session title")); + assert_eq!(thread.preview, first_request); + assert_eq!(thread.name.as_deref(), Some("Fix auth flow")); let request_id = mcp .send_thread_read_request(ThreadReadParams { @@ -761,6 +765,18 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { assert_eq!(response.thread.turns.len(), 1); let items = &response.thread.turns[0].items; assert_eq!(items.len(), 3); + match &items[0] { + ThreadItem::UserMessage { content, .. } => { + assert_eq!( + content, + &vec![UserInput::Text { + text: first_request.to_string(), + text_elements: Vec::new(), + }] + ); + } + other => panic!("expected user message item, got {other:?}"), + } assert_eq!( items.last(), Some(&ThreadItem::AgentMessage { diff --git a/codex-rs/external-agent-sessions/Cargo.toml b/codex-rs/external-agent-sessions/Cargo.toml index c3639f8e851f..2bf6eaffa241 100644 --- a/codex-rs/external-agent-sessions/Cargo.toml +++ b/codex-rs/external-agent-sessions/Cargo.toml @@ -22,4 +22,5 @@ sha2 = { workspace = true } [dev-dependencies] codex-app-server-protocol = { workspace = true } +pretty_assertions = { workspace = true } tempfile = { workspace = true } diff --git a/codex-rs/external-agent-sessions/src/export.rs b/codex-rs/external-agent-sessions/src/export.rs index 6391d27bd378..1bc9814aaf44 100644 --- a/codex-rs/external-agent-sessions/src/export.rs +++ b/codex-rs/external-agent-sessions/src/export.rs @@ -3,6 +3,8 @@ use crate::ImportedExternalAgentSession; use crate::MessageRole; use crate::records::read_session_import; use crate::summarize_for_label; +use crate::title::SessionTitleCandidates; +use crate::title::fallback_title_from_user_message; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AgentMessageEvent; @@ -36,11 +38,18 @@ pub(crate) fn load_session_for_import_with_content_sha256( return Ok(None); }; let messages = parsed.messages; - let first_user_message = messages + let first_user_message_text = messages .iter() .find(|message| message.role == MessageRole::User) - .map(|message| summarize_for_label(&message.text)); - let title = parsed.source_title.or_else(|| first_user_message.clone()); + .map(|message| message.text.as_str()); + let first_user_message = first_user_message_text.map(summarize_for_label); + let fallback_title = first_user_message_text.map(fallback_title_from_user_message); + let title = SessionTitleCandidates { + custom_title: parsed.custom_title, + ai_title: parsed.ai_title, + fallback_title, + } + .select(); let rollout_items = rollout_items_from_messages(messages); if rollout_items.is_empty() { return Ok(None); @@ -373,6 +382,31 @@ mod tests { assert_eq!(imported.title.as_deref(), Some("named by source app")); } + #[test] + fn sanitizes_only_the_imported_session_fallback_title() { + let root = TempDir::new().expect("tempdir"); + let project_root = root.path().join("repo"); + std::fs::create_dir_all(&project_root).expect("project root"); + let path = root.path().join("session.jsonl"); + let message = "\ncontrol context\n\nFix auth flow"; + std::fs::write(&path, jsonl(&[record("user", message, &project_root)])).expect("session"); + + let imported = load_session_for_import(&path) + .expect("load") + .expect("session"); + let imported_user_message = imported.rollout_items.iter().find_map(|item| match item { + RolloutItem::EventMsg(EventMsg::UserMessage(event)) => Some(event.message.as_str()), + _ => None, + }); + + assert_eq!(imported.title.as_deref(), Some("Fix auth flow")); + assert_eq!( + imported.first_user_message.as_deref(), + Some("") + ); + assert_eq!(imported_user_message, Some(message)); + } + #[test] fn emits_token_usage_for_imported_history() { let root = TempDir::new().expect("tempdir"); diff --git a/codex-rs/external-agent-sessions/src/lib.rs b/codex-rs/external-agent-sessions/src/lib.rs index 0b7a4eb2bac9..b13322dffb44 100644 --- a/codex-rs/external-agent-sessions/src/lib.rs +++ b/codex-rs/external-agent-sessions/src/lib.rs @@ -4,6 +4,7 @@ mod detect; mod export; mod ledger; mod records; +mod title; use codex_protocol::protocol::RolloutItem; use std::io; diff --git a/codex-rs/external-agent-sessions/src/records.rs b/codex-rs/external-agent-sessions/src/records.rs index 00307fa1d52f..be4f83bac64b 100644 --- a/codex-rs/external-agent-sessions/src/records.rs +++ b/codex-rs/external-agent-sessions/src/records.rs @@ -1,7 +1,8 @@ use crate::ConversationMessage; use crate::ExternalAgentSessionMigration; use crate::MessageRole; -use crate::summarize_for_label; +use crate::title::SessionTitleCandidates; +use crate::title::fallback_title_from_user_message; use crate::truncate; use serde_json::Value as JsonValue; use sha2::Digest; @@ -25,7 +26,8 @@ pub struct SessionSummary { pub(super) struct ParsedSessionImport { pub cwd: Option, - pub source_title: Option, + pub custom_title: Option, + pub ai_title: Option, pub messages: Vec, pub content_sha256: String, } @@ -36,7 +38,7 @@ pub fn summarize_session(path: &Path) -> io::Result> { let mut cwd = None; let mut custom_title = None; let mut ai_title = None; - let mut title = None; + let mut fallback_title = None; let mut latest_timestamp = None; let mut saw_message = false; @@ -65,8 +67,8 @@ pub fn summarize_session(path: &Path) -> io::Result> { continue; }; saw_message = true; - if title.is_none() && message.role == MessageRole::User { - title = Some(summarize_for_label(&message.text)); + if fallback_title.is_none() && message.role == MessageRole::User { + fallback_title = Some(fallback_title_from_user_message(&message.text)); } if let Some(timestamp) = message.timestamp { latest_timestamp = @@ -88,7 +90,12 @@ pub fn summarize_session(path: &Path) -> io::Result> { migration: ExternalAgentSessionMigration { path: path.to_path_buf(), cwd, - title: custom_title.or(ai_title).or(title), + title: SessionTitleCandidates { + custom_title, + ai_title, + fallback_title, + } + .select(), }, })) } @@ -133,7 +140,8 @@ pub(super) fn read_session_import(path: &Path) -> io::Result", ""), + ("", ""), + ("", ""), +]; + +pub(super) struct SessionTitleCandidates { + pub custom_title: Option, + pub ai_title: Option, + pub fallback_title: Option, +} + +impl SessionTitleCandidates { + pub fn select(self) -> Option { + self.custom_title.or(self.ai_title).or(self.fallback_title) + } +} + +pub(super) fn fallback_title_from_user_message(message: &str) -> String { + let message = strip_leading_control_wrappers(message); + let first_meaningful_line = message.lines().map(str::trim).find(|line| !line.is_empty()); + truncate( + first_meaningful_line.unwrap_or(IMPORTED_SESSION_FALLBACK_TITLE), + SESSION_TITLE_MAX_LEN, + ) +} + +fn strip_leading_control_wrappers(message: &str) -> &str { + let mut remainder = message.trim_start(); + while let Some(wrapper_end) = leading_control_wrapper_end(remainder) { + remainder = remainder[wrapper_end..].trim_start(); + } + remainder +} + +fn leading_control_wrapper_end(text: &str) -> Option { + let (outer_tag, opening_len) = recognized_opening_tag(text)?; + let mut open_tags = vec![outer_tag]; + let mut cursor = opening_len; + + while !open_tags.is_empty() { + cursor += text.get(cursor..)?.find('<')?; + let candidate = text.get(cursor..)?; + if let Some((tag, token_len)) = recognized_opening_tag(candidate) { + open_tags.push(tag); + cursor += token_len; + continue; + } + if let Some((tag, token_len)) = recognized_closing_tag(candidate) { + if open_tags.last().copied() != Some(tag) { + return None; + } + open_tags.pop(); + cursor += token_len; + continue; + } + cursor += 1; + } + + Some(cursor) +} + +fn recognized_opening_tag(text: &str) -> Option<(usize, usize)> { + RECOGNIZED_CONTROL_WRAPPERS + .iter() + .enumerate() + .find_map(|(index, (opening, _closing))| { + text.starts_with(opening).then_some((index, opening.len())) + }) +} + +fn recognized_closing_tag(text: &str) -> Option<(usize, usize)> { + RECOGNIZED_CONTROL_WRAPPERS + .iter() + .enumerate() + .find_map(|(index, (_opening, closing))| { + text.starts_with(closing).then_some((index, closing.len())) + }) +} + +#[cfg(test)] +#[path = "title_tests.rs"] +mod tests; diff --git a/codex-rs/external-agent-sessions/src/title_tests.rs b/codex-rs/external-agent-sessions/src/title_tests.rs new file mode 100644 index 000000000000..0cc461d68164 --- /dev/null +++ b/codex-rs/external-agent-sessions/src/title_tests.rs @@ -0,0 +1,95 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn preserves_valid_custom_title_unchanged() { + let custom_title = "Keep this custom title"; + + assert_eq!( + SessionTitleCandidates { + custom_title: Some(custom_title.to_string()), + ai_title: Some("AI title".to_string()), + fallback_title: Some("fallback title".to_string()), + } + .select(), + Some(custom_title.to_string()) + ); +} + +#[test] +fn preserves_valid_ai_title_unchanged_without_custom_title() { + let ai_title = "Keep this AI title"; + + assert_eq!( + SessionTitleCandidates { + custom_title: None, + ai_title: Some(ai_title.to_string()), + fallback_title: Some("fallback title".to_string()), + } + .select(), + Some(ai_title.to_string()) + ); +} + +#[test] +fn strips_nested_repeated_and_multiline_leading_control_wrappers() { + let message = "\ + \n\ + outer context\n\ + \n\ + nested context\n\ + \n\ + \n\ + \n\ + src/auth.rs\n\ + \n\ + \n\ + Fix auth flow\n\ + Additional details"; + + assert_eq!(fallback_title_from_user_message(message), "Fix auth flow"); +} + +#[test] +fn uses_safe_fallback_for_empty_or_control_only_messages() { + assert_eq!( + fallback_title_from_user_message(""), + IMPORTED_SESSION_FALLBACK_TITLE + ); + assert_eq!( + fallback_title_from_user_message( + "review\n\ + context" + ), + IMPORTED_SESSION_FALLBACK_TITLE + ); +} + +#[test] +fn uses_first_meaningful_line_from_ordinary_messages() { + assert_eq!( + fallback_title_from_user_message("\n \n Fix auth flow \nAdditional details"), + "Fix auth flow" + ); +} + +#[test] +fn preserves_unknown_and_user_authored_angle_bracket_text() { + assert_eq!( + fallback_title_from_user_message("Keep this text Fix auth flow"), + "Keep this text Fix auth flow" + ); + assert_eq!( + fallback_title_from_user_message("Explain tags"), + "Explain tags" + ); +} + +#[test] +fn bounds_fallback_titles_to_120_characters() { + let message = "x".repeat(121); + let title = fallback_title_from_user_message(&message); + + assert_eq!(title.chars().count(), SESSION_TITLE_MAX_LEN); + assert_eq!(title, format!("{}...", "x".repeat(117))); +} From 66bd11df0806a43565819a72228e884967b76d2a Mon Sep 17 00:00:00 2001 From: stefanstokic-oai Date: Wed, 24 Jun 2026 12:55:11 -0400 Subject: [PATCH 2/5] Recognize additional imported session wrappers --- codex-rs/external-agent-sessions/src/title.rs | 7 +++++- .../src/title_tests.rs | 22 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/codex-rs/external-agent-sessions/src/title.rs b/codex-rs/external-agent-sessions/src/title.rs index 29084fa91fa2..ea28d42be9b3 100644 --- a/codex-rs/external-agent-sessions/src/title.rs +++ b/codex-rs/external-agent-sessions/src/title.rs @@ -2,8 +2,13 @@ use crate::SESSION_TITLE_MAX_LEN; use crate::truncate; const IMPORTED_SESSION_FALLBACK_TITLE: &str = "Imported session"; -const RECOGNIZED_CONTROL_WRAPPERS: [(&str, &str); 3] = [ +const RECOGNIZED_CONTROL_WRAPPERS: [(&str, &str); 8] = [ ("", ""), + ("", ""), + ("", ""), + ("", ""), + ("", ""), + ("", ""), ("", ""), ("", ""), ]; diff --git a/codex-rs/external-agent-sessions/src/title_tests.rs b/codex-rs/external-agent-sessions/src/title_tests.rs index 0cc461d68164..0e6d41676260 100644 --- a/codex-rs/external-agent-sessions/src/title_tests.rs +++ b/codex-rs/external-agent-sessions/src/title_tests.rs @@ -50,6 +50,28 @@ fn strips_nested_repeated_and_multiline_leading_control_wrappers() { assert_eq!(fallback_title_from_user_message(message), "Fix auth flow"); } +#[test] +fn strips_observed_external_agent_control_wrapper_families() { + let cases = [ + "\n\ + abc123\n\ + completed\n\ + \n\ + Fix auth flow", + "review\n\ + /review\n\ + src/auth.rs\n\ + Fix auth flow", + "Command output follows\n\ + tests passed\n\ + Fix auth flow", + ]; + + for message in cases { + assert_eq!(fallback_title_from_user_message(message), "Fix auth flow"); + } +} + #[test] fn uses_safe_fallback_for_empty_or_control_only_messages() { assert_eq!( From b81f3963afe9b30bf5a3da8676ca114657a3f028 Mon Sep 17 00:00:00 2001 From: stefanstokic-oai Date: Tue, 7 Jul 2026 09:28:12 -0400 Subject: [PATCH 3/5] Recognize remaining Claude control wrappers --- codex-rs/external-agent-sessions/src/title.rs | 4 +++- codex-rs/external-agent-sessions/src/title_tests.rs | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/codex-rs/external-agent-sessions/src/title.rs b/codex-rs/external-agent-sessions/src/title.rs index ea28d42be9b3..ad45302eb010 100644 --- a/codex-rs/external-agent-sessions/src/title.rs +++ b/codex-rs/external-agent-sessions/src/title.rs @@ -2,15 +2,17 @@ use crate::SESSION_TITLE_MAX_LEN; use crate::truncate; const IMPORTED_SESSION_FALLBACK_TITLE: &str = "Imported session"; -const RECOGNIZED_CONTROL_WRAPPERS: [(&str, &str); 8] = [ +const RECOGNIZED_CONTROL_WRAPPERS: [(&str, &str); 10] = [ ("", ""), ("", ""), ("", ""), ("", ""), + ("", ""), ("", ""), ("", ""), ("", ""), ("", ""), + ("", ""), ]; pub(super) struct SessionTitleCandidates { diff --git a/codex-rs/external-agent-sessions/src/title_tests.rs b/codex-rs/external-agent-sessions/src/title_tests.rs index 0e6d41676260..c09c90a54182 100644 --- a/codex-rs/external-agent-sessions/src/title_tests.rs +++ b/codex-rs/external-agent-sessions/src/title_tests.rs @@ -65,6 +65,10 @@ fn strips_observed_external_agent_control_wrapper_families() { "Command output follows\n\ tests passed\n\ Fix auth flow", + "tests failed\n\ + Fix auth flow", + "src/auth.rs:1-5\n\ + Fix auth flow", ]; for message in cases { From 763898663d40d6bc3e767edb27f74b42129db2f3 Mon Sep 17 00:00:00 2001 From: stefanstokic-oai Date: Tue, 7 Jul 2026 12:36:34 -0400 Subject: [PATCH 4/5] Skip control-only records for imported titles --- .../tests/suite/v2/external_agent_config.rs | 42 +++++++++--- .../external-agent-sessions/src/export.rs | 67 ++++++++++++++++++- .../external-agent-sessions/src/records.rs | 13 +++- codex-rs/external-agent-sessions/src/title.rs | 14 ++-- .../src/title_tests.rs | 27 ++++---- 5 files changed, 130 insertions(+), 33 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs index c26fa98db7c4..e83b63661886 100644 --- a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs +++ b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs @@ -615,12 +615,20 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); let session_dir = external_agent_home(codex_home.path()).join("projects/repo"); let session_path = session_dir.join("session.jsonl"); - let first_request = "\ncontrol context\n\nFix auth flow"; + let control_request = "src/auth.rs:1-5"; + let first_request = "Fix auth flow"; std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&session_dir)?; std::fs::write( &session_path, [ + serde_json::json!({ + "type": "user", + "cwd": &project_root, + "timestamp": &recent_timestamp, + "message": { "content": control_request }, + }) + .to_string(), serde_json::json!({ "type": "user", "cwd": &project_root, @@ -747,7 +755,7 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { .expect("expected imported thread") .clone(); assert_eq!(imported_thread_id, thread.id.to_string()); - assert_eq!(thread.preview, first_request); + assert_eq!(thread.preview, control_request); assert_eq!(thread.name.as_deref(), Some("Fix auth flow")); let request_id = mcp @@ -762,10 +770,24 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { ) .await??; let response: ThreadReadResponse = to_response(response)?; - assert_eq!(response.thread.turns.len(), 1); - let items = &response.thread.turns[0].items; - assert_eq!(items.len(), 3); - match &items[0] { + assert_eq!(response.thread.turns.len(), 2); + let control_items = &response.thread.turns[0].items; + assert_eq!(control_items.len(), 1); + match &control_items[0] { + ThreadItem::UserMessage { content, .. } => { + assert_eq!( + content, + &vec![UserInput::Text { + text: control_request.to_string(), + text_elements: Vec::new(), + }] + ); + } + other => panic!("expected user message item, got {other:?}"), + } + let imported_items = &response.thread.turns[1].items; + assert_eq!(imported_items.len(), 3); + match &imported_items[0] { ThreadItem::UserMessage { content, .. } => { assert_eq!( content, @@ -778,9 +800,9 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { other => panic!("expected user message item, got {other:?}"), } assert_eq!( - items.last(), + imported_items.last(), Some(&ThreadItem::AgentMessage { - id: "item-3".into(), + id: "item-4".into(), text: "".into(), phase: None, memory_citation: None, @@ -834,8 +856,8 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { ) .await??; let response: ThreadReadResponse = to_response(response)?; - assert_eq!(response.thread.turns.len(), 2); - match &response.thread.turns[1].items[1] { + assert_eq!(response.thread.turns.len(), 3); + match &response.thread.turns[2].items[1] { ThreadItem::AgentMessage { text, .. } => assert_eq!(text, "follow-up answer"), other => panic!("expected agent message item, got {other:?}"), } diff --git a/codex-rs/external-agent-sessions/src/export.rs b/codex-rs/external-agent-sessions/src/export.rs index 1bc9814aaf44..992770f85547 100644 --- a/codex-rs/external-agent-sessions/src/export.rs +++ b/codex-rs/external-agent-sessions/src/export.rs @@ -3,6 +3,7 @@ use crate::ImportedExternalAgentSession; use crate::MessageRole; use crate::records::read_session_import; use crate::summarize_for_label; +use crate::title::IMPORTED_SESSION_FALLBACK_TITLE; use crate::title::SessionTitleCandidates; use crate::title::fallback_title_from_user_message; use codex_protocol::models::ContentItem; @@ -43,7 +44,11 @@ pub(crate) fn load_session_for_import_with_content_sha256( .find(|message| message.role == MessageRole::User) .map(|message| message.text.as_str()); let first_user_message = first_user_message_text.map(summarize_for_label); - let fallback_title = first_user_message_text.map(fallback_title_from_user_message); + let fallback_title = messages + .iter() + .filter(|message| message.role == MessageRole::User) + .find_map(|message| fallback_title_from_user_message(&message.text)) + .or_else(|| first_user_message_text.map(|_| IMPORTED_SESSION_FALLBACK_TITLE.to_string())); let title = SessionTitleCandidates { custom_title: parsed.custom_title, ai_title: parsed.ai_title, @@ -407,6 +412,66 @@ mod tests { assert_eq!(imported_user_message, Some(message)); } + #[test] + fn skips_control_only_user_messages_when_choosing_fallback_title() { + let root = TempDir::new().expect("tempdir"); + let project_root = root.path().join("repo"); + std::fs::create_dir_all(&project_root).expect("project root"); + let path = root.path().join("session.jsonl"); + let control_message = "src/auth.rs:1-5"; + std::fs::write( + &path, + jsonl(&[ + record("user", control_message, &project_root), + record("user", "Fix auth flow", &project_root), + ]), + ) + .expect("session"); + + let imported = load_session_for_import(&path) + .expect("load") + .expect("session"); + + assert_eq!(imported.title.as_deref(), Some("Fix auth flow")); + assert_eq!( + imported.first_user_message.as_deref(), + Some(control_message) + ); + } + + #[test] + fn uses_safe_fallback_after_all_user_messages_are_control_only() { + let root = TempDir::new().expect("tempdir"); + let project_root = root.path().join("repo"); + std::fs::create_dir_all(&project_root).expect("project root"); + let path = root.path().join("session.jsonl"); + std::fs::write( + &path, + jsonl(&[ + record( + "user", + "src/auth.rs:1-5", + &project_root, + ), + record( + "user", + "tests failed", + &project_root, + ), + ]), + ) + .expect("session"); + + let imported = load_session_for_import(&path) + .expect("load") + .expect("session"); + + assert_eq!( + imported.title.as_deref(), + Some(IMPORTED_SESSION_FALLBACK_TITLE) + ); + } + #[test] fn emits_token_usage_for_imported_history() { let root = TempDir::new().expect("tempdir"); diff --git a/codex-rs/external-agent-sessions/src/records.rs b/codex-rs/external-agent-sessions/src/records.rs index be4f83bac64b..2901714a19fb 100644 --- a/codex-rs/external-agent-sessions/src/records.rs +++ b/codex-rs/external-agent-sessions/src/records.rs @@ -1,6 +1,7 @@ use crate::ConversationMessage; use crate::ExternalAgentSessionMigration; use crate::MessageRole; +use crate::title::IMPORTED_SESSION_FALLBACK_TITLE; use crate::title::SessionTitleCandidates; use crate::title::fallback_title_from_user_message; use crate::truncate; @@ -39,6 +40,7 @@ pub fn summarize_session(path: &Path) -> io::Result> { let mut custom_title = None; let mut ai_title = None; let mut fallback_title = None; + let mut saw_user_message = false; let mut latest_timestamp = None; let mut saw_message = false; @@ -67,8 +69,11 @@ pub fn summarize_session(path: &Path) -> io::Result> { continue; }; saw_message = true; - if fallback_title.is_none() && message.role == MessageRole::User { - fallback_title = Some(fallback_title_from_user_message(&message.text)); + if message.role == MessageRole::User { + saw_user_message = true; + if fallback_title.is_none() { + fallback_title = fallback_title_from_user_message(&message.text); + } } if let Some(timestamp) = message.timestamp { latest_timestamp = @@ -93,7 +98,9 @@ pub fn summarize_session(path: &Path) -> io::Result> { title: SessionTitleCandidates { custom_title, ai_title, - fallback_title, + fallback_title: fallback_title.or_else(|| { + saw_user_message.then(|| IMPORTED_SESSION_FALLBACK_TITLE.to_string()) + }), } .select(), }, diff --git a/codex-rs/external-agent-sessions/src/title.rs b/codex-rs/external-agent-sessions/src/title.rs index ad45302eb010..97e8cda22430 100644 --- a/codex-rs/external-agent-sessions/src/title.rs +++ b/codex-rs/external-agent-sessions/src/title.rs @@ -1,7 +1,7 @@ use crate::SESSION_TITLE_MAX_LEN; use crate::truncate; -const IMPORTED_SESSION_FALLBACK_TITLE: &str = "Imported session"; +pub(super) const IMPORTED_SESSION_FALLBACK_TITLE: &str = "Imported session"; const RECOGNIZED_CONTROL_WRAPPERS: [(&str, &str); 10] = [ ("", ""), ("", ""), @@ -27,13 +27,13 @@ impl SessionTitleCandidates { } } -pub(super) fn fallback_title_from_user_message(message: &str) -> String { +pub(super) fn fallback_title_from_user_message(message: &str) -> Option { let message = strip_leading_control_wrappers(message); - let first_meaningful_line = message.lines().map(str::trim).find(|line| !line.is_empty()); - truncate( - first_meaningful_line.unwrap_or(IMPORTED_SESSION_FALLBACK_TITLE), - SESSION_TITLE_MAX_LEN, - ) + message + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .map(|line| truncate(line, SESSION_TITLE_MAX_LEN)) } fn strip_leading_control_wrappers(message: &str) -> &str { diff --git a/codex-rs/external-agent-sessions/src/title_tests.rs b/codex-rs/external-agent-sessions/src/title_tests.rs index c09c90a54182..83e7e30874b7 100644 --- a/codex-rs/external-agent-sessions/src/title_tests.rs +++ b/codex-rs/external-agent-sessions/src/title_tests.rs @@ -47,7 +47,10 @@ fn strips_nested_repeated_and_multiline_leading_control_wrappers() { Fix auth flow\n\ Additional details"; - assert_eq!(fallback_title_from_user_message(message), "Fix auth flow"); + assert_eq!( + fallback_title_from_user_message(message), + Some("Fix auth flow".to_string()) + ); } #[test] @@ -72,22 +75,22 @@ fn strips_observed_external_agent_control_wrapper_families() { ]; for message in cases { - assert_eq!(fallback_title_from_user_message(message), "Fix auth flow"); + assert_eq!( + fallback_title_from_user_message(message), + Some("Fix auth flow".to_string()) + ); } } #[test] -fn uses_safe_fallback_for_empty_or_control_only_messages() { - assert_eq!( - fallback_title_from_user_message(""), - IMPORTED_SESSION_FALLBACK_TITLE - ); +fn returns_no_candidate_for_empty_or_control_only_messages() { + assert_eq!(fallback_title_from_user_message(""), None); assert_eq!( fallback_title_from_user_message( "review\n\ context" ), - IMPORTED_SESSION_FALLBACK_TITLE + None ); } @@ -95,7 +98,7 @@ fn uses_safe_fallback_for_empty_or_control_only_messages() { fn uses_first_meaningful_line_from_ordinary_messages() { assert_eq!( fallback_title_from_user_message("\n \n Fix auth flow \nAdditional details"), - "Fix auth flow" + Some("Fix auth flow".to_string()) ); } @@ -103,18 +106,18 @@ fn uses_first_meaningful_line_from_ordinary_messages() { fn preserves_unknown_and_user_authored_angle_bracket_text() { assert_eq!( fallback_title_from_user_message("Keep this text Fix auth flow"), - "Keep this text Fix auth flow" + Some("Keep this text Fix auth flow".to_string()) ); assert_eq!( fallback_title_from_user_message("Explain tags"), - "Explain tags" + Some("Explain tags".to_string()) ); } #[test] fn bounds_fallback_titles_to_120_characters() { let message = "x".repeat(121); - let title = fallback_title_from_user_message(&message); + let title = fallback_title_from_user_message(&message).expect("title"); assert_eq!(title.chars().count(), SESSION_TITLE_MAX_LEN); assert_eq!(title, format!("{}...", "x".repeat(117))); From 2d63eb66775a8d3d0e4ce836701ef9fa1b18a856 Mon Sep 17 00:00:00 2001 From: stefanstokic-oai Date: Wed, 8 Jul 2026 12:32:10 -0400 Subject: [PATCH 5/5] Remove unnecessary pretty_assertions dependency --- codex-rs/Cargo.lock | 1 - codex-rs/external-agent-sessions/Cargo.toml | 1 - codex-rs/external-agent-sessions/src/title_tests.rs | 1 - 3 files changed, 3 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index f96f896d47c7..9e250998ff88 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3048,7 +3048,6 @@ dependencies = [ "codex-app-server-protocol", "codex-protocol", "codex-utils-output-truncation", - "pretty_assertions", "serde", "serde_json", "sha2 0.10.9", diff --git a/codex-rs/external-agent-sessions/Cargo.toml b/codex-rs/external-agent-sessions/Cargo.toml index 2bf6eaffa241..c3639f8e851f 100644 --- a/codex-rs/external-agent-sessions/Cargo.toml +++ b/codex-rs/external-agent-sessions/Cargo.toml @@ -22,5 +22,4 @@ sha2 = { workspace = true } [dev-dependencies] codex-app-server-protocol = { workspace = true } -pretty_assertions = { workspace = true } tempfile = { workspace = true } diff --git a/codex-rs/external-agent-sessions/src/title_tests.rs b/codex-rs/external-agent-sessions/src/title_tests.rs index 83e7e30874b7..fc068c0e3908 100644 --- a/codex-rs/external-agent-sessions/src/title_tests.rs +++ b/codex-rs/external-agent-sessions/src/title_tests.rs @@ -1,5 +1,4 @@ use super::*; -use pretty_assertions::assert_eq; #[test] fn preserves_valid_custom_title_unchanged() {