Skip to content

Commit abbf02f

Browse files
committed
feat: introduce GetConversationSummary RPC
1 parent a596e9c commit abbf02f

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)