Skip to content

Commit 5ee8a17

Browse files
authored
feat: introduce GetConversationSummary RPC (#5803)
This adds an RPC to the app server to the the `ConversationSummary` via a rollout path. Now that the VS Code extension supports showing the Codex UI in an editor panel where the URI of the panel maps to the rollout file, we need to be able to get the `ConversationSummary` from the rollout file directly.
1 parent 81be54b commit 5ee8a17

File tree

4 files changed

+163
-8
lines changed

4 files changed

+163
-8
lines changed

codex-rs/app-server-protocol/src/protocol.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,10 @@ client_request_definitions! {
147147
params: NewConversationParams,
148148
response: NewConversationResponse,
149149
},
150+
GetConversationSummary {
151+
params: GetConversationSummaryParams,
152+
response: GetConversationSummaryResponse,
153+
},
150154
/// List recorded Codex conversations (rollouts) with optional pagination and search.
151155
ListConversations {
152156
params: ListConversationsParams,
@@ -322,6 +326,18 @@ pub struct ResumeConversationResponse {
322326
pub initial_messages: Option<Vec<EventMsg>>,
323327
}
324328

329+
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
330+
#[serde(rename_all = "camelCase")]
331+
pub struct GetConversationSummaryParams {
332+
pub rollout_path: PathBuf,
333+
}
334+
335+
#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)]
336+
#[serde(rename_all = "camelCase")]
337+
pub struct GetConversationSummaryResponse {
338+
pub summary: ConversationSummary,
339+
}
340+
325341
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)]
326342
#[serde(rename_all = "camelCase")]
327343
pub struct ListConversationsParams {

codex-rs/app-server/src/codex_message_processor.rs

Lines changed: 139 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ use codex_app_server_protocol::ExecOneOffCommandResponse;
2121
use codex_app_server_protocol::FuzzyFileSearchParams;
2222
use codex_app_server_protocol::FuzzyFileSearchResponse;
2323
use codex_app_server_protocol::GetAccountRateLimitsResponse;
24+
use codex_app_server_protocol::GetConversationSummaryParams;
25+
use codex_app_server_protocol::GetConversationSummaryResponse;
2426
use codex_app_server_protocol::GetUserAgentResponse;
2527
use codex_app_server_protocol::GetUserSavedConfigResponse;
2628
use codex_app_server_protocol::GitDiffToRemoteResponse;
@@ -87,6 +89,7 @@ use codex_core::protocol::EventMsg;
8789
use codex_core::protocol::ExecApprovalRequestEvent;
8890
use codex_core::protocol::Op;
8991
use codex_core::protocol::ReviewDecision;
92+
use codex_core::read_head_for_summary;
9093
use codex_feedback::CodexFeedback;
9194
use codex_login::ServerOptions as LoginServerOptions;
9295
use codex_login::ShutdownHandle;
@@ -101,6 +104,8 @@ use codex_protocol::user_input::UserInput as CoreInputItem;
101104
use codex_utils_json_to_toml::json_to_toml;
102105
use std::collections::HashMap;
103106
use std::ffi::OsStr;
107+
use std::io::Error as IoError;
108+
use std::path::Path;
104109
use std::path::PathBuf;
105110
use std::sync::Arc;
106111
use 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+
17271812
fn 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
}

codex-rs/core/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ pub use rollout::find_conversation_path_by_id_str;
7777
pub use rollout::list::ConversationItem;
7878
pub use rollout::list::ConversationsPage;
7979
pub use rollout::list::Cursor;
80+
pub use rollout::list::read_head_for_summary;
8081
mod function_tool;
8182
mod state;
8283
mod tasks;

codex-rs/core/src/rollout/list.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,13 @@ async fn read_head_and_tail(
451451
Ok(summary)
452452
}
453453

454+
/// Read up to `HEAD_RECORD_LIMIT` records from the start of the rollout file at `path`.
455+
/// This should be enough to produce a summary including the session meta line.
456+
pub async fn read_head_for_summary(path: &Path) -> io::Result<Vec<serde_json::Value>> {
457+
let summary = read_head_and_tail(path, HEAD_RECORD_LIMIT, 0).await?;
458+
Ok(summary.head)
459+
}
460+
454461
async fn read_tail_records(
455462
path: &Path,
456463
max_records: usize,

0 commit comments

Comments
 (0)