diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 639493b4..eb4206b7 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -31,7 +31,6 @@ use tracing::error; use tracing::info; use tracing::trace; use tracing::warn; -use uuid::Uuid; use crate::ModelProviderInfo; use crate::apply_patch; @@ -104,6 +103,7 @@ use crate::protocol::TokenUsageInfo; use crate::protocol::TurnDiffEvent; use crate::protocol::WebSearchBeginEvent; use crate::rollout::RolloutRecorder; +use crate::rollout::RolloutRecorderParams; use crate::safety::SafetyCheck; use crate::safety::assess_command_safety; use crate::safety::assess_safety_for_untrusted_command; @@ -362,7 +362,6 @@ impl Session { tx_event: Sender, initial_history: InitialHistory, ) -> anyhow::Result<(Arc, TurnContext)> { - let conversation_id = ConversationId::from(Uuid::new_v4()); let ConfigureSession { provider, model, @@ -380,6 +379,20 @@ impl Session { return Err(anyhow::anyhow!("cwd is not absolute: {cwd:?}")); } + let (conversation_id, rollout_params) = match &initial_history { + InitialHistory::New | InitialHistory::Forked(_) => { + let conversation_id = ConversationId::default(); + ( + conversation_id, + RolloutRecorderParams::new(conversation_id, user_instructions.clone()), + ) + } + InitialHistory::Resumed(resumed_history) => ( + resumed_history.conversation_id, + RolloutRecorderParams::resume(resumed_history.rollout_path.clone()), + ), + }; + // Error messages to dispatch after SessionConfigured is sent. let mut post_session_configured_error_events = Vec::::new(); @@ -389,7 +402,7 @@ impl Session { // - spin up MCP connection manager // - perform default shell discovery // - load history metadata - let rollout_fut = RolloutRecorder::new(&config, conversation_id, user_instructions.clone()); + let rollout_fut = RolloutRecorder::new(&config, rollout_params); let mcp_fut = McpConnectionManager::new(config.mcp_servers.clone()); let default_shell_fut = shell::default_user_shell(); @@ -481,7 +494,10 @@ impl Session { // If resuming, include converted initial messages in the payload so UIs can render them immediately. let initial_messages = match &initial_history { InitialHistory::New => None, - InitialHistory::Resumed(items) => Some(sess.build_initial_messages(items)), + InitialHistory::Forked(items) => Some(sess.build_initial_messages(items)), + InitialHistory::Resumed(resumed_history) => { + Some(sess.build_initial_messages(&resumed_history.history)) + } }; let events = std::iter::once(Event { @@ -530,8 +546,12 @@ impl Session { InitialHistory::New => { self.record_initial_history_new(turn_context).await; } - InitialHistory::Resumed(items) => { - self.record_initial_history_resumed(items).await; + InitialHistory::Forked(items) => { + self.record_initial_history_from_items(items).await; + } + InitialHistory::Resumed(resumed_history) => { + self.record_initial_history_from_items(resumed_history.history) + .await; } } } @@ -553,8 +573,8 @@ impl Session { self.record_conversation_items(&conversation_items).await; } - async fn record_initial_history_resumed(&self, items: Vec) { - self.record_conversation_items(&items).await; + async fn record_initial_history_from_items(&self, items: Vec) { + self.record_conversation_items_internal(&items, false).await; } /// build the initial messages vector for SessionConfigured by converting @@ -663,8 +683,14 @@ impl Session { /// Records items to both the rollout and the chat completions/ZDR /// transcript, if enabled. async fn record_conversation_items(&self, items: &[ResponseItem]) { + self.record_conversation_items_internal(items, true).await; + } + + async fn record_conversation_items_internal(&self, items: &[ResponseItem], persist: bool) { debug!("Recording items for conversation: {items:?}"); - self.record_state_snapshot(items).await; + if persist { + self.record_state_snapshot(items).await; + } self.state.lock_unchecked().history.record_items(items); } diff --git a/codex-rs/core/src/conversation_manager.rs b/codex-rs/core/src/conversation_manager.rs index 6cab2760..6fac42d5 100644 --- a/codex-rs/core/src/conversation_manager.rs +++ b/codex-rs/core/src/conversation_manager.rs @@ -1,12 +1,5 @@ -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::Arc; - use crate::AuthManager; use crate::CodexAuth; -use codex_protocol::mcp_protocol::ConversationId; -use tokio::sync::RwLock; - use crate::codex::Codex; use crate::codex::CodexSpawnOk; use crate::codex::INITIAL_SUBMIT_ID; @@ -18,12 +11,25 @@ use crate::protocol::Event; use crate::protocol::EventMsg; use crate::protocol::SessionConfiguredEvent; use crate::rollout::RolloutRecorder; +use codex_protocol::mcp_protocol::ConversationId; use codex_protocol::models::ResponseItem; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::RwLock; + +#[derive(Debug, Clone, PartialEq)] +pub struct ResumedHistory { + pub conversation_id: ConversationId, + pub history: Vec, + pub rollout_path: PathBuf, +} #[derive(Debug, Clone, PartialEq)] pub enum InitialHistory { New, - Resumed(Vec), + Resumed(ResumedHistory), + Forked(Vec), } /// Represents a newly created Codex conversation, including the first event @@ -77,7 +83,7 @@ impl ConversationManager { let CodexSpawnOk { codex, conversation_id, - } = { Codex::spawn(config, auth_manager, InitialHistory::New).await? }; + } = Codex::spawn(config, auth_manager, InitialHistory::New).await?; self.finalize_spawn(codex, conversation_id).await } } @@ -172,7 +178,7 @@ impl ConversationManager { /// and all items that follow them. fn truncate_after_dropping_last_messages(items: Vec, n: usize) -> InitialHistory { if n == 0 { - return InitialHistory::Resumed(items); + return InitialHistory::Forked(items); } // Walk backwards counting only `user` Message items, find cut index. @@ -194,7 +200,7 @@ fn truncate_after_dropping_last_messages(items: Vec, n: usize) -> // No prefix remains after dropping; start a new conversation. InitialHistory::New } else { - InitialHistory::Resumed(items.into_iter().take(cut_index).collect()) + InitialHistory::Forked(items.into_iter().take(cut_index).collect()) } } @@ -252,7 +258,7 @@ mod tests { let truncated = truncate_after_dropping_last_messages(items.clone(), 1); assert_eq!( truncated, - InitialHistory::Resumed(vec![items[0].clone(), items[1].clone(), items[2].clone(),]) + InitialHistory::Forked(vec![items[0].clone(), items[1].clone(), items[2].clone(),]) ); let truncated2 = truncate_after_dropping_last_messages(items, 2); diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 6d26ad2e..3b47830c 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -62,6 +62,7 @@ pub mod terminal; mod tool_apply_patch; pub mod turn_diff_tracker; pub use rollout::RolloutRecorder; +pub use rollout::SessionMeta; pub use rollout::list::ConversationItem; pub use rollout::list::ConversationsPage; pub use rollout::list::Cursor; diff --git a/codex-rs/core/src/message_history.rs b/codex-rs/core/src/message_history.rs index bfdbde67..bfd8bf0a 100644 --- a/codex-rs/core/src/message_history.rs +++ b/codex-rs/core/src/message_history.rs @@ -23,7 +23,6 @@ use std::path::PathBuf; use serde::Deserialize; use serde::Serialize; -use codex_protocol::mcp_protocol::ConversationId; use std::time::Duration; use tokio::fs; use tokio::io::AsyncReadExt; @@ -31,6 +30,7 @@ use tokio::io::AsyncReadExt; use crate::config::Config; use crate::config_types::HistoryPersistence; +use codex_protocol::mcp_protocol::ConversationId; #[cfg(unix)] use std::os::unix::fs::OpenOptionsExt; #[cfg(unix)] diff --git a/codex-rs/core/src/rollout/mod.rs b/codex-rs/core/src/rollout/mod.rs index c6b4fc58..4883517c 100644 --- a/codex-rs/core/src/rollout/mod.rs +++ b/codex-rs/core/src/rollout/mod.rs @@ -7,6 +7,8 @@ pub(crate) mod policy; pub mod recorder; pub use recorder::RolloutRecorder; +pub use recorder::RolloutRecorderParams; +pub use recorder::SessionMeta; pub use recorder::SessionStateSnapshot; #[cfg(test)] diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index 66a65a95..90758335 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -4,6 +4,7 @@ use std::fs::File; use std::fs::{self}; use std::io::Error as IoError; use std::path::Path; +use std::path::PathBuf; use codex_protocol::mcp_protocol::ConversationId; use serde::Deserialize; @@ -26,6 +27,7 @@ use super::list::get_conversations; use super::policy::is_persisted_response_item; use crate::config::Config; use crate::conversation_manager::InitialHistory; +use crate::conversation_manager::ResumedHistory; use crate::git_info::GitInfo; use crate::git_info::collect_git_info; use codex_protocol::models::ResponseItem; @@ -72,12 +74,36 @@ pub struct RolloutRecorder { tx: Sender, } +#[derive(Clone)] +pub enum RolloutRecorderParams { + Create { + conversation_id: ConversationId, + instructions: Option, + }, + Resume { + path: PathBuf, + }, +} + enum RolloutCmd { AddItems(Vec), UpdateState(SessionStateSnapshot), Shutdown { ack: oneshot::Sender<()> }, } +impl RolloutRecorderParams { + pub fn new(conversation_id: ConversationId, instructions: Option) -> Self { + Self::Create { + conversation_id, + instructions, + } + } + + pub fn resume(path: PathBuf) -> Self { + Self::Resume { path } + } +} + impl RolloutRecorder { #[allow(dead_code)] /// List conversations (rollout files) under the provided Codex home directory. @@ -92,24 +118,43 @@ impl RolloutRecorder { /// Attempt to create a new [`RolloutRecorder`]. If the sessions directory /// cannot be created or the rollout file cannot be opened we return the /// error so the caller can decide whether to disable persistence. - pub async fn new( - config: &Config, - conversation_id: ConversationId, - instructions: Option, - ) -> std::io::Result { - let LogFileInfo { - file, - conversation_id: session_id, - timestamp, - } = create_log_file(config, conversation_id)?; + pub async fn new(config: &Config, params: RolloutRecorderParams) -> std::io::Result { + let (file, meta) = match params { + RolloutRecorderParams::Create { + conversation_id, + instructions, + } => { + let LogFileInfo { + file, + conversation_id: session_id, + timestamp, + } = create_log_file(config, conversation_id)?; - let timestamp_format: &[FormatItem] = format_description!( - "[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]Z" - ); - let timestamp = timestamp - .to_offset(time::UtcOffset::UTC) - .format(timestamp_format) - .map_err(|e| IoError::other(format!("failed to format timestamp: {e}")))?; + let timestamp_format: &[FormatItem] = format_description!( + "[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]Z" + ); + let timestamp = timestamp + .to_offset(time::UtcOffset::UTC) + .format(timestamp_format) + .map_err(|e| IoError::other(format!("failed to format timestamp: {e}")))?; + + ( + tokio::fs::File::from_std(file), + Some(SessionMeta { + timestamp, + id: session_id, + instructions, + }), + ) + } + RolloutRecorderParams::Resume { path } => ( + tokio::fs::OpenOptions::new() + .append(true) + .open(path) + .await?, + None, + ), + }; // Clone the cwd for the spawned task to collect git info asynchronously let cwd = config.cwd.clone(); @@ -122,16 +167,7 @@ impl RolloutRecorder { // Spawn a Tokio task that owns the file handle and performs async // writes. Using `tokio::fs::File` keeps everything on the async I/O // driver instead of blocking the runtime. - tokio::task::spawn(rollout_writer( - tokio::fs::File::from_std(file), - rx, - Some(SessionMeta { - timestamp, - id: session_id, - instructions, - }), - cwd, - )); + tokio::task::spawn(rollout_writer(file, rx, meta, cwd)); Ok(Self { tx }) } @@ -164,13 +200,28 @@ impl RolloutRecorder { pub async fn get_rollout_history(path: &Path) -> std::io::Result { info!("Resuming rollout from {path:?}"); + tracing::error!("Resuming rollout from {path:?}"); let text = tokio::fs::read_to_string(path).await?; let mut lines = text.lines(); - let _ = lines + let first_line = lines .next() .ok_or_else(|| IoError::other("empty session file"))?; - let mut items = Vec::new(); + let conversation_id = match serde_json::from_str::(first_line) { + Ok(rollout_session_meta) => { + tracing::error!( + "Parsed conversation ID from rollout file: {:?}", + rollout_session_meta.id + ); + Some(rollout_session_meta.id) + } + Err(e) => { + return Err(IoError::other(format!( + "failed to parse first line of rollout file as SessionMeta: {e}" + ))); + } + }; + let mut items = Vec::new(); for line in lines { if line.trim().is_empty() { continue; @@ -198,12 +249,24 @@ impl RolloutRecorder { } } - info!("Resumed rollout successfully from {path:?}"); + tracing::error!( + "Resumed rollout with {} items, conversation ID: {:?}", + items.len(), + conversation_id + ); + let conversation_id = conversation_id + .ok_or_else(|| IoError::other("failed to parse conversation ID from rollout file"))?; + if items.is_empty() { - Ok(InitialHistory::New) - } else { - Ok(InitialHistory::Resumed(items)) + return Ok(InitialHistory::New); } + + info!("Resumed rollout successfully from {path:?}"); + Ok(InitialHistory::Resumed(ResumedHistory { + conversation_id, + history: items, + rollout_path: path.to_path_buf(), + })) } pub async fn shutdown(&self) -> std::io::Result<()> { @@ -331,7 +394,7 @@ impl JsonlWriter { async fn write_line(&mut self, item: &impl serde::Serialize) -> std::io::Result<()> { let mut json = serde_json::to_string(item)?; json.push('\n'); - let _ = self.file.write_all(json.as_bytes()).await; + self.file.write_all(json.as_bytes()).await?; self.file.flush().await?; Ok(()) } diff --git a/codex-rs/core/tests/suite/cli_stream.rs b/codex-rs/core/tests/suite/cli_stream.rs index 695aa9f1..ee4eef70 100644 --- a/codex-rs/core/tests/suite/cli_stream.rs +++ b/codex-rs/core/tests/suite/cli_stream.rs @@ -388,8 +388,7 @@ async fn integration_creates_and_checks_session_file() { "No message found in session file containing the marker" ); - // Second run: resume should create a NEW session file that contains both old and new history. - let orig_len = content.lines().count(); + // Second run: resume should update the existing file. let marker2 = format!("integration-resume-{}", Uuid::new_v4()); let prompt2 = format!("echo {marker2}"); // Cross‑platform safe resume override. On Windows, backslashes in a TOML string must be escaped @@ -449,8 +448,8 @@ async fn integration_creates_and_checks_session_file() { } let resumed_path = resumed_path.expect("No resumed session file found containing the marker2"); - // Resume should have written to a new file, not the original one. - assert_ne!( + // Resume should write to the existing log file. + assert_eq!( resumed_path, path, "resume should create a new session file" ); @@ -464,14 +463,6 @@ async fn integration_creates_and_checks_session_file() { resumed_content.contains(&marker2), "resumed file missing resumed marker" ); - - // Original file should remain unchanged. - let content_after = std::fs::read_to_string(&path).unwrap(); - assert_eq!( - content_after.lines().count(), - orig_len, - "original rollout file should not change on resume" - ); } /// Integration test to verify git info is collected and recorded in session files. diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 70e6e2b6..1aa1a49d 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -15,6 +15,7 @@ use core_test_support::wait_for_event; use serde_json::json; use std::io::Write; use tempfile::TempDir; +use uuid::Uuid; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; @@ -122,11 +123,10 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { let tmpdir = TempDir::new().unwrap(); let session_path = tmpdir.path().join("resume-session.jsonl"); let mut f = std::fs::File::create(&session_path).unwrap(); - // First line: meta (content not used by reader other than non-empty) writeln!( f, "{}", - serde_json::json!({"meta":"test","instructions":"be nice"}) + json!({"meta":"test","instructions":"be nice", "id": Uuid::new_v4(), "timestamp": "2024-01-01T00:00:00Z"}) ) .unwrap(); @@ -202,7 +202,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { .clone() .expect("expected initial messages for resumed session"); let initial_json = serde_json::to_value(&initial_msgs).unwrap(); - let expected_initial_json = serde_json::json!([ + let expected_initial_json = json!([ { "type": "user_message", "message": "resumed user message", "kind": "plain" }, { "type": "agent_message", "message": "resumed assistant message" } ]); @@ -221,7 +221,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { let request = &server.received_requests().await.unwrap()[0]; let request_body = request.body_json::().unwrap(); - let expected_input = serde_json::json!([ + let expected_input = json!([ { "type": "message", "role": "user", @@ -967,7 +967,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { assert_eq!(requests.len(), 3, "expected 3 requests (one per turn)"); // Replace full-array compare with tail-only raw JSON compare using a single hard-coded value. - let r3_tail_expected = serde_json::json!([ + let r3_tail_expected = json!([ { "type": "message", "role": "user", diff --git a/codex-rs/mcp-server/src/codex_message_processor.rs b/codex-rs/mcp-server/src/codex_message_processor.rs index 923ebbec..e5f44b56 100644 --- a/codex-rs/mcp-server/src/codex_message_processor.rs +++ b/codex-rs/mcp-server/src/codex_message_processor.rs @@ -1,8 +1,3 @@ -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::Arc; -use std::time::Duration; - use crate::error_code::INTERNAL_ERROR_CODE; use crate::error_code::INVALID_REQUEST_ERROR_CODE; use crate::json_to_toml::json_to_toml; @@ -14,6 +9,7 @@ use codex_core::ConversationManager; use codex_core::Cursor as RolloutCursor; use codex_core::NewConversation; use codex_core::RolloutRecorder; +use codex_core::SessionMeta; use codex_core::auth::CLIENT_ID; use codex_core::config::Config; use codex_core::config::ConfigOverrides; @@ -70,8 +66,16 @@ use codex_protocol::mcp_protocol::SendUserTurnParams; use codex_protocol::mcp_protocol::SendUserTurnResponse; use codex_protocol::mcp_protocol::ServerNotification; use codex_protocol::mcp_protocol::UserSavedConfig; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::InputMessageKind; +use codex_protocol::protocol::USER_MESSAGE_BEGIN; use mcp_types::JSONRPCErrorError; use mcp_types::RequestId; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; use tokio::sync::Mutex; use tokio::sync::oneshot; use tracing::error; @@ -570,16 +574,11 @@ impl CodexMessageProcessor { } }; - // Build summaries - let mut items: Vec = Vec::new(); - for it in page.items.into_iter() { - let (timestamp, preview) = extract_ts_and_preview(&it.head); - items.push(ConversationSummary { - path: it.path, - preview, - timestamp, - }); - } + let items = page + .items + .into_iter() + .filter_map(|it| extract_conversation_summary(it.path, &it.head)) + .collect(); // Encode next_cursor as a plain string let next_cursor = match page.next_cursor { @@ -633,19 +632,29 @@ impl CodexMessageProcessor { session_configured, .. }) => { - let event = codex_core::protocol::Event { + let event = Event { id: "".to_string(), - msg: codex_core::protocol::EventMsg::SessionConfigured( - session_configured.clone(), - ), + msg: EventMsg::SessionConfigured(session_configured.clone()), }; self.outgoing.send_event_as_notification(&event, None).await; + let initial_messages = session_configured.initial_messages.map(|msgs| { + msgs.into_iter() + .filter(|event| { + // Don't send non-plain user messages (like user instructions + // or environment context) back so they don't get rendered. + if let EventMsg::UserMessage(user_message) = event { + return matches!(user_message.kind, Some(InputMessageKind::Plain)); + } + true + }) + .collect() + }); // Reply with conversation id + model and initial messages (when present) let response = codex_protocol::mcp_protocol::ResumeConversationResponse { conversation_id, model: session_configured.model.clone(), - initial_messages: session_configured.initial_messages.clone(), + initial_messages, }; self.outgoing.send_response(request_id, response).await; } @@ -833,11 +842,11 @@ impl CodexMessageProcessor { let mut params = match serde_json::to_value(event.clone()) { Ok(serde_json::Value::Object(map)) => map, Ok(_) => { - tracing::error!("event did not serialize to an object"); + error!("event did not serialize to an object"); continue; } Err(err) => { - tracing::error!("failed to serialize event: {err}"); + error!("failed to serialize event: {err}"); continue; } }; @@ -1020,7 +1029,7 @@ fn derive_config_from_params( async fn on_patch_approval_response( event_id: String, - receiver: tokio::sync::oneshot::Receiver, + receiver: oneshot::Receiver, codex: Arc, ) { let response = receiver.await; @@ -1062,14 +1071,14 @@ async fn on_patch_approval_response( async fn on_exec_approval_response( event_id: String, - receiver: tokio::sync::oneshot::Receiver, + receiver: oneshot::Receiver, conversation: Arc, ) { let response = receiver.await; let value = match response { Ok(value) => value, Err(err) => { - tracing::error!("request failed: {err:?}"); + error!("request failed: {err:?}"); return; } }; @@ -1096,37 +1105,99 @@ async fn on_exec_approval_response( } } -fn extract_ts_and_preview(head: &[serde_json::Value]) -> (Option, String) { - let ts = head - .first() - .and_then(|v| v.get("timestamp")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let preview = find_first_user_text(head).unwrap_or_default(); - (ts, preview) +fn extract_conversation_summary( + path: PathBuf, + head: &[serde_json::Value], +) -> Option { + let session_meta = match head.first() { + Some(first_line) => match serde_json::from_value::(first_line.clone()) { + Ok(session_meta) => session_meta, + Err(..) => return None, + }, + None => return None, + }; + + let preview = head + .iter() + .filter_map(|value| serde_json::from_value::(value.clone()).ok()) + .find_map(|item| match item { + ResponseItem::Message { content, .. } => { + content.into_iter().find_map(|content| match content { + ContentItem::InputText { text } => { + match InputMessageKind::from(("user", &text)) { + InputMessageKind::Plain => Some(text), + _ => None, + } + } + _ => None, + }) + } + _ => None, + })?; + + let preview = match preview.find(USER_MESSAGE_BEGIN) { + Some(idx) => preview[idx + USER_MESSAGE_BEGIN.len()..].trim(), + None => preview.as_str(), + }; + + let timestamp = if session_meta.timestamp.is_empty() { + None + } else { + Some(session_meta.timestamp.clone()) + }; + + Some(ConversationSummary { + conversation_id: session_meta.id, + timestamp, + path, + preview: preview.to_string(), + }) } -fn find_first_user_text(head: &[serde_json::Value]) -> Option { - use codex_core::protocol::InputMessageKind; - for v in head.iter() { - let t = v.get("type").and_then(|x| x.as_str()).unwrap_or(""); - if t != "message" { - continue; - } - if v.get("role").and_then(|x| x.as_str()) != Some("user") { - continue; - } - if let Some(arr) = v.get("content").and_then(|c| c.as_array()) { - for c in arr.iter() { - if let (Some("input_text"), Some(txt)) = - (c.get("type").and_then(|t| t.as_str()), c.get("text")) - && let Some(s) = txt.as_str() - && matches!(InputMessageKind::from(("user", s)), InputMessageKind::Plain) - { - return Some(s.to_string()); - } - } - } +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use serde_json::json; + + #[test] + fn extract_conversation_summary_prefers_plain_user_messages() { + let conversation_id = + ConversationId(Uuid::parse_str("3f941c35-29b3-493b-b0a4-e25800d9aeb0").unwrap()); + let timestamp = Some("2025-09-05T16:53:11.850Z".to_string()); + let path = PathBuf::from("rollout.jsonl"); + + let head = vec![ + json!({ + "id": conversation_id.0, + "timestamp": timestamp, + }), + json!({ + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": "\n\n".to_string(), + }], + }), + json!({ + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": format!(" {USER_MESSAGE_BEGIN}Count to 5"), + }], + }), + ]; + + let summary = extract_conversation_summary(path.clone(), &head).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"); } - None } diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 4be14485..feeb7f01 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -5,6 +5,10 @@ use std::collections::HashMap; use std::sync::Arc; +use crate::exec_approval::handle_exec_approval_request; +use crate::outgoing_message::OutgoingMessageSender; +use crate::outgoing_message::OutgoingNotificationMeta; +use crate::patch_approval::handle_patch_approval_request; use codex_core::CodexConversation; use codex_core::ConversationManager; use codex_core::NewConversation; @@ -26,11 +30,6 @@ use mcp_types::TextContent; use serde_json::json; use tokio::sync::Mutex; -use crate::exec_approval::handle_exec_approval_request; -use crate::outgoing_message::OutgoingMessageSender; -use crate::outgoing_message::OutgoingNotificationMeta; -use crate::patch_approval::handle_patch_approval_request; - pub(crate) const INVALID_PARAMS_ERROR_CODE: i64 = -32602; /// Run a complete Codex session and stream events back to the client. diff --git a/codex-rs/mcp-server/tests/suite/list_resume.rs b/codex-rs/mcp-server/tests/suite/list_resume.rs index 98734922..eef69c6d 100644 --- a/codex-rs/mcp-server/tests/suite/list_resume.rs +++ b/codex-rs/mcp-server/tests/suite/list_resume.rs @@ -157,7 +157,7 @@ fn create_fake_rollout(codex_home: &Path, filename_ts: &str, meta_rfc3339: &str, let file_path = dir.join(format!("rollout-{filename_ts}-{uuid}.jsonl")); let mut lines = Vec::new(); // Meta line with timestamp - lines.push(json!({"timestamp": meta_rfc3339}).to_string()); + lines.push(json!({"timestamp": meta_rfc3339, "id": uuid}).to_string()); // Minimal user message entry as a persisted response item lines.push( json!({ diff --git a/codex-rs/mcp-types/Cargo.toml b/codex-rs/mcp-types/Cargo.toml index 7ebc10af..e39cb64d 100644 --- a/codex-rs/mcp-types/Cargo.toml +++ b/codex-rs/mcp-types/Cargo.toml @@ -9,4 +9,4 @@ workspace = true [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" -ts-rs = { version = "11", features = ["serde-json-impl"] } +ts-rs = { version = "11", features = ["serde-json-impl", "no-serde-warnings"] } diff --git a/codex-rs/protocol-ts/src/lib.rs b/codex-rs/protocol-ts/src/lib.rs index c414b3e7..d5645147 100644 --- a/codex-rs/protocol-ts/src/lib.rs +++ b/codex-rs/protocol-ts/src/lib.rs @@ -20,35 +20,25 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { codex_protocol::mcp_protocol::InputItem::export_all_to(out_dir)?; codex_protocol::mcp_protocol::ClientRequest::export_all_to(out_dir)?; codex_protocol::mcp_protocol::ServerRequest::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::NewConversationParams::export_all_to(out_dir)?; codex_protocol::mcp_protocol::NewConversationResponse::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::AddConversationListenerParams::export_all_to(out_dir)?; codex_protocol::mcp_protocol::AddConversationSubscriptionResponse::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::RemoveConversationListenerParams::export_all_to(out_dir)?; codex_protocol::mcp_protocol::RemoveConversationSubscriptionResponse::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::SendUserMessageParams::export_all_to(out_dir)?; codex_protocol::mcp_protocol::SendUserMessageResponse::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::SendUserTurnParams::export_all_to(out_dir)?; codex_protocol::mcp_protocol::SendUserTurnResponse::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::InterruptConversationParams::export_all_to(out_dir)?; codex_protocol::mcp_protocol::InterruptConversationResponse::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::GitDiffToRemoteParams::export_all_to(out_dir)?; codex_protocol::mcp_protocol::GitDiffToRemoteResponse::export_all_to(out_dir)?; codex_protocol::mcp_protocol::LoginChatGptResponse::export_all_to(out_dir)?; codex_protocol::mcp_protocol::LoginChatGptCompleteNotification::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::CancelLoginChatGptParams::export_all_to(out_dir)?; codex_protocol::mcp_protocol::CancelLoginChatGptResponse::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::LogoutChatGptParams::export_all_to(out_dir)?; codex_protocol::mcp_protocol::LogoutChatGptResponse::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::GetAuthStatusParams::export_all_to(out_dir)?; codex_protocol::mcp_protocol::GetAuthStatusResponse::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::ApplyPatchApprovalParams::export_all_to(out_dir)?; codex_protocol::mcp_protocol::ApplyPatchApprovalResponse::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::ExecCommandApprovalParams::export_all_to(out_dir)?; codex_protocol::mcp_protocol::ExecCommandApprovalResponse::export_all_to(out_dir)?; codex_protocol::mcp_protocol::GetUserSavedConfigResponse::export_all_to(out_dir)?; codex_protocol::mcp_protocol::GetUserAgentResponse::export_all_to(out_dir)?; codex_protocol::mcp_protocol::ServerNotification::export_all_to(out_dir)?; + codex_protocol::mcp_protocol::ListConversationsResponse::export_all_to(out_dir)?; + codex_protocol::mcp_protocol::ResumeConversationResponse::export_all_to(out_dir)?; generate_index_ts(out_dir)?; diff --git a/codex-rs/protocol/Cargo.toml b/codex-rs/protocol/Cargo.toml index 0d433bb5..10a76d6e 100644 --- a/codex-rs/protocol/Cargo.toml +++ b/codex-rs/protocol/Cargo.toml @@ -24,7 +24,7 @@ strum = "0.27.2" strum_macros = "0.27.2" sys-locale = "0.3.2" tracing = "0.1.41" -ts-rs = { version = "11", features = ["uuid-impl", "serde-json-impl"] } +ts-rs = { version = "11", features = ["uuid-impl", "serde-json-impl", "no-serde-warnings"] } uuid = { version = "1", features = ["serde", "v4"] } [dev-dependencies] diff --git a/codex-rs/protocol/src/custom_prompts.rs b/codex-rs/protocol/src/custom_prompts.rs index 8567c1c9..be402051 100644 --- a/codex-rs/protocol/src/custom_prompts.rs +++ b/codex-rs/protocol/src/custom_prompts.rs @@ -1,8 +1,9 @@ use serde::Deserialize; use serde::Serialize; use std::path::PathBuf; +use ts_rs::TS; -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, TS)] pub struct CustomPrompt { pub name: String, pub path: PathBuf, diff --git a/codex-rs/protocol/src/mcp_protocol.rs b/codex-rs/protocol/src/mcp_protocol.rs index c3263d4d..2af1a951 100644 --- a/codex-rs/protocol/src/mcp_protocol.rs +++ b/codex-rs/protocol/src/mcp_protocol.rs @@ -19,7 +19,7 @@ use strum_macros::Display; use ts_rs::TS; use uuid::Uuid; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS, Hash, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS, Hash)] #[ts(type = "string")] pub struct ConversationId(pub Uuid); @@ -29,6 +29,12 @@ impl ConversationId { } } +impl Default for ConversationId { + fn default() -> Self { + Self::new() + } +} + impl Display for ConversationId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) @@ -199,7 +205,7 @@ pub struct NewConversationResponse { pub model: String, } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, TS)] #[serde(rename_all = "camelCase")] pub struct ResumeConversationResponse { pub conversation_id: ConversationId, @@ -222,6 +228,7 @@ pub struct ListConversationsParams { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] #[serde(rename_all = "camelCase")] pub struct ConversationSummary { + pub conversation_id: ConversationId, pub path: PathBuf, pub preview: String, /// RFC3339 timestamp string for the session start, if available. @@ -631,4 +638,10 @@ mod tests { serde_json::to_value(&request).unwrap(), ); } + + #[test] + fn test_conversation_id_default_is_not_zeroes() { + let id = ConversationId::default(); + assert_ne!(id.0, Uuid::nil()); + } } diff --git a/codex-rs/protocol/src/message_history.rs b/codex-rs/protocol/src/message_history.rs index 5d3799a5..ba799931 100644 --- a/codex-rs/protocol/src/message_history.rs +++ b/codex-rs/protocol/src/message_history.rs @@ -1,7 +1,8 @@ use serde::Deserialize; use serde::Serialize; +use ts_rs::TS; -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, TS)] pub struct HistoryEntry { pub conversation_id: String, pub ts: u64, diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index e8dbf19c..115d4596 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -6,10 +6,11 @@ use serde::Deserialize; use serde::Deserializer; use serde::Serialize; use serde::ser::Serializer; +use ts_rs::TS; use crate::protocol::InputItem; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ResponseInputItem { Message { @@ -30,7 +31,7 @@ pub enum ResponseInputItem { }, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ContentItem { InputText { text: String }, @@ -38,7 +39,7 @@ pub enum ContentItem { OutputText { text: String }, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ResponseItem { Message { @@ -159,7 +160,7 @@ impl From for ResponseItem { } } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] #[serde(rename_all = "snake_case")] pub enum LocalShellStatus { Completed, @@ -167,13 +168,13 @@ pub enum LocalShellStatus { Incomplete, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum LocalShellAction { Exec(LocalShellExecAction), } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] pub struct LocalShellExecAction { pub command: Vec, pub timeout_ms: Option, @@ -182,7 +183,7 @@ pub struct LocalShellExecAction { pub user: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum WebSearchAction { Search { @@ -192,13 +193,13 @@ pub enum WebSearchAction { Other, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ReasoningItemReasoningSummary { SummaryText { text: String }, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ReasoningItemContent { ReasoningText { text: String }, @@ -242,7 +243,7 @@ impl From> for ResponseInputItem { /// If the `name` of a `ResponseItem::FunctionCall` is either `container.exec` /// or shell`, the `arguments` field should deserialize to this struct. -#[derive(Deserialize, Debug, Clone, PartialEq)] +#[derive(Deserialize, Debug, Clone, PartialEq, TS)] pub struct ShellToolCallParams { pub command: Vec, pub workdir: Option, @@ -256,7 +257,7 @@ pub struct ShellToolCallParams { pub justification: Option, } -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, TS)] pub struct FunctionCallOutputPayload { pub content: String, pub success: Option, diff --git a/codex-rs/protocol/src/parse_command.rs b/codex-rs/protocol/src/parse_command.rs index 326f6cf5..d1fbe835 100644 --- a/codex-rs/protocol/src/parse_command.rs +++ b/codex-rs/protocol/src/parse_command.rs @@ -1,7 +1,8 @@ use serde::Deserialize; use serde::Serialize; +use ts_rs::TS; -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ParsedCommand { Read { diff --git a/codex-rs/protocol/src/plan_tool.rs b/codex-rs/protocol/src/plan_tool.rs index 78ef9cd4..79e672ac 100644 --- a/codex-rs/protocol/src/plan_tool.rs +++ b/codex-rs/protocol/src/plan_tool.rs @@ -1,8 +1,9 @@ use serde::Deserialize; use serde::Serialize; +use ts_rs::TS; // Types for the TODO tool arguments matching codex-vscode/todo-mcp/src/main.rs -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(rename_all = "snake_case")] pub enum StepStatus { Pending, @@ -10,14 +11,14 @@ pub enum StepStatus { Completed, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(deny_unknown_fields)] pub struct PlanItemArg { pub step: String, pub status: StepStatus, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(deny_unknown_fields)] pub struct UpdatePlanArgs { #[serde(default)] diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index f751da2f..eeaa72cd 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -33,6 +33,7 @@ pub const USER_INSTRUCTIONS_OPEN_TAG: &str = ""; pub const USER_INSTRUCTIONS_CLOSE_TAG: &str = ""; pub const ENVIRONMENT_CONTEXT_OPEN_TAG: &str = ""; pub const ENVIRONMENT_CONTEXT_CLOSE_TAG: &str = ""; +pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize)] @@ -404,7 +405,7 @@ pub struct Event { } /// Response event from the agent -#[derive(Debug, Clone, Deserialize, Serialize, Display)] +#[derive(Debug, Clone, Deserialize, Serialize, Display, TS)] #[serde(tag = "type", rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] pub enum EventMsg { @@ -503,22 +504,22 @@ pub enum EventMsg { // Individual event payload types matching each `EventMsg` variant. -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct ErrorEvent { pub message: String, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct TaskCompleteEvent { pub last_agent_message: Option, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct TaskStartedEvent { pub model_context_window: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, Default)] +#[derive(Debug, Clone, Deserialize, Serialize, Default, TS)] pub struct TokenUsage { pub input_tokens: u64, pub cached_input_tokens: u64, @@ -527,7 +528,7 @@ pub struct TokenUsage { pub total_tokens: u64, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct TokenUsageInfo { pub total_token_usage: TokenUsage, pub last_token_usage: TokenUsage, @@ -564,7 +565,7 @@ impl TokenUsageInfo { } } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct TokenCountEvent { pub info: Option, } @@ -673,12 +674,12 @@ impl fmt::Display for FinalOutput { } } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct AgentMessageEvent { pub message: String, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, TS)] #[serde(rename_all = "snake_case")] pub enum InputMessageKind { /// Plain user text (default) @@ -689,7 +690,7 @@ pub enum InputMessageKind { EnvironmentContext, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct UserMessageEvent { pub message: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -719,35 +720,35 @@ where } } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct AgentMessageDeltaEvent { pub delta: String, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct AgentReasoningEvent { pub text: String, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct AgentReasoningRawContentEvent { pub text: String, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct AgentReasoningRawContentDeltaEvent { pub delta: String, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct AgentReasoningSectionBreakEvent {} -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct AgentReasoningDeltaEvent { pub delta: String, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct McpInvocation { /// Name of the MCP server as defined in the config. pub server: String, @@ -757,18 +758,19 @@ pub struct McpInvocation { pub arguments: Option, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct McpToolCallBeginEvent { /// Identifier so this can be paired with the McpToolCallEnd event. pub call_id: String, pub invocation: McpInvocation, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct McpToolCallEndEvent { /// Identifier for the corresponding McpToolCallBegin that finished. pub call_id: String, pub invocation: McpInvocation, + #[ts(type = "string")] pub duration: Duration, /// Result of the tool call. Note this could be an error. pub result: Result, @@ -783,12 +785,12 @@ impl McpToolCallEndEvent { } } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct WebSearchBeginEvent { pub call_id: String, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct WebSearchEndEvent { pub call_id: String, pub query: String, @@ -796,13 +798,13 @@ pub struct WebSearchEndEvent { /// Response payload for `Op::GetHistory` containing the current session's /// in-memory transcript. -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct ConversationHistoryResponseEvent { pub conversation_id: ConversationId, pub entries: Vec, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct ExecCommandBeginEvent { /// Identifier so this can be paired with the ExecCommandEnd event. pub call_id: String, @@ -813,7 +815,7 @@ pub struct ExecCommandBeginEvent { pub parsed_cmd: Vec, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct ExecCommandEndEvent { /// Identifier for the ExecCommandBegin that finished. pub call_id: String, @@ -827,12 +829,13 @@ pub struct ExecCommandEndEvent { /// The command's exit code. pub exit_code: i32, /// The duration of the command execution. + #[ts(type = "string")] pub duration: Duration, /// Formatted output from the command, as seen by the model. pub formatted_output: String, } -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] #[serde(rename_all = "snake_case")] pub enum ExecOutputStream { Stdout, @@ -840,7 +843,7 @@ pub enum ExecOutputStream { } #[serde_as] -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)] pub struct ExecCommandOutputDeltaEvent { /// Identifier for the ExecCommandBegin that produced this chunk. pub call_id: String, @@ -848,10 +851,11 @@ pub struct ExecCommandOutputDeltaEvent { pub stream: ExecOutputStream, /// Raw bytes from the stream (may not be valid UTF-8). #[serde_as(as = "serde_with::base64::Base64")] + #[ts(type = "string")] pub chunk: Vec, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct ExecApprovalRequestEvent { /// Identifier for the associated exec call, if available. pub call_id: String, @@ -864,7 +868,7 @@ pub struct ExecApprovalRequestEvent { pub reason: Option, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct ApplyPatchApprovalRequestEvent { /// Responses API call id for the associated patch apply call, if available. pub call_id: String, @@ -877,17 +881,17 @@ pub struct ApplyPatchApprovalRequestEvent { pub grant_root: Option, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct BackgroundEventEvent { pub message: String, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct StreamErrorEvent { pub message: String, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct PatchApplyBeginEvent { /// Identifier so this can be paired with the PatchApplyEnd event. pub call_id: String, @@ -897,7 +901,7 @@ pub struct PatchApplyBeginEvent { pub changes: HashMap, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct PatchApplyEndEvent { /// Identifier for the PatchApplyBegin that finished. pub call_id: String, @@ -909,12 +913,12 @@ pub struct PatchApplyEndEvent { pub success: bool, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct TurnDiffEvent { pub unified_diff: String, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct GetHistoryEntryResponseEvent { pub offset: usize, pub log_id: u64, @@ -924,19 +928,19 @@ pub struct GetHistoryEntryResponseEvent { } /// Response payload for `Op::ListMcpTools`. -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct McpListToolsResponseEvent { /// Fully qualified tool name -> tool definition. pub tools: std::collections::HashMap, } /// Response payload for `Op::ListCustomPrompts`. -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct ListCustomPromptsResponseEvent { pub custom_prompts: Vec, } -#[derive(Debug, Default, Clone, Deserialize, Serialize)] +#[derive(Debug, Default, Clone, Deserialize, Serialize, TS)] pub struct SessionConfiguredEvent { /// Name left as session_id instead of conversation_id for backwards compatibility. pub session_id: ConversationId, @@ -993,7 +997,7 @@ pub enum FileChange { }, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct Chunk { /// 1-based line index of the first line in the original file pub orig_index: u32, @@ -1001,7 +1005,7 @@ pub struct Chunk { pub inserted_lines: Vec, } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct TurnAbortedEvent { pub reason: TurnAbortReason, } diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index b893fd79..039c9403 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -9,6 +9,7 @@ use color_eyre::eyre::Result; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; + /// Aggregates all backtrack-related state used by the App. #[derive(Default)] pub(crate) struct BacktrackState { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index e423e804..3b612033 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -177,6 +177,10 @@ fn resumed_initial_messages_render_history() { ); } +#[cfg_attr( + target_os = "macos", + ignore = "system configuration APIs are blocked under macOS seatbelt" +)] #[tokio::test(flavor = "current_thread")] async fn helpers_are_available_and_do_not_panic() { let (tx_raw, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 1c4afea8..f34a9a55 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -8,7 +8,6 @@ use codex_core::ConversationItem; use codex_core::ConversationsPage; use codex_core::Cursor; use codex_core::RolloutRecorder; -use codex_core::protocol::InputMessageKind; use color_eyre::eyre::Result; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -24,6 +23,10 @@ use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; use crate::tui::Tui; use crate::tui::TuiEvent; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::InputMessageKind; +use codex_protocol::protocol::USER_MESSAGE_BEGIN; const PAGE_SIZE: usize = 25; @@ -273,7 +276,7 @@ fn head_to_row(item: &ConversationItem) -> Option { ts = Some(parsed.with_timezone(&Utc)); } - let preview = find_first_user_text(&item.head)?; + let preview = preview_from_head(&item.head)?; let preview = preview.trim().to_string(); if preview.is_empty() { return None; @@ -285,37 +288,42 @@ fn head_to_row(item: &ConversationItem) -> Option { }) } -/// Return the first plain user text from the JSONL `head` of a rollout. -/// -/// Strategy: scan for the first `{ type: "message", role: "user" }` entry and -/// then return the first `content` item where `{ type: "input_text" }` that is -/// classified as `InputMessageKind::Plain` (i.e., not wrapped in -/// `` or `` tags). -fn find_first_user_text(head: &[serde_json::Value]) -> Option { - for v in head.iter() { - let t = v.get("type").and_then(|x| x.as_str()).unwrap_or(""); - if t != "message" { - continue; - } - if v.get("role").and_then(|x| x.as_str()) != Some("user") { - continue; - } - if let Some(arr) = v.get("content").and_then(|c| c.as_array()) { - for c in arr.iter() { - if let (Some("input_text"), Some(txt)) = - (c.get("type").and_then(|t| t.as_str()), c.get("text")) - && let Some(s) = txt.as_str() - { - // Skip XML-wrapped user_instructions/environment_context blocks and - // return the first plain user text we find. - if matches!(InputMessageKind::from(("user", s)), InputMessageKind::Plain) { - return Some(s.to_string()); - } +fn preview_from_head(head: &[serde_json::Value]) -> Option { + head.iter() + .filter_map(|value| serde_json::from_value::(value.clone()).ok()) + .find_map(|item| match item { + ResponseItem::Message { content, .. } => { + // Find the actual user message (as opposed to user instructions or ide context) + let preview = content + .into_iter() + .filter_map(|content| match content { + ContentItem::InputText { text } + if matches!( + InputMessageKind::from(("user", text.as_str())), + InputMessageKind::Plain + ) => + { + // Strip ide context. + let text = match text.find(USER_MESSAGE_BEGIN) { + Some(idx) => { + text[idx + USER_MESSAGE_BEGIN.len()..].trim().to_string() + } + None => text, + }; + Some(text) + } + _ => None, + }) + .collect::(); + + if preview.is_empty() { + None + } else { + Some(preview) } } - } - } - None + _ => None, + }) } fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> { @@ -452,31 +460,26 @@ mod tests { } #[test] - fn skips_user_instructions_and_env_context() { + fn preview_uses_first_message_input_text() { let head = vec![ json!({ "timestamp": "2025-01-01T00:00:00Z" }), json!({ "type": "message", "role": "user", "content": [ - { "type": "input_text", "text": "hi" } + { "type": "input_text", "text": "hi" }, + { "type": "input_text", "text": "real question" }, + { "type": "input_image", "image_url": "ignored" } ] }), json!({ "type": "message", "role": "user", - "content": [ - { "type": "input_text", "text": "cwd" } - ] - }), - json!({ - "type": "message", - "role": "user", - "content": [ { "type": "input_text", "text": "real question" } ] + "content": [ { "type": "input_text", "text": "later text" } ] }), ]; - let first = find_first_user_text(&head); - assert_eq!(first.as_deref(), Some("real question")); + let preview = preview_from_head(&head); + assert_eq!(preview.as_deref(), Some("real question")); } #[test]