diff --git a/Cargo.lock b/Cargo.lock index 660f1b6..4553c48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -199,8 +199,7 @@ dependencies = [ [[package]] name = "agent-client-protocol" version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5efba6592048ef8a9ac97de8d79b2d9933d8ac4d94f7a2de102348fed0c61103" +source = "git+https://github.com/daniel-agentee/rust-sdk?rev=d28b51bab500b8aad1b38da658c04f705dcd17c3#d28b51bab500b8aad1b38da658c04f705dcd17c3" dependencies = [ "agent-client-protocol-derive", "agent-client-protocol-schema", @@ -221,8 +220,7 @@ dependencies = [ [[package]] name = "agent-client-protocol-derive" version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d176a10d4cb06e0262a738c3c5bf21ff0968db13a666e31cbca94a3d3d72e7c" +source = "git+https://github.com/daniel-agentee/rust-sdk?rev=d28b51bab500b8aad1b38da658c04f705dcd17c3#d28b51bab500b8aad1b38da658c04f705dcd17c3" dependencies = [ "quote", "syn 2.0.117", @@ -231,8 +229,7 @@ dependencies = [ [[package]] name = "agent-client-protocol-schema" version = "0.13.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c290bfa00c6b52339db66f8e9cf711d5f08530800529f7d619ff24d6cba253d0" +source = "git+https://github.com/daniel-agentee/agent-client-protocol?rev=23977c0193c786d0747464a46b5a561ca15c62e2#23977c0193c786d0747464a46b5a561ca15c62e2" dependencies = [ "anyhow", "derive_more 2.1.1", @@ -350,7 +347,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -361,7 +358,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -468,9 +465,9 @@ dependencies = [ [[package]] name = "async-executor" -version = "1.14.0" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" dependencies = [ "async-task", "concurrent-queue", @@ -551,9 +548,9 @@ dependencies = [ [[package]] name = "async-signal" -version = "0.2.14" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" dependencies = [ "async-io", "async-lock", @@ -3443,13 +3440,13 @@ checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] name = "dbus" -version = "0.9.11" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4" dependencies = [ "libc", "libdbus-sys", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3720,7 +3717,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3988,7 +3985,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4066,7 +4063,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -5782,7 +5779,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.58.0", ] [[package]] @@ -6180,7 +6177,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6954,7 +6951,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7629,9 +7626,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "piper" -version = "0.2.5" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" dependencies = [ "atomic-waker", "fastrand", @@ -8767,7 +8764,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -8780,7 +8777,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8839,7 +8836,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8914,9 +8911,9 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.29" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" dependencies = [ "windows-sys 0.61.2", ] @@ -9625,7 +9622,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -10158,7 +10155,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -10222,7 +10219,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -10862,7 +10859,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset 0.9.1", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -11118,11 +11115,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ - "wit-bindgen 0.57.1", + "wit-bindgen", ] [[package]] @@ -11131,7 +11128,7 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] @@ -11396,7 +11393,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -11797,12 +11794,6 @@ dependencies = [ "wit-bindgen-rust-macro", ] -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - [[package]] name = "wit-bindgen-core" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index c12e1d5..77b100a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,5 +62,13 @@ unused = "warn" tokio-tungstenite = { git = "https://github.com/openai-oss-forks/tokio-tungstenite", rev = "132f5b39c862e3a970f731d709608b3e6276d5f6" } tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "9200079d3b54a1ff51072e24d81fd354f085156f" } +# Temporary: pull `session/setTitle` support before it ships on crates.io. +# Remove both entries once `agent-client-protocol-schema` and `agent-client-protocol` +# publish releases containing the new types. Tracking: +# - https://github.com/agentclientprotocol/agent-client-protocol/pull/1199 (schema) +# - https://github.com/agentclientprotocol/rust-sdk/pull/164 (routing) +agent-client-protocol-schema = { git = "https://github.com/daniel-agentee/agent-client-protocol", rev = "23977c0193c786d0747464a46b5a561ca15c62e2" } +agent-client-protocol = { git = "https://github.com/daniel-agentee/rust-sdk", rev = "d28b51bab500b8aad1b38da658c04f705dcd17c3" } + [patch."https://github.com/openai/codex"] codex-utils-pty = { path = "vendor/codex-utils-pty" } diff --git a/src/codex_agent.rs b/src/codex_agent.rs index b9cb0c0..9eed4a7 100644 --- a/src/codex_agent.rs +++ b/src/codex_agent.rs @@ -7,15 +7,16 @@ use acp::schema::{ McpCapabilities, McpServer, McpServerHttp, McpServerStdio, NewSessionRequest, NewSessionResponse, PromptCapabilities, PromptRequest, PromptResponse, ProtocolVersion, ResumeSessionRequest, ResumeSessionResponse, SessionCapabilities, SessionCloseCapabilities, - SessionId, SessionInfo, SessionListCapabilities, SessionResumeCapabilities, + SessionId, SessionInfo, SessionInfoUpdate, SessionListCapabilities, SessionNotification, + SessionResumeCapabilities, SessionSetTitleCapabilities, SessionUpdate, SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, SetSessionModeRequest, - SetSessionModeResponse, + SetSessionModeResponse, SetSessionTitleRequest, SetSessionTitleResponse, }; use acp::{Agent, Client, ConnectTo, ConnectionTo, Error}; use agent_client_protocol as acp; use codex_config::{DEFAULT_MCP_SERVER_ENVIRONMENT_ID, McpServerConfig, McpServerTransportConfig}; use codex_core::{ - NewThread, RolloutRecorder, StateDbHandle, ThreadManager, config::Config, + NewThread, RolloutRecorder, StateDbHandle, ThreadManager, append_thread_name, config::Config, find_thread_path_by_id_str, init_state_db, resolve_installation_id, thread_store_from_config, }; use codex_exec_server::{EnvironmentManager, ExecServerRuntimePaths}; @@ -40,7 +41,7 @@ use std::{ use tracing::{debug, info}; use unicode_segmentation::UnicodeSegmentation; -use crate::thread::Thread; +use crate::thread::{Thread, ThreadOptions}; /// The Codex implementation of the ACP Agent. /// @@ -286,6 +287,28 @@ impl CodexAgent { }, acp::on_receive_request!(), ) + .on_receive_request( + { + let agent = agent.clone(); + async move |request: SetSessionTitleRequest, + responder, + cx: ConnectionTo| { + let agent = agent.clone(); + let notification_cx = cx.clone(); + cx.spawn(async move { + responder.respond_with_result( + agent + .set_session_title(request, |notification| { + notification_cx.send_notification(notification) + }) + .await, + ) + })?; + Ok(()) + } + }, + acp::on_receive_request!(), + ) .on_receive_request( { let agent = agent.clone(); @@ -311,13 +334,12 @@ impl CodexAgent { } fn get_thread(&self, session_id: &SessionId) -> Result, Error> { - Ok(self - .sessions - .lock() - .unwrap() - .get(session_id) - .ok_or_else(|| Error::resource_not_found(None))? - .clone()) + self.loaded_thread(session_id) + .ok_or_else(|| Error::resource_not_found(None)) + } + + fn loaded_thread(&self, session_id: &SessionId) -> Option> { + self.sessions.lock().unwrap().get(session_id).cloned() } async fn check_auth(&self) -> Result<(), Error> { @@ -458,7 +480,8 @@ impl CodexAgent { agent_capabilities.session_capabilities = SessionCapabilities::new() .close(SessionCloseCapabilities::new()) .list(SessionListCapabilities::new()) - .resume(SessionResumeCapabilities::new()); + .resume(SessionResumeCapabilities::new()) + .set_title(SessionSetTitleCapabilities::new()); let mut auth_methods = vec![ CodexAuthMethod::ChatGpt.into(), @@ -587,7 +610,10 @@ impl CodexAgent { self.auth_manager.clone(), Arc::new(self.thread_manager.get_models_manager()), self.client_capabilities.clone(), - config.clone(), + ThreadOptions { + config: config.clone(), + state_db: self.state_db.clone(), + }, cx, )); let load = thread.load().await?; @@ -701,7 +727,10 @@ impl CodexAgent { self.auth_manager.clone(), Arc::new(self.thread_manager.get_models_manager()), self.client_capabilities.clone(), - config.clone(), + ThreadOptions { + config: config.clone(), + state_db: self.state_db.clone(), + }, cx, )); @@ -825,6 +854,87 @@ impl CodexAgent { Ok(SetSessionModeResponse::default()) } + async fn set_session_title( + &self, + args: SetSessionTitleRequest, + notify_client: impl FnOnce(SessionNotification) -> Result<(), Error>, + ) -> Result { + info!("Setting session title for session: {}", args.session_id); + self.check_auth().await?; + + let session_id = args.session_id; + let title = args.title; + let thread_id = ThreadId::from_string(&session_id.0).map_err(Error::into_internal_error)?; + let loaded_thread = self.loaded_thread(&session_id); + let mut title_updated_in_state = false; + + if let Some(state_db) = self.state_db.as_deref() { + match state_db.update_thread_title(thread_id, &title).await { + Ok(true) => { + title_updated_in_state = true; + } + Ok(false) => { + debug!( + "Thread metadata unavailable before title update for session: {}", + session_id + ); + } + Err(err) => { + return Err(Error::internal_error() + .data(format!("failed to update thread title: {err}"))); + } + } + } + + if let Some(thread) = loaded_thread { + let notification_title = notification_title_for_update(&title); + thread.set_title(title, notification_title).await?; + } else { + if !title_updated_in_state { + let rollout_path = find_thread_path_by_id_str( + &self.config.codex_home, + session_id.0.as_ref(), + self.state_db.as_deref(), + ) + .await + .map_err(|err| { + Error::internal_error().data(format!( + "failed to locate thread before title update: {err}" + )) + })?; + + if rollout_path.is_none() { + return Err(Error::resource_not_found(None)); + } + + if let Some(state_db) = self.state_db.as_deref() { + match state_db.update_thread_title(thread_id, &title).await { + Ok(_) => {} + Err(err) => { + return Err(Error::internal_error() + .data(format!("failed to update thread title: {err}"))); + } + } + } + } + + let notification_title = notification_title_for_update(&title); + append_thread_name(&self.config.codex_home, thread_id, &title) + .await + .map_err(|err| { + Error::internal_error().data(format!("failed to index thread title: {err}")) + })?; + notify_client(SessionNotification::new( + session_id, + SessionUpdate::SessionInfoUpdate( + SessionInfoUpdate::new().title(notification_title), + ), + ))?; + } + + Ok(SetSessionTitleResponse::default()) + } + async fn set_session_config_option( &self, args: SetSessionConfigOptionRequest, @@ -938,14 +1048,30 @@ fn format_session_title(message: &str) -> Option { } fn stored_session_title(name: Option<&str>, preview: &str) -> Option { - [name, Some(preview)] - .into_iter() - .flatten() - .find_map(format_session_title) + match name { + Some(name) if name.trim().is_empty() => None, + Some(name) => format_session_title(name), + None => format_session_title(preview), + } +} + +fn notification_title_for_update(title: &str) -> Option { + format_session_title(title) } #[cfg(test)] mod tests { + use std::{ + io::Write, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, + }; + + use acp::schema::MaybeUndefined; + use codex_core::config::ConfigOverrides; + use codex_core::find_thread_name_by_id; + use uuid::Uuid; + use super::*; #[test] @@ -957,14 +1083,336 @@ mod tests { } #[test] - fn stored_session_title_falls_back_to_preview() { + fn stored_session_title_falls_back_to_preview_when_name_is_absent() { assert_eq!( stored_session_title(None, "preview"), Some("preview".to_string()) ); + } + + #[test] + fn stored_session_title_preserves_explicitly_cleared_name() { + assert_eq!(stored_session_title(Some(" "), "preview"), None); + } + + #[test] + fn notification_title_for_update_matches_session_list_formatting() { assert_eq!( - stored_session_title(Some(" "), "preview"), - Some("preview".to_string()) + notification_title_for_update(" custom\ntitle "), + Some("custom title".to_string()) + ); + assert_eq!(notification_title_for_update(" "), None); + + let long_title = "a".repeat(SESSION_TITLE_MAX_GRAPHEMES + 1); + let expected = format!("{}...", "a".repeat(SESSION_TITLE_MAX_GRAPHEMES - 3)); + assert_eq!(notification_title_for_update(&long_title), Some(expected)); + } + + fn write_minimal_rollout_with_id( + codex_home: &Path, + id: Uuid, + user_message: Option<&str>, + ) -> anyhow::Result { + let sessions = codex_home.join("sessions/2024/01/01"); + std::fs::create_dir_all(&sessions)?; + + let file = sessions.join(format!("rollout-2024-01-01T00-00-00-{id}.jsonl")); + let mut writer = std::fs::File::create(&file)?; + writeln!( + writer, + "{}", + serde_json::json!({ + "timestamp": "2024-01-01T00:00:00.000Z", + "type": "session_meta", + "payload": { + "id": id.to_string(), + "timestamp": "2024-01-01T00:00:00Z", + "cwd": ".", + "originator": "test", + "cli_version": "test", + "model_provider": "test-provider" + } + }) + )?; + + if let Some(message) = user_message { + writeln!( + writer, + "{}", + serde_json::json!({ + "timestamp": "2024-01-01T00:00:00.000Z", + "type": "event_msg", + "payload": { + "type": "user_message", + "message": message, + "kind": "plain" + } + }) + )?; + } + + Ok(file) + } + + #[tokio::test] + async fn set_session_title_persists_unloaded_session() -> anyhow::Result<()> { + let codex_home = + std::env::temp_dir().join(format!("codex-acp-unloaded-title-{}", Uuid::new_v4())); + std::fs::create_dir_all(&codex_home)?; + + let mut config = Config::load_with_cli_overrides_and_harness_overrides( + vec![], + ConfigOverrides::default(), + ) + .await?; + config.codex_home = codex_home.clone().try_into()?; + config.model_provider_id = "test-provider".to_string(); + + let agent = CodexAgent::new(config, None).await?; + let thread_uuid = Uuid::new_v4(); + let thread_id = ThreadId::from_string(&thread_uuid.to_string())?; + write_minimal_rollout_with_id(&codex_home, thread_uuid, None)?; + let session_id = SessionId::new(thread_id.to_string()); + let notifications = Arc::new(Mutex::new(Vec::new())); + let captured_notifications = notifications.clone(); + + agent + .set_session_title( + SetSessionTitleRequest::new(session_id.clone(), "Renamed historical session"), + |notification| { + captured_notifications.lock().unwrap().push(notification); + Ok(()) + }, + ) + .await?; + + assert_eq!( + codex_core::find_thread_name_by_id(&agent.config.codex_home, &thread_id).await?, + Some("Renamed historical session".to_string()) ); + + let notifications = notifications.lock().unwrap(); + assert!( + notifications.iter().any(|notification| { + notification.session_id == session_id + && matches!( + ¬ification.update, + SessionUpdate::SessionInfoUpdate(update) + if matches!( + &update.title, + MaybeUndefined::Value(title) if title == "Renamed historical session" + ) + ) + }), + "expected SessionInfoUpdate for unloaded rename; got: {notifications:#?}" + ); + drop(notifications); + + std::fs::remove_dir_all(codex_home).ok(); + + Ok(()) + } + + #[tokio::test] + async fn set_session_title_persists_db_backed_unloaded_session_to_name_index() + -> anyhow::Result<()> { + let codex_home = + std::env::temp_dir().join(format!("codex-acp-db-backed-title-{}", Uuid::new_v4())); + std::fs::create_dir_all(&codex_home)?; + + let mut config = Config::load_with_cli_overrides_and_harness_overrides( + vec![], + ConfigOverrides::default(), + ) + .await?; + config.codex_home = codex_home.clone().try_into()?; + config.model_provider_id = "test-provider".to_string(); + + let agent = CodexAgent::new(config, None).await?; + let thread_uuid = Uuid::new_v4(); + let thread_id = ThreadId::from_string(&thread_uuid.to_string())?; + let rollout_path = write_minimal_rollout_with_id(&codex_home, thread_uuid, None)?; + let session_id = SessionId::new(thread_id.to_string()); + + agent + .set_session_title( + SetSessionTitleRequest::new(session_id.clone(), "Seed DB metadata"), + |_| Ok(()), + ) + .await?; + + std::fs::remove_file(rollout_path)?; + std::fs::remove_file(codex_home.join("session_index.jsonl"))?; + + agent + .set_session_title( + SetSessionTitleRequest::new(session_id, "DB backed title"), + |_| Ok(()), + ) + .await?; + + assert_eq!( + find_thread_name_by_id(&agent.config.codex_home, &thread_id).await?, + Some("DB backed title".to_string()) + ); + + std::fs::remove_dir_all(codex_home).ok(); + + Ok(()) + } + + #[tokio::test] + async fn set_session_title_clear_notifies_with_cleared_title() -> anyhow::Result<()> { + let codex_home = + std::env::temp_dir().join(format!("codex-acp-clear-title-{}", Uuid::new_v4())); + std::fs::create_dir_all(&codex_home)?; + + let mut config = Config::load_with_cli_overrides_and_harness_overrides( + vec![], + ConfigOverrides::default(), + ) + .await?; + config.codex_home = codex_home.clone().try_into()?; + config.model_provider_id = "test-provider".to_string(); + + let agent = CodexAgent::new(config, None).await?; + let thread_uuid = Uuid::new_v4(); + let thread_id = ThreadId::from_string(&thread_uuid.to_string())?; + write_minimal_rollout_with_id( + &codex_home, + thread_uuid, + Some("Summarize the quarterly plan"), + )?; + let session_id = SessionId::new(thread_id.to_string()); + let notifications = Arc::new(Mutex::new(Vec::new())); + let captured_notifications = notifications.clone(); + + agent + .set_session_title( + SetSessionTitleRequest::new(session_id.clone(), " "), + |notification| { + captured_notifications.lock().unwrap().push(notification); + Ok(()) + }, + ) + .await?; + + assert_eq!( + codex_core::find_thread_name_by_id(&agent.config.codex_home, &thread_id).await?, + Some(" ".to_string()) + ); + + let notifications = notifications.lock().unwrap(); + assert!( + notifications.iter().any(|notification| { + notification.session_id == session_id + && matches!( + ¬ification.update, + SessionUpdate::SessionInfoUpdate(update) + if matches!(&update.title, MaybeUndefined::Null) + ) + }), + "expected SessionInfoUpdate with cleared title after clearing custom title; got: {notifications:#?}" + ); + drop(notifications); + + std::fs::remove_dir_all(codex_home).ok(); + + Ok(()) + } + + #[tokio::test] + async fn set_session_title_rejects_missing_unloaded_session() -> anyhow::Result<()> { + let codex_home = + std::env::temp_dir().join(format!("codex-acp-missing-title-{}", Uuid::new_v4())); + std::fs::create_dir_all(&codex_home)?; + + let mut config = Config::load_with_cli_overrides_and_harness_overrides( + vec![], + ConfigOverrides::default(), + ) + .await?; + config.codex_home = codex_home.clone().try_into()?; + config.model_provider_id = "test-provider".to_string(); + + let agent = CodexAgent::new(config, None).await?; + let thread_id = ThreadId::default(); + let session_id = SessionId::new(thread_id.to_string()); + let notifications = Arc::new(Mutex::new(Vec::new())); + let captured_notifications = notifications.clone(); + + let result = agent + .set_session_title( + SetSessionTitleRequest::new(session_id, "Orphan title"), + |notification| { + captured_notifications.lock().unwrap().push(notification); + Ok(()) + }, + ) + .await; + + let err = match result { + Ok(_) => panic!("expected missing unloaded session to be rejected"), + Err(err) => err, + }; + assert_eq!(err.code, acp::ErrorCode::ResourceNotFound); + assert_eq!( + codex_core::find_thread_name_by_id(&agent.config.codex_home, &thread_id).await?, + None + ); + assert!(notifications.lock().unwrap().is_empty()); + + std::fs::remove_dir_all(codex_home).ok(); + + Ok(()) + } + + #[tokio::test] + async fn set_session_title_requires_authentication_before_metadata_update() -> anyhow::Result<()> + { + let codex_home = + std::env::temp_dir().join(format!("codex-acp-auth-title-{}", Uuid::new_v4())); + std::fs::create_dir_all(&codex_home)?; + + let mut config = Config::load_with_cli_overrides_and_harness_overrides( + vec![], + ConfigOverrides::default(), + ) + .await?; + config.codex_home = codex_home.clone().try_into()?; + config.model_provider_id = "openai".to_string(); + + let agent = CodexAgent::new(config, None).await?; + let thread_uuid = Uuid::new_v4(); + let thread_id = ThreadId::from_string(&thread_uuid.to_string())?; + write_minimal_rollout_with_id(&codex_home, thread_uuid, None)?; + let session_id = SessionId::new(thread_id.to_string()); + let notifications = Arc::new(Mutex::new(Vec::new())); + let captured_notifications = notifications.clone(); + + let result = agent + .set_session_title( + SetSessionTitleRequest::new(session_id, "Unauthenticated title"), + |notification| { + captured_notifications.lock().unwrap().push(notification); + Ok(()) + }, + ) + .await; + + let err = match result { + Ok(_) => panic!("expected unauthenticated title update to be rejected"), + Err(err) => err, + }; + assert_eq!(err.code, acp::ErrorCode::AuthRequired); + assert_eq!( + find_thread_name_by_id(&agent.config.codex_home, &thread_id).await?, + None + ); + assert!(notifications.lock().unwrap().is_empty()); + + std::fs::remove_dir_all(codex_home).ok(); + + Ok(()) } } diff --git a/src/thread.rs b/src/thread.rs index fd1f1c2..abd790c 100644 --- a/src/thread.rs +++ b/src/thread.rs @@ -17,8 +17,8 @@ use agent_client_protocol::{ RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse, ResourceLink, SelectedPermissionOutcome, SessionConfigId, SessionConfigOption, SessionConfigOptionCategory, SessionConfigOptionValue, SessionConfigSelectOption, - SessionConfigValueId, SessionId, SessionMode, SessionModeId, SessionModeState, - SessionNotification, SessionUpdate, StopReason, Terminal, TextContent, + SessionConfigValueId, SessionId, SessionInfoUpdate, SessionMode, SessionModeId, + SessionModeState, SessionNotification, SessionUpdate, StopReason, Terminal, TextContent, TextResourceContents, ToolCall, ToolCallContent, ToolCallId, ToolCallLocation, ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind, UnstructuredCommandInput, UsageUpdate, @@ -26,7 +26,7 @@ use agent_client_protocol::{ }; use codex_apply_patch::parse_patch; use codex_core::{ - CodexThread, + CodexThread, StateDbHandle, append_thread_name, config::{Config, set_project_trust_level}, review_format::format_review_findings_block, review_prompts::user_facing_hint, @@ -34,6 +34,7 @@ use codex_core::{ use codex_login::auth::AuthManager; use codex_models_manager::manager::{ModelsManager, RefreshStrategy}; use codex_protocol::{ + ThreadId, approvals::{ ElicitationRequest, ElicitationRequestEvent, GuardianAssessmentAction, GuardianCommandSource, @@ -83,6 +84,7 @@ use itertools::Itertools; use serde_json::json; use tokio::sync::{mpsc, oneshot}; use tracing::{error, info, warn}; +use unicode_segmentation::UnicodeSegmentation; use uuid::Uuid; /// Abstraction over the ACP connection for sending notifications and requests @@ -113,6 +115,7 @@ impl ClientSender for AcpConnection { static APPROVAL_PRESETS: LazyLock> = LazyLock::new(builtin_approval_presets); const INIT_COMMAND_PROMPT: &str = include_str!("./prompt_for_init_command.md"); +const SESSION_TITLE_MAX_GRAPHEMES: usize = 120; const CODEX_READ_ONLY_PROFILE_ID: &str = ":read-only"; const CODEX_WORKSPACE_PROFILE_ID: &str = ":workspace"; const CODEX_DANGER_NO_SANDBOX_PROFILE_ID: &str = ":danger-no-sandbox"; @@ -209,6 +212,40 @@ fn mode_trusts_project(mode_id: &str) -> bool { matches!(mode_id, "auto" | "full-access") } +fn truncate_title_graphemes(text: &str, max_graphemes: usize) -> String { + let mut graphemes = text.grapheme_indices(true); + + if let Some((byte_index, _)) = graphemes.nth(max_graphemes) { + if max_graphemes >= 3 { + let mut truncate_graphemes = text.grapheme_indices(true); + if let Some((truncate_byte_index, _)) = truncate_graphemes.nth(max_graphemes - 3) { + let truncated = &text[..truncate_byte_index]; + format!("{truncated}...") + } else { + text.to_string() + } + } else { + let truncated = &text[..byte_index]; + truncated.to_string() + } + } else { + text.to_string() + } +} + +fn notification_title_for_rename(title: &str) -> Option { + let normalized = title.replace(['\r', '\n'], " "); + let trimmed = normalized.trim(); + if trimmed.is_empty() { + None + } else { + Some(truncate_title_graphemes( + trimmed, + SESSION_TITLE_MAX_GRAPHEMES, + )) + } +} + /// Trait for abstracting over the `CodexThread` to make testing easier. pub trait CodexThreadImpl: Send + Sync { fn submit(&self, op: Op) @@ -284,6 +321,11 @@ enum ThreadMessage { mode: SessionModeId, response_tx: oneshot::Sender>, }, + SetTitle { + title: String, + notification_title: Option, + response_tx: oneshot::Sender>, + }, SetConfigOption { config_id: SessionConfigId, value: SessionConfigOptionValue, @@ -316,14 +358,19 @@ pub struct Thread { _handle: tokio::task::JoinHandle<()>, } +pub(crate) struct ThreadOptions { + pub(crate) config: Config, + pub(crate) state_db: Option, +} + impl Thread { - pub fn new( + pub(crate) fn new( session_id: SessionId, thread: Arc, auth: Arc, models_manager: Arc, client_capabilities: Arc>, - config: Config, + options: ThreadOptions, cx: ConnectionTo, ) -> Self { let (message_tx, message_rx) = mpsc::unbounded_channel(); @@ -334,7 +381,8 @@ impl Thread { SessionClient::new(session_id, cx, client_capabilities), thread.clone(), models_manager, - config, + options.config, + options.state_db, message_rx, resolution_tx, resolution_rx, @@ -397,6 +445,25 @@ impl Thread { .map_err(|e| Error::internal_error().data(e.to_string()))? } + pub async fn set_title( + &self, + title: impl Into, + notification_title: Option, + ) -> Result<(), Error> { + let (response_tx, response_rx) = oneshot::channel(); + + let message = ThreadMessage::SetTitle { + title: title.into(), + notification_title, + response_tx, + }; + drop(self.message_tx.send(message)); + + response_rx + .await + .map_err(|e| Error::internal_error().data(e.to_string()))? + } + pub async fn set_config_option( &self, config_id: SessionConfigId, @@ -2747,6 +2814,8 @@ struct ThreadActor { thread: Arc, /// The configuration for the thread. config: Config, + /// Optional SQLite state DB used for DB-backed session metadata updates. + state_db: Option, /// The models available for this thread. models_manager: Arc, /// Internal message sender used to route spawned interaction results back to the actor. @@ -2769,6 +2838,7 @@ impl ThreadActor { thread: Arc, models_manager: Arc, config: Config, + state_db: Option, message_rx: mpsc::UnboundedReceiver, resolution_tx: mpsc::UnboundedSender, resolution_rx: mpsc::UnboundedReceiver, @@ -2778,6 +2848,7 @@ impl ThreadActor { client, thread, config, + state_db, models_manager, resolution_tx, submissions: HashMap::new(), @@ -2848,6 +2919,14 @@ impl ThreadActor { drop(response_tx.send(result)); self.maybe_emit_config_options_update().await; } + ThreadMessage::SetTitle { + title, + notification_title, + response_tx, + } => { + let result = self.handle_set_title(title, notification_title).await; + drop(response_tx.send(result)); + } ThreadMessage::SetConfigOption { config_id, value, @@ -2930,6 +3009,11 @@ impl ThreadActor { "summarize conversation to prevent hitting the context limit", ), AvailableCommand::new("logout", "logout of Codex"), + AvailableCommand::new("rename", "rename this session").input( + AvailableCommandInput::Unstructured(UnstructuredCommandInput::new( + "new session title", + )), + ), ] } @@ -3238,6 +3322,29 @@ impl ThreadActor { self.auth.logout().await?; return Err(Error::auth_required()); } + "rename" => { + let title = rest.trim(); + if title.is_empty() { + self.client + .send_agent_text("Usage: /rename \n"); + } else { + let notification_title = notification_title_for_rename(title); + match self + .handle_set_title(title.to_owned(), notification_title) + .await + { + Ok(()) => self + .client + .send_agent_text(format!("Renamed session to \"{title}\".\n")), + Err(err) => self.client.send_agent_text(format!( + "Failed to rename session: {}\n", + err.message + )), + } + } + drop(response_tx.send(Ok(StopReason::EndTurn))); + return Ok(response_rx); + } _ => { op = Op::UserInput { items, @@ -3321,6 +3428,39 @@ impl ThreadActor { Ok(()) } + async fn handle_set_title( + &mut self, + title: String, + notification_title: Option, + ) -> Result<(), Error> { + let thread_id = + ThreadId::from_string(&self.client.session_id.0).map_err(Error::into_internal_error)?; + + if let Some(state_db) = self.state_db.as_deref() { + state_db + .update_thread_title(thread_id, &title) + .await + .map_err(|err| { + Error::internal_error().data(format!("failed to update thread title: {err}")) + })?; + } + + append_thread_name(&self.config.codex_home, thread_id, &title) + .await + .map_err(|err| { + Error::internal_error().data(format!("failed to index thread title: {err}")) + })?; + + // Tell the connected client the title changed so it can repaint the + // session list without waiting for the next session/list call. + self.client + .send_notification(SessionUpdate::SessionInfoUpdate( + SessionInfoUpdate::new().title(notification_title), + )); + + Ok(()) + } + async fn get_current_model(&self) -> String { self.models_manager.get_model(&self.config.model).await } @@ -4105,7 +4245,8 @@ fn extract_slash_command(content: &[UserInput]) -> Option<(&str, &str)> { #[cfg(test)] mod tests { use std::collections::VecDeque; - use std::path::PathBuf; + use std::io::Write; + use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::AtomicUsize; use std::time::Duration; @@ -4118,6 +4259,32 @@ mod tests { use super::*; + fn write_minimal_rollout_with_id(codex_home: &Path, id: Uuid) -> anyhow::Result { + let sessions = codex_home.join("sessions/2024/01/01"); + std::fs::create_dir_all(&sessions)?; + + let file = sessions.join(format!("rollout-2024-01-01T00-00-00-{id}.jsonl")); + let mut writer = std::fs::File::create(&file)?; + writeln!( + writer, + "{}", + serde_json::json!({ + "timestamp": "2024-01-01T00:00:00.000Z", + "type": "session_meta", + "payload": { + "id": id.to_string(), + "timestamp": "2024-01-01T00:00:00Z", + "cwd": ".", + "originator": "test", + "cli_version": "test", + "model_provider": "test-provider" + } + }) + )?; + + Ok(file) + } + #[tokio::test] async fn test_prompt() -> anyhow::Result<()> { let (session_id, client, _, message_tx, _handle) = setup().await?; @@ -4279,6 +4446,165 @@ mod tests { Ok(()) } + #[test] + fn rename_is_listed_in_builtin_commands() { + let commands = ThreadActor::::builtin_commands(); + let rename = commands + .iter() + .find(|c| c.name == "rename") + .expect("rename builtin should be exposed"); + assert!( + rename.input.is_some(), + "rename should advertise an unstructured input field for the new title" + ); + } + + #[test] + fn rename_notification_title_matches_session_list_formatting() { + assert_eq!( + notification_title_for_rename(" custom\ntitle "), + Some("custom title".to_string()) + ); + assert_eq!(notification_title_for_rename(" "), None); + + let long_title = "a".repeat(SESSION_TITLE_MAX_GRAPHEMES + 1); + let expected = format!("{}...", "a".repeat(SESSION_TITLE_MAX_GRAPHEMES - 3)); + assert_eq!(notification_title_for_rename(&long_title), Some(expected)); + } + + #[tokio::test] + async fn slash_rename_without_arg_shows_usage_message() -> anyhow::Result<()> { + let (session_id, client, _thread, message_tx, _handle) = setup().await?; + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); + + message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new(session_id.clone(), vec!["/rename".into()]), + response_tx: prompt_response_tx, + })?; + + let stop_reason = prompt_response_rx.await??.await??; + assert_eq!(stop_reason, StopReason::EndTurn); + drop(message_tx); + + let notifications = client.notifications.lock().unwrap(); + assert!( + notifications.iter().any(|notification| matches!( + ¬ification.update, + SessionUpdate::AgentMessageChunk(ContentChunk { + content: ContentBlock::Text(TextContent { text, .. }), + .. + }) if text.contains("Usage: /rename") + )), + "expected a usage message for `/rename` with no argument; got: {notifications:#?}" + ); + + Ok(()) + } + + #[tokio::test] + async fn slash_rename_with_arg_writes_title_to_session_index_and_state_db() -> anyhow::Result<()> + { + let thread_uuid = Uuid::new_v4(); + let thread_id = ThreadId::from_string(&thread_uuid.to_string())?; + let session_id = SessionId::new(thread_id.to_string()); + let client = Arc::new(StubClient::new()); + let session_client = + SessionClient::with_client(session_id.clone(), client.clone(), Arc::default()); + let conversation = Arc::new(StubCodexThread::new()); + let models_manager = Arc::new(StubModelsManager); + let mut config = Config::load_with_cli_overrides_and_harness_overrides( + vec![], + ConfigOverrides::default(), + ) + .await?; + let codex_home = + std::env::temp_dir().join(format!("codex-acp-slash-rename-{}", Uuid::new_v4())); + std::fs::create_dir_all(&codex_home)?; + config.codex_home = codex_home.clone().try_into()?; + let codex_home_abs = config.codex_home.clone(); + let state_db = codex_core::init_state_db(&config) + .await + .expect("test config should initialize a state DB"); + write_minimal_rollout_with_id(&codex_home, thread_uuid)?; + let rollout_path = codex_core::find_thread_path_by_id_str( + &codex_home_abs, + session_id.0.as_ref(), + Some(&state_db), + ) + .await?; + assert!(rollout_path.is_some()); + assert!( + state_db + .update_thread_title(thread_id, "Old DB title") + .await? + ); + + let (message_tx, message_rx) = tokio::sync::mpsc::unbounded_channel(); + let (resolution_tx, resolution_rx) = tokio::sync::mpsc::unbounded_channel(); + let actor = ThreadActor::new( + StubAuth, + session_client, + conversation.clone(), + models_manager, + config, + Some(state_db.clone()), + message_rx, + resolution_tx, + resolution_rx, + ); + let _handle = tokio::spawn(actor.spawn()); + + let (prompt_response_tx, prompt_response_rx) = tokio::sync::oneshot::channel(); + message_tx.send(ThreadMessage::Prompt { + request: PromptRequest::new( + session_id.clone(), + vec!["/rename Quarterly\nreview thread".into()], + ), + response_tx: prompt_response_tx, + })?; + + let stop_reason = prompt_response_rx.await??.await??; + assert_eq!(stop_reason, StopReason::EndTurn); + drop(message_tx); + + assert_eq!( + codex_core::find_thread_name_by_id(&codex_home_abs, &thread_id).await?, + Some("Quarterly\nreview thread".to_string()) + ); + let metadata = state_db + .get_thread(thread_id) + .await? + .expect("state DB should keep the renamed thread metadata"); + assert_eq!(metadata.title, "Quarterly\nreview thread"); + + let notifications = client.notifications.lock().unwrap(); + assert!( + notifications.iter().any(|notification| matches!( + ¬ification.update, + SessionUpdate::SessionInfoUpdate(update) + if matches!( + &update.title, + agent_client_protocol::schema::MaybeUndefined::Value(title) + if title == "Quarterly review thread" + ) + )), + "expected a normalized SessionInfoUpdate for `/rename`; got: {notifications:#?}" + ); + assert!( + notifications.iter().any(|notification| matches!( + ¬ification.update, + SessionUpdate::AgentMessageChunk(ContentChunk { + content: ContentBlock::Text(TextContent { text, .. }), + .. + }) if text.contains("Renamed session to") && text.contains("Quarterly\nreview thread") + )), + "expected a confirmation message for `/rename`; got: {notifications:#?}" + ); + + std::fs::remove_dir_all(codex_home).ok(); + Ok(()) + } + #[test] fn test_guardian_execve_summary_uses_argv_without_duplication() -> anyhow::Result<()> { let action = GuardianAssessmentAction::Execve { @@ -4660,6 +4986,7 @@ mod tests { conversation.clone(), models_manager, config, + None, message_rx, resolution_tx, resolution_rx, @@ -4669,6 +4996,133 @@ mod tests { Ok((session_id, client, conversation, message_tx, handle)) } + #[tokio::test] + async fn test_set_session_title_renames_underlying_thread() -> anyhow::Result<()> { + let thread_id = ThreadId::default(); + let session_id = SessionId::new(thread_id.to_string()); + let client = Arc::new(StubClient::new()); + let session_client = + SessionClient::with_client(session_id.clone(), client.clone(), Arc::default()); + let conversation = Arc::new(StubCodexThread::new()); + let models_manager = Arc::new(StubModelsManager); + let mut config = Config::load_with_cli_overrides_and_harness_overrides( + vec![], + ConfigOverrides::default(), + ) + .await?; + let codex_home = std::env::temp_dir().join(format!("codex-acp-title-{}", Uuid::new_v4())); + std::fs::create_dir_all(&codex_home)?; + config.codex_home = codex_home.clone().try_into()?; + let codex_home_abs = config.codex_home.clone(); + + let (message_tx, message_rx) = tokio::sync::mpsc::unbounded_channel(); + let (resolution_tx, resolution_rx) = tokio::sync::mpsc::unbounded_channel(); + let actor = ThreadActor::new( + StubAuth, + session_client, + conversation.clone(), + models_manager, + config, + None, + message_rx, + resolution_tx, + resolution_rx, + ); + + let thread = Thread { + thread: conversation, + message_tx, + _handle: tokio::spawn(actor.spawn()), + }; + + thread + .set_title("Renamed from ACP", Some("Renamed from ACP".to_string())) + .await?; + + assert_eq!( + codex_core::find_thread_name_by_id(&codex_home_abs, &thread_id).await?, + Some("Renamed from ACP".to_string()) + ); + + { + let notifications = client.notifications.lock().unwrap(); + assert!( + notifications.iter().any(|n| matches!( + &n.update, + SessionUpdate::SessionInfoUpdate(update) + if matches!(&update.title, agent_client_protocol::schema::MaybeUndefined::Value(t) if t == "Renamed from ACP") + )), + "expected a SessionInfoUpdate carrying the new title; got: {notifications:#?}" + ); + } + + thread.shutdown().await?; + std::fs::remove_dir_all(codex_home).ok(); + + Ok(()) + } + + #[tokio::test] + async fn test_set_session_title_clear_emits_null_title() -> anyhow::Result<()> { + let thread_id = ThreadId::default(); + let session_id = SessionId::new(thread_id.to_string()); + let client = Arc::new(StubClient::new()); + let session_client = + SessionClient::with_client(session_id.clone(), client.clone(), Arc::default()); + let conversation = Arc::new(StubCodexThread::new()); + let models_manager = Arc::new(StubModelsManager); + let mut config = Config::load_with_cli_overrides_and_harness_overrides( + vec![], + ConfigOverrides::default(), + ) + .await?; + let codex_home = std::env::temp_dir().join(format!("codex-acp-clear-{}", Uuid::new_v4())); + std::fs::create_dir_all(&codex_home)?; + config.codex_home = codex_home.clone().try_into()?; + + let (message_tx, message_rx) = tokio::sync::mpsc::unbounded_channel(); + let (resolution_tx, resolution_rx) = tokio::sync::mpsc::unbounded_channel(); + let actor = ThreadActor::new( + StubAuth, + session_client, + conversation.clone(), + models_manager, + config, + None, + message_rx, + resolution_tx, + resolution_rx, + ); + + let thread = Thread { + thread: conversation, + message_tx, + _handle: tokio::spawn(actor.spawn()), + }; + + thread.set_title(" ", None).await?; + + { + let notifications = client.notifications.lock().unwrap(); + assert!( + notifications.iter().any(|n| matches!( + &n.update, + SessionUpdate::SessionInfoUpdate(update) + if matches!( + &update.title, + agent_client_protocol::schema::MaybeUndefined::Null + ) + )), + "expected a SessionInfoUpdate clearing the title; got: {notifications:#?}" + ); + } + + thread.shutdown().await?; + std::fs::remove_dir_all(codex_home).ok(); + + Ok(()) + } + struct StubAuth; impl Auth for StubAuth { @@ -5642,6 +6096,7 @@ mod tests { conversation.clone(), models_manager, config, + None, message_rx, resolution_tx, resolution_rx,