diff --git a/codex-rs/app-server-protocol/src/protocol.rs b/codex-rs/app-server-protocol/src/protocol.rs index 462e816f7e..8de05ed229 100644 --- a/codex-rs/app-server-protocol/src/protocol.rs +++ b/codex-rs/app-server-protocol/src/protocol.rs @@ -140,6 +140,10 @@ client_request_definitions! { params: NewConversationParams, response: NewConversationResponse, }, + GetConversationSummary { + params: GetConversationSummaryParams, + response: GetConversationSummaryResponse, + }, /// List recorded Codex conversations (rollouts) with optional pagination and search. ListConversations { params: ListConversationsParams, @@ -315,6 +319,18 @@ pub struct ResumeConversationResponse { pub initial_messages: Option>, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct GetConversationSummaryParams { + pub rollout_path: PathBuf, +} + +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct GetConversationSummaryResponse { + pub summary: ConversationSummary, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct ListConversationsParams { diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index d639c6ca82..c830834abf 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -21,6 +21,8 @@ use codex_app_server_protocol::ExecOneOffCommandResponse; use codex_app_server_protocol::FuzzyFileSearchParams; use codex_app_server_protocol::FuzzyFileSearchResponse; use codex_app_server_protocol::GetAccountRateLimitsResponse; +use codex_app_server_protocol::GetConversationSummaryParams; +use codex_app_server_protocol::GetConversationSummaryResponse; use codex_app_server_protocol::GetUserAgentResponse; use codex_app_server_protocol::GetUserSavedConfigResponse; use codex_app_server_protocol::GitDiffToRemoteResponse; @@ -85,6 +87,7 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::ExecApprovalRequestEvent; use codex_core::protocol::Op; use codex_core::protocol::ReviewDecision; +use codex_core::read_head_for_summary; use codex_login::ServerOptions as LoginServerOptions; use codex_login::ShutdownHandle; use codex_login::run_login_server; @@ -98,6 +101,8 @@ use codex_protocol::user_input::UserInput as CoreInputItem; use codex_utils_json_to_toml::json_to_toml; use std::collections::HashMap; use std::ffi::OsStr; +use std::io::Error as IoError; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -170,6 +175,9 @@ impl CodexMessageProcessor { // created before processing any subsequent messages. self.process_new_conversation(request_id, params).await; } + ClientRequest::GetConversationSummary { request_id, params } => { + self.get_conversation_summary(request_id, params).await; + } ClientRequest::ListConversations { request_id, params } => { self.handle_list_conversations(request_id, params).await; } @@ -813,6 +821,39 @@ impl CodexMessageProcessor { } } + async fn get_conversation_summary( + &self, + request_id: RequestId, + params: GetConversationSummaryParams, + ) { + let GetConversationSummaryParams { rollout_path } = params; + let path = if rollout_path.is_relative() { + self.config.codex_home.join(&rollout_path) + } else { + rollout_path.clone() + }; + let fallback_provider = self.config.model_provider_id.as_str(); + + match read_summary_from_rollout(&path, fallback_provider).await { + Ok(summary) => { + let response = GetConversationSummaryResponse { summary }; + self.outgoing.send_response(request_id, response).await; + } + Err(err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!( + "failed to load conversation summary from {}: {}", + path.display(), + err + ), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + } + } + } + async fn handle_list_conversations( &self, request_id: RequestId, @@ -1644,6 +1685,34 @@ async fn on_exec_approval_response( } } +async fn read_summary_from_rollout( + path: &Path, + fallback_provider: &str, +) -> std::io::Result { + let head = read_head_for_summary(path).await?; + + let Some(first) = head.first() else { + return Err(IoError::other(format!( + "rollout at {} is empty", + path.display() + ))); + }; + + serde_json::from_value::(first.clone()).map_err(|_| { + IoError::other(format!( + "rollout at {} does not start with session metadata", + path.display() + )) + })?; + + extract_conversation_summary(path.to_path_buf(), &head, fallback_provider).ok_or_else(|| { + IoError::other(format!( + "rollout at {} is missing a user message", + path.display() + )) + }) +} + fn extract_conversation_summary( path: PathBuf, head: &[serde_json::Value], @@ -1730,14 +1799,15 @@ mod tests { let summary = extract_conversation_summary(path.clone(), &head, "test-provider").expect("summary"); - assert_eq!(summary.conversation_id, conversation_id); - assert_eq!( - summary.timestamp, - Some("2025-09-05T16:53:11.850Z".to_string()) - ); - assert_eq!(summary.path, path); - assert_eq!(summary.preview, "Count to 5"); - assert_eq!(summary.model_provider, "test-provider"); + let expected = ConversationSummary { + conversation_id, + timestamp, + path, + preview: "Count to 5".to_string(), + model_provider: "test-provider".to_string(), + }; + + assert_eq!(summary, expected); Ok(()) } } diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 34b6df4a5a..f7d86c41a5 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -77,6 +77,7 @@ pub use rollout::find_conversation_path_by_id_str; pub use rollout::list::ConversationItem; pub use rollout::list::ConversationsPage; pub use rollout::list::Cursor; +pub use rollout::list::read_head_for_summary; mod function_tool; mod state; mod tasks; diff --git a/codex-rs/core/src/rollout/list.rs b/codex-rs/core/src/rollout/list.rs index 4a3fec7934..13663beb29 100644 --- a/codex-rs/core/src/rollout/list.rs +++ b/codex-rs/core/src/rollout/list.rs @@ -451,6 +451,13 @@ async fn read_head_and_tail( Ok(summary) } +/// Read up to `HEAD_RECORD_LIMIT` records from the start of the rollout file at `path`. +/// This should be enough to produce a summary including the session meta line. +pub async fn read_head_for_summary(path: &Path) -> io::Result> { + let summary = read_head_and_tail(path, HEAD_RECORD_LIMIT, 0).await?; + Ok(summary.head) +} + async fn read_tail_records( path: &Path, max_records: usize,