diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 873d0a222a..5a333c2616 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -298,17 +298,6 @@ dependencies = [ "syn 2.0.104", ] -[[package]] -name = "async-trait" -version = "0.1.88" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", -] - [[package]] name = "atomic-waker" version = "1.1.2" @@ -1279,12 +1268,12 @@ dependencies = [ [[package]] name = "deadpool" -version = "0.10.0" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb84100978c1c7b37f09ed3ce3e5f843af02c2a2c431bae5b19230dad2c1b490" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" dependencies = [ - "async-trait", "deadpool-runtime", + "lazy_static", "num_cpus", "tokio", ] @@ -2131,13 +2120,14 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" dependencies = [ + "atomic-waker", "bytes", "futures-channel", - "futures-util", + "futures-core", "h2", "http", "http-body", @@ -2145,6 +2135,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", + "pin-utils", "smallvec", "tokio", "want", @@ -6272,12 +6263,11 @@ checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" [[package]] name = "wiremock" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b8b99d4cdbf36b239a9532e31fe4fb8acc38d1897c1761e161550a7dc78e6a" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" dependencies = [ "assert-json-diff", - "async-trait", "base64 0.22.1", "deadpool", "futures", diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index e326ea3324..d561d36946 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -6,7 +6,6 @@ use codex_apply_patch::APPLY_PATCH_TOOL_INSTRUCTIONS; use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::config_types::Verbosity as VerbosityConfig; -use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use futures::Stream; use serde::Serialize; @@ -20,10 +19,6 @@ use tokio::sync::mpsc; /// with this content. const BASE_INSTRUCTIONS: &str = include_str!("../prompt.md"); -/// wraps user instructions message in a tag for the model to parse more easily. -const USER_INSTRUCTIONS_START: &str = "\n\n"; -const USER_INSTRUCTIONS_END: &str = "\n\n"; - /// API request payload for a single model turn #[derive(Default, Debug, Clone)] pub struct Prompt { @@ -68,17 +63,6 @@ impl Prompt { pub(crate) fn get_formatted_input(&self) -> Vec { self.input.clone() } - - /// Creates a formatted user instructions message from a string - pub(crate) fn format_user_instructions_message(ui: &str) -> ResponseItem { - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: format!("{USER_INSTRUCTIONS_START}{ui}{USER_INSTRUCTIONS_END}"), - }], - } - } } #[derive(Debug)] diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 36c48020bc..2028c4240e 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -9,6 +9,7 @@ use std::sync::atomic::AtomicU64; use std::time::Duration; use crate::AuthManager; +use crate::event_mapping::map_response_item_to_event_messages; use async_channel::Receiver; use async_channel::Sender; use codex_apply_patch::ApplyPatchAction; @@ -75,9 +76,7 @@ use crate::project_doc::get_user_instructions; use crate::protocol::AgentMessageDeltaEvent; use crate::protocol::AgentMessageEvent; use crate::protocol::AgentReasoningDeltaEvent; -use crate::protocol::AgentReasoningEvent; use crate::protocol::AgentReasoningRawContentDeltaEvent; -use crate::protocol::AgentReasoningRawContentEvent; use crate::protocol::AgentReasoningSectionBreakEvent; use crate::protocol::ApplyPatchApprovalRequestEvent; use crate::protocol::AskForApproval; @@ -102,13 +101,13 @@ use crate::protocol::Submission; use crate::protocol::TaskCompleteEvent; use crate::protocol::TurnDiffEvent; use crate::protocol::WebSearchBeginEvent; -use crate::protocol::WebSearchEndEvent; use crate::rollout::RolloutRecorder; use crate::safety::SafetyCheck; use crate::safety::assess_command_safety; use crate::safety::assess_safety_for_untrusted_command; use crate::shell; use crate::turn_diff_tracker::TurnDiffTracker; +use crate::user_instructions::UserInstructions; use crate::user_notification::UserNotification; use crate::util::backoff; use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig; @@ -117,12 +116,9 @@ use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::LocalShellAction; -use codex_protocol::models::ReasoningItemContent; -use codex_protocol::models::ReasoningItemReasoningSummary; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::models::ShellToolCallParams; -use codex_protocol::models::WebSearchAction; // A convenience extension trait for acquiring mutex locks where poisoning is // unrecoverable and should abort the program. This avoids scattered `.unwrap()` @@ -199,6 +195,7 @@ impl Codex { config.clone(), auth_manager.clone(), tx_event.clone(), + conversation_history.clone(), ) .await .map_err(|e| { @@ -361,6 +358,7 @@ impl Session { config: Arc, auth_manager: Arc, tx_event: Sender, + initial_history: InitialHistory, ) -> anyhow::Result<(Arc, TurnContext)> { let session_id = Uuid::new_v4(); let ConfigureSession { @@ -480,6 +478,12 @@ impl Session { }); // Dispatch the SessionConfiguredEvent first and then report any errors. + // 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)), + }; + let events = std::iter::once(Event { id: INITIAL_SUBMIT_ID.to_owned(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { @@ -487,6 +491,7 @@ impl Session { model, history_log_id, history_entry_count, + initial_messages, }), }) .chain(post_session_configured_error_events.into_iter()); @@ -537,7 +542,7 @@ impl Session { // TODO: Those items shouldn't be "user messages" IMO. Maybe developer messages. let mut conversation_items = Vec::::with_capacity(2); if let Some(user_instructions) = turn_context.user_instructions.as_deref() { - conversation_items.push(Prompt::format_user_instructions_message(user_instructions)); + conversation_items.push(UserInstructions::new(user_instructions.to_string()).into()); } conversation_items.push(ResponseItem::from(EnvironmentContext::new( Some(turn_context.cwd.clone()), @@ -552,6 +557,17 @@ impl Session { self.record_conversation_items(&items).await; } + /// build the initial messages vector for SessionConfigured by converting + /// ResponseItems into EventMsg. + fn build_initial_messages(&self, items: &[ResponseItem]) -> Vec { + items + .iter() + .flat_map(|item| { + map_response_item_to_event_messages(item, self.show_raw_agent_reasoning) + }) + .collect() + } + /// Sends the given event to the client and swallows the send event, if /// any, logging it as an error. pub(crate) async fn send_event(&self, event: Event) { @@ -1915,53 +1931,6 @@ async fn handle_response_item( ) -> CodexResult> { debug!(?item, "Output item"); let output = match item { - ResponseItem::Message { content, .. } => { - for item in content { - if let ContentItem::OutputText { text } = item { - let event = Event { - id: sub_id.to_string(), - msg: EventMsg::AgentMessage(AgentMessageEvent { message: text }), - }; - sess.tx_event.send(event).await.ok(); - } - } - None - } - ResponseItem::Reasoning { - id: _, - summary, - content, - encrypted_content: _, - } => { - for item in summary { - let text = match item { - ReasoningItemReasoningSummary::SummaryText { text } => text, - }; - let event = Event { - id: sub_id.to_string(), - msg: EventMsg::AgentReasoning(AgentReasoningEvent { text }), - }; - sess.tx_event.send(event).await.ok(); - } - if sess.show_raw_agent_reasoning - && let Some(content) = content - { - for item in content { - let text = match item { - ReasoningItemContent::ReasoningText { text } => text, - ReasoningItemContent::Text { text } => text, - }; - let event = Event { - id: sub_id.to_string(), - msg: EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { - text, - }), - }; - sess.tx_event.send(event).await.ok(); - } - } - None - } ResponseItem::FunctionCall { name, arguments, @@ -2051,12 +2020,14 @@ async fn handle_response_item( debug!("unexpected CustomToolCallOutput from stream"); None } - ResponseItem::WebSearchCall { id, action, .. } => { - if let WebSearchAction::Search { query } = action { - let call_id = id.unwrap_or_else(|| "".to_string()); + ResponseItem::Message { .. } + | ResponseItem::Reasoning { .. } + | ResponseItem::WebSearchCall { .. } => { + let msgs = map_response_item_to_event_messages(&item, sess.show_raw_agent_reasoning); + for msg in msgs { let event = Event { id: sub_id.to_string(), - msg: EventMsg::WebSearchEnd(WebSearchEndEvent { call_id, query }), + msg, }; sess.tx_event.send(event).await.ok(); } diff --git a/codex-rs/core/src/environment_context.rs b/codex-rs/core/src/environment_context.rs index b7ee862517..0a8b09d1ea 100644 --- a/codex-rs/core/src/environment_context.rs +++ b/codex-rs/core/src/environment_context.rs @@ -8,12 +8,10 @@ use crate::shell::Shell; use codex_protocol::config_types::SandboxMode; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG; +use codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; use std::path::PathBuf; -/// wraps environment context message in a tag for the model to parse more easily. -pub(crate) const ENVIRONMENT_CONTEXT_START: &str = ""; -pub(crate) const ENVIRONMENT_CONTEXT_END: &str = ""; - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, DeriveDisplay)] #[serde(rename_all = "kebab-case")] #[strum(serialize_all = "kebab-case")] @@ -79,7 +77,7 @@ impl EnvironmentContext { /// /// ``` pub fn serialize_to_xml(self) -> String { - let mut lines = vec![ENVIRONMENT_CONTEXT_START.to_string()]; + let mut lines = vec![ENVIRONMENT_CONTEXT_OPEN_TAG.to_string()]; if let Some(cwd) = self.cwd { lines.push(format!(" {}", cwd.to_string_lossy())); } @@ -101,7 +99,7 @@ impl EnvironmentContext { { lines.push(format!(" {shell_name}")); } - lines.push(ENVIRONMENT_CONTEXT_END.to_string()); + lines.push(ENVIRONMENT_CONTEXT_CLOSE_TAG.to_string()); lines.join("\n") } } diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs new file mode 100644 index 0000000000..c9ad8a16b8 --- /dev/null +++ b/codex-rs/core/src/event_mapping.rs @@ -0,0 +1,98 @@ +use crate::protocol::AgentMessageEvent; +use crate::protocol::AgentReasoningEvent; +use crate::protocol::AgentReasoningRawContentEvent; +use crate::protocol::EventMsg; +use crate::protocol::InputMessageKind; +use crate::protocol::UserMessageEvent; +use crate::protocol::WebSearchEndEvent; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ReasoningItemContent; +use codex_protocol::models::ReasoningItemReasoningSummary; +use codex_protocol::models::ResponseItem; +use codex_protocol::models::WebSearchAction; + +/// Convert a `ResponseItem` into zero or more `EventMsg` values that the UI can render. +/// +/// When `show_raw_agent_reasoning` is false, raw reasoning content events are omitted. +pub(crate) fn map_response_item_to_event_messages( + item: &ResponseItem, + show_raw_agent_reasoning: bool, +) -> Vec { + match item { + ResponseItem::Message { role, content, .. } => { + // Do not surface system messages as user events. + if role == "system" { + return Vec::new(); + } + + let events: Vec = content + .iter() + .filter_map(|content_item| match content_item { + ContentItem::OutputText { text } => { + Some(EventMsg::AgentMessage(AgentMessageEvent { + message: text.clone(), + })) + } + ContentItem::InputText { text } => { + let trimmed = text.trim_start(); + let kind = if trimmed.starts_with("") { + Some(InputMessageKind::EnvironmentContext) + } else if trimmed.starts_with("") { + Some(InputMessageKind::UserInstructions) + } else { + Some(InputMessageKind::Plain) + }; + Some(EventMsg::UserMessage(UserMessageEvent { + message: text.clone(), + kind, + })) + } + _ => None, + }) + .collect(); + events + } + + ResponseItem::Reasoning { + summary, content, .. + } => { + let mut events = Vec::new(); + for ReasoningItemReasoningSummary::SummaryText { text } in summary { + events.push(EventMsg::AgentReasoning(AgentReasoningEvent { + text: text.clone(), + })); + } + if let Some(items) = content.as_ref().filter(|_| show_raw_agent_reasoning) { + for c in items { + let text = match c { + ReasoningItemContent::ReasoningText { text } + | ReasoningItemContent::Text { text } => text, + }; + events.push(EventMsg::AgentReasoningRawContent( + AgentReasoningRawContentEvent { text: text.clone() }, + )); + } + } + events + } + + ResponseItem::WebSearchCall { id, action, .. } => match action { + WebSearchAction::Search { query } => { + let call_id = id.clone().unwrap_or_else(|| "".to_string()); + vec![EventMsg::WebSearchEnd(WebSearchEndEvent { + call_id, + query: query.clone(), + })] + } + WebSearchAction::Other => Vec::new(), + }, + + // Variants that require side effects are handled by higher layers and do not emit events here. + ResponseItem::FunctionCall { .. } + | ResponseItem::FunctionCallOutput { .. } + | ResponseItem::LocalShellCall { .. } + | ResponseItem::CustomToolCall { .. } + | ResponseItem::CustomToolCallOutput { .. } + | ResponseItem::Other => Vec::new(), + } +} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 9f731298f8..358c58de0c 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -34,12 +34,14 @@ mod mcp_tool_call; mod message_history; mod model_provider_info; pub mod parse_command; +mod user_instructions; pub use model_provider_info::BUILT_IN_OSS_MODEL_PROVIDER_ID; pub use model_provider_info::ModelProviderInfo; pub use model_provider_info::WireApi; pub use model_provider_info::built_in_model_providers; pub use model_provider_info::create_oss_provider_with_base_url; mod conversation_manager; +mod event_mapping; pub use conversation_manager::ConversationManager; pub use conversation_manager::NewConversation; // Re-export common auth types for workspace consumers @@ -59,7 +61,10 @@ pub mod spawn; pub mod terminal; mod tool_apply_patch; pub mod turn_diff_tracker; +pub use rollout::RolloutRecorder; +pub use rollout::list::ConversationItem; pub use rollout::list::ConversationsPage; +pub use rollout::list::Cursor; mod user_notification; pub mod util; pub use apply_patch::CODEX_APPLY_PATCH_ARG1; diff --git a/codex-rs/core/src/rollout/list.rs b/codex-rs/core/src/rollout/list.rs index 375140c47a..44f08e67a4 100644 --- a/codex-rs/core/src/rollout/list.rs +++ b/codex-rs/core/src/rollout/list.rs @@ -34,7 +34,8 @@ pub struct ConversationItem { } /// Hard cap to bound worst‑case work per request. -const MAX_SCAN_FILES: usize = 50_000; +const MAX_SCAN_FILES: usize = 10_000; +const HEAD_RECORD_LIMIT: usize = 10; /// Pagination cursor identifying a file by timestamp and UUID. #[derive(Debug, Clone, PartialEq, Eq)] @@ -166,7 +167,9 @@ async fn traverse_directories_for_paths( if items.len() == page_size { break 'outer; } - let head = read_first_jsonl_records(&path, 5).await.unwrap_or_default(); + let head = read_first_jsonl_records(&path, HEAD_RECORD_LIMIT) + .await + .unwrap_or_default(); items.push(ConversationItem { path, head }); } } diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index 68bcb439d7..10fb64be15 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -107,6 +107,7 @@ impl RolloutRecorder { "[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}")))?; diff --git a/codex-rs/core/src/user_instructions.rs b/codex-rs/core/src/user_instructions.rs new file mode 100644 index 0000000000..76054edcb3 --- /dev/null +++ b/codex-rs/core/src/user_instructions.rs @@ -0,0 +1,42 @@ +use serde::Deserialize; +use serde::Serialize; + +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::USER_INSTRUCTIONS_CLOSE_TAG; +use codex_protocol::protocol::USER_INSTRUCTIONS_OPEN_TAG; + +/// Wraps user instructions in a tag so the model can classify them easily. + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename = "user_instructions", rename_all = "snake_case")] +pub(crate) struct UserInstructions { + text: String, +} + +impl UserInstructions { + pub fn new>(text: T) -> Self { + Self { text: text.into() } + } + + /// Serializes the user instructions to an XML-like tagged block that starts + /// with so clients can classify it. + pub fn serialize_to_xml(self) -> String { + format!( + "{USER_INSTRUCTIONS_OPEN_TAG}\n\n{}\n\n{USER_INSTRUCTIONS_CLOSE_TAG}", + self.text + ) + } +} + +impl From for ResponseItem { + fn from(ui: UserInstructions) -> Self { + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: ui.serialize_to_xml(), + }], + } + } +} diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index dd152dcbb2..1bf7ef0c00 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -13,6 +13,7 @@ use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id; use core_test_support::wait_for_event; use serde_json::json; +use std::io::Write; use tempfile::TempDir; use wiremock::Mock; use wiremock::MockServer; @@ -108,6 +109,141 @@ fn write_auth_json( fake_jwt } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn resume_includes_initial_messages_and_sends_prior_items() { + if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + // Create a fake rollout session file with prior user + system + assistant messages. + 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"}) + ) + .unwrap(); + + // Prior item: user message (should be delivered) + let prior_user = codex_protocol::models::ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![codex_protocol::models::ContentItem::InputText { + text: "resumed user message".to_string(), + }], + }; + writeln!(f, "{}", serde_json::to_string(&prior_user).unwrap()).unwrap(); + + // Prior item: system message (excluded from API history) + let prior_system = codex_protocol::models::ResponseItem::Message { + id: None, + role: "system".to_string(), + content: vec![codex_protocol::models::ContentItem::OutputText { + text: "resumed system instruction".to_string(), + }], + }; + writeln!(f, "{}", serde_json::to_string(&prior_system).unwrap()).unwrap(); + + // Prior item: assistant message + let prior_item = codex_protocol::models::ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![codex_protocol::models::ContentItem::OutputText { + text: "resumed assistant message".to_string(), + }], + }; + writeln!(f, "{}", serde_json::to_string(&prior_item).unwrap()).unwrap(); + drop(f); + + // Mock server that will receive the resumed request + let server = MockServer::start().await; + let first = ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_raw(sse_completed("resp1"), "text/event-stream"); + Mock::given(method("POST")) + .and(path("/v1/responses")) + .respond_with(first) + .expect(1) + .mount(&server) + .await; + + // Configure Codex to resume from our file + let model_provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + let codex_home = TempDir::new().unwrap(); + let mut config = load_default_config_for_test(&codex_home); + config.model_provider = model_provider; + config.experimental_resume = Some(session_path.clone()); + // Also configure user instructions to ensure they are NOT delivered on resume. + config.user_instructions = Some("be nice".to_string()); + + let conversation_manager = + ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let NewConversation { + conversation: codex, + session_configured, + .. + } = conversation_manager + .new_conversation(config) + .await + .expect("create new conversation"); + + // 1) Assert initial_messages contains the prior user + assistant messages as EventMsg entries + let initial_msgs = session_configured + .initial_messages + .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!([ + { "type": "user_message", "message": "resumed user message", "kind": "plain" }, + { "type": "agent_message", "message": "resumed assistant message" } + ]); + assert_eq!(initial_json, expected_initial_json); + + // 2) Submit new input; the request body must include the prior item followed by the new user input. + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: "hello".into(), + }], + }) + .await + .unwrap(); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + let request = &server.received_requests().await.unwrap()[0]; + let request_body = request.body_json::().unwrap(); + let expected_input = serde_json::json!([ + { + "type": "message", + "id": null, + "role": "user", + "content": [{ "type": "input_text", "text": "resumed user message" }] + }, + { + "type": "message", + "id": null, + "role": "assistant", + "content": [{ "type": "output_text", "text": "resumed assistant message" }] + }, + { + "type": "message", + "id": null, + "role": "user", + "content": [{ "type": "input_text", "text": "hello" }] + } + ]); + assert_eq!(request_body["input"], expected_input); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn includes_session_id_and_model_headers_in_request() { if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index cc633277cd..0ee60b93ff 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -515,6 +515,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { model, history_log_id: _, history_entry_count: _, + initial_messages: _, } = session_configured_event; ts_println!( @@ -551,6 +552,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { }, EventMsg::ShutdownComplete => return CodexStatus::Shutdown, EventMsg::ConversationHistory(_) => {} + EventMsg::UserMessage(_) => {} } CodexStatus::Running } diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index b297dadd9b..48d520b5d6 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -279,6 +279,7 @@ async fn run_codex_tool_session_inner( | EventMsg::PlanUpdate(_) | EventMsg::TurnAborted(_) | EventMsg::ConversationHistory(_) + | EventMsg::UserMessage(_) | EventMsg::ShutdownComplete => { // For now, we do not do anything extra for these // events. Note that diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index abfc542e24..c26c8e3dec 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -260,6 +260,7 @@ mod tests { model: "gpt-4o".to_string(), history_log_id: 1, history_entry_count: 1000, + initial_messages: None, }), }; @@ -289,6 +290,7 @@ mod tests { model: "gpt-4o".to_string(), history_log_id: 1, history_entry_count: 1000, + initial_messages: None, }; let event = Event { id: "1".to_string(), diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 6cdfd933b1..71b517092a 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -309,6 +309,8 @@ impl std::ops::Deref for FunctionCallOutputPayload { } } +// (Moved event mapping logic into codex-core to avoid coupling protocol to UI-facing events.) + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 315ec09f74..7bcd818a9a 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -27,6 +27,13 @@ use crate::models::ResponseItem; use crate::parse_command::ParsedCommand; use crate::plan_tool::UpdatePlanArgs; +/// Open/close tags for special user-input blocks. Used across crates to avoid +/// duplicated hardcoded strings. +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 = ""; + /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Submission { @@ -417,6 +424,9 @@ pub enum EventMsg { /// Agent text output message AgentMessage(AgentMessageEvent), + /// User/system input message (what was sent to the model) + UserMessage(UserMessageEvent), + /// Agent text output delta message AgentMessageDelta(AgentMessageDeltaEvent), @@ -610,6 +620,47 @@ pub struct AgentMessageEvent { pub message: String, } +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum InputMessageKind { + /// Plain user text (default) + Plain, + /// XML-wrapped user instructions (...) + UserInstructions, + /// XML-wrapped environment context (...) + EnvironmentContext, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UserMessageEvent { + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, +} + +impl From<(T, U)> for InputMessageKind +where + T: AsRef, + U: AsRef, +{ + fn from(value: (T, U)) -> Self { + let (_role, message) = value; + let message = message.as_ref(); + let trimmed = message.trim(); + if trimmed.starts_with(ENVIRONMENT_CONTEXT_OPEN_TAG) + && trimmed.ends_with(ENVIRONMENT_CONTEXT_CLOSE_TAG) + { + InputMessageKind::EnvironmentContext + } else if trimmed.starts_with(USER_INSTRUCTIONS_OPEN_TAG) + && trimmed.ends_with(USER_INSTRUCTIONS_CLOSE_TAG) + { + InputMessageKind::UserInstructions + } else { + InputMessageKind::Plain + } + } +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct AgentMessageDeltaEvent { pub delta: String, @@ -839,6 +890,11 @@ pub struct SessionConfiguredEvent { /// Current number of entries in the history log. pub history_entry_count: usize, + + /// Optional initial messages (as events) for resumed sessions. + /// When present, UIs can use these to seed the history. + #[serde(skip_serializing_if = "Option::is_none")] + pub initial_messages: Option>, } /// User's decision in response to an ExecApprovalRequest. @@ -914,6 +970,7 @@ mod tests { model: "codex-mini-latest".to_string(), history_log_id: 0, history_entry_count: 0, + initial_messages: None, }), }; let serialized = serde_json::to_string(&event).unwrap(); diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 439dbcdfab..40a7f20886 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -4,6 +4,7 @@ use crate::app_event_sender::AppEventSender; use crate::chatwidget::ChatWidget; use crate::file_search::FileSearchManager; use crate::pager_overlay::Overlay; +use crate::resume_picker::ResumeSelection; use crate::tui; use crate::tui::TuiEvent; use codex_ansi_escape::ansi_escape_line; @@ -12,6 +13,7 @@ use codex_core::ConversationManager; use codex_core::config::Config; use codex_core::protocol::TokenUsage; use color_eyre::eyre::Result; +use color_eyre::eyre::WrapErr; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -61,6 +63,7 @@ impl App { config: Config, initial_prompt: Option, initial_images: Vec, + resume_selection: ResumeSelection, ) -> Result { use tokio_stream::StreamExt; let (app_event_tx, mut app_event_rx) = unbounded_channel(); @@ -70,15 +73,44 @@ impl App { let enhanced_keys_supported = supports_keyboard_enhancement().unwrap_or(false); - let chat_widget = ChatWidget::new( - config.clone(), - conversation_manager.clone(), - tui.frame_requester(), - app_event_tx.clone(), - initial_prompt, - initial_images, - enhanced_keys_supported, - ); + let chat_widget = match resume_selection { + ResumeSelection::StartFresh | ResumeSelection::Exit => { + let init = crate::chatwidget::ChatWidgetInit { + config: config.clone(), + frame_requester: tui.frame_requester(), + app_event_tx: app_event_tx.clone(), + initial_prompt: initial_prompt.clone(), + initial_images: initial_images.clone(), + enhanced_keys_supported, + }; + ChatWidget::new(init, conversation_manager.clone()) + } + ResumeSelection::Resume(path) => { + let resumed = conversation_manager + .resume_conversation_from_rollout( + config.clone(), + path.clone(), + auth_manager.clone(), + ) + .await + .wrap_err_with(|| { + format!("Failed to resume session from {}", path.display()) + })?; + let init = crate::chatwidget::ChatWidgetInit { + config: config.clone(), + frame_requester: tui.frame_requester(), + app_event_tx: app_event_tx.clone(), + initial_prompt: initial_prompt.clone(), + initial_images: initial_images.clone(), + enhanced_keys_supported, + }; + ChatWidget::new_from_existing( + init, + resumed.conversation, + resumed.session_configured, + ) + } + }; let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); @@ -168,15 +200,15 @@ impl App { async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { match event { AppEvent::NewSession => { - self.chat_widget = ChatWidget::new( - self.config.clone(), - self.server.clone(), - tui.frame_requester(), - self.app_event_tx.clone(), - None, - Vec::new(), - self.enhanced_keys_supported, - ); + let init = crate::chatwidget::ChatWidgetInit { + config: self.config.clone(), + frame_requester: tui.frame_requester(), + app_event_tx: self.app_event_tx.clone(), + initial_prompt: None, + initial_images: Vec::new(), + enhanced_keys_supported: self.enhanced_keys_supported, + }; + self.chat_widget = ChatWidget::new(init, self.server.clone()); tui.frame_requester().schedule_frame(); } AppEvent::InsertHistoryCell(cell) => { diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 9c1251c38f..c716d6c4ab 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -319,14 +319,16 @@ impl App { ) { let conv = new_conv.conversation; let session_configured = new_conv.session_configured; - self.chat_widget = crate::chatwidget::ChatWidget::new_from_existing( - cfg, - conv, - session_configured, - tui.frame_requester(), - self.app_event_tx.clone(), - self.enhanced_keys_supported, - ); + let init = crate::chatwidget::ChatWidgetInit { + config: cfg, + frame_requester: tui.frame_requester(), + app_event_tx: self.app_event_tx.clone(), + initial_prompt: None, + initial_images: Vec::new(), + enhanced_keys_supported: self.enhanced_keys_supported, + }; + self.chat_widget = + crate::chatwidget::ChatWidget::new_from_existing(init, conv, session_configured); // Trim transcript up to the selected user message and re-render it. self.trim_transcript_for_backtrack(drop_count); self.render_transcript_once(tui); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f86658a8aa..7514921136 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -19,6 +19,7 @@ use codex_core::protocol::ExecApprovalRequestEvent; use codex_core::protocol::ExecCommandBeginEvent; use codex_core::protocol::ExecCommandEndEvent; use codex_core::protocol::InputItem; +use codex_core::protocol::InputMessageKind; use codex_core::protocol::ListCustomPromptsResponseEvent; use codex_core::protocol::McpListToolsResponseEvent; use codex_core::protocol::McpToolCallBeginEvent; @@ -30,6 +31,7 @@ use codex_core::protocol::TaskCompleteEvent; use codex_core::protocol::TokenUsage; use codex_core::protocol::TurnAbortReason; use codex_core::protocol::TurnDiffEvent; +use codex_core::protocol::UserMessageEvent; use codex_core::protocol::WebSearchBeginEvent; use codex_core::protocol::WebSearchEndEvent; use codex_protocol::parse_command::ParsedCommand; @@ -89,6 +91,16 @@ struct RunningCommand { parsed_cmd: Vec, } +/// Common initialization parameters shared by all `ChatWidget` constructors. +pub(crate) struct ChatWidgetInit { + pub(crate) config: Config, + pub(crate) frame_requester: FrameRequester, + pub(crate) app_event_tx: AppEventSender, + pub(crate) initial_prompt: Option, + pub(crate) initial_images: Vec, + pub(crate) enhanced_keys_supported: bool, +} + pub(crate) struct ChatWidget { app_event_tx: AppEventSender, codex_op_tx: UnboundedSender, @@ -112,6 +124,9 @@ pub(crate) struct ChatWidget { frame_requester: FrameRequester, // Whether to include the initial welcome banner on session configured show_welcome_banner: bool, + // When resuming an existing session (selected via resume picker), avoid an + // immediate redraw on SessionConfigured to prevent a gratuitous UI flicker. + suppress_session_configured_redraw: bool, // User messages queued while a turn is in progress queued_user_messages: VecDeque, } @@ -148,6 +163,10 @@ impl ChatWidget { self.bottom_pane .set_history_metadata(event.history_log_id, event.history_entry_count); self.session_id = Some(event.session_id); + let initial_messages = event.initial_messages.clone(); + if let Some(messages) = initial_messages { + self.replay_initial_messages(messages); + } self.add_to_history(history_cell::new_session_info( &self.config, event, @@ -158,7 +177,9 @@ impl ChatWidget { if let Some(user_message) = self.initial_user_message.take() { self.submit_user_message(user_message); } - self.request_redraw(); + if !self.suppress_session_configured_redraw { + self.request_redraw(); + } } fn on_agent_message(&mut self, message: String) { @@ -602,14 +623,17 @@ impl ChatWidget { } pub(crate) fn new( - config: Config, + common: ChatWidgetInit, conversation_manager: Arc, - frame_requester: FrameRequester, - app_event_tx: AppEventSender, - initial_prompt: Option, - initial_images: Vec, - enhanced_keys_supported: bool, ) -> Self { + let ChatWidgetInit { + config, + frame_requester, + app_event_tx, + initial_prompt, + initial_images, + enhanced_keys_supported, + } = common; let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), conversation_manager); @@ -643,18 +667,24 @@ impl ChatWidget { session_id: None, queued_user_messages: VecDeque::new(), show_welcome_banner: true, + suppress_session_configured_redraw: false, } } /// Create a ChatWidget attached to an existing conversation (e.g., a fork). pub(crate) fn new_from_existing( - config: Config, + common: ChatWidgetInit, conversation: std::sync::Arc, session_configured: codex_core::protocol::SessionConfiguredEvent, - frame_requester: FrameRequester, - app_event_tx: AppEventSender, - enhanced_keys_supported: bool, ) -> Self { + let ChatWidgetInit { + config, + frame_requester, + app_event_tx, + initial_prompt, + initial_images, + enhanced_keys_supported, + } = common; let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); @@ -675,7 +705,10 @@ impl ChatWidget { }), active_exec_cell: None, config: config.clone(), - initial_user_message: None, + initial_user_message: create_initial_user_message( + initial_prompt.unwrap_or_default(), + initial_images, + ), total_token_usage: TokenUsage::default(), last_token_usage: TokenUsage::default(), stream: StreamController::new(config), @@ -687,6 +720,7 @@ impl ChatWidget { session_id: None, queued_user_messages: VecDeque::new(), show_welcome_banner: false, + suppress_session_configured_redraw: true, } } @@ -950,9 +984,32 @@ impl ChatWidget { } } + /// Replay a subset of initial events into the UI to seed the transcript when + /// resuming an existing session. This approximates the live event flow and + /// is intentionally conservative: only safe-to-replay items are rendered to + /// avoid triggering side effects. Event ids are passed as `None` to + /// distinguish replayed events from live ones. + fn replay_initial_messages(&mut self, events: Vec) { + for msg in events { + if matches!(msg, EventMsg::SessionConfigured(_)) { + continue; + } + // `id: None` indicates a synthetic/fake id coming from replay. + self.dispatch_event_msg(None, msg, true); + } + } + pub(crate) fn handle_codex_event(&mut self, event: Event) { let Event { id, msg } = event; + self.dispatch_event_msg(Some(id), msg, false); + } + /// Dispatch a protocol `EventMsg` to the appropriate handler. + /// + /// `id` is `Some` for live events and `None` for replayed events from + /// `replay_initial_messages()`. Callers should treat `None` as a "fake" id + /// that must not be used to correlate follow-up actions. + fn dispatch_event_msg(&mut self, id: Option, msg: EventMsg, from_replay: bool) { match msg { EventMsg::AgentMessageDelta(_) | EventMsg::AgentReasoningDelta(_) @@ -990,8 +1047,13 @@ impl ChatWidget { } }, EventMsg::PlanUpdate(update) => self.on_plan_update(update), - EventMsg::ExecApprovalRequest(ev) => self.on_exec_approval_request(id, ev), - EventMsg::ApplyPatchApprovalRequest(ev) => self.on_apply_patch_approval_request(id, ev), + EventMsg::ExecApprovalRequest(ev) => { + // For replayed events, synthesize an empty id (these should not occur). + self.on_exec_approval_request(id.clone().unwrap_or_default(), ev) + } + EventMsg::ApplyPatchApprovalRequest(ev) => { + self.on_apply_patch_approval_request(id.clone().unwrap_or_default(), ev) + } EventMsg::ExecCommandBegin(ev) => self.on_exec_command_begin(ev), EventMsg::ExecCommandOutputDelta(delta) => self.on_exec_command_output_delta(delta), EventMsg::PatchApplyBegin(ev) => self.on_patch_apply_begin(ev), @@ -1010,14 +1072,33 @@ impl ChatWidget { self.on_background_event(message) } EventMsg::StreamError(StreamErrorEvent { message }) => self.on_stream_error(message), + EventMsg::UserMessage(ev) => { + if from_replay { + self.on_user_message_event(ev); + } + } EventMsg::ConversationHistory(ev) => { - // Forward to App so it can process backtrack flows. self.app_event_tx .send(crate::app_event::AppEvent::ConversationHistory(ev)); } } } + fn on_user_message_event(&mut self, event: UserMessageEvent) { + match event.kind { + Some(InputMessageKind::EnvironmentContext) + | Some(InputMessageKind::UserInstructions) => { + // Skip XML‑wrapped context blocks in the transcript. + } + Some(InputMessageKind::Plain) | None => { + let message = event.message.trim(); + if !message.is_empty() { + self.add_to_history(history_cell::new_user_prompt(message.to_string())); + } + } + } + } + fn request_redraw(&mut self) { self.frame_requester.schedule_frame(); } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 3ca1b84142..1b1a7832b3 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -19,6 +19,7 @@ use codex_core::protocol::ExecApprovalRequestEvent; use codex_core::protocol::ExecCommandBeginEvent; use codex_core::protocol::ExecCommandEndEvent; use codex_core::protocol::FileChange; +use codex_core::protocol::InputMessageKind; use codex_core::protocol::PatchApplyBeginEvent; use codex_core::protocol::PatchApplyEndEvent; use codex_core::protocol::StreamErrorEvent; @@ -34,6 +35,7 @@ use std::io::BufRead; use std::io::BufReader; use std::path::PathBuf; use tokio::sync::mpsc::unbounded_channel; +use uuid::Uuid; fn test_config() -> Config { // Use base defaults to avoid depending on host state. @@ -126,6 +128,53 @@ fn final_answer_without_newline_is_flushed_immediately() { ); } +#[test] +fn resumed_initial_messages_render_history() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(); + + let configured = codex_core::protocol::SessionConfiguredEvent { + session_id: Uuid::nil(), + model: "test-model".to_string(), + history_log_id: 0, + history_entry_count: 0, + initial_messages: Some(vec![ + EventMsg::UserMessage(codex_core::protocol::UserMessageEvent { + message: "hello from user".to_string(), + kind: Some(InputMessageKind::Plain), + }), + EventMsg::AgentMessage(AgentMessageEvent { + message: "assistant reply".to_string(), + }), + ]), + }; + + chat.handle_codex_event(Event { + id: "initial".into(), + msg: EventMsg::SessionConfigured(configured), + }); + + let cells = drain_insert_history(&mut rx); + let mut merged_lines = Vec::new(); + for lines in cells { + let text = lines + .iter() + .flat_map(|line| line.spans.iter()) + .map(|span| span.content.clone()) + .collect::(); + merged_lines.push(text); + } + + let text_blob = merged_lines.join("\n"); + assert!( + text_blob.contains("hello from user"), + "expected replayed user message", + ); + assert!( + text_blob.contains("assistant reply"), + "expected replayed agent message", + ); +} + #[tokio::test(flavor = "current_thread")] async fn helpers_are_available_and_do_not_panic() { let (tx_raw, _rx) = unbounded_channel::(); @@ -134,15 +183,15 @@ async fn helpers_are_available_and_do_not_panic() { let conversation_manager = Arc::new(ConversationManager::with_auth(CodexAuth::from_api_key( "test", ))); - let mut w = ChatWidget::new( - cfg, - conversation_manager, - crate::tui::FrameRequester::test_dummy(), - tx, - None, - Vec::new(), - false, - ); + let init = ChatWidgetInit { + config: cfg, + frame_requester: crate::tui::FrameRequester::test_dummy(), + app_event_tx: tx, + initial_prompt: None, + initial_images: Vec::new(), + enhanced_keys_supported: false, + }; + let mut w = ChatWidget::new(init, conversation_manager); // Basic construction sanity. let _ = &mut w; } @@ -184,6 +233,7 @@ fn make_chatwidget_manual() -> ( frame_requester: crate::tui::FrameRequester::test_dummy(), show_welcome_banner: true, queued_user_messages: std::collections::VecDeque::new(), + suppress_session_configured_redraw: false, }; (widget, rx, op_rx) } diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index 8eb6d6b896..f419274af0 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -13,6 +13,30 @@ pub struct Cli { #[arg(long = "image", short = 'i', value_name = "FILE", value_delimiter = ',', num_args = 1..)] pub images: Vec, + /// Open an interactive picker to resume a previous session recorded on disk + /// instead of starting a new one. + /// + /// Notes: + /// - Mutually exclusive with `--continue`. + /// - The picker displays recent sessions and a preview of the first real user + /// message to help you select the right one. + #[arg(long = "resume", default_value_t = false, conflicts_with = "continue")] + pub resume: bool, + + /// Continue the most recent conversation without showing the picker. + /// + /// Notes: + /// - Mutually exclusive with `--resume`. + /// - If no recorded sessions are found, this behaves like starting fresh. + /// - Equivalent to picking the newest item in the resume picker. + #[arg( + id = "continue", + long = "continue", + default_value_t = false, + conflicts_with = "resume" + )] + pub r#continue: bool, + /// Model the agent should use. #[arg(long, short = 'm')] pub model: Option, diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 50f3e3154f..7295c8afa4 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -646,6 +646,7 @@ pub(crate) fn new_session_info( session_id: _, history_log_id: _, history_entry_count: _, + initial_messages: _, } = event; if is_first_event { let cwd_str = match relativize_to_home(&config.cwd) { diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 8899b0e139..c05efb678c 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -7,6 +7,7 @@ use app::App; use codex_core::AuthManager; use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID; use codex_core::CodexAuth; +use codex_core::RolloutRecorder; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; @@ -47,6 +48,7 @@ mod markdown_stream; pub mod onboarding; mod pager_overlay; mod render; +mod resume_picker; mod session_log; mod shimmer; mod slash_command; @@ -299,7 +301,13 @@ async fn run_ratatui_app( // Initialize high-fidelity session event logging if enabled. session_log::maybe_init(&config); - let Cli { prompt, images, .. } = cli; + let Cli { + prompt, + images, + resume, + r#continue, + .. + } = cli; let auth_manager = AuthManager::shared( config.codex_home.clone(), @@ -327,7 +335,37 @@ async fn run_ratatui_app( } } - let app_result = App::run(&mut tui, auth_manager, config, prompt, images).await; + let resume_selection = if r#continue { + match RolloutRecorder::list_conversations(&config.codex_home, 1, None).await { + Ok(page) => page + .items + .first() + .map(|it| resume_picker::ResumeSelection::Resume(it.path.clone())) + .unwrap_or(resume_picker::ResumeSelection::StartFresh), + Err(_) => resume_picker::ResumeSelection::StartFresh, + } + } else if resume { + match resume_picker::run_resume_picker(&mut tui, &config.codex_home).await? { + resume_picker::ResumeSelection::Exit => { + restore(); + session_log::log_session_end(); + return Ok(codex_core::protocol::TokenUsage::default()); + } + other => other, + } + } else { + resume_picker::ResumeSelection::StartFresh + }; + + let app_result = App::run( + &mut tui, + auth_manager, + config, + prompt, + images, + resume_selection, + ) + .await; restore(); // Mark the end of the recorded session. diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs new file mode 100644 index 0000000000..1c4afea8db --- /dev/null +++ b/codex-rs/tui/src/resume_picker.rs @@ -0,0 +1,504 @@ +use std::path::Path; +use std::path::PathBuf; + +use chrono::DateTime; +use chrono::TimeZone; +use chrono::Utc; +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; +use crossterm::event::KeyEventKind; +use ratatui::layout::Constraint; +use ratatui::layout::Layout; +use ratatui::layout::Rect; +use ratatui::style::Stylize as _; +use ratatui::text::Line; +use tokio_stream::StreamExt; + +use crate::text_formatting::truncate_text; +use crate::tui::FrameRequester; +use crate::tui::Tui; +use crate::tui::TuiEvent; + +const PAGE_SIZE: usize = 25; + +#[derive(Debug, Clone)] +pub enum ResumeSelection { + StartFresh, + Resume(PathBuf), + Exit, +} + +/// Interactive session picker that lists recorded rollout files with simple +/// search and pagination. Shows the first user input as the preview, relative +/// time (e.g., "5 seconds ago"), and the absolute path. +pub async fn run_resume_picker(tui: &mut Tui, codex_home: &Path) -> Result { + let alt = AltScreenGuard::enter(tui); + let mut state = PickerState::new(codex_home.to_path_buf(), alt.tui.frame_requester()); + state.load_page(None).await?; + state.request_frame(); + + let mut events = alt.tui.event_stream(); + while let Some(ev) = events.next().await { + match ev { + TuiEvent::Key(key) => { + if matches!(key.kind, KeyEventKind::Release) { + continue; + } + if let Some(sel) = state.handle_key(key).await? { + return Ok(sel); + } + } + TuiEvent::Draw => { + draw_picker(alt.tui, &state)?; + } + // Ignore paste and attach-image in picker + _ => {} + } + } + + // Fallback – treat as cancel/new + Ok(ResumeSelection::StartFresh) +} + +/// RAII guard that ensures we leave the alt-screen on scope exit. +struct AltScreenGuard<'a> { + tui: &'a mut Tui, +} + +impl<'a> AltScreenGuard<'a> { + fn enter(tui: &'a mut Tui) -> Self { + let _ = tui.enter_alt_screen(); + Self { tui } + } +} + +impl Drop for AltScreenGuard<'_> { + fn drop(&mut self) { + let _ = self.tui.leave_alt_screen(); + } +} + +struct PickerState { + codex_home: PathBuf, + requester: FrameRequester, + // pagination + pagination: Pagination, + // data + all_rows: Vec, // unfiltered rows for current page + filtered_rows: Vec, + selected: usize, + // search + query: String, +} + +#[derive(Debug, Clone)] +struct Pagination { + current_anchor: Option, + backstack: Vec>, // track previous anchors for ←/a + next_cursor: Option, + page_index: usize, +} + +#[derive(Clone)] +struct Row { + path: PathBuf, + preview: String, + ts: Option>, +} + +impl PickerState { + fn new(codex_home: PathBuf, requester: FrameRequester) -> Self { + Self { + codex_home, + requester, + pagination: Pagination { + current_anchor: None, + backstack: vec![None], + next_cursor: None, + page_index: 0, + }, + all_rows: Vec::new(), + filtered_rows: Vec::new(), + selected: 0, + query: String::new(), + } + } + + fn request_frame(&self) { + self.requester.schedule_frame(); + } + + async fn handle_key(&mut self, key: KeyEvent) -> Result> { + match key.code { + KeyCode::Esc => return Ok(Some(ResumeSelection::StartFresh)), + KeyCode::Char('c') + if key + .modifiers + .contains(crossterm::event::KeyModifiers::CONTROL) => + { + return Ok(Some(ResumeSelection::Exit)); + } + KeyCode::Enter => { + if let Some(row) = self.filtered_rows.get(self.selected) { + return Ok(Some(ResumeSelection::Resume(row.path.clone()))); + } + } + KeyCode::Up => { + if self.selected > 0 { + self.selected -= 1; + } + self.request_frame(); + } + KeyCode::Down => { + if self.selected + 1 < self.filtered_rows.len() { + self.selected += 1; + } + self.request_frame(); + } + KeyCode::Left | KeyCode::Char('a') => { + self.prev_page().await?; + } + KeyCode::Right | KeyCode::Char('d') => { + self.next_page().await?; + } + KeyCode::Backspace => { + self.query.pop(); + self.apply_filter(); + } + KeyCode::Char(c) => { + // basic text input for search + if !key + .modifiers + .contains(crossterm::event::KeyModifiers::CONTROL) + && !key.modifiers.contains(crossterm::event::KeyModifiers::ALT) + { + self.query.push(c); + self.apply_filter(); + } + } + _ => {} + } + Ok(None) + } + + async fn prev_page(&mut self) -> Result<()> { + if self.pagination.page_index == 0 { + return Ok(()); + } + // current_anchor points to the page we just loaded; backstack[page_index-1] is the anchor to reload + if self.pagination.page_index > 0 { + self.pagination.page_index -= 1; + let anchor = self + .pagination + .backstack + .get(self.pagination.page_index) + .cloned() + .flatten(); + self.pagination.current_anchor = anchor.clone(); + self.load_page(anchor.as_ref()).await?; + } + Ok(()) + } + + async fn next_page(&mut self) -> Result<()> { + if let Some(next) = self.pagination.next_cursor.clone() { + // Record the anchor for the page we are moving to at index new_index + let new_index = self.pagination.page_index + 1; + if self.pagination.backstack.len() <= new_index { + self.pagination.backstack.resize(new_index + 1, None); + } + self.pagination.backstack[new_index] = Some(next.clone()); + self.pagination.current_anchor = Some(next.clone()); + self.pagination.page_index = new_index; + let anchor = self.pagination.current_anchor.clone(); + self.load_page(anchor.as_ref()).await?; + } + Ok(()) + } + + async fn load_page(&mut self, anchor: Option<&Cursor>) -> Result<()> { + let page = RolloutRecorder::list_conversations(&self.codex_home, PAGE_SIZE, anchor).await?; + self.pagination.next_cursor = page.next_cursor.clone(); + self.all_rows = to_rows(page); + self.apply_filter(); + // reset selection on new page + self.selected = 0; + Ok(()) + } + + fn apply_filter(&mut self) { + if self.query.is_empty() { + self.filtered_rows = self.all_rows.clone(); + } else { + let q = self.query.to_lowercase(); + self.filtered_rows = self + .all_rows + .iter() + .filter(|r| r.preview.to_lowercase().contains(&q)) + .cloned() + .collect(); + } + if self.selected >= self.filtered_rows.len() { + self.selected = self.filtered_rows.len().saturating_sub(1); + } + self.request_frame(); + } +} + +fn to_rows(page: ConversationsPage) -> Vec { + use std::cmp::Reverse; + let mut rows: Vec = page + .items + .into_iter() + .filter_map(|it| head_to_row(&it)) + .collect(); + // Ensure newest-first ordering within the page by timestamp when available. + let epoch = Utc.timestamp_opt(0, 0).single().unwrap_or_else(Utc::now); + rows.sort_by_key(|r| Reverse(r.ts.unwrap_or(epoch))); + rows +} + +fn head_to_row(item: &ConversationItem) -> Option { + let mut ts: Option> = None; + if let Some(first) = item.head.first() + && let Some(t) = first.get("timestamp").and_then(|v| v.as_str()) + && let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(t) + { + ts = Some(parsed.with_timezone(&Utc)); + } + + let preview = find_first_user_text(&item.head)?; + let preview = preview.trim().to_string(); + if preview.is_empty() { + return None; + } + Some(Row { + path: item.path.clone(), + preview, + ts, + }) +} + +/// 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()); + } + } + } + } + } + None +} + +fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> { + // Render full-screen overlay + let height = tui.terminal.size()?.height; + tui.draw(height, |frame| { + let area = frame.area(); + let [header, search, list, hint] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(area.height.saturating_sub(3)), + Constraint::Length(1), + ]) + .areas(area); + + // Header + frame.render_widget_ref( + Line::from(vec!["Resume a previous session".bold().cyan()]), + header, + ); + + // Search line + let q = if state.query.is_empty() { + "Type to search".dim().to_string() + } else { + format!("Search: {}", state.query) + }; + frame.render_widget_ref(Line::from(q), search); + + // List + render_list(frame, list, state); + + // Hint line + let hint_line: Line = vec![ + "Enter".bold(), + " to resume ".into(), + "Esc".bold(), + " to start new ".into(), + "Ctrl+C".into(), + " to quit ".dim(), + "←/a".into(), + " prev ".dim(), + "→/d".into(), + " next".dim(), + ] + .into(); + frame.render_widget_ref(hint_line, hint); + }) +} + +fn render_list(frame: &mut crate::custom_terminal::Frame, area: Rect, state: &PickerState) { + let rows = &state.filtered_rows; + if rows.is_empty() { + frame.render_widget_ref(Line::from("No sessions found".italic().dim()), area); + return; + } + + // Compute how many rows fit (1 line per item) + let capacity = area.height as usize; + let start = state.selected.saturating_sub(capacity.saturating_sub(1)); + let visible = &rows[start..rows.len().min(start + capacity)]; + + let mut y = area.y; + for (idx, row) in visible.iter().enumerate() { + let is_sel = start + idx == state.selected; + let marker = if is_sel { "> ".bold() } else { " ".into() }; + let ts = row + .ts + .map(human_time_ago) + .unwrap_or_else(|| "".to_string()) + .dim(); + let max_cols = area.width.saturating_sub(6) as usize; + let preview = truncate_text(&row.preview, max_cols); + + let line: Line = vec![marker, ts, " ".into(), preview.into()].into(); + let rect = Rect::new(area.x, y, area.width, 1); + frame.render_widget_ref(line, rect); + y = y.saturating_add(1); + } +} + +fn human_time_ago(ts: DateTime) -> String { + let now = Utc::now(); + let delta = now - ts; + let secs = delta.num_seconds(); + if secs < 60 { + let n = secs.max(0); + if n == 1 { + format!("{n} second ago") + } else { + format!("{n} seconds ago") + } + } else if secs < 60 * 60 { + let m = secs / 60; + if m == 1 { + format!("{m} minute ago") + } else { + format!("{m} minutes ago") + } + } else if secs < 60 * 60 * 24 { + let h = secs / 3600; + if h == 1 { + format!("{h} hour ago") + } else { + format!("{h} hours ago") + } + } else { + let d = secs / (60 * 60 * 24); + if d == 1 { + format!("{d} day ago") + } else { + format!("{d} days ago") + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn head_with_ts_and_user_text(ts: &str, texts: &[&str]) -> Vec { + vec![ + json!({ "timestamp": ts }), + json!({ + "type": "message", + "role": "user", + "content": texts + .iter() + .map(|t| json!({ "type": "input_text", "text": *t })) + .collect::>() + }), + ] + } + + #[test] + fn skips_user_instructions_and_env_context() { + let head = vec![ + json!({ "timestamp": "2025-01-01T00:00:00Z" }), + json!({ + "type": "message", + "role": "user", + "content": [ + { "type": "input_text", "text": "hi" } + ] + }), + json!({ + "type": "message", + "role": "user", + "content": [ + { "type": "input_text", "text": "cwd" } + ] + }), + json!({ + "type": "message", + "role": "user", + "content": [ { "type": "input_text", "text": "real question" } ] + }), + ]; + let first = find_first_user_text(&head); + assert_eq!(first.as_deref(), Some("real question")); + } + + #[test] + fn to_rows_sorts_descending_by_timestamp() { + // Construct two items with different timestamps and real user text. + let a = ConversationItem { + path: PathBuf::from("/tmp/a.jsonl"), + head: head_with_ts_and_user_text("2025-01-01T00:00:00Z", &["A"]), + }; + let b = ConversationItem { + path: PathBuf::from("/tmp/b.jsonl"), + head: head_with_ts_and_user_text("2025-01-02T00:00:00Z", &["B"]), + }; + let rows = to_rows(ConversationsPage { + items: vec![a, b], + next_cursor: None, + num_scanned_files: 0, + reached_scan_cap: false, + }); + assert_eq!(rows.len(), 2); + // Expect the newer timestamp (B) first + assert!(rows[0].preview.contains('B')); + assert!(rows[1].preview.contains('A')); + } +} diff --git a/docs/getting-started.md b/docs/getting-started.md index 691caae0e7..cb9fc736ac 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -10,6 +10,18 @@ Key flags: `--model/-m`, `--ask-for-approval/-a`. +Resume options: + +- `--resume`: open an interactive picker of recent sessions (shows a preview of the first real user message). Conflicts with `--continue`. +- `--continue`: resume the most recent session without showing the picker (falls back to starting fresh if none exist). Conflicts with `--resume`. + +Examples: + +```shell +codex --resume +codex --continue +``` + ### Running with a prompt as input You can also run Codex CLI with a prompt as input: