@@ -21,6 +21,8 @@ use codex_app_server_protocol::ExecOneOffCommandResponse;
2121use codex_app_server_protocol:: FuzzyFileSearchParams ;
2222use codex_app_server_protocol:: FuzzyFileSearchResponse ;
2323use codex_app_server_protocol:: GetAccountRateLimitsResponse ;
24+ use codex_app_server_protocol:: GetConversationSummaryParams ;
25+ use codex_app_server_protocol:: GetConversationSummaryResponse ;
2426use codex_app_server_protocol:: GetUserAgentResponse ;
2527use codex_app_server_protocol:: GetUserSavedConfigResponse ;
2628use codex_app_server_protocol:: GitDiffToRemoteResponse ;
@@ -87,6 +89,7 @@ use codex_core::protocol::EventMsg;
8789use codex_core:: protocol:: ExecApprovalRequestEvent ;
8890use codex_core:: protocol:: Op ;
8991use codex_core:: protocol:: ReviewDecision ;
92+ use codex_core:: read_head_for_summary;
9093use codex_feedback:: CodexFeedback ;
9194use codex_login:: ServerOptions as LoginServerOptions ;
9295use codex_login:: ShutdownHandle ;
@@ -101,6 +104,8 @@ use codex_protocol::user_input::UserInput as CoreInputItem;
101104use codex_utils_json_to_toml:: json_to_toml;
102105use std:: collections:: HashMap ;
103106use std:: ffi:: OsStr ;
107+ use std:: io:: Error as IoError ;
108+ use std:: path:: Path ;
104109use std:: path:: PathBuf ;
105110use std:: sync:: Arc ;
106111use std:: sync:: atomic:: AtomicBool ;
@@ -176,6 +181,9 @@ impl CodexMessageProcessor {
176181 // created before processing any subsequent messages.
177182 self . process_new_conversation ( request_id, params) . await ;
178183 }
184+ ClientRequest :: GetConversationSummary { request_id, params } => {
185+ self . get_conversation_summary ( request_id, params) . await ;
186+ }
179187 ClientRequest :: ListConversations { request_id, params } => {
180188 self . handle_list_conversations ( request_id, params) . await ;
181189 }
@@ -822,6 +830,39 @@ impl CodexMessageProcessor {
822830 }
823831 }
824832
833+ async fn get_conversation_summary (
834+ & self ,
835+ request_id : RequestId ,
836+ params : GetConversationSummaryParams ,
837+ ) {
838+ let GetConversationSummaryParams { rollout_path } = params;
839+ let path = if rollout_path. is_relative ( ) {
840+ self . config . codex_home . join ( & rollout_path)
841+ } else {
842+ rollout_path. clone ( )
843+ } ;
844+ let fallback_provider = self . config . model_provider_id . as_str ( ) ;
845+
846+ match read_summary_from_rollout ( & path, fallback_provider) . await {
847+ Ok ( summary) => {
848+ let response = GetConversationSummaryResponse { summary } ;
849+ self . outgoing . send_response ( request_id, response) . await ;
850+ }
851+ Err ( err) => {
852+ let error = JSONRPCErrorError {
853+ code : INTERNAL_ERROR_CODE ,
854+ message : format ! (
855+ "failed to load conversation summary from {}: {}" ,
856+ path. display( ) ,
857+ err
858+ ) ,
859+ data : None ,
860+ } ;
861+ self . outgoing . send_error ( request_id, error) . await ;
862+ }
863+ }
864+ }
865+
825866 async fn handle_list_conversations (
826867 & self ,
827868 request_id : RequestId ,
@@ -1724,6 +1765,50 @@ async fn on_exec_approval_response(
17241765 }
17251766}
17261767
1768+ async fn read_summary_from_rollout (
1769+ path : & Path ,
1770+ fallback_provider : & str ,
1771+ ) -> std:: io:: Result < ConversationSummary > {
1772+ let head = read_head_for_summary ( path) . await ?;
1773+
1774+ let Some ( first) = head. first ( ) else {
1775+ return Err ( IoError :: other ( format ! (
1776+ "rollout at {} is empty" ,
1777+ path. display( )
1778+ ) ) ) ;
1779+ } ;
1780+
1781+ let session_meta = serde_json:: from_value :: < SessionMeta > ( first. clone ( ) ) . map_err ( |_| {
1782+ IoError :: other ( format ! (
1783+ "rollout at {} does not start with session metadata" ,
1784+ path. display( )
1785+ ) )
1786+ } ) ?;
1787+
1788+ if let Some ( summary) =
1789+ extract_conversation_summary ( path. to_path_buf ( ) , & head, fallback_provider)
1790+ {
1791+ return Ok ( summary) ;
1792+ }
1793+
1794+ let timestamp = if session_meta. timestamp . is_empty ( ) {
1795+ None
1796+ } else {
1797+ Some ( session_meta. timestamp . clone ( ) )
1798+ } ;
1799+ let model_provider = session_meta
1800+ . model_provider
1801+ . unwrap_or_else ( || fallback_provider. to_string ( ) ) ;
1802+
1803+ Ok ( ConversationSummary {
1804+ conversation_id : session_meta. id ,
1805+ timestamp,
1806+ path : path. to_path_buf ( ) ,
1807+ preview : String :: new ( ) ,
1808+ model_provider,
1809+ } )
1810+ }
1811+
17271812fn extract_conversation_summary (
17281813 path : PathBuf ,
17291814 head : & [ serde_json:: Value ] ,
@@ -1772,6 +1857,7 @@ mod tests {
17721857 use anyhow:: Result ;
17731858 use pretty_assertions:: assert_eq;
17741859 use serde_json:: json;
1860+ use tempfile:: TempDir ;
17751861
17761862 #[ test]
17771863 fn extract_conversation_summary_prefers_plain_user_messages ( ) -> Result < ( ) > {
@@ -1810,14 +1896,59 @@ mod tests {
18101896 let summary =
18111897 extract_conversation_summary ( path. clone ( ) , & head, "test-provider" ) . expect ( "summary" ) ;
18121898
1813- assert_eq ! ( summary. conversation_id, conversation_id) ;
1814- assert_eq ! (
1815- summary. timestamp,
1816- Some ( "2025-09-05T16:53:11.850Z" . to_string( ) )
1817- ) ;
1818- assert_eq ! ( summary. path, path) ;
1819- assert_eq ! ( summary. preview, "Count to 5" ) ;
1820- assert_eq ! ( summary. model_provider, "test-provider" ) ;
1899+ let expected = ConversationSummary {
1900+ conversation_id,
1901+ timestamp,
1902+ path,
1903+ preview : "Count to 5" . to_string ( ) ,
1904+ model_provider : "test-provider" . to_string ( ) ,
1905+ } ;
1906+
1907+ assert_eq ! ( summary, expected) ;
1908+ Ok ( ( ) )
1909+ }
1910+
1911+ #[ tokio:: test]
1912+ async fn read_summary_from_rollout_returns_empty_preview_when_no_user_message ( ) -> Result < ( ) > {
1913+ use codex_protocol:: protocol:: RolloutItem ;
1914+ use codex_protocol:: protocol:: RolloutLine ;
1915+ use codex_protocol:: protocol:: SessionMetaLine ;
1916+ use std:: fs;
1917+
1918+ let temp_dir = TempDir :: new ( ) ?;
1919+ let path = temp_dir. path ( ) . join ( "rollout.jsonl" ) ;
1920+
1921+ let conversation_id = ConversationId :: from_string ( "bfd12a78-5900-467b-9bc5-d3d35df08191" ) ?;
1922+ let timestamp = "2025-09-05T16:53:11.850Z" . to_string ( ) ;
1923+
1924+ let session_meta = SessionMeta {
1925+ id : conversation_id,
1926+ timestamp : timestamp. clone ( ) ,
1927+ model_provider : None ,
1928+ ..SessionMeta :: default ( )
1929+ } ;
1930+
1931+ let line = RolloutLine {
1932+ timestamp : timestamp. clone ( ) ,
1933+ item : RolloutItem :: SessionMeta ( SessionMetaLine {
1934+ meta : session_meta. clone ( ) ,
1935+ git : None ,
1936+ } ) ,
1937+ } ;
1938+
1939+ fs:: write ( & path, format ! ( "{}\n " , serde_json:: to_string( & line) ?) ) ?;
1940+
1941+ let summary = read_summary_from_rollout ( path. as_path ( ) , "fallback" ) . await ?;
1942+
1943+ let expected = ConversationSummary {
1944+ conversation_id,
1945+ timestamp : Some ( timestamp) ,
1946+ path : path. clone ( ) ,
1947+ preview : String :: new ( ) ,
1948+ model_provider : "fallback" . to_string ( ) ,
1949+ } ;
1950+
1951+ assert_eq ! ( summary, expected) ;
18211952 Ok ( ( ) )
18221953 }
18231954}
0 commit comments