diff --git a/codex-rs/app-server-protocol/src/protocol.rs b/codex-rs/app-server-protocol/src/protocol.rs index 74e9b6f9e2..f164ce5601 100644 --- a/codex-rs/app-server-protocol/src/protocol.rs +++ b/codex-rs/app-server-protocol/src/protocol.rs @@ -147,6 +147,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, @@ -322,6 +326,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 9b66322047..1dba61a524 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; @@ -87,6 +89,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_feedback::CodexFeedback; use codex_login::ServerOptions as LoginServerOptions; use codex_login::ShutdownHandle; @@ -101,6 +104,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; @@ -176,6 +181,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; } @@ -822,6 +830,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, @@ -1724,6 +1765,50 @@ 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() + ))); + }; + + let session_meta = serde_json::from_value::(first.clone()).map_err(|_| { + IoError::other(format!( + "rollout at {} does not start with session metadata", + path.display() + )) + })?; + + if let Some(summary) = + extract_conversation_summary(path.to_path_buf(), &head, fallback_provider) + { + return Ok(summary); + } + + let timestamp = if session_meta.timestamp.is_empty() { + None + } else { + Some(session_meta.timestamp.clone()) + }; + let model_provider = session_meta + .model_provider + .unwrap_or_else(|| fallback_provider.to_string()); + + Ok(ConversationSummary { + conversation_id: session_meta.id, + timestamp, + path: path.to_path_buf(), + preview: String::new(), + model_provider, + }) +} + fn extract_conversation_summary( path: PathBuf, head: &[serde_json::Value], @@ -1772,6 +1857,7 @@ mod tests { use anyhow::Result; use pretty_assertions::assert_eq; use serde_json::json; + use tempfile::TempDir; #[test] fn extract_conversation_summary_prefers_plain_user_messages() -> Result<()> { @@ -1810,14 +1896,59 @@ 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(()) + } + + #[tokio::test] + async fn read_summary_from_rollout_returns_empty_preview_when_no_user_message() -> Result<()> { + use codex_protocol::protocol::RolloutItem; + use codex_protocol::protocol::RolloutLine; + use codex_protocol::protocol::SessionMetaLine; + use std::fs; + + let temp_dir = TempDir::new()?; + let path = temp_dir.path().join("rollout.jsonl"); + + let conversation_id = ConversationId::from_string("bfd12a78-5900-467b-9bc5-d3d35df08191")?; + let timestamp = "2025-09-05T16:53:11.850Z".to_string(); + + let session_meta = SessionMeta { + id: conversation_id, + timestamp: timestamp.clone(), + model_provider: None, + ..SessionMeta::default() + }; + + let line = RolloutLine { + timestamp: timestamp.clone(), + item: RolloutItem::SessionMeta(SessionMetaLine { + meta: session_meta.clone(), + git: None, + }), + }; + + fs::write(&path, format!("{}\n", serde_json::to_string(&line)?))?; + + let summary = read_summary_from_rollout(path.as_path(), "fallback").await?; + + let expected = ConversationSummary { + conversation_id, + timestamp: Some(timestamp), + path: path.clone(), + preview: String::new(), + model_provider: "fallback".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,