From 48aeb67f7a1cf54c37fc07955fd69131e0d256c3 Mon Sep 17 00:00:00 2001 From: gt-oai Date: Mon, 26 Jan 2026 15:58:14 +0000 Subject: [PATCH 01/43] Fix flakey conversation flow test (#9784) I've seen this test fail with: ``` - Mock #1. Expected range of matching incoming requests: == 2 Number of matched incoming requests: 1 ``` This is because we pop the wrong task_complete events and then the test exits. I think this is because the MCP events are now buffered after https://github.com/openai/codex/pull/8874. So: 1. clear the buffer before we do any user message sending 2. additionally listen for task start before task complete 3. use the ID from task start to find the correct task complete event. --- .../suite/codex_message_processor_flow.rs | 37 +++++++++++++++++-- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs index b97acfc40c..d905c3c1b5 100644 --- a/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs +++ b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs @@ -108,6 +108,10 @@ async fn test_codex_jsonrpc_conversation_flow() -> Result<()> { let AddConversationSubscriptionResponse { subscription_id } = to_response::(add_listener_resp)?; + // Drop any buffered events from conversation setup to avoid + // matching an earlier task_complete. + mcp.clear_message_buffer(); + // 3) sendUserMessage (should trigger notifications; we only validate an OK response) let send_user_id = mcp .send_send_user_message_request(SendUserMessageParams { @@ -125,13 +129,38 @@ async fn test_codex_jsonrpc_conversation_flow() -> Result<()> { .await??; let SendUserMessageResponse {} = to_response::(send_user_resp)?; - // Verify the task_finished notification is received. - // Note this also ensures that the final request to the server was made. - let task_finished_notification: JSONRPCNotification = timeout( + let task_started_notification: JSONRPCNotification = timeout( DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("codex/event/task_complete"), + mcp.read_stream_until_notification_message("codex/event/task_started"), ) .await??; + let task_started_event: Event = serde_json::from_value( + task_started_notification + .params + .clone() + .expect("task_started should have params"), + ) + .expect("task_started should deserialize to Event"); + + // Verify the task_finished notification for this turn is received. + // Note this also ensures that the final request to the server was made. + let task_finished_notification: JSONRPCNotification = loop { + let notification: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("codex/event/task_complete"), + ) + .await??; + let event: Event = serde_json::from_value( + notification + .params + .clone() + .expect("task_complete should have params"), + ) + .expect("task_complete should deserialize to Event"); + if event.id == task_started_event.id { + break notification; + } + }; let serde_json::Value::Object(map) = task_finished_notification .params .expect("notification should have params") From 3f338e4a6a70413dea864441f06f72becd49615c Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 26 Jan 2026 17:21:42 +0100 Subject: [PATCH 02/43] feat: explorer collab (#9918) --- codex-rs/core/src/agent/role.rs | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index acb2481fc8..dd05c18470 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -1,20 +1,22 @@ use crate::config::Config; use crate::protocol::SandboxPolicy; +use codex_protocol::openai_models::ReasoningEffort; use serde::Deserialize; use serde::Serialize; /// Base instructions for the orchestrator role. const ORCHESTRATOR_PROMPT: &str = include_str!("../../templates/agents/orchestrator.md"); -/// Base instructions for the worker role. -const WORKER_PROMPT: &str = include_str!("../../gpt-5.2-codex_prompt.md"); -/// Default worker model override used by the worker role. -const WORKER_MODEL: &str = "gpt-5.2-codex"; +/// Default model override used. +// TODO(jif) update when we have something smarter. +const EXPLORER_MODEL: &str = "gpt-5.2-codex"; /// Enumerated list of all supported agent roles. const ALL_ROLES: [AgentRole; 3] = [ AgentRole::Default, - AgentRole::Orchestrator, + AgentRole::Explorer, AgentRole::Worker, + // TODO(jif) add when we have stable prompts + models + // AgentRole::Orchestrator, ]; /// Hard-coded agent role selection used when spawning sub-agents. @@ -27,6 +29,8 @@ pub enum AgentRole { Orchestrator, /// Task-executing agent with a fixed model override. Worker, + /// Task-executing agent with a fixed model override. + Explorer, } /// Immutable profile data that drives per-agent configuration overrides. @@ -36,6 +40,8 @@ pub struct AgentProfile { pub base_instructions: Option<&'static str>, /// Optional model override. pub model: Option<&'static str>, + /// Optional reasoning effort override. + pub reasoning_effort: Option, /// Whether to force a read-only sandbox policy. pub read_only: bool, } @@ -58,8 +64,13 @@ impl AgentRole { ..Default::default() }, AgentRole::Worker => AgentProfile { - base_instructions: Some(WORKER_PROMPT), - model: Some(WORKER_MODEL), + // base_instructions: Some(WORKER_PROMPT), + // model: Some(WORKER_MODEL), + ..Default::default() + }, + AgentRole::Explorer => AgentProfile { + model: Some(EXPLORER_MODEL), + reasoning_effort: Some(ReasoningEffort::Low), ..Default::default() }, } @@ -74,6 +85,9 @@ impl AgentRole { if let Some(model) = profile.model { config.model = Some(model.to_string()); } + if let Some(reasoning_effort) = profile.reasoning_effort { + config.model_reasoning_effort = Some(reasoning_effort) + } if profile.read_only { config .sandbox_policy From 70d59593988d3af390527a6aadd6e801da5d6a33 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 26 Jan 2026 18:05:36 +0100 Subject: [PATCH 03/43] feat: disable collab at max depth (#9899) --- codex-rs/core/src/codex.rs | 11 ++++++- codex-rs/core/src/tools/handlers/collab.rs | 34 +++++++++++++++------- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 8a66fca0e1..6401605eec 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -12,6 +12,7 @@ use crate::CodexAuth; use crate::SandboxState; use crate::agent::AgentControl; use crate::agent::AgentStatus; +use crate::agent::MAX_THREAD_SPAWN_DEPTH; use crate::agent::agent_status_from_event; use crate::compact; use crate::compact::run_inline_auto_compact_task; @@ -52,6 +53,7 @@ use codex_protocol::protocol::RawResponseItemEvent; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnStartedEvent; @@ -240,7 +242,7 @@ impl Codex { /// Spawn a new [`Codex`] and initialize the session. #[allow(clippy::too_many_arguments)] pub(crate) async fn spawn( - config: Config, + mut config: Config, auth_manager: Arc, models_manager: Arc, skills_manager: Arc, @@ -262,6 +264,12 @@ impl Codex { ); } + if let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) = session_source + && depth >= MAX_THREAD_SPAWN_DEPTH + { + config.features.disable(Feature::Collab); + } + let enabled_skills = loaded_skills.enabled_skills(); let user_instructions = get_user_instructions(&config, Some(&enabled_skills)).await; @@ -3587,6 +3595,7 @@ mod tests { use crate::shell::default_user_shell; use crate::tools::format_exec_output_str; + use codex_protocol::ThreadId; use codex_protocol::models::FunctionCallOutputPayload; use crate::protocol::CompactedItem; diff --git a/codex-rs/core/src/tools/handlers/collab.rs b/codex-rs/core/src/tools/handlers/collab.rs index 83ee53905b..00190648f0 100644 --- a/codex-rs/core/src/tools/handlers/collab.rs +++ b/codex-rs/core/src/tools/handlers/collab.rs @@ -1,8 +1,10 @@ use crate::agent::AgentStatus; +use crate::agent::exceeds_thread_spawn_depth_limit; use crate::codex::Session; use crate::codex::TurnContext; use crate::config::Config; use crate::error::CodexErr; +use crate::features::Feature; use crate::function_tool::FunctionCallError; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; @@ -78,7 +80,7 @@ impl ToolHandler for CollabHandler { mod spawn { use super::*; use crate::agent::AgentRole; - use crate::agent::MAX_THREAD_SPAWN_DEPTH; + use crate::agent::exceeds_thread_spawn_depth_limit; use crate::agent::next_thread_spawn_depth; use codex_protocol::protocol::SessionSource; @@ -113,9 +115,9 @@ mod spawn { let session_source = turn.client.get_session_source(); let child_depth = next_thread_spawn_depth(&session_source); if exceeds_thread_spawn_depth_limit(child_depth) { - return Err(FunctionCallError::RespondToModel(format!( - "agent depth limit reached: max depth is {MAX_THREAD_SPAWN_DEPTH}" - ))); + return Err(FunctionCallError::RespondToModel( + "Agent depth limit reached. Solve the task yourself.".to_string(), + )); } session .send_event( @@ -128,8 +130,11 @@ mod spawn { .into(), ) .await; - let mut config = - build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; + let mut config = build_agent_spawn_config( + &session.get_base_instructions().await, + turn.as_ref(), + child_depth, + )?; agent_role .apply_to_config(&mut config) .map_err(FunctionCallError::RespondToModel)?; @@ -582,6 +587,7 @@ fn collab_agent_error(agent_id: ThreadId, err: CodexErr) -> FunctionCallError { fn build_agent_spawn_config( base_instructions: &BaseInstructions, turn: &TurnContext, + child_depth: i32, ) -> Result { let base_config = turn.client.config(); let mut config = (*base_config).clone(); @@ -607,6 +613,12 @@ fn build_agent_spawn_config( .map_err(|err| { FunctionCallError::RespondToModel(format!("sandbox_policy is invalid: {err}")) })?; + + // If the new agent will be at max depth: + if exceeds_thread_spawn_depth_limit(child_depth + 1) { + config.features.disable(Feature::Collab); + } + Ok(config) } @@ -778,9 +790,9 @@ mod tests { }; assert_eq!( err, - FunctionCallError::RespondToModel(format!( - "agent depth limit reached: max depth is {MAX_THREAD_SPAWN_DEPTH}" - )) + FunctionCallError::RespondToModel( + "Agent depth limit reached. Solve the task yourself.".to_string() + ) ); } @@ -1144,7 +1156,7 @@ mod tests { turn.approval_policy = AskForApproval::Never; turn.sandbox_policy = SandboxPolicy::DangerFullAccess; - let config = build_agent_spawn_config(&base_instructions, &turn).expect("spawn config"); + let config = build_agent_spawn_config(&base_instructions, &turn, 0).expect("spawn config"); let mut expected = (*turn.client.config()).clone(); expected.base_instructions = Some(base_instructions.text); expected.model = Some(turn.client.get_model()); @@ -1189,7 +1201,7 @@ mod tests { text: "base".to_string(), }; - let config = build_agent_spawn_config(&base_instructions, &turn).expect("spawn config"); + let config = build_agent_spawn_config(&base_instructions, &turn, 0).expect("spawn config"); assert_eq!(config.user_instructions, base_config.user_instructions); } From 6316e57497d049abb25b9b5318e7219e95c30ff4 Mon Sep 17 00:00:00 2001 From: gt-oai Date: Mon, 26 Jan 2026 17:49:31 +0000 Subject: [PATCH 04/43] Fix up config disabled err msg (#9916) **Before:** image **After:** image Two changes: 1. only display if there is a `config.toml` that is skipped (i.e. if there is just `.codex/skills` but no `.codex/config.toml` we do not display the error) 2. clarify the implications and the fix in the error message. --- codex-rs/app-server/src/lib.rs | 8 +++++-- codex-rs/core/src/config_loader/mod.rs | 32 ++++++++++++-------------- codex-rs/tui/src/app.rs | 8 +++++-- 3 files changed, 27 insertions(+), 21 deletions(-) diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index c6d943ae46..b92e182668 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -134,7 +134,7 @@ fn project_config_warning(config: &Config) -> Option .disabled_reason .as_ref() .map(ToString::to_string) - .unwrap_or_else(|| "Config folder disabled.".to_string()), + .unwrap_or_else(|| "config.toml is disabled.".to_string()), )); } } @@ -143,7 +143,11 @@ fn project_config_warning(config: &Config) -> Option return None; } - let mut message = "The following config folders are disabled:\n".to_string(); + let mut message = concat!( + "Project config.toml files are disabled in the following folders. ", + "Settings in those files are ignored, but skills and exec policies still load.\n", + ) + .to_string(); for (index, (folder, reason)) in disabled_folders.iter().enumerate() { let display_index = index + 1; message.push_str(&format!(" {display_index}. {folder}\n")); diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 6d85a79360..ae56c384e4 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -512,10 +512,10 @@ impl ProjectTrustContext { let user_config_file = self.user_config_file.as_path().display(); match decision.trust_level { Some(TrustLevel::Untrusted) => Some(format!( - "{trust_key} is marked as untrusted in {user_config_file}. Mark it trusted to enable project config folders." + "{trust_key} is marked as untrusted in {user_config_file}. To load config.toml, mark it trusted." )), _ => Some(format!( - "Add {trust_key} as a trusted project in {user_config_file}." + "To load config.toml, add {trust_key} as a trusted project in {user_config_file}." )), } } @@ -526,21 +526,16 @@ fn project_layer_entry( dot_codex_folder: &AbsolutePathBuf, layer_dir: &AbsolutePathBuf, config: TomlValue, + config_toml_exists: bool, ) -> ConfigLayerEntry { - match trust_context.disabled_reason_for_dir(layer_dir) { - Some(reason) => ConfigLayerEntry::new_disabled( - ConfigLayerSource::Project { - dot_codex_folder: dot_codex_folder.clone(), - }, - config, - reason, - ), - None => ConfigLayerEntry::new( - ConfigLayerSource::Project { - dot_codex_folder: dot_codex_folder.clone(), - }, - config, - ), + let source = ConfigLayerSource::Project { + dot_codex_folder: dot_codex_folder.clone(), + }; + + if config_toml_exists && let Some(reason) = trust_context.disabled_reason_for_dir(layer_dir) { + ConfigLayerEntry::new_disabled(source, config, reason) + } else { + ConfigLayerEntry::new(source, config) } } @@ -715,13 +710,15 @@ async fn load_project_layers( &dot_codex_abs, &layer_dir, TomlValue::Table(toml::map::Map::new()), + true, )); continue; } }; let config = resolve_relative_paths_in_config_toml(config, dot_codex_abs.as_path())?; - let entry = project_layer_entry(trust_context, &dot_codex_abs, &layer_dir, config); + let entry = + project_layer_entry(trust_context, &dot_codex_abs, &layer_dir, config, true); layers.push(entry); } Err(err) => { @@ -734,6 +731,7 @@ async fn load_project_layers( &dot_codex_abs, &layer_dir, TomlValue::Table(toml::map::Map::new()), + false, )); } else { let config_file_display = config_file.as_path().display(); diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index d3185adf34..bc006a2928 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -201,7 +201,7 @@ fn emit_project_config_warnings(app_event_tx: &AppEventSender, config: &Config) .disabled_reason .as_ref() .map(ToString::to_string) - .unwrap_or_else(|| "Config folder disabled.".to_string()), + .unwrap_or_else(|| "config.toml is disabled.".to_string()), )); } @@ -209,7 +209,11 @@ fn emit_project_config_warnings(app_event_tx: &AppEventSender, config: &Config) return; } - let mut message = "The following config folders are disabled:\n".to_string(); + let mut message = concat!( + "Project config.toml files are disabled in the following folders. ", + "Settings in those files are ignored, but skills and exec policies still load.\n", + ) + .to_string(); for (index, (folder, reason)) in disabled_folders.iter().enumerate() { let display_index = index + 1; message.push_str(&format!(" {display_index}. {folder}\n")); From 3ba702c5b6eb53523d3ff644789e900fffa3533c Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Mon, 26 Jan 2026 09:52:38 -0800 Subject: [PATCH 05/43] Feat: add isOther to question returned by request user input tool (#9890) ### Summary Add `isOther` to question object from request_user_input tool input and remove `other` option from the tool prompt to better handle tool input. --- codex-rs/app-server-protocol/src/protocol/v2.rs | 2 ++ codex-rs/app-server/src/bespoke_event_handling.rs | 1 + codex-rs/app-server/tests/common/responses.rs | 1 + codex-rs/core/src/tools/spec.rs | 12 +++++++++++- codex-rs/core/tests/suite/request_user_input.rs | 2 ++ codex-rs/docs/protocol_v1.md | 2 +- codex-rs/protocol/src/request_user_input.rs | 4 ++++ .../tui/src/bottom_pane/request_user_input/mod.rs | 3 +++ 8 files changed, 25 insertions(+), 2 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 65648bbdb0..28954c7cde 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2420,6 +2420,8 @@ pub struct ToolRequestUserInputQuestion { pub id: String, pub header: String, pub question: String, + #[serde(default)] + pub is_other: bool, pub options: Option>, } diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 0cb6fb1bc6..20e7db762b 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -278,6 +278,7 @@ pub(crate) async fn apply_bespoke_event_handling( id: question.id, header: question.header, question: question.question, + is_other: question.is_other, options: question.options.map(|options| { options .into_iter() diff --git a/codex-rs/app-server/tests/common/responses.rs b/codex-rs/app-server/tests/common/responses.rs index e15319e02f..2a5b5d2159 100644 --- a/codex-rs/app-server/tests/common/responses.rs +++ b/codex-rs/app-server/tests/common/responses.rs @@ -67,6 +67,7 @@ pub fn create_request_user_input_sse_response(call_id: &str) -> anyhow::Result ToolSpec { let options_schema = JsonSchema::Array { description: Some( - "Optional 2-3 mutually exclusive choices. Put the recommended option first and suffix its label with \"(Recommended)\". Only include \"Other\" option if we want to include a free form option. If the question is free form in nature, please do not have any option." + "Optional 2-3 mutually exclusive choices. Put the recommended option first and suffix its label with \"(Recommended)\". Do not include an \"Other\" option in this list; use isOther on the question to request a free form choice. If the question is free form in nature, please do not have any option." .to_string(), ), items: Box::new(JsonSchema::Object { @@ -586,6 +586,15 @@ fn create_request_user_input_tool() -> ToolSpec { description: Some("Single-sentence prompt shown to the user.".to_string()), }, ); + question_props.insert( + "isOther".to_string(), + JsonSchema::Boolean { + description: Some( + "True when this question should include a free-form \"Other\" option. Otherwise false." + .to_string(), + ), + }, + ); question_props.insert("options".to_string(), options_schema); let questions_schema = JsonSchema::Array { @@ -596,6 +605,7 @@ fn create_request_user_input_tool() -> ToolSpec { "id".to_string(), "header".to_string(), "question".to_string(), + "isOther".to_string(), ]), additional_properties: Some(false.into()), }), diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 74d3fc98f2..2d81fc9ad8 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -94,6 +94,7 @@ async fn request_user_input_round_trip_resolves_pending() -> anyhow::Result<()> "id": "confirm_path", "header": "Confirm", "question": "Proceed with the plan?", + "isOther": false, "options": [{ "label": "Yes (Recommended)", "description": "Continue the current plan." @@ -213,6 +214,7 @@ where "id": "confirm_path", "header": "Confirm", "question": "Proceed with the plan?", + "isOther": false, "options": [{ "label": "Yes (Recommended)", "description": "Continue the current plan." diff --git a/codex-rs/docs/protocol_v1.md b/codex-rs/docs/protocol_v1.md index f5579008b2..ee49cac0a6 100644 --- a/codex-rs/docs/protocol_v1.md +++ b/codex-rs/docs/protocol_v1.md @@ -75,7 +75,7 @@ For complete documentation of the `Op` and `EventMsg` variants, refer to [protoc - `EventMsg` - `EventMsg::AgentMessage` – Messages from the `Model` - `EventMsg::ExecApprovalRequest` – Request approval from user to execute a command - - `EventMsg::RequestUserInput` – Request user input for a tool call + - `EventMsg::RequestUserInput` – Request user input for a tool call (questions can include options plus `isOther` to add a free-form choice) - `EventMsg::TurnComplete` – A turn completed successfully - `EventMsg::Error` – A turn stopped with an error - `EventMsg::Warning` – A non-fatal warning that the client should surface to the user diff --git a/codex-rs/protocol/src/request_user_input.rs b/codex-rs/protocol/src/request_user_input.rs index 7c0e02c592..0e58348da4 100644 --- a/codex-rs/protocol/src/request_user_input.rs +++ b/codex-rs/protocol/src/request_user_input.rs @@ -16,6 +16,10 @@ pub struct RequestUserInputQuestion { pub id: String, pub header: String, pub question: String, + #[serde(rename = "isOther", default)] + #[schemars(rename = "isOther")] + #[ts(rename = "isOther")] + pub is_other: bool, #[serde(skip_serializing_if = "Option::is_none")] pub options: Option>, } diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index a01aa2296b..aaa6391770 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -508,6 +508,7 @@ mod tests { id: id.to_string(), header: header.to_string(), question: "Choose an option.".to_string(), + is_other: false, options: Some(vec![ RequestUserInputQuestionOption { label: "Option 1".to_string(), @@ -530,6 +531,7 @@ mod tests { id: id.to_string(), header: header.to_string(), question: "Share details.".to_string(), + is_other: false, options: None, } } @@ -696,6 +698,7 @@ mod tests { id: "q1".to_string(), header: "Next Step".to_string(), question: "What would you like to do next?".to_string(), + is_other: false, options: Some(vec![ RequestUserInputQuestionOption { label: "Discuss a code change (Recommended)".to_string(), From 01d7f8095b54dc3b194def8a4d90de02c93f2fbf Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 26 Jan 2026 19:01:35 +0100 Subject: [PATCH 06/43] feat: codex exec mapping of collab tools (#9817) THIS IS NOT THE FINAL UX --- .../src/event_processor_with_human_output.rs | 245 ++++++++++++++- .../src/event_processor_with_jsonl_output.rs | 285 ++++++++++++++++++ codex-rs/exec/src/exec_events.rs | 54 ++++ .../tests/event_processor_with_json_output.rs | 139 +++++++++ 4 files changed, 714 insertions(+), 9 deletions(-) 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 76c22caa75..6275ceaf8d 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -3,7 +3,16 @@ use codex_common::elapsed::format_elapsed; use codex_core::config::Config; use codex_core::protocol::AgentMessageEvent; use codex_core::protocol::AgentReasoningRawContentEvent; +use codex_core::protocol::AgentStatus; use codex_core::protocol::BackgroundEventEvent; +use codex_core::protocol::CollabAgentInteractionBeginEvent; +use codex_core::protocol::CollabAgentInteractionEndEvent; +use codex_core::protocol::CollabAgentSpawnBeginEvent; +use codex_core::protocol::CollabAgentSpawnEndEvent; +use codex_core::protocol::CollabCloseBeginEvent; +use codex_core::protocol::CollabCloseEndEvent; +use codex_core::protocol::CollabWaitingBeginEvent; +use codex_core::protocol::CollabWaitingEndEvent; use codex_core::protocol::DeprecationNoticeEvent; use codex_core::protocol::ErrorEvent; use codex_core::protocol::Event; @@ -571,15 +580,161 @@ impl EventProcessor for EventProcessorWithHumanOutput { EventMsg::ContextCompacted(_) => { ts_msg!(self, "context compacted"); } - EventMsg::CollabAgentSpawnBegin(_) - | EventMsg::CollabAgentSpawnEnd(_) - | EventMsg::CollabAgentInteractionBegin(_) - | EventMsg::CollabAgentInteractionEnd(_) - | EventMsg::CollabWaitingBegin(_) - | EventMsg::CollabWaitingEnd(_) - | EventMsg::CollabCloseBegin(_) - | EventMsg::CollabCloseEnd(_) => { - // TODO(jif) handle collab tools. + EventMsg::CollabAgentSpawnBegin(CollabAgentSpawnBeginEvent { + call_id, + sender_thread_id: _, + prompt, + }) => { + ts_msg!( + self, + "{} {}", + "collab".style(self.magenta), + format_collab_invocation("spawn_agent", &call_id, Some(&prompt)) + .style(self.bold) + ); + } + EventMsg::CollabAgentSpawnEnd(CollabAgentSpawnEndEvent { + call_id, + sender_thread_id: _, + new_thread_id, + prompt, + status, + }) => { + let success = new_thread_id.is_some() && !is_collab_status_failure(&status); + let title_style = if success { self.green } else { self.red }; + let title = format!( + "{} {}:", + format_collab_invocation("spawn_agent", &call_id, Some(&prompt)), + format_collab_status(&status) + ); + ts_msg!(self, "{}", title.style(title_style)); + if let Some(new_thread_id) = new_thread_id { + eprintln!(" agent: {}", new_thread_id.to_string().style(self.dimmed)); + } + } + EventMsg::CollabAgentInteractionBegin(CollabAgentInteractionBeginEvent { + call_id, + sender_thread_id: _, + receiver_thread_id, + prompt, + }) => { + ts_msg!( + self, + "{} {}", + "collab".style(self.magenta), + format_collab_invocation("send_input", &call_id, Some(&prompt)) + .style(self.bold) + ); + eprintln!( + " receiver: {}", + receiver_thread_id.to_string().style(self.dimmed) + ); + } + EventMsg::CollabAgentInteractionEnd(CollabAgentInteractionEndEvent { + call_id, + sender_thread_id: _, + receiver_thread_id, + prompt, + status, + }) => { + let success = !is_collab_status_failure(&status); + let title_style = if success { self.green } else { self.red }; + let title = format!( + "{} {}:", + format_collab_invocation("send_input", &call_id, Some(&prompt)), + format_collab_status(&status) + ); + ts_msg!(self, "{}", title.style(title_style)); + eprintln!( + " receiver: {}", + receiver_thread_id.to_string().style(self.dimmed) + ); + } + EventMsg::CollabWaitingBegin(CollabWaitingBeginEvent { + sender_thread_id: _, + receiver_thread_ids, + call_id, + }) => { + ts_msg!( + self, + "{} {}", + "collab".style(self.magenta), + format_collab_invocation("wait", &call_id, None).style(self.bold) + ); + eprintln!( + " receivers: {}", + format_receiver_list(&receiver_thread_ids).style(self.dimmed) + ); + } + EventMsg::CollabWaitingEnd(CollabWaitingEndEvent { + sender_thread_id: _, + call_id, + statuses, + }) => { + if statuses.is_empty() { + ts_msg!( + self, + "{} {}:", + format_collab_invocation("wait", &call_id, None), + "timed out".style(self.yellow) + ); + return CodexStatus::Running; + } + let success = !statuses.values().any(is_collab_status_failure); + let title_style = if success { self.green } else { self.red }; + let title = format!( + "{} {} agents complete:", + format_collab_invocation("wait", &call_id, None), + statuses.len() + ); + ts_msg!(self, "{}", title.style(title_style)); + let mut sorted = statuses + .into_iter() + .map(|(thread_id, status)| (thread_id.to_string(), status)) + .collect::>(); + sorted.sort_by(|(left, _), (right, _)| left.cmp(right)); + for (thread_id, status) in sorted { + eprintln!( + " {} {}", + thread_id.style(self.dimmed), + format_collab_status(&status).style(style_for_agent_status(&status, self)) + ); + } + } + EventMsg::CollabCloseBegin(CollabCloseBeginEvent { + call_id, + sender_thread_id: _, + receiver_thread_id, + }) => { + ts_msg!( + self, + "{} {}", + "collab".style(self.magenta), + format_collab_invocation("close_agent", &call_id, None).style(self.bold) + ); + eprintln!( + " receiver: {}", + receiver_thread_id.to_string().style(self.dimmed) + ); + } + EventMsg::CollabCloseEnd(CollabCloseEndEvent { + call_id, + sender_thread_id: _, + receiver_thread_id, + status, + }) => { + let success = !is_collab_status_failure(&status); + let title_style = if success { self.green } else { self.red }; + let title = format!( + "{} {}:", + format_collab_invocation("close_agent", &call_id, None), + format_collab_status(&status) + ); + ts_msg!(self, "{}", title.style(title_style)); + eprintln!( + " receiver: {}", + receiver_thread_id.to_string().style(self.dimmed) + ); } EventMsg::ShutdownComplete => return CodexStatus::Shutdown, EventMsg::WebSearchBegin(_) @@ -654,6 +809,78 @@ fn format_file_change(change: &FileChange) -> &'static str { } } +fn format_collab_invocation(tool: &str, call_id: &str, prompt: Option<&str>) -> String { + let prompt = prompt + .map(str::trim) + .filter(|prompt| !prompt.is_empty()) + .map(|prompt| truncate_preview(prompt, 120)); + match prompt { + Some(prompt) => format!("{tool}({call_id}, prompt=\"{prompt}\")"), + None => format!("{tool}({call_id})"), + } +} + +fn format_collab_status(status: &AgentStatus) -> String { + match status { + AgentStatus::PendingInit => "pending init".to_string(), + AgentStatus::Running => "running".to_string(), + AgentStatus::Completed(Some(message)) => { + let preview = truncate_preview(message.trim(), 120); + if preview.is_empty() { + "completed".to_string() + } else { + format!("completed: \"{preview}\"") + } + } + AgentStatus::Completed(None) => "completed".to_string(), + AgentStatus::Errored(message) => { + let preview = truncate_preview(message.trim(), 120); + if preview.is_empty() { + "errored".to_string() + } else { + format!("errored: \"{preview}\"") + } + } + AgentStatus::Shutdown => "shutdown".to_string(), + AgentStatus::NotFound => "not found".to_string(), + } +} + +fn style_for_agent_status( + status: &AgentStatus, + processor: &EventProcessorWithHumanOutput, +) -> Style { + match status { + AgentStatus::PendingInit | AgentStatus::Shutdown => processor.dimmed, + AgentStatus::Running => processor.cyan, + AgentStatus::Completed(_) => processor.green, + AgentStatus::Errored(_) | AgentStatus::NotFound => processor.red, + } +} + +fn is_collab_status_failure(status: &AgentStatus) -> bool { + matches!(status, AgentStatus::Errored(_) | AgentStatus::NotFound) +} + +fn format_receiver_list(ids: &[codex_protocol::ThreadId]) -> String { + if ids.is_empty() { + return "none".to_string(); + } + ids.iter() + .map(ToString::to_string) + .collect::>() + .join(", ") +} + +fn truncate_preview(text: &str, max_chars: usize) -> String { + if text.chars().count() <= max_chars { + return text.to_string(); + } + + let preview = text.chars().take(max_chars).collect::(); + format!("{preview}…") +} + fn format_mcp_invocation(invocation: &McpInvocation) -> String { // Build fully-qualified tool name: server.tool let fq_tool_name = format!("{}.{}", invocation.server, invocation.tool); diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output.rs b/codex-rs/exec/src/event_processor_with_jsonl_output.rs index 3679b57380..779ec9292d 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs @@ -6,6 +6,11 @@ use crate::event_processor::CodexStatus; use crate::event_processor::EventProcessor; use crate::event_processor::handle_last_message; use crate::exec_events::AgentMessageItem; +use crate::exec_events::CollabAgentState; +use crate::exec_events::CollabAgentStatus; +use crate::exec_events::CollabTool; +use crate::exec_events::CollabToolCallItem; +use crate::exec_events::CollabToolCallStatus; use crate::exec_events::CommandExecutionItem; use crate::exec_events::CommandExecutionStatus; use crate::exec_events::ErrorItem; @@ -35,6 +40,15 @@ use crate::exec_events::Usage; use crate::exec_events::WebSearchItem; use codex_core::config::Config; use codex_core::protocol; +use codex_core::protocol::AgentStatus as CoreAgentStatus; +use codex_core::protocol::CollabAgentInteractionBeginEvent; +use codex_core::protocol::CollabAgentInteractionEndEvent; +use codex_core::protocol::CollabAgentSpawnBeginEvent; +use codex_core::protocol::CollabAgentSpawnEndEvent; +use codex_core::protocol::CollabCloseBeginEvent; +use codex_core::protocol::CollabCloseEndEvent; +use codex_core::protocol::CollabWaitingBeginEvent; +use codex_core::protocol::CollabWaitingEndEvent; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; use serde_json::Value as JsonValue; @@ -51,6 +65,7 @@ pub struct EventProcessorWithJsonOutput { running_todo_list: Option, last_total_token_usage: Option, running_mcp_tool_calls: HashMap, + running_collab_tool_calls: HashMap, last_critical_error: Option, } @@ -75,6 +90,12 @@ struct RunningMcpToolCall { arguments: JsonValue, } +#[derive(Debug, Clone)] +struct RunningCollabToolCall { + tool: CollabTool, + item_id: String, +} + impl EventProcessorWithJsonOutput { pub fn new(last_message_path: Option) -> Self { Self { @@ -85,6 +106,7 @@ impl EventProcessorWithJsonOutput { running_todo_list: None, last_total_token_usage: None, running_mcp_tool_calls: HashMap::new(), + running_collab_tool_calls: HashMap::new(), last_critical_error: None, } } @@ -102,6 +124,18 @@ impl EventProcessorWithJsonOutput { } protocol::EventMsg::McpToolCallBegin(ev) => self.handle_mcp_tool_call_begin(ev), protocol::EventMsg::McpToolCallEnd(ev) => self.handle_mcp_tool_call_end(ev), + protocol::EventMsg::CollabAgentSpawnBegin(ev) => self.handle_collab_spawn_begin(ev), + protocol::EventMsg::CollabAgentSpawnEnd(ev) => self.handle_collab_spawn_end(ev), + protocol::EventMsg::CollabAgentInteractionBegin(ev) => { + self.handle_collab_interaction_begin(ev) + } + protocol::EventMsg::CollabAgentInteractionEnd(ev) => { + self.handle_collab_interaction_end(ev) + } + protocol::EventMsg::CollabWaitingBegin(ev) => self.handle_collab_wait_begin(ev), + protocol::EventMsg::CollabWaitingEnd(ev) => self.handle_collab_wait_end(ev), + protocol::EventMsg::CollabCloseBegin(ev) => self.handle_collab_close_begin(ev), + protocol::EventMsg::CollabCloseEnd(ev) => self.handle_collab_close_end(ev), protocol::EventMsg::PatchApplyBegin(ev) => self.handle_patch_apply_begin(ev), protocol::EventMsg::PatchApplyEnd(ev) => self.handle_patch_apply_end(ev), protocol::EventMsg::WebSearchBegin(_) => Vec::new(), @@ -341,6 +375,219 @@ impl EventProcessorWithJsonOutput { vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] } + fn handle_collab_spawn_begin(&mut self, ev: &CollabAgentSpawnBeginEvent) -> Vec { + self.start_collab_tool_call( + &ev.call_id, + CollabTool::SpawnAgent, + ev.sender_thread_id.to_string(), + Vec::new(), + Some(ev.prompt.clone()), + ) + } + + fn handle_collab_spawn_end(&mut self, ev: &CollabAgentSpawnEndEvent) -> Vec { + let (receiver_thread_ids, agents_states) = match ev.new_thread_id { + Some(id) => { + let receiver_id = id.to_string(); + let agent_state = CollabAgentState::from(ev.status.clone()); + ( + vec![receiver_id.clone()], + [(receiver_id, agent_state)].into_iter().collect(), + ) + } + None => (Vec::new(), HashMap::new()), + }; + let status = if ev.new_thread_id.is_some() && !is_collab_failure(&ev.status) { + CollabToolCallStatus::Completed + } else { + CollabToolCallStatus::Failed + }; + self.finish_collab_tool_call( + &ev.call_id, + CollabTool::SpawnAgent, + ev.sender_thread_id.to_string(), + receiver_thread_ids, + Some(ev.prompt.clone()), + agents_states, + status, + ) + } + + fn handle_collab_interaction_begin( + &mut self, + ev: &CollabAgentInteractionBeginEvent, + ) -> Vec { + self.start_collab_tool_call( + &ev.call_id, + CollabTool::SendInput, + ev.sender_thread_id.to_string(), + vec![ev.receiver_thread_id.to_string()], + Some(ev.prompt.clone()), + ) + } + + fn handle_collab_interaction_end( + &mut self, + ev: &CollabAgentInteractionEndEvent, + ) -> Vec { + let receiver_id = ev.receiver_thread_id.to_string(); + let agent_state = CollabAgentState::from(ev.status.clone()); + let status = if is_collab_failure(&ev.status) { + CollabToolCallStatus::Failed + } else { + CollabToolCallStatus::Completed + }; + self.finish_collab_tool_call( + &ev.call_id, + CollabTool::SendInput, + ev.sender_thread_id.to_string(), + vec![receiver_id.clone()], + Some(ev.prompt.clone()), + [(receiver_id, agent_state)].into_iter().collect(), + status, + ) + } + + fn handle_collab_wait_begin(&mut self, ev: &CollabWaitingBeginEvent) -> Vec { + self.start_collab_tool_call( + &ev.call_id, + CollabTool::Wait, + ev.sender_thread_id.to_string(), + ev.receiver_thread_ids + .iter() + .map(ToString::to_string) + .collect(), + None, + ) + } + + fn handle_collab_wait_end(&mut self, ev: &CollabWaitingEndEvent) -> Vec { + let status = if ev.statuses.values().any(is_collab_failure) { + CollabToolCallStatus::Failed + } else { + CollabToolCallStatus::Completed + }; + let mut receiver_thread_ids = ev + .statuses + .keys() + .map(ToString::to_string) + .collect::>(); + receiver_thread_ids.sort(); + let agents_states = ev + .statuses + .iter() + .map(|(thread_id, status)| { + ( + thread_id.to_string(), + CollabAgentState::from(status.clone()), + ) + }) + .collect(); + self.finish_collab_tool_call( + &ev.call_id, + CollabTool::Wait, + ev.sender_thread_id.to_string(), + receiver_thread_ids, + None, + agents_states, + status, + ) + } + + fn handle_collab_close_begin(&mut self, ev: &CollabCloseBeginEvent) -> Vec { + self.start_collab_tool_call( + &ev.call_id, + CollabTool::CloseAgent, + ev.sender_thread_id.to_string(), + vec![ev.receiver_thread_id.to_string()], + None, + ) + } + + fn handle_collab_close_end(&mut self, ev: &CollabCloseEndEvent) -> Vec { + let receiver_id = ev.receiver_thread_id.to_string(); + let agent_state = CollabAgentState::from(ev.status.clone()); + let status = if is_collab_failure(&ev.status) { + CollabToolCallStatus::Failed + } else { + CollabToolCallStatus::Completed + }; + self.finish_collab_tool_call( + &ev.call_id, + CollabTool::CloseAgent, + ev.sender_thread_id.to_string(), + vec![receiver_id.clone()], + None, + [(receiver_id, agent_state)].into_iter().collect(), + status, + ) + } + + fn start_collab_tool_call( + &mut self, + call_id: &str, + tool: CollabTool, + sender_thread_id: String, + receiver_thread_ids: Vec, + prompt: Option, + ) -> Vec { + let item_id = self.get_next_item_id(); + self.running_collab_tool_calls.insert( + call_id.to_string(), + RunningCollabToolCall { + tool: tool.clone(), + item_id: item_id.clone(), + }, + ); + let item = ThreadItem { + id: item_id, + details: ThreadItemDetails::CollabToolCall(CollabToolCallItem { + tool, + sender_thread_id, + receiver_thread_ids, + prompt, + agents_states: HashMap::new(), + status: CollabToolCallStatus::InProgress, + }), + }; + vec![ThreadEvent::ItemStarted(ItemStartedEvent { item })] + } + + #[allow(clippy::too_many_arguments)] + fn finish_collab_tool_call( + &mut self, + call_id: &str, + tool: CollabTool, + sender_thread_id: String, + receiver_thread_ids: Vec, + prompt: Option, + agents_states: HashMap, + status: CollabToolCallStatus, + ) -> Vec { + let (tool, item_id) = match self.running_collab_tool_calls.remove(call_id) { + Some(running) => (running.tool, running.item_id), + None => { + warn!( + call_id, + "Received collab tool end without begin; synthesizing new item" + ); + (tool, self.get_next_item_id()) + } + }; + let item = ThreadItem { + id: item_id, + details: ThreadItemDetails::CollabToolCall(CollabToolCallItem { + tool, + sender_thread_id, + receiver_thread_ids, + prompt, + agents_states, + status, + }), + }; + vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] + } + fn handle_patch_apply_begin( &mut self, ev: &protocol::PatchApplyBeginEvent, @@ -512,6 +759,44 @@ impl EventProcessorWithJsonOutput { } } +fn is_collab_failure(status: &CoreAgentStatus) -> bool { + matches!( + status, + CoreAgentStatus::Errored(_) | CoreAgentStatus::NotFound + ) +} + +impl From for CollabAgentState { + fn from(value: CoreAgentStatus) -> Self { + match value { + CoreAgentStatus::PendingInit => Self { + status: CollabAgentStatus::PendingInit, + message: None, + }, + CoreAgentStatus::Running => Self { + status: CollabAgentStatus::Running, + message: None, + }, + CoreAgentStatus::Completed(message) => Self { + status: CollabAgentStatus::Completed, + message, + }, + CoreAgentStatus::Errored(message) => Self { + status: CollabAgentStatus::Errored, + message: Some(message), + }, + CoreAgentStatus::Shutdown => Self { + status: CollabAgentStatus::Shutdown, + message: None, + }, + CoreAgentStatus::NotFound => Self { + status: CollabAgentStatus::NotFound, + message: None, + }, + } + } +} + impl EventProcessor for EventProcessorWithJsonOutput { fn print_config_summary(&mut self, _: &Config, _: &str, ev: &protocol::SessionConfiguredEvent) { self.process_event(protocol::Event { diff --git a/codex-rs/exec/src/exec_events.rs b/codex-rs/exec/src/exec_events.rs index f3726dad76..e113a5c4b4 100644 --- a/codex-rs/exec/src/exec_events.rs +++ b/codex-rs/exec/src/exec_events.rs @@ -2,6 +2,7 @@ use mcp_types::ContentBlock as McpContentBlock; use serde::Deserialize; use serde::Serialize; use serde_json::Value as JsonValue; +use std::collections::HashMap; use ts_rs::TS; /// Top-level JSONL events emitted by codex exec @@ -113,6 +114,9 @@ pub enum ThreadItemDetails { /// Represents a call to an MCP tool. The item starts when the invocation is /// dispatched and completes when the MCP server reports success or failure. McpToolCall(McpToolCallItem), + /// Represents a call to a collab tool. The item starts when the collab tool is + /// invoked and completes when the collab tool reports success or failure. + CollabToolCall(CollabToolCallItem), /// Captures a web search request. It starts when the search is kicked off /// and completes when results are returned to the agent. WebSearch(WebSearchItem), @@ -198,6 +202,56 @@ pub enum McpToolCallStatus { Failed, } +/// The status of a collab tool call. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default, TS)] +#[serde(rename_all = "snake_case")] +pub enum CollabToolCallStatus { + #[default] + InProgress, + Completed, + Failed, +} + +/// Supported collab tools. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] +#[serde(rename_all = "snake_case")] +pub enum CollabTool { + SpawnAgent, + SendInput, + Wait, + CloseAgent, +} + +/// The status of a collab agent. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] +#[serde(rename_all = "snake_case")] +pub enum CollabAgentStatus { + PendingInit, + Running, + Completed, + Errored, + Shutdown, + NotFound, +} + +/// Last known state of a collab agent. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] +pub struct CollabAgentState { + pub status: CollabAgentStatus, + pub message: Option, +} + +/// A call to a collab tool. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +pub struct CollabToolCallItem { + pub tool: CollabTool, + pub sender_thread_id: String, + pub receiver_thread_ids: Vec, + pub prompt: Option, + pub agents_states: HashMap, + pub status: CollabToolCallStatus, +} + /// Result payload produced by an MCP tool invocation. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] pub struct McpToolCallItemResult { diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 43de1f95f8..cb2a283b30 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -1,6 +1,10 @@ use codex_core::protocol::AgentMessageEvent; use codex_core::protocol::AgentReasoningEvent; +use codex_core::protocol::AgentStatus; use codex_core::protocol::AskForApproval; +use codex_core::protocol::CollabAgentSpawnBeginEvent; +use codex_core::protocol::CollabAgentSpawnEndEvent; +use codex_core::protocol::CollabWaitingEndEvent; use codex_core::protocol::ErrorEvent; use codex_core::protocol::Event; use codex_core::protocol::EventMsg; @@ -19,6 +23,11 @@ use codex_core::protocol::WarningEvent; use codex_core::protocol::WebSearchEndEvent; use codex_exec::event_processor_with_jsonl_output::EventProcessorWithJsonOutput; use codex_exec::exec_events::AgentMessageItem; +use codex_exec::exec_events::CollabAgentState; +use codex_exec::exec_events::CollabAgentStatus; +use codex_exec::exec_events::CollabTool; +use codex_exec::exec_events::CollabToolCallItem; +use codex_exec::exec_events::CollabToolCallStatus; use codex_exec::exec_events::CommandExecutionItem; use codex_exec::exec_events::CommandExecutionStatus; use codex_exec::exec_events::ErrorItem; @@ -44,6 +53,7 @@ use codex_exec::exec_events::TurnFailedEvent; use codex_exec::exec_events::TurnStartedEvent; use codex_exec::exec_events::Usage; use codex_exec::exec_events::WebSearchItem; +use codex_protocol::ThreadId; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; @@ -444,6 +454,135 @@ fn mcp_tool_call_defaults_arguments_and_preserves_structured_content() { ); } +#[test] +fn collab_spawn_begin_and_end_emit_item_events() { + let mut ep = EventProcessorWithJsonOutput::new(None); + let sender_thread_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8").unwrap(); + let new_thread_id = ThreadId::from_string("9e107d9d-372b-4b8c-a2a4-1d9bb3fce0c1").unwrap(); + let prompt = "draft a plan".to_string(); + + let begin = event( + "c1", + EventMsg::CollabAgentSpawnBegin(CollabAgentSpawnBeginEvent { + call_id: "call-10".to_string(), + sender_thread_id, + prompt: prompt.clone(), + }), + ); + let begin_events = ep.collect_thread_events(&begin); + assert_eq!( + begin_events, + vec![ThreadEvent::ItemStarted(ItemStartedEvent { + item: ThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::CollabToolCall(CollabToolCallItem { + tool: CollabTool::SpawnAgent, + sender_thread_id: sender_thread_id.to_string(), + receiver_thread_ids: Vec::new(), + prompt: Some(prompt.clone()), + agents_states: std::collections::HashMap::new(), + status: CollabToolCallStatus::InProgress, + }), + }, + })] + ); + + let end = event( + "c2", + EventMsg::CollabAgentSpawnEnd(CollabAgentSpawnEndEvent { + call_id: "call-10".to_string(), + sender_thread_id, + new_thread_id: Some(new_thread_id), + prompt: prompt.clone(), + status: AgentStatus::Running, + }), + ); + let end_events = ep.collect_thread_events(&end); + assert_eq!( + end_events, + vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::CollabToolCall(CollabToolCallItem { + tool: CollabTool::SpawnAgent, + sender_thread_id: sender_thread_id.to_string(), + receiver_thread_ids: vec![new_thread_id.to_string()], + prompt: Some(prompt), + agents_states: [( + new_thread_id.to_string(), + CollabAgentState { + status: CollabAgentStatus::Running, + message: None, + }, + )] + .into_iter() + .collect(), + status: CollabToolCallStatus::Completed, + }), + }, + })] + ); +} + +#[test] +fn collab_wait_end_without_begin_synthesizes_failed_item() { + let mut ep = EventProcessorWithJsonOutput::new(None); + let sender_thread_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8").unwrap(); + let running_thread_id = ThreadId::from_string("3f76d2a0-943e-4f43-8a38-b289c9c6c3d1").unwrap(); + let failed_thread_id = ThreadId::from_string("c1dfd96e-1f0c-4f26-9b4f-1aa02c2d3c4d").unwrap(); + let mut receiver_thread_ids = vec![running_thread_id.to_string(), failed_thread_id.to_string()]; + receiver_thread_ids.sort(); + let mut statuses = std::collections::HashMap::new(); + statuses.insert( + running_thread_id, + AgentStatus::Completed(Some("done".to_string())), + ); + statuses.insert(failed_thread_id, AgentStatus::Errored("boom".to_string())); + + let end = event( + "c3", + EventMsg::CollabWaitingEnd(CollabWaitingEndEvent { + sender_thread_id, + call_id: "call-11".to_string(), + statuses: statuses.clone(), + }), + ); + let events = ep.collect_thread_events(&end); + assert_eq!( + events, + vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::CollabToolCall(CollabToolCallItem { + tool: CollabTool::Wait, + sender_thread_id: sender_thread_id.to_string(), + receiver_thread_ids, + prompt: None, + agents_states: [ + ( + running_thread_id.to_string(), + CollabAgentState { + status: CollabAgentStatus::Completed, + message: Some("done".to_string()), + }, + ), + ( + failed_thread_id.to_string(), + CollabAgentState { + status: CollabAgentStatus::Errored, + message: Some("boom".to_string()), + }, + ), + ] + .into_iter() + .collect(), + status: CollabToolCallStatus::Failed, + }), + }, + })] + ); +} + #[test] fn plan_update_after_complete_starts_new_todo_list_with_new_id() { let mut ep = EventProcessorWithJsonOutput::new(None); From fdc69df4540c3f57ddd7047c50ddf4a34d8380a8 Mon Sep 17 00:00:00 2001 From: gt-oai Date: Mon, 26 Jan 2026 18:05:30 +0000 Subject: [PATCH 07/43] Fix flakey shell snapshot test (#9919) Sometimes fails with: ``` failures: ---- shell_snapshot::tests::timed_out_snapshot_shell_is_terminated stdout ---- thread 'shell_snapshot::tests::timed_out_snapshot_shell_is_terminated' panicked at codex-rs/core/src/shell_snapshot.rs:588:9: expected timeout error, got Failed to execute sh Caused by: Text file busy (os error 26) failures: shell_snapshot::tests::timed_out_snapshot_shell_is_terminated test result: FAILED. 815 passed; 1 failed; 4 ignored; 0 measured; 0 filtered out; finished in 18.00s ``` --- codex-rs/core/src/shell_snapshot.rs | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index b578c4232f..78581186c7 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -464,8 +464,6 @@ mod tests { use pretty_assertions::assert_eq; #[cfg(unix)] use std::os::unix::ffi::OsStrExt; - #[cfg(target_os = "linux")] - use std::os::unix::fs::PermissionsExt; #[cfg(unix)] use std::process::Command; #[cfg(target_os = "linux")] @@ -562,27 +560,16 @@ mod tests { use tokio::time::sleep; let dir = tempdir()?; - let shell_path = dir.path().join("hanging-shell.sh"); let pid_path = dir.path().join("pid"); - - let script = format!( - "#!/bin/sh\n\ - echo $$ > {}\n\ - sleep 30\n", - pid_path.display() - ); - fs::write(&shell_path, script).await?; - let mut permissions = std::fs::metadata(&shell_path)?.permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(&shell_path, permissions)?; + let script = format!("echo $$ > \"{}\"; sleep 30", pid_path.display()); let shell = Shell { shell_type: ShellType::Sh, - shell_path, + shell_path: PathBuf::from("/bin/sh"), shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), }; - let err = run_script_with_timeout(&shell, "ignored", Duration::from_millis(500), true) + let err = run_script_with_timeout(&shell, &script, Duration::from_secs(1), true) .await .expect_err("snapshot shell should time out"); assert!( From 375a5ef05116a3d289dc548402387f86e28851cc Mon Sep 17 00:00:00 2001 From: Gene Oden Date: Mon, 26 Jan 2026 10:07:25 -0800 Subject: [PATCH 08/43] fix: attempt to reduce high cpu usage when using collab (#9776) Reproduce with a prompt like this with collab enabled: ``` Examine the code at . Find the most urgent issue to resolve and describe it to me. ``` Existing behavior causes the top-level agent to busy wait on subagents. --- codex-rs/core/src/tools/handlers/collab.rs | 39 ++++++++++++++++++- codex-rs/core/src/tools/spec.rs | 3 +- .../core/templates/agents/orchestrator.md | 1 + 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/codex-rs/core/src/tools/handlers/collab.rs b/codex-rs/core/src/tools/handlers/collab.rs index 00190648f0..b1666949f6 100644 --- a/codex-rs/core/src/tools/handlers/collab.rs +++ b/codex-rs/core/src/tools/handlers/collab.rs @@ -28,6 +28,8 @@ use serde::Serialize; pub struct CollabHandler; +/// Minimum wait timeout to prevent tight polling loops from burning CPU. +pub(crate) const MIN_WAIT_TIMEOUT_MS: i64 = 10_000; pub(crate) const DEFAULT_WAIT_TIMEOUT_MS: i64 = 30_000; pub(crate) const MAX_WAIT_TIMEOUT_MS: i64 = 300_000; @@ -323,6 +325,8 @@ mod wait { .collect::, _>>()?; // Validate timeout. + // Very short timeouts encourage busy-polling loops in the orchestrator prompt and can + // cause high CPU usage even with a single active worker, so clamp to a minimum. let timeout_ms = args.timeout_ms.unwrap_or(DEFAULT_WAIT_TIMEOUT_MS); let timeout_ms = match timeout_ms { ms if ms <= 0 => { @@ -330,7 +334,7 @@ mod wait { "timeout_ms must be greater than zero".to_owned(), )); } - ms => ms.min(MAX_WAIT_TIMEOUT_MS), + ms => ms.clamp(MIN_WAIT_TIMEOUT_MS, MAX_WAIT_TIMEOUT_MS), }; session @@ -1012,7 +1016,7 @@ mod tests { "wait", function_payload(json!({ "ids": [agent_id.to_string()], - "timeout_ms": 10 + "timeout_ms": MIN_WAIT_TIMEOUT_MS })), ); let output = CollabHandler @@ -1043,6 +1047,37 @@ mod tests { .expect("shutdown should submit"); } + #[tokio::test] + async fn wait_clamps_short_timeouts_to_minimum() { + let (mut session, turn) = make_session_and_context().await; + let manager = thread_manager(); + session.services.agent_control = manager.agent_control(); + let config = turn.client.config().as_ref().clone(); + let thread = manager.start_thread(config).await.expect("start thread"); + let agent_id = thread.thread_id; + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "wait", + function_payload(json!({ + "ids": [agent_id.to_string()], + "timeout_ms": 10 + })), + ); + + let early = timeout(Duration::from_millis(50), CollabHandler.handle(invocation)).await; + assert!( + early.is_err(), + "wait should not return before the minimum timeout clamp" + ); + + let _ = thread + .thread + .submit(Op::Shutdown {}) + .await + .expect("shutdown should submit"); + } + #[tokio::test] async fn wait_returns_final_status_without_timeout() { let (mut session, turn) = make_session_and_context().await; diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index c06b9556e7..33010e58e1 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -8,6 +8,7 @@ use crate::tools::handlers::apply_patch::create_apply_patch_freeform_tool; use crate::tools::handlers::apply_patch::create_apply_patch_json_tool; use crate::tools::handlers::collab::DEFAULT_WAIT_TIMEOUT_MS; use crate::tools::handlers::collab::MAX_WAIT_TIMEOUT_MS; +use crate::tools::handlers::collab::MIN_WAIT_TIMEOUT_MS; use crate::tools::registry::ToolRegistryBuilder; use codex_protocol::config_types::WebSearchMode; use codex_protocol::dynamic_tools::DynamicToolSpec; @@ -517,7 +518,7 @@ fn create_wait_tool() -> ToolSpec { "timeout_ms".to_string(), JsonSchema::Number { description: Some(format!( - "Optional timeout in milliseconds. Defaults to {DEFAULT_WAIT_TIMEOUT_MS} and max {MAX_WAIT_TIMEOUT_MS}." + "Optional timeout in milliseconds. Defaults to {DEFAULT_WAIT_TIMEOUT_MS}, min {MIN_WAIT_TIMEOUT_MS}, and max {MAX_WAIT_TIMEOUT_MS}. Avoid tight polling loops; prefer longer waits (seconds to minutes)." )), }, ); diff --git a/codex-rs/core/templates/agents/orchestrator.md b/codex-rs/core/templates/agents/orchestrator.md index 09b6ea0b4e..c303591d48 100644 --- a/codex-rs/core/templates/agents/orchestrator.md +++ b/codex-rs/core/templates/agents/orchestrator.md @@ -52,6 +52,7 @@ You are Codex Orchestrator, based on GPT-5. You are running as an orchestration * Workers must not revert, overwrite, or conflict with others’ work. * By default, workers must not spawn sub-agents unless explicitly allowed. * When multiple workers are active, you may pass multiple IDs to `wait` to react to the first completion and keep the workflow event-driven and use a long timeout (e.g. 5 minutes). +* Do not busy-poll `wait` with very short timeouts. Prefer waits measured in seconds (or minutes) so the system is idle while workers run. ## Collab tools From e471ebc5d241bcd355855585cde8409e5dc34916 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 26 Jan 2026 10:27:18 -0800 Subject: [PATCH 09/43] prompt (#9928) # External (non-OpenAI) Pull Request Requirements Before opening this Pull Request, please read the dedicated "Contributing" markdown file or your PR may be closed: https://github.com/openai/codex/blob/main/docs/contributing.md If your PR conforms to our contribution guidelines, replace this text with a detailed and high quality description of your changes. Include a link to a bug report or enhancement request. --- codex-rs/core/templates/collaboration_mode/plan.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/codex-rs/core/templates/collaboration_mode/plan.md b/codex-rs/core/templates/collaboration_mode/plan.md index 3d0447c729..b8e0af03f1 100644 --- a/codex-rs/core/templates/collaboration_mode/plan.md +++ b/codex-rs/core/templates/collaboration_mode/plan.md @@ -2,6 +2,10 @@ You work in 2 phases and you should *chat your way* to a great plan before finalizing it. +While in **Plan Mode**, you must not perform any mutating or execution actions. Once you enter Plan Mode, you remain there until you are **explicitly instructed otherwise**. Plan Mode may continue across multiple user messages unless a developer message ends it. + +User intent, tone, or imperative language does **not** trigger a mode change. If a user asks for execution while you are still in Plan Mode, you must treat that request as a prompt to **plan the execution**, not to carry it out. + PHASE 1 — Intent chat (what they actually want) - Keep asking until you can clearly state: goal + success criteria, audience, in/out of scope, constraints, current state, and the key preferences/tradeoffs. - Bias toward questions over guessing: if any high‑impact ambiguity remains, do NOT plan yet—ask. From 09251387e07cc2fb9a311e927b685cda99e884e6 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 26 Jan 2026 20:07:54 +0100 Subject: [PATCH 10/43] chore: update interrupt message (#9925) --- codex-rs/core/src/tasks/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index a2f30aa522..bbf9d9c276 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -41,7 +41,7 @@ pub(crate) use undo::UndoTask; pub(crate) use user_shell::UserShellCommandTask; const GRACEFULL_INTERRUPTION_TIMEOUT_MS: u64 = 100; -const TURN_ABORTED_INTERRUPTED_GUIDANCE: &str = "The user interrupted the previous turn. Do not continue or repeat work from that turn unless the user explicitly asks. If any tools/commands were aborted, they may have partially executed; verify current state before retrying."; +const TURN_ABORTED_INTERRUPTED_GUIDANCE: &str = "The user interrupted the previous turn on purpose. If any tools/commands were aborted, they may have partially executed; verify current state before retrying."; /// Thin wrapper that exposes the parts of [`Session`] task runners need. #[derive(Clone)] @@ -253,7 +253,7 @@ impl Session { role: "user".to_string(), content: vec![ContentItem::InputText { text: format!( - "{TURN_ABORTED_OPEN_TAG}\n {sub_id}\n interrupted\n {TURN_ABORTED_INTERRUPTED_GUIDANCE}\n" + "{TURN_ABORTED_OPEN_TAG}\n{TURN_ABORTED_INTERRUPTED_GUIDANCE}\n" ), }], end_turn: None, From 62266b13f86422627993cc17dd8090afd9b5e77c Mon Sep 17 00:00:00 2001 From: Charley Cunningham Date: Mon, 26 Jan 2026 11:24:36 -0800 Subject: [PATCH 11/43] Add thread/unarchive to restore archived rollouts (#9843) ## Summary - Adds a new `thread/unarchive` RPC to move archived thread rollouts back into the active `sessions/` tree. ## What changed - **Protocol** - Adds `thread/unarchive` request/response types and wiring. - **Server** - Implements `thread_unarchive` in the app server. - Validates the archived rollout path and thread ID. - Restores the rollout to `sessions/YYYY/MM/DD/...` based on the rollout filename timestamp. - **Core** - Adds `find_archived_thread_path_by_id_str` helper for archived rollouts. - **Docs** - Documents the new RPC and usage example. - **Tests** - Adds an end-to-end server test that: 1) starts a thread, 2) archives it, 3) unarchives it, 4) asserts the file is restored to `sessions/`. ## How to use ```json { "method": "thread/unarchive", "id": 24, "params": { "threadId": "" } } ``` ## Author Codex Session `codex resume 019bf158-54b6-7960-a696-9d85df7e1bc1` (soon I'll make this kind of session UUID forkable by anyone with the right `session_object_storage_url` line in their config, but for now just pasting it here for my reference) --- .../src/protocol/common.rs | 4 + .../app-server-protocol/src/protocol/v2.rs | 14 ++ codex-rs/app-server/README.md | 10 ++ .../app-server/src/codex_message_processor.rs | 151 ++++++++++++++++++ .../app-server/tests/common/mcp_process.rs | 10 ++ codex-rs/app-server/tests/suite/v2/mod.rs | 1 + .../tests/suite/v2/thread_unarchive.rs | 101 ++++++++++++ codex-rs/core/src/lib.rs | 2 + codex-rs/core/src/rollout/list.rs | 38 ++++- codex-rs/core/src/rollout/mod.rs | 2 + codex-rs/core/src/rollout/tests.rs | 12 ++ .../core/tests/suite/rollout_list_find.rs | 26 ++- codex-rs/docs/codex_mcp_interface.md | 4 + 13 files changed, 367 insertions(+), 8 deletions(-) create mode 100644 codex-rs/app-server/tests/suite/v2/thread_unarchive.rs diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 75acf96f2a..e32b060902 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -117,6 +117,10 @@ client_request_definitions! { params: v2::ThreadArchiveParams, response: v2::ThreadArchiveResponse, }, + ThreadUnarchive => "thread/unarchive" { + params: v2::ThreadUnarchiveParams, + response: v2::ThreadUnarchiveResponse, + }, ThreadRollback => "thread/rollback" { params: v2::ThreadRollbackParams, response: v2::ThreadRollbackResponse, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 28954c7cde..049340a284 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1223,6 +1223,20 @@ pub struct ThreadArchiveParams { #[ts(export_to = "v2/")] pub struct ThreadArchiveResponse {} +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadUnarchiveParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadUnarchiveResponse { + pub thread: Thread, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index a4860ea512..38c3ecee55 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -81,6 +81,7 @@ Example (from OpenAI's official VSCode extension): - `thread/loaded/list` — list the thread ids currently loaded in memory. - `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. - `thread/archive` — move a thread’s rollout file into the archived directory; returns `{}` on success. +- `thread/unarchive` — move an archived rollout file back into the sessions directory; returns the restored `thread` on success. - `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success. - `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. - `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. @@ -223,6 +224,15 @@ Use `thread/archive` to move the persisted rollout (stored as a JSONL file on di An archived thread will not appear in `thread/list` unless `archived` is set to `true`. +### Example: Unarchive a thread + +Use `thread/unarchive` to move an archived rollout back into the sessions directory. + +```json +{ "method": "thread/unarchive", "id": 24, "params": { "threadId": "thr_b" } } +{ "id": 24, "result": { "thread": { "id": "thr_b" } } } +``` + ### Example: Start a turn (send user input) Turns attach user input (text or images) to a thread and trigger Codex generation. The `input` field is a list of discriminated unions: diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index ab2f31fc51..643d7b0c7b 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -114,6 +114,8 @@ use codex_app_server_protocol::ThreadSortKey; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStartedNotification; +use codex_app_server_protocol::ThreadUnarchiveParams; +use codex_app_server_protocol::ThreadUnarchiveResponse; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnError; use codex_app_server_protocol::TurnInterruptParams; @@ -151,6 +153,7 @@ use codex_core::error::CodexErr; use codex_core::exec::ExecParams; use codex_core::exec_env::create_env; use codex_core::features::Feature; +use codex_core::find_archived_thread_path_by_id_str; use codex_core::find_thread_path_by_id_str; use codex_core::git_info::git_diff_to_remote; use codex_core::mcp::collect_mcp_snapshot; @@ -164,6 +167,7 @@ use codex_core::protocol::ReviewTarget as CoreReviewTarget; use codex_core::protocol::SessionConfiguredEvent; use codex_core::read_head_for_summary; use codex_core::read_session_meta_line; +use codex_core::rollout_date_parts; use codex_core::sandboxing::SandboxPermissions; use codex_feedback::CodexFeedback; use codex_login::ServerOptions as LoginServerOptions; @@ -404,6 +408,9 @@ impl CodexMessageProcessor { ClientRequest::ThreadArchive { request_id, params } => { self.thread_archive(request_id, params).await; } + ClientRequest::ThreadUnarchive { request_id, params } => { + self.thread_unarchive(request_id, params).await; + } ClientRequest::ThreadRollback { request_id, params } => { self.thread_rollback(request_id, params).await; } @@ -1643,6 +1650,150 @@ impl CodexMessageProcessor { } } + async fn thread_unarchive(&mut self, request_id: RequestId, params: ThreadUnarchiveParams) { + let thread_id = match ThreadId::from_string(¶ms.thread_id) { + Ok(id) => id, + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("invalid thread id: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + let archived_path = match find_archived_thread_path_by_id_str( + &self.config.codex_home, + &thread_id.to_string(), + ) + .await + { + Ok(Some(path)) => path, + Ok(None) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("no archived rollout found for thread id {thread_id}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("failed to locate archived thread id {thread_id}: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + let rollout_path_display = archived_path.display().to_string(); + let fallback_provider = self.config.model_provider_id.clone(); + let archived_folder = self + .config + .codex_home + .join(codex_core::ARCHIVED_SESSIONS_SUBDIR); + + let result: Result = async { + let canonical_archived_dir = tokio::fs::canonicalize(&archived_folder).await.map_err( + |err| JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!( + "failed to unarchive thread: unable to resolve archived directory: {err}" + ), + data: None, + }, + )?; + let canonical_rollout_path = tokio::fs::canonicalize(&archived_path).await; + let canonical_rollout_path = if let Ok(path) = canonical_rollout_path + && path.starts_with(&canonical_archived_dir) + { + path + } else { + return Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!( + "rollout path `{rollout_path_display}` must be in archived directory" + ), + data: None, + }); + }; + + let required_suffix = format!("{thread_id}.jsonl"); + let Some(file_name) = canonical_rollout_path.file_name().map(OsStr::to_owned) else { + return Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("rollout path `{rollout_path_display}` missing file name"), + data: None, + }); + }; + if !file_name + .to_string_lossy() + .ends_with(required_suffix.as_str()) + { + return Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!( + "rollout path `{rollout_path_display}` does not match thread id {thread_id}" + ), + data: None, + }); + } + + let Some((year, month, day)) = rollout_date_parts(&file_name) else { + return Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!( + "rollout path `{rollout_path_display}` missing filename timestamp" + ), + data: None, + }); + }; + + let sessions_folder = self.config.codex_home.join(codex_core::SESSIONS_SUBDIR); + let dest_dir = sessions_folder.join(year).join(month).join(day); + let restored_path = dest_dir.join(&file_name); + tokio::fs::create_dir_all(&dest_dir) + .await + .map_err(|err| JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to unarchive thread: {err}"), + data: None, + })?; + tokio::fs::rename(&canonical_rollout_path, &restored_path) + .await + .map_err(|err| JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to unarchive thread: {err}"), + data: None, + })?; + let summary = + read_summary_from_rollout(restored_path.as_path(), fallback_provider.as_str()) + .await + .map_err(|err| JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to read unarchived thread: {err}"), + data: None, + })?; + Ok(summary_to_thread(summary)) + } + .await; + + match result { + Ok(thread) => { + let response = ThreadUnarchiveResponse { thread }; + self.outgoing.send_response(request_id, response).await; + } + Err(err) => { + self.outgoing.send_error(request_id, err).await; + } + } + } + async fn thread_rollback(&mut self, request_id: RequestId, params: ThreadRollbackParams) { let ThreadRollbackParams { thread_id, diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 0be10c5fc6..fa160b4c41 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -53,6 +53,7 @@ use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadRollbackParams; use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadUnarchiveParams; use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnStartParams; use codex_core::default_client::CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR; @@ -365,6 +366,15 @@ impl McpProcess { self.send_request("thread/archive", params).await } + /// Send a `thread/unarchive` JSON-RPC request. + pub async fn send_thread_unarchive_request( + &mut self, + params: ThreadUnarchiveParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/unarchive", params).await + } + /// Send a `thread/rollback` JSON-RPC request. pub async fn send_thread_rollback_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 4d9e105147..09ec866781 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -18,5 +18,6 @@ mod thread_read; mod thread_resume; mod thread_rollback; mod thread_start; +mod thread_unarchive; mod turn_interrupt; mod turn_start; diff --git a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs new file mode 100644 index 0000000000..c56b39e33b --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs @@ -0,0 +1,101 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadArchiveParams; +use codex_app_server_protocol::ThreadArchiveResponse; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::ThreadUnarchiveParams; +use codex_app_server_protocol::ThreadUnarchiveResponse; +use codex_core::find_archived_thread_path_by_id_str; +use codex_core::find_thread_path_by_id_str; +use std::path::Path; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + +#[tokio::test] +async fn thread_unarchive_moves_rollout_back_into_sessions_directory() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let rollout_path = find_thread_path_by_id_str(codex_home.path(), &thread.id) + .await? + .expect("expected rollout path for thread id to exist"); + + let archive_id = mcp + .send_thread_archive_request(ThreadArchiveParams { + thread_id: thread.id.clone(), + }) + .await?; + let archive_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(archive_id)), + ) + .await??; + let _: ThreadArchiveResponse = to_response::(archive_resp)?; + + let archived_path = find_archived_thread_path_by_id_str(codex_home.path(), &thread.id) + .await? + .expect("expected archived rollout path for thread id to exist"); + let archived_path_display = archived_path.display(); + assert!( + archived_path.exists(), + "expected {archived_path_display} to exist" + ); + + let unarchive_id = mcp + .send_thread_unarchive_request(ThreadUnarchiveParams { + thread_id: thread.id.clone(), + }) + .await?; + let unarchive_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(unarchive_id)), + ) + .await??; + let _: ThreadUnarchiveResponse = to_response::(unarchive_resp)?; + + let rollout_path_display = rollout_path.display(); + assert!( + rollout_path.exists(), + "expected rollout path {rollout_path_display} to be restored" + ); + assert!( + !archived_path.exists(), + "expected archived rollout path {archived_path_display} to be moved" + ); + + Ok(()) +} + +fn create_config_toml(codex_home: &Path) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write(config_toml, config_contents()) +} + +fn config_contents() -> &'static str { + r#"model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +"# +} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 7e1d80ace5..f662f41b21 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -98,6 +98,7 @@ pub use rollout::INTERACTIVE_SESSION_SOURCES; pub use rollout::RolloutRecorder; pub use rollout::SESSIONS_SUBDIR; pub use rollout::SessionMeta; +pub use rollout::find_archived_thread_path_by_id_str; #[deprecated(note = "use find_thread_path_by_id_str")] pub use rollout::find_conversation_path_by_id_str; pub use rollout::find_thread_path_by_id_str; @@ -108,6 +109,7 @@ pub use rollout::list::ThreadsPage; pub use rollout::list::parse_cursor; pub use rollout::list::read_head_for_summary; pub use rollout::list::read_session_meta_line; +pub use rollout::rollout_date_parts; mod function_tool; mod state; mod tasks; diff --git a/codex-rs/core/src/rollout/list.rs b/codex-rs/core/src/rollout/list.rs index 14b2addc55..9609f366b2 100644 --- a/codex-rs/core/src/rollout/list.rs +++ b/codex-rs/core/src/rollout/list.rs @@ -1,4 +1,5 @@ use std::cmp::Reverse; +use std::ffi::OsStr; use std::io::{self}; use std::num::NonZero; use std::ops::ControlFlow; @@ -15,6 +16,7 @@ use time::format_description::well_known::Rfc3339; use time::macros::format_description; use uuid::Uuid; +use super::ARCHIVED_SESSIONS_SUBDIR; use super::SESSIONS_SUBDIR; use crate::protocol::EventMsg; use codex_file_search as file_search; @@ -1054,11 +1056,9 @@ fn truncate_to_seconds(dt: OffsetDateTime) -> Option { dt.replace_nanosecond(0).ok() } -/// Locate a recorded thread rollout file by its UUID string using the existing -/// paginated listing implementation. Returns `Ok(Some(path))` if found, `Ok(None)` if not present -/// or the id is invalid. -pub async fn find_thread_path_by_id_str( +async fn find_thread_path_by_id_str_in_subdir( codex_home: &Path, + subdir: &str, id_str: &str, ) -> io::Result> { // Validate UUID format early. @@ -1067,7 +1067,7 @@ pub async fn find_thread_path_by_id_str( } let mut root = codex_home.to_path_buf(); - root.push(SESSIONS_SUBDIR); + root.push(subdir); if !root.exists() { return Ok(None); } @@ -1099,3 +1099,31 @@ pub async fn find_thread_path_by_id_str( .next() .map(|m| root.join(m.path))) } + +/// Locate a recorded thread rollout file by its UUID string using the existing +/// paginated listing implementation. Returns `Ok(Some(path))` if found, `Ok(None)` if not present +/// or the id is invalid. +pub async fn find_thread_path_by_id_str( + codex_home: &Path, + id_str: &str, +) -> io::Result> { + find_thread_path_by_id_str_in_subdir(codex_home, SESSIONS_SUBDIR, id_str).await +} + +/// Locate an archived thread rollout file by its UUID string. +pub async fn find_archived_thread_path_by_id_str( + codex_home: &Path, + id_str: &str, +) -> io::Result> { + find_thread_path_by_id_str_in_subdir(codex_home, ARCHIVED_SESSIONS_SUBDIR, id_str).await +} + +/// Extract the `YYYY/MM/DD` directory components from a rollout filename. +pub fn rollout_date_parts(file_name: &OsStr) -> Option<(String, String, String)> { + let name = file_name.to_string_lossy(); + let date = name.strip_prefix("rollout-")?.get(..10)?; + let year = date.get(..4)?.to_string(); + let month = date.get(5..7)?.to_string(); + let day = date.get(8..10)?.to_string(); + Some((year, month, day)) +} diff --git a/codex-rs/core/src/rollout/mod.rs b/codex-rs/core/src/rollout/mod.rs index 5b65bada7c..fbddfecf35 100644 --- a/codex-rs/core/src/rollout/mod.rs +++ b/codex-rs/core/src/rollout/mod.rs @@ -15,9 +15,11 @@ pub(crate) mod truncation; pub use codex_protocol::protocol::SessionMeta; pub(crate) use error::map_session_init_error; +pub use list::find_archived_thread_path_by_id_str; pub use list::find_thread_path_by_id_str; #[deprecated(note = "use find_thread_path_by_id_str")] pub use list::find_thread_path_by_id_str as find_conversation_path_by_id_str; +pub use list::rollout_date_parts; pub use recorder::RolloutRecorder; pub use recorder::RolloutRecorderParams; diff --git a/codex-rs/core/src/rollout/tests.rs b/codex-rs/core/src/rollout/tests.rs index 75bf0cc3f7..34afae2c94 100644 --- a/codex-rs/core/src/rollout/tests.rs +++ b/codex-rs/core/src/rollout/tests.rs @@ -1,5 +1,6 @@ #![allow(clippy::unwrap_used, clippy::expect_used)] +use std::ffi::OsStr; use std::fs::File; use std::fs::FileTimes; use std::fs::{self}; @@ -21,6 +22,7 @@ use crate::rollout::list::ThreadItem; use crate::rollout::list::ThreadSortKey; use crate::rollout::list::ThreadsPage; use crate::rollout::list::get_threads; +use crate::rollout::rollout_date_parts; use anyhow::Result; use codex_protocol::ThreadId; use codex_protocol::models::ContentItem; @@ -43,6 +45,16 @@ fn provider_vec(providers: &[&str]) -> Vec { .collect() } +#[test] +fn rollout_date_parts_extracts_directory_components() { + let file_name = OsStr::new("rollout-2025-03-01T09-00-00-123.jsonl"); + let parts = rollout_date_parts(file_name); + assert_eq!( + parts, + Some(("2025".to_string(), "03".to_string(), "01".to_string())) + ); +} + fn write_session_file( root: &Path, ts_str: &str, diff --git a/codex-rs/core/tests/suite/rollout_list_find.rs b/codex-rs/core/tests/suite/rollout_list_find.rs index a4213729bc..2af6192dbe 100644 --- a/codex-rs/core/tests/suite/rollout_list_find.rs +++ b/codex-rs/core/tests/suite/rollout_list_find.rs @@ -3,14 +3,15 @@ use std::io::Write; use std::path::Path; use std::path::PathBuf; +use codex_core::find_archived_thread_path_by_id_str; use codex_core::find_thread_path_by_id_str; use tempfile::TempDir; use uuid::Uuid; -/// Create sessions/YYYY/MM/DD and write a minimal rollout file containing the +/// Create /YYYY/MM/DD and write a minimal rollout file containing the /// provided conversation id in the SessionMeta line. Returns the absolute path. -fn write_minimal_rollout_with_id(codex_home: &Path, id: Uuid) -> PathBuf { - let sessions = codex_home.join("sessions/2024/01/01"); +fn write_minimal_rollout_with_id_in_subdir(codex_home: &Path, subdir: &str, id: Uuid) -> PathBuf { + let sessions = codex_home.join(subdir).join("2024/01/01"); std::fs::create_dir_all(&sessions).unwrap(); let file = sessions.join(format!("rollout-2024-01-01T00-00-00-{id}.jsonl")); @@ -37,6 +38,12 @@ fn write_minimal_rollout_with_id(codex_home: &Path, id: Uuid) -> PathBuf { file } +/// Create sessions/YYYY/MM/DD and write a minimal rollout file containing the +/// provided conversation id in the SessionMeta line. Returns the absolute path. +fn write_minimal_rollout_with_id(codex_home: &Path, id: Uuid) -> PathBuf { + write_minimal_rollout_with_id_in_subdir(codex_home, "sessions", id) +} + #[tokio::test] async fn find_locates_rollout_file_by_id() { let home = TempDir::new().unwrap(); @@ -79,3 +86,16 @@ async fn find_ignores_granular_gitignore_rules() { assert_eq!(found, Some(expected)); } + +#[tokio::test] +async fn find_archived_locates_rollout_file_by_id() { + let home = TempDir::new().unwrap(); + let id = Uuid::new_v4(); + let expected = write_minimal_rollout_with_id_in_subdir(home.path(), "archived_sessions", id); + + let found = find_archived_thread_path_by_id_str(home.path(), &id.to_string()) + .await + .unwrap(); + + assert_eq!(found, Some(expected)); +} diff --git a/codex-rs/docs/codex_mcp_interface.md b/codex-rs/docs/codex_mcp_interface.md index 2c8e124c1f..edd693d5ed 100644 --- a/codex-rs/docs/codex_mcp_interface.md +++ b/codex-rs/docs/codex_mcp_interface.md @@ -79,6 +79,10 @@ Interrupt a running turn: `interruptConversation`. List/resume/archive: `listConversations`, `resumeConversation`, `archiveConversation`. +For v2 threads, use `thread/list` with `archived: true` to list archived rollouts and +`thread/unarchive` to restore them to the active sessions directory (it returns the restored +thread summary). + ## Models Fetch the catalog of models available in the current Codex build with `model/list`. The request accepts optional pagination inputs: From b77bf4d36d76bcf069d1edb3daa0a01bea7367b3 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 26 Jan 2026 11:43:36 -0800 Subject: [PATCH 12/43] Aligned feature stage names with public feature maturity stages (#9929) We've recently standardized a [feature maturity model](https://developers.openai.com/codex/feature-maturity) that we're using in our docs and support forums to communicate expectations to users. This PR updates the internal stage names and descriptions to match. This change involves a simple internal rename and updates to a few user-visible strings. No functional change. --- codex-rs/cli/src/main.rs | 4 +- codex-rs/core/src/client.rs | 8 ++-- codex-rs/core/src/features.rs | 40 +++++++++---------- .../bottom_pane/experimental_features_view.rs | 11 +++-- codex-rs/tui/src/bottom_pane/mod.rs | 2 +- codex-rs/tui/src/chatwidget.rs | 10 ++--- ...t__tests__experimental_features_popup.snap | 2 +- codex-rs/tui/src/chatwidget/tests.rs | 6 +-- codex-rs/tui/src/slash_command.rs | 2 +- codex-rs/tui/src/tooltips.rs | 6 +-- 10 files changed, 48 insertions(+), 43 deletions(-) diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 103cc4b349..583a2e32e8 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -453,8 +453,8 @@ enum FeaturesSubcommand { fn stage_str(stage: codex_core::features::Stage) -> &'static str { use codex_core::features::Stage; match stage { - Stage::Beta => "experimental", - Stage::Experimental { .. } => "beta", + Stage::UnderDevelopment => "under development", + Stage::Experimental { .. } => "experimental", Stage::Stable => "stable", Stage::Deprecated => "deprecated", Stage::Removed => "removed", diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 437380deef..f415c3604a 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -625,11 +625,13 @@ fn build_api_prompt(prompt: &Prompt, instructions: String, tools_json: Vec ApiHeaderMap { +fn experimental_feature_headers(config: &Config) -> ApiHeaderMap { let enabled = FEATURES .iter() .filter_map(|spec| { - if spec.stage.beta_menu_description().is_some() && config.features.enabled(spec.id) { + if spec.stage.experimental_menu_description().is_some() + && config.features.enabled(spec.id) + { Some(spec.key) } else { None @@ -650,7 +652,7 @@ fn build_responses_headers( config: &Config, turn_state: Option<&Arc>>, ) -> ApiHeaderMap { - let mut headers = beta_feature_headers(config); + let mut headers = experimental_feature_headers(config); headers.insert( WEB_SEARCH_ELIGIBLE_HEADER, HeaderValue::from_static( diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 5e2a0d5d50..07cc098bec 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -21,8 +21,8 @@ pub(crate) use legacy::legacy_feature_keys; /// High-level lifecycle stage for a feature. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Stage { - /// Closed beta features to be used while developing or within the company. - Beta, + /// Features that are still under development, not ready for external use + UnderDevelopment, /// Experimental features made available to users through the `/experimental` menu Experimental { name: &'static str, @@ -38,14 +38,14 @@ pub enum Stage { } impl Stage { - pub fn beta_menu_name(self) -> Option<&'static str> { + pub fn experimental_menu_name(self) -> Option<&'static str> { match self { Stage::Experimental { name, .. } => Some(name), _ => None, } } - pub fn beta_menu_description(self) -> Option<&'static str> { + pub fn experimental_menu_description(self) -> Option<&'static str> { match self { Stage::Experimental { menu_description, .. @@ -54,7 +54,7 @@ impl Stage { } } - pub fn beta_announcement(self) -> Option<&'static str> { + pub fn experimental_announcement(self) -> Option<&'static str> { match self { Stage::Experimental { announcement, .. } => Some(announcement), _ => None, @@ -343,10 +343,10 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::WebSearchCached, key: "web_search_cached", - stage: Stage::Beta, + stage: Stage::UnderDevelopment, default_enabled: false, }, - // Beta program. Rendered in the `/experimental` menu for users. + // Experimental program. Rendered in the `/experimental` menu for users. FeatureSpec { id: Feature::UnifiedExec, key: "unified_exec", @@ -370,43 +370,43 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::ChildAgentsMd, key: "child_agents_md", - stage: Stage::Beta, + stage: Stage::UnderDevelopment, default_enabled: false, }, FeatureSpec { id: Feature::ApplyPatchFreeform, key: "apply_patch_freeform", - stage: Stage::Beta, + stage: Stage::UnderDevelopment, default_enabled: false, }, FeatureSpec { id: Feature::ExecPolicy, key: "exec_policy", - stage: Stage::Beta, + stage: Stage::UnderDevelopment, default_enabled: true, }, FeatureSpec { id: Feature::WindowsSandbox, key: "experimental_windows_sandbox", - stage: Stage::Beta, + stage: Stage::UnderDevelopment, default_enabled: false, }, FeatureSpec { id: Feature::WindowsSandboxElevated, key: "elevated_windows_sandbox", - stage: Stage::Beta, + stage: Stage::UnderDevelopment, default_enabled: false, }, FeatureSpec { id: Feature::RemoteCompaction, key: "remote_compaction", - stage: Stage::Beta, + stage: Stage::UnderDevelopment, default_enabled: true, }, FeatureSpec { id: Feature::RemoteModels, key: "remote_models", - stage: Stage::Beta, + stage: Stage::UnderDevelopment, default_enabled: true, }, FeatureSpec { @@ -421,26 +421,26 @@ pub const FEATURES: &[FeatureSpec] = &[ #[cfg(windows)] default_enabled: true, #[cfg(not(windows))] - stage: Stage::Beta, + stage: Stage::UnderDevelopment, #[cfg(not(windows))] default_enabled: false, }, FeatureSpec { id: Feature::EnableRequestCompression, key: "enable_request_compression", - stage: Stage::Beta, + stage: Stage::UnderDevelopment, default_enabled: false, }, FeatureSpec { id: Feature::Collab, key: "collab", - stage: Stage::Beta, + stage: Stage::UnderDevelopment, default_enabled: false, }, FeatureSpec { id: Feature::Connectors, key: "connectors", - stage: Stage::Beta, + stage: Stage::UnderDevelopment, default_enabled: false, }, FeatureSpec { @@ -456,13 +456,13 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::CollaborationModes, key: "collaboration_modes", - stage: Stage::Beta, + stage: Stage::UnderDevelopment, default_enabled: false, }, FeatureSpec { id: Feature::ResponsesWebsockets, key: "responses_websockets", - stage: Stage::Beta, + stage: Stage::UnderDevelopment, default_enabled: false, }, ]; diff --git a/codex-rs/tui/src/bottom_pane/experimental_features_view.rs b/codex-rs/tui/src/bottom_pane/experimental_features_view.rs index dd0e84eb6d..07cfce163f 100644 --- a/codex-rs/tui/src/bottom_pane/experimental_features_view.rs +++ b/codex-rs/tui/src/bottom_pane/experimental_features_view.rs @@ -29,7 +29,7 @@ use super::selection_popup_common::GenericDisplayRow; use super::selection_popup_common::measure_rows_height; use super::selection_popup_common::render_rows; -pub(crate) struct BetaFeatureItem { +pub(crate) struct ExperimentalFeatureItem { pub feature: Feature, pub name: String, pub description: String, @@ -37,7 +37,7 @@ pub(crate) struct BetaFeatureItem { } pub(crate) struct ExperimentalFeaturesView { - features: Vec, + features: Vec, state: ScrollState, complete: bool, app_event_tx: AppEventSender, @@ -46,11 +46,14 @@ pub(crate) struct ExperimentalFeaturesView { } impl ExperimentalFeaturesView { - pub(crate) fn new(features: Vec, app_event_tx: AppEventSender) -> Self { + pub(crate) fn new( + features: Vec, + app_event_tx: AppEventSender, + ) -> Self { let mut header = ColumnRenderable::new(); header.push(Line::from("Experimental features".bold())); header.push(Line::from( - "Toggle beta features. Changes are saved to config.toml.".dim(), + "Toggle experimental features. Changes are saved to config.toml.".dim(), )); let mut view = Self { diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index a570bfb384..aea847d212 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -108,7 +108,7 @@ pub(crate) use chat_composer::InputResult; use codex_protocol::custom_prompts::CustomPrompt; use crate::status_indicator_widget::StatusIndicatorWidget; -pub(crate) use experimental_features_view::BetaFeatureItem; +pub(crate) use experimental_features_view::ExperimentalFeatureItem; pub(crate) use experimental_features_view::ExperimentalFeaturesView; pub(crate) use list_selection_view::SelectionAction; pub(crate) use list_selection_view::SelectionItem; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index aa420a3ea9..6f45ec25ce 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -133,12 +133,12 @@ use crate::app_event::WindowsSandboxEnableMode; use crate::app_event::WindowsSandboxFallbackReason; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ApprovalRequest; -use crate::bottom_pane::BetaFeatureItem; use crate::bottom_pane::BottomPane; use crate::bottom_pane::BottomPaneParams; use crate::bottom_pane::CancellationEvent; use crate::bottom_pane::CollaborationModeIndicator; use crate::bottom_pane::DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED; +use crate::bottom_pane::ExperimentalFeatureItem; use crate::bottom_pane::ExperimentalFeaturesView; use crate::bottom_pane::InputResult; use crate::bottom_pane::LocalImageAttachment; @@ -4029,12 +4029,12 @@ impl ChatWidget { } pub(crate) fn open_experimental_popup(&mut self) { - let features: Vec = FEATURES + let features: Vec = FEATURES .iter() .filter_map(|spec| { - let name = spec.stage.beta_menu_name()?; - let description = spec.stage.beta_menu_description()?; - Some(BetaFeatureItem { + let name = spec.stage.experimental_menu_name()?; + let description = spec.stage.experimental_menu_description()?; + Some(ExperimentalFeatureItem { feature: spec.id, name: name.to_string(), description: description.to_string(), diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_features_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_features_popup.snap index c0de5e4eef..4101717cf9 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_features_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__experimental_features_popup.snap @@ -3,7 +3,7 @@ source: tui/src/chatwidget/tests.rs expression: popup --- Experimental features - Toggle beta features. Changes are saved to config.toml. + Toggle experimental features. Changes are saved to config.toml. › [ ] Ghost snapshots Capture undo snapshots each turn. [x] Shell tool Allow the model to run shell commands. diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index fab175edcf..44aa0340cb 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -2911,13 +2911,13 @@ async fn experimental_features_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; let features = vec![ - BetaFeatureItem { + ExperimentalFeatureItem { feature: Feature::GhostCommit, name: "Ghost snapshots".to_string(), description: "Capture undo snapshots each turn.".to_string(), enabled: false, }, - BetaFeatureItem { + ExperimentalFeatureItem { feature: Feature::ShellTool, name: "Shell tool".to_string(), description: "Allow the model to run shell commands.".to_string(), @@ -2937,7 +2937,7 @@ async fn experimental_features_toggle_saves_on_exit() { let expected_feature = Feature::GhostCommit; let view = ExperimentalFeaturesView::new( - vec![BetaFeatureItem { + vec![ExperimentalFeatureItem { feature: expected_feature, name: "Ghost snapshots".to_string(), description: "Capture undo snapshots each turn.".to_string(), diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 28d27d2301..806cc39e04 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -67,7 +67,7 @@ impl SlashCommand { SlashCommand::Approvals => "choose what Codex can do without approval", SlashCommand::Permissions => "choose what Codex is allowed to do", SlashCommand::ElevateSandbox => "set up elevated agent sandbox", - SlashCommand::Experimental => "toggle beta features", + SlashCommand::Experimental => "toggle experimental features", SlashCommand::Mcp => "list configured MCP tools", SlashCommand::Logout => "log out of Codex", SlashCommand::Rollout => "print the rollout file path", diff --git a/codex-rs/tui/src/tooltips.rs b/codex-rs/tui/src/tooltips.rs index 8acfcce892..1b59eb4f80 100644 --- a/codex-rs/tui/src/tooltips.rs +++ b/codex-rs/tui/src/tooltips.rs @@ -15,15 +15,15 @@ lazy_static! { static ref ALL_TOOLTIPS: Vec<&'static str> = { let mut tips = Vec::new(); tips.extend(TOOLTIPS.iter().copied()); - tips.extend(beta_tooltips()); + tips.extend(experimental_tooltips()); tips }; } -fn beta_tooltips() -> Vec<&'static str> { +fn experimental_tooltips() -> Vec<&'static str> { FEATURES .iter() - .filter_map(|spec| spec.stage.beta_announcement()) + .filter_map(|spec| spec.stage.experimental_announcement()) .collect() } From 6a02fdde76e3d3d4eebaa5c008802a5c61e1c2f2 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Mon, 26 Jan 2026 13:39:00 -0800 Subject: [PATCH 13/43] ensure codex bundle zip is created in dist/ (#9934) cd-ing into the tmp bundle directory was putting the .zip in the wrong place --- .github/workflows/rust-release.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 3625c830dd..35b91e25d5 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -303,7 +303,9 @@ jobs: cp "$dest/$base" "$bundle_dir/$base" cp "$runner_src" "$bundle_dir/codex-command-runner.exe" cp "$setup_src" "$bundle_dir/codex-windows-sandbox-setup.exe" - (cd "$bundle_dir" && 7z a "$dest/${base}.zip" .) + # Use an absolute path so bundle zips land in the real dist + # dir even when 7z runs from a temp directory. + (cd "$bundle_dir" && 7z a "$(pwd)/$dest/${base}.zip" .) else echo "warning: missing sandbox binaries; falling back to single-binary zip" echo "warning: expected $runner_src and $setup_src" From 247fb2de647e60ebdfba41e4f10e317193e39406 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 26 Jan 2026 21:54:19 +0000 Subject: [PATCH 14/43] [app-server] feat: add filtering on thread list (#9897) --- .../app-server-protocol/src/protocol/v2.rs | 21 ++ codex-rs/app-server/README.md | 1 + .../app-server/src/codex_message_processor.rs | 20 +- codex-rs/app-server/src/filters.rs | 155 ++++++++++++ codex-rs/app-server/src/lib.rs | 1 + codex-rs/app-server/tests/common/lib.rs | 1 + codex-rs/app-server/tests/common/rollout.rs | 23 +- .../app-server/tests/suite/v2/thread_list.rs | 229 +++++++++++++++++- codex-rs/debug-client/src/client.rs | 1 + 9 files changed, 447 insertions(+), 5 deletions(-) create mode 100644 codex-rs/app-server/src/filters.rs diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 049340a284..699ad5f52a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1274,11 +1274,32 @@ pub struct ThreadListParams { /// Optional provider filter; when set, only sessions recorded under these /// providers are returned. When present but empty, includes all providers. pub model_providers: Option>, + /// Optional source filter; when set, only sessions from these source kinds + /// are returned. When omitted or empty, defaults to interactive sources. + pub source_kinds: Option>, /// Optional archived filter; when set to true, only archived threads are returned. /// If false or null, only non-archived threads are returned. pub archived: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub enum ThreadSourceKind { + Cli, + #[serde(rename = "vscode")] + #[ts(rename = "vscode")] + VsCode, + Exec, + AppServer, + SubAgent, + SubAgentReview, + SubAgentCompact, + SubAgentThreadSpawn, + SubAgentOther, + Unknown, +} + #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 38c3ecee55..adf8ef0b45 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -167,6 +167,7 @@ To branch from a stored session, call `thread/fork` with the `thread.id`. This c - `limit` — server defaults to a reasonable page size if unset. - `sortKey` — `created_at` (default) or `updated_at`. - `modelProviders` — restrict results to specific providers; unset, null, or an empty array will include all providers. +- `sourceKinds` — restrict results to specific sources; omit or pass `[]` for interactive sessions only (`cli`, `vscode`). - `archived` — when `true`, list archived threads only. When `false` or `null`, list non-archived threads (default). Example: diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 643d7b0c7b..42a90d2e96 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -111,6 +111,7 @@ use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadRollbackParams; use codex_app_server_protocol::ThreadSortKey; +use codex_app_server_protocol::ThreadSourceKind; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStartedNotification; @@ -132,7 +133,6 @@ use codex_chatgpt::connectors; use codex_core::AuthManager; use codex_core::CodexThread; use codex_core::Cursor as RolloutCursor; -use codex_core::INTERACTIVE_SESSION_SOURCES; use codex_core::InitialHistory; use codex_core::NewThread; use codex_core::RolloutRecorder; @@ -209,6 +209,9 @@ use tracing::info; use tracing::warn; use uuid::Uuid; +use crate::filters::compute_source_filters; +use crate::filters::source_kind_matches; + type PendingInterruptQueue = Vec<(RequestId, ApiVersion)>; pub(crate) type PendingInterrupts = Arc>>; @@ -1845,6 +1848,7 @@ impl CodexMessageProcessor { limit, sort_key, model_providers, + source_kinds, archived, } = params; @@ -1861,6 +1865,7 @@ impl CodexMessageProcessor { requested_page_size, cursor, model_providers, + source_kinds, core_sort_key, archived.unwrap_or(false), ) @@ -2538,6 +2543,7 @@ impl CodexMessageProcessor { requested_page_size, cursor, model_providers, + None, CoreThreadSortKey::UpdatedAt, false, ) @@ -2558,6 +2564,7 @@ impl CodexMessageProcessor { requested_page_size: usize, cursor: Option, model_providers: Option>, + source_kinds: Option>, sort_key: CoreThreadSortKey, archived: bool, ) -> Result<(Vec, Option), JSONRPCErrorError> { @@ -2587,6 +2594,8 @@ impl CodexMessageProcessor { None => Some(vec![self.config.model_provider_id.clone()]), }; let fallback_provider = self.config.model_provider_id.clone(); + let (allowed_sources_vec, source_kind_filter) = compute_source_filters(source_kinds); + let allowed_sources = allowed_sources_vec.as_slice(); while remaining > 0 { let page_size = remaining.min(THREAD_LIST_MAX_LIMIT); @@ -2596,7 +2605,7 @@ impl CodexMessageProcessor { page_size, cursor_obj.as_ref(), sort_key, - INTERACTIVE_SESSION_SOURCES, + allowed_sources, model_provider_filter.as_deref(), fallback_provider.as_str(), ) @@ -2612,7 +2621,7 @@ impl CodexMessageProcessor { page_size, cursor_obj.as_ref(), sort_key, - INTERACTIVE_SESSION_SOURCES, + allowed_sources, model_provider_filter.as_deref(), fallback_provider.as_str(), ) @@ -2641,6 +2650,11 @@ impl CodexMessageProcessor { updated_at, ) }) + .filter(|summary| { + source_kind_filter + .as_ref() + .is_none_or(|filter| source_kind_matches(&summary.source, filter)) + }) .collect::>(); if filtered.len() > remaining { filtered.truncate(remaining); diff --git a/codex-rs/app-server/src/filters.rs b/codex-rs/app-server/src/filters.rs new file mode 100644 index 0000000000..bd784c3dcc --- /dev/null +++ b/codex-rs/app-server/src/filters.rs @@ -0,0 +1,155 @@ +use codex_app_server_protocol::ThreadSourceKind; +use codex_core::INTERACTIVE_SESSION_SOURCES; +use codex_protocol::protocol::SessionSource as CoreSessionSource; +use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; + +pub(crate) fn compute_source_filters( + source_kinds: Option>, +) -> (Vec, Option>) { + let Some(source_kinds) = source_kinds else { + return (INTERACTIVE_SESSION_SOURCES.to_vec(), None); + }; + + if source_kinds.is_empty() { + return (INTERACTIVE_SESSION_SOURCES.to_vec(), None); + } + + let requires_post_filter = source_kinds.iter().any(|kind| { + matches!( + kind, + ThreadSourceKind::Exec + | ThreadSourceKind::AppServer + | ThreadSourceKind::SubAgent + | ThreadSourceKind::SubAgentReview + | ThreadSourceKind::SubAgentCompact + | ThreadSourceKind::SubAgentThreadSpawn + | ThreadSourceKind::SubAgentOther + | ThreadSourceKind::Unknown + ) + }); + + if requires_post_filter { + (Vec::new(), Some(source_kinds)) + } else { + let interactive_sources = source_kinds + .iter() + .filter_map(|kind| match kind { + ThreadSourceKind::Cli => Some(CoreSessionSource::Cli), + ThreadSourceKind::VsCode => Some(CoreSessionSource::VSCode), + ThreadSourceKind::Exec + | ThreadSourceKind::AppServer + | ThreadSourceKind::SubAgent + | ThreadSourceKind::SubAgentReview + | ThreadSourceKind::SubAgentCompact + | ThreadSourceKind::SubAgentThreadSpawn + | ThreadSourceKind::SubAgentOther + | ThreadSourceKind::Unknown => None, + }) + .collect::>(); + (interactive_sources, Some(source_kinds)) + } +} + +pub(crate) fn source_kind_matches(source: &CoreSessionSource, filter: &[ThreadSourceKind]) -> bool { + filter.iter().any(|kind| match kind { + ThreadSourceKind::Cli => matches!(source, CoreSessionSource::Cli), + ThreadSourceKind::VsCode => matches!(source, CoreSessionSource::VSCode), + ThreadSourceKind::Exec => matches!(source, CoreSessionSource::Exec), + ThreadSourceKind::AppServer => matches!(source, CoreSessionSource::Mcp), + ThreadSourceKind::SubAgent => matches!(source, CoreSessionSource::SubAgent(_)), + ThreadSourceKind::SubAgentReview => { + matches!( + source, + CoreSessionSource::SubAgent(CoreSubAgentSource::Review) + ) + } + ThreadSourceKind::SubAgentCompact => { + matches!( + source, + CoreSessionSource::SubAgent(CoreSubAgentSource::Compact) + ) + } + ThreadSourceKind::SubAgentThreadSpawn => matches!( + source, + CoreSessionSource::SubAgent(CoreSubAgentSource::ThreadSpawn { .. }) + ), + ThreadSourceKind::SubAgentOther => matches!( + source, + CoreSessionSource::SubAgent(CoreSubAgentSource::Other(_)) + ), + ThreadSourceKind::Unknown => matches!(source, CoreSessionSource::Unknown), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_protocol::ThreadId; + use pretty_assertions::assert_eq; + use uuid::Uuid; + + #[test] + fn compute_source_filters_defaults_to_interactive_sources() { + let (allowed_sources, filter) = compute_source_filters(None); + + assert_eq!(allowed_sources, INTERACTIVE_SESSION_SOURCES.to_vec()); + assert_eq!(filter, None); + } + + #[test] + fn compute_source_filters_empty_means_interactive_sources() { + let (allowed_sources, filter) = compute_source_filters(Some(Vec::new())); + + assert_eq!(allowed_sources, INTERACTIVE_SESSION_SOURCES.to_vec()); + assert_eq!(filter, None); + } + + #[test] + fn compute_source_filters_interactive_only_skips_post_filtering() { + let source_kinds = vec![ThreadSourceKind::Cli, ThreadSourceKind::VsCode]; + let (allowed_sources, filter) = compute_source_filters(Some(source_kinds.clone())); + + assert_eq!( + allowed_sources, + vec![CoreSessionSource::Cli, CoreSessionSource::VSCode] + ); + assert_eq!(filter, Some(source_kinds)); + } + + #[test] + fn compute_source_filters_subagent_variant_requires_post_filtering() { + let source_kinds = vec![ThreadSourceKind::SubAgentReview]; + let (allowed_sources, filter) = compute_source_filters(Some(source_kinds.clone())); + + assert_eq!(allowed_sources, Vec::new()); + assert_eq!(filter, Some(source_kinds)); + } + + #[test] + fn source_kind_matches_distinguishes_subagent_variants() { + let parent_thread_id = + ThreadId::from_string(&Uuid::new_v4().to_string()).expect("valid thread id"); + let review = CoreSessionSource::SubAgent(CoreSubAgentSource::Review); + let spawn = CoreSessionSource::SubAgent(CoreSubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + }); + + assert!(source_kind_matches( + &review, + &[ThreadSourceKind::SubAgentReview] + )); + assert!(!source_kind_matches( + &review, + &[ThreadSourceKind::SubAgentThreadSpawn] + )); + assert!(source_kind_matches( + &spawn, + &[ThreadSourceKind::SubAgentThreadSpawn] + )); + assert!(!source_kind_matches( + &spawn, + &[ThreadSourceKind::SubAgentReview] + )); + } +} diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index b92e182668..ca588e59d4 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -42,6 +42,7 @@ mod codex_message_processor; mod config_api; mod dynamic_tools; mod error_code; +mod filters; mod fuzzy_file_search; mod message_processor; mod models; diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 8093121403..12bd4049b4 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -30,6 +30,7 @@ pub use responses::create_final_assistant_message_sse_response; pub use responses::create_request_user_input_sse_response; pub use responses::create_shell_command_sse_response; pub use rollout::create_fake_rollout; +pub use rollout::create_fake_rollout_with_source; pub use rollout::create_fake_rollout_with_text_elements; pub use rollout::rollout_path; use serde::de::DeserializeOwned; diff --git a/codex-rs/app-server/tests/common/rollout.rs b/codex-rs/app-server/tests/common/rollout.rs index e2e55a6544..26316eddbd 100644 --- a/codex-rs/app-server/tests/common/rollout.rs +++ b/codex-rs/app-server/tests/common/rollout.rs @@ -38,6 +38,27 @@ pub fn create_fake_rollout( preview: &str, model_provider: Option<&str>, git_info: Option, +) -> Result { + create_fake_rollout_with_source( + codex_home, + filename_ts, + meta_rfc3339, + preview, + model_provider, + git_info, + SessionSource::Cli, + ) +} + +/// Create a minimal rollout file with an explicit session source. +pub fn create_fake_rollout_with_source( + codex_home: &Path, + filename_ts: &str, + meta_rfc3339: &str, + preview: &str, + model_provider: Option<&str>, + git_info: Option, + source: SessionSource, ) -> Result { let uuid = Uuid::new_v4(); let uuid_str = uuid.to_string(); @@ -57,7 +78,7 @@ pub fn create_fake_rollout( cwd: PathBuf::from("/"), originator: "codex".to_string(), cli_version: "0.0.0".to_string(), - source: SessionSource::Cli, + source, model_provider: model_provider.map(str::to_string), base_instructions: None, }; diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index 8eb253df65..f310b6c562 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -1,6 +1,7 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_fake_rollout; +use app_test_support::create_fake_rollout_with_source; use app_test_support::rollout_path; use app_test_support::to_response; use chrono::DateTime; @@ -12,8 +13,12 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SessionSource; use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadSortKey; +use codex_app_server_protocol::ThreadSourceKind; use codex_core::ARCHIVED_SESSIONS_SUBDIR; +use codex_protocol::ThreadId; use codex_protocol::protocol::GitInfo as CoreGitInfo; +use codex_protocol::protocol::SessionSource as CoreSessionSource; +use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use std::cmp::Reverse; use std::fs; @@ -38,9 +43,10 @@ async fn list_threads( cursor: Option, limit: Option, providers: Option>, + source_kinds: Option>, archived: Option, ) -> Result { - list_threads_with_sort(mcp, cursor, limit, providers, None, archived).await + list_threads_with_sort(mcp, cursor, limit, providers, source_kinds, None, archived).await } async fn list_threads_with_sort( @@ -48,6 +54,7 @@ async fn list_threads_with_sort( cursor: Option, limit: Option, providers: Option>, + source_kinds: Option>, sort_key: Option, archived: Option, ) -> Result { @@ -57,6 +64,7 @@ async fn list_threads_with_sort( limit, sort_key, model_providers: providers, + source_kinds, archived, }) .await?; @@ -131,6 +139,7 @@ async fn thread_list_basic_empty() -> Result<()> { Some(10), Some(vec!["mock_provider".to_string()]), None, + None, ) .await?; assert!(data.is_empty()); @@ -194,6 +203,7 @@ async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> { Some(2), Some(vec!["mock_provider".to_string()]), None, + None, ) .await?; assert_eq!(data1.len(), 2); @@ -219,6 +229,7 @@ async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> { Some(2), Some(vec!["mock_provider".to_string()]), None, + None, ) .await?; assert!(data2.len() <= 2); @@ -269,6 +280,7 @@ async fn thread_list_respects_provider_filter() -> Result<()> { Some(10), Some(vec!["other_provider".to_string()]), None, + None, ) .await?; assert_eq!(data.len(), 1); @@ -287,6 +299,207 @@ async fn thread_list_respects_provider_filter() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_list_empty_source_kinds_defaults_to_interactive_only() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let cli_id = create_fake_rollout( + codex_home.path(), + "2025-02-01T10-00-00", + "2025-02-01T10:00:00Z", + "CLI", + Some("mock_provider"), + None, + )?; + let exec_id = create_fake_rollout_with_source( + codex_home.path(), + "2025-02-01T11-00-00", + "2025-02-01T11:00:00Z", + "Exec", + Some("mock_provider"), + None, + CoreSessionSource::Exec, + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + let ThreadListResponse { data, next_cursor } = list_threads( + &mut mcp, + None, + Some(10), + Some(vec!["mock_provider".to_string()]), + Some(Vec::new()), + None, + ) + .await?; + + assert_eq!(next_cursor, None); + let ids: Vec<_> = data.iter().map(|thread| thread.id.as_str()).collect(); + assert_eq!(ids, vec![cli_id.as_str()]); + assert_ne!(cli_id, exec_id); + assert_eq!(data[0].source, SessionSource::Cli); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_filters_by_source_kind_subagent_thread_spawn() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let cli_id = create_fake_rollout( + codex_home.path(), + "2025-02-01T10-00-00", + "2025-02-01T10:00:00Z", + "CLI", + Some("mock_provider"), + None, + )?; + + let parent_thread_id = ThreadId::from_string(&Uuid::new_v4().to_string())?; + let subagent_id = create_fake_rollout_with_source( + codex_home.path(), + "2025-02-01T11-00-00", + "2025-02-01T11:00:00Z", + "SubAgent", + Some("mock_provider"), + None, + CoreSessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + }), + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + let ThreadListResponse { data, next_cursor } = list_threads( + &mut mcp, + None, + Some(10), + Some(vec!["mock_provider".to_string()]), + Some(vec![ThreadSourceKind::SubAgentThreadSpawn]), + None, + ) + .await?; + + assert_eq!(next_cursor, None); + let ids: Vec<_> = data.iter().map(|thread| thread.id.as_str()).collect(); + assert_eq!(ids, vec![subagent_id.as_str()]); + assert_ne!(cli_id, subagent_id); + assert!(matches!(data[0].source, SessionSource::SubAgent(_))); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_filters_by_subagent_variant() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let parent_thread_id = ThreadId::from_string(&Uuid::new_v4().to_string())?; + + let review_id = create_fake_rollout_with_source( + codex_home.path(), + "2025-02-02T09-00-00", + "2025-02-02T09:00:00Z", + "Review", + Some("mock_provider"), + None, + CoreSessionSource::SubAgent(SubAgentSource::Review), + )?; + let compact_id = create_fake_rollout_with_source( + codex_home.path(), + "2025-02-02T10-00-00", + "2025-02-02T10:00:00Z", + "Compact", + Some("mock_provider"), + None, + CoreSessionSource::SubAgent(SubAgentSource::Compact), + )?; + let spawn_id = create_fake_rollout_with_source( + codex_home.path(), + "2025-02-02T11-00-00", + "2025-02-02T11:00:00Z", + "Spawn", + Some("mock_provider"), + None, + CoreSessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + }), + )?; + let other_id = create_fake_rollout_with_source( + codex_home.path(), + "2025-02-02T12-00-00", + "2025-02-02T12:00:00Z", + "Other", + Some("mock_provider"), + None, + CoreSessionSource::SubAgent(SubAgentSource::Other("custom".to_string())), + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + let review = list_threads( + &mut mcp, + None, + Some(10), + Some(vec!["mock_provider".to_string()]), + Some(vec![ThreadSourceKind::SubAgentReview]), + None, + ) + .await?; + let review_ids: Vec<_> = review + .data + .iter() + .map(|thread| thread.id.as_str()) + .collect(); + assert_eq!(review_ids, vec![review_id.as_str()]); + + let compact = list_threads( + &mut mcp, + None, + Some(10), + Some(vec!["mock_provider".to_string()]), + Some(vec![ThreadSourceKind::SubAgentCompact]), + None, + ) + .await?; + let compact_ids: Vec<_> = compact + .data + .iter() + .map(|thread| thread.id.as_str()) + .collect(); + assert_eq!(compact_ids, vec![compact_id.as_str()]); + + let spawn = list_threads( + &mut mcp, + None, + Some(10), + Some(vec!["mock_provider".to_string()]), + Some(vec![ThreadSourceKind::SubAgentThreadSpawn]), + None, + ) + .await?; + let spawn_ids: Vec<_> = spawn.data.iter().map(|thread| thread.id.as_str()).collect(); + assert_eq!(spawn_ids, vec![spawn_id.as_str()]); + + let other = list_threads( + &mut mcp, + None, + Some(10), + Some(vec!["mock_provider".to_string()]), + Some(vec![ThreadSourceKind::SubAgentOther]), + None, + ) + .await?; + let other_ids: Vec<_> = other.data.iter().map(|thread| thread.id.as_str()).collect(); + assert_eq!(other_ids, vec![other_id.as_str()]); + + Ok(()) +} + #[tokio::test] async fn thread_list_fetches_until_limit_or_exhausted() -> Result<()> { let codex_home = TempDir::new()?; @@ -319,6 +532,7 @@ async fn thread_list_fetches_until_limit_or_exhausted() -> Result<()> { Some(8), Some(vec!["target_provider".to_string()]), None, + None, ) .await?; assert_eq!( @@ -364,6 +578,7 @@ async fn thread_list_enforces_max_limit() -> Result<()> { Some(200), Some(vec!["mock_provider".to_string()]), None, + None, ) .await?; assert_eq!( @@ -410,6 +625,7 @@ async fn thread_list_stops_when_not_enough_filtered_results_exist() -> Result<() Some(10), Some(vec!["target_provider".to_string()]), None, + None, ) .await?; assert_eq!( @@ -457,6 +673,7 @@ async fn thread_list_includes_git_info() -> Result<()> { Some(10), Some(vec!["mock_provider".to_string()]), None, + None, ) .await?; let thread = data @@ -516,6 +733,7 @@ async fn thread_list_default_sorts_by_created_at() -> Result<()> { Some(vec!["mock_provider".to_string()]), None, None, + None, ) .await?; @@ -575,6 +793,7 @@ async fn thread_list_sort_updated_at_orders_by_mtime() -> Result<()> { None, Some(10), Some(vec!["mock_provider".to_string()]), + None, Some(ThreadSortKey::UpdatedAt), None, ) @@ -639,6 +858,7 @@ async fn thread_list_updated_at_paginates_with_cursor() -> Result<()> { None, Some(2), Some(vec!["mock_provider".to_string()]), + None, Some(ThreadSortKey::UpdatedAt), None, ) @@ -655,6 +875,7 @@ async fn thread_list_updated_at_paginates_with_cursor() -> Result<()> { Some(cursor1), Some(2), Some(vec!["mock_provider".to_string()]), + None, Some(ThreadSortKey::UpdatedAt), None, ) @@ -696,6 +917,7 @@ async fn thread_list_created_at_tie_breaks_by_uuid() -> Result<()> { Some(10), Some(vec!["mock_provider".to_string()]), None, + None, ) .await?; @@ -747,6 +969,7 @@ async fn thread_list_updated_at_tie_breaks_by_uuid() -> Result<()> { None, Some(10), Some(vec!["mock_provider".to_string()]), + None, Some(ThreadSortKey::UpdatedAt), None, ) @@ -787,6 +1010,7 @@ async fn thread_list_updated_at_uses_mtime() -> Result<()> { None, Some(10), Some(vec!["mock_provider".to_string()]), + None, Some(ThreadSortKey::UpdatedAt), None, ) @@ -846,6 +1070,7 @@ async fn thread_list_archived_filter() -> Result<()> { Some(10), Some(vec!["mock_provider".to_string()]), None, + None, ) .await?; assert_eq!(data.len(), 1); @@ -856,6 +1081,7 @@ async fn thread_list_archived_filter() -> Result<()> { None, Some(10), Some(vec!["mock_provider".to_string()]), + None, Some(true), ) .await?; @@ -878,6 +1104,7 @@ async fn thread_list_invalid_cursor_returns_error() -> Result<()> { limit: Some(2), sort_key: None, model_providers: Some(vec!["mock_provider".to_string()]), + source_kinds: None, archived: None, }) .await?; diff --git a/codex-rs/debug-client/src/client.rs b/codex-rs/debug-client/src/client.rs index c0a2746ee6..b5bac6fb58 100644 --- a/codex-rs/debug-client/src/client.rs +++ b/codex-rs/debug-client/src/client.rs @@ -173,6 +173,7 @@ impl AppServerClient { limit: None, sort_key: None, model_providers: None, + source_kinds: None, archived: None, }, }; From bdc4742bfc72f0366a48a3a2886cd2f19c2d1548 Mon Sep 17 00:00:00 2001 From: blevy-oai Date: Mon, 26 Jan 2026 14:13:04 -0800 Subject: [PATCH 15/43] Add MCP server `scopes` config and use it as fallback for OAuth login (#9647) ### Motivation - Allow MCP OAuth flows to request scopes defined in `config.toml` instead of requiring users to always pass `--scopes` on the CLI. CLI/remote parameters should still override config values. ### Description - Add optional `scopes: Option>` to `McpServerConfig` and `RawMcpServerConfig`, and propagate it through deserialization and the built config types. - Serialize `scopes` into the MCP server TOML via `serialize_mcp_server_table` in `core/src/config/edit.rs` and include `scopes` in the generated config schema (`core/config.schema.json`). - CLI: update `codex-rs/cli/src/mcp_cmd.rs` `run_login` to fall back to `server.scopes` when the `--scopes` flag is empty, with explicit CLI scopes still taking precedence. - App server: update `codex-rs/app-server/src/codex_message_processor.rs` `mcp_server_oauth_login` to use `params.scopes.or_else(|| server.scopes.clone())` so the RPC path also respects configured scopes. - Update many test fixtures to initialize the new `scopes` field (set to `None`) so test code builds with the new struct field. ### Testing - Ran config tooling and formatters: `just write-config-schema` (succeeded), `just fmt` (succeeded), and `just fix -p codex-core`, `just fix -p codex-cli`, `just fix -p codex-app-server` (succeeded where applicable). - Ran unit tests for the CLI: `cargo test -p codex-cli` (passed). - Ran unit tests for core: `cargo test -p codex-core` (ran; many tests passed but several failed, including model refresh/403-related tests, shell snapshot/timeouts, and several `unified_exec` expectations). - Ran app-server tests: `cargo test -p codex-app-server` (ran; many integration-suite tests failed due to mocked/remote HTTP 401/403 responses and wiremock expectations). If you want, I can split the tests into smaller focused runs or help debug the failing integration tests (they appear to be unrelated to the config change and stem from external HTTP/mocking behaviors encountered during the test runs). ------ [Codex Task](https://chatgpt.com/codex/tasks/task_i_69718f505914832ea1f334b3ba064553) --- codex-rs/app-server/src/codex_message_processor.rs | 2 ++ codex-rs/cli/src/mcp_cmd.rs | 6 ++++++ codex-rs/core/config.schema.json | 7 +++++++ codex-rs/core/src/config/edit.rs | 11 +++++++++++ codex-rs/core/src/config/mod.rs | 14 ++++++++++++++ codex-rs/core/src/config/types.rs | 8 ++++++++ codex-rs/core/src/mcp_connection_manager.rs | 2 ++ codex-rs/core/tests/suite/rmcp_client.rs | 6 ++++++ codex-rs/core/tests/suite/truncation.rs | 3 +++ codex-rs/tui/src/history_cell.rs | 2 ++ 10 files changed, 61 insertions(+) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 42a90d2e96..fec9b2abdb 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -2865,6 +2865,8 @@ impl CodexMessageProcessor { } }; + let scopes = scopes.or_else(|| server.scopes.clone()); + match perform_oauth_login_return_url( &name, &url, diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index 22cd18dde3..7cc42c4c49 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -247,6 +247,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, + scopes: None, }; servers.insert(name.clone(), new_entry); @@ -348,6 +349,11 @@ async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs) _ => bail!("OAuth login is only supported for streamable HTTP servers."), }; + let mut scopes = scopes; + if scopes.is_empty() { + scopes = server.scopes.clone().unwrap_or_default(); + } + perform_oauth_login( &name, &url, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 68b9ba699c..36d8f676cc 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -750,6 +750,13 @@ }, "type": "object" }, + "scopes": { + "default": null, + "items": { + "type": "string" + }, + "type": "array" + }, "startup_timeout_ms": { "default": null, "format": "uint64", diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index b99fde92bf..a34e689348 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -167,6 +167,11 @@ mod document_helpers { { entry["disabled_tools"] = array_from_iter(disabled_tools.iter().cloned()); } + if let Some(scopes) = &config.scopes + && !scopes.is_empty() + { + entry["scopes"] = array_from_iter(scopes.iter().cloned()); + } entry } @@ -1373,6 +1378,7 @@ gpt-5 = "gpt-5.1" tool_timeout_sec: None, enabled_tools: Some(vec!["one".to_string(), "two".to_string()]), disabled_tools: None, + scopes: None, }, ); @@ -1395,6 +1401,7 @@ gpt-5 = "gpt-5.1" tool_timeout_sec: None, enabled_tools: None, disabled_tools: Some(vec!["forbidden".to_string()]), + scopes: None, }, ); @@ -1460,6 +1467,7 @@ foo = { command = "cmd" } tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, + scopes: None, }, ); @@ -1504,6 +1512,7 @@ foo = { command = "cmd" } # keep me tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, + scopes: None, }, ); @@ -1547,6 +1556,7 @@ foo = { command = "cmd", args = ["--flag"] } # keep me tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, + scopes: None, }, ); @@ -1591,6 +1601,7 @@ foo = { command = "cmd" } tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, + scopes: None, }, ); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index ca3ccc1ffd..031f3c6f33 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1772,6 +1772,7 @@ mod tests { tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, + scopes: None, } } @@ -1789,6 +1790,7 @@ mod tests { tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, + scopes: None, } } @@ -2614,6 +2616,7 @@ profile = "project" tool_timeout_sec: Some(Duration::from_secs(5)), enabled_tools: None, disabled_tools: None, + scopes: None, }, ); @@ -2768,6 +2771,7 @@ bearer_token = "secret" tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, + scopes: None, }, )]); @@ -2837,6 +2841,7 @@ ZIG_VAR = "3" tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, + scopes: None, }, )]); @@ -2886,6 +2891,7 @@ ZIG_VAR = "3" tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, + scopes: None, }, )]); @@ -2933,6 +2939,7 @@ ZIG_VAR = "3" tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, + scopes: None, }, )]); @@ -2996,6 +3003,7 @@ startup_timeout_sec = 2.0 tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, + scopes: None, }, )]); apply_blocking( @@ -3071,6 +3079,7 @@ X-Auth = "DOCS_AUTH" tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, + scopes: None, }, )]); @@ -3099,6 +3108,7 @@ X-Auth = "DOCS_AUTH" tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, + scopes: None, }, ); apply_blocking( @@ -3165,6 +3175,7 @@ url = "https://example.com/mcp" tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, + scopes: None, }, ), ( @@ -3183,6 +3194,7 @@ url = "https://example.com/mcp" tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, + scopes: None, }, ), ]); @@ -3264,6 +3276,7 @@ url = "https://example.com/mcp" tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, + scopes: None, }, )]); @@ -3307,6 +3320,7 @@ url = "https://example.com/mcp" tool_timeout_sec: None, enabled_tools: Some(vec!["allowed".to_string()]), disabled_tools: Some(vec!["blocked".to_string()]), + scopes: None, }, )]); diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 1b3f381d3b..96379dfa03 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -73,6 +73,10 @@ pub struct McpServerConfig { /// Explicit deny-list of tools. These tools will be removed after applying `enabled_tools`. #[serde(default, skip_serializing_if = "Option::is_none")] pub disabled_tools: Option>, + + /// Optional OAuth scopes to request during MCP login. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scopes: Option>, } // Raw MCP config shape used for deserialization and JSON Schema generation. @@ -113,6 +117,8 @@ pub(crate) struct RawMcpServerConfig { pub enabled_tools: Option>, #[serde(default)] pub disabled_tools: Option>, + #[serde(default)] + pub scopes: Option>, } impl<'de> Deserialize<'de> for McpServerConfig { @@ -134,6 +140,7 @@ impl<'de> Deserialize<'de> for McpServerConfig { let enabled = raw.enabled.unwrap_or_else(default_enabled); let enabled_tools = raw.enabled_tools.clone(); let disabled_tools = raw.disabled_tools.clone(); + let scopes = raw.scopes.clone(); fn throw_if_set(transport: &str, field: &str, value: Option<&T>) -> Result<(), E> where @@ -188,6 +195,7 @@ impl<'de> Deserialize<'de> for McpServerConfig { disabled_reason: None, enabled_tools, disabled_tools, + scopes, }) } } diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index 434db3b2fa..9462f745ba 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -1182,6 +1182,7 @@ mod tests { tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, + scopes: None, }, auth_status: McpAuthStatus::Unsupported, }; @@ -1227,6 +1228,7 @@ mod tests { tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, + scopes: None, }, auth_status: McpAuthStatus::Unsupported, }; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 4210a5c6ba..7eaeef6590 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -93,6 +93,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, + scopes: None, }, ); config @@ -233,6 +234,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, + scopes: None, }, ); config @@ -431,6 +433,7 @@ async fn stdio_image_completions_round_trip() -> anyhow::Result<()> { tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, + scopes: None, }, ); config @@ -577,6 +580,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, + scopes: None, }, ); config @@ -734,6 +738,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, + scopes: None, }, ); config @@ -923,6 +928,7 @@ async fn streamable_http_with_oauth_round_trip() -> anyhow::Result<()> { tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, + scopes: None, }, ); config diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index cf03e09920..c24e263f9e 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -431,6 +431,7 @@ async fn mcp_tool_call_output_exceeds_limit_truncated_for_model() -> Result<()> tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, + scopes: None, }, ); config @@ -523,6 +524,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, + scopes: None, }, ); config @@ -786,6 +788,7 @@ async fn mcp_tool_call_output_not_truncated_with_custom_limit() -> Result<()> { tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, + scopes: None, }, ); config diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 174f2a7f6a..94da257d9d 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1953,6 +1953,7 @@ mod tests { tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, + scopes: None, }; let mut servers = config.mcp_servers.get().clone(); servers.insert("docs".to_string(), stdio_config); @@ -1974,6 +1975,7 @@ mod tests { tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, + scopes: None, }; servers.insert("http".to_string(), http_config); config From 159ff062813653996b507d51dd451566440d2ec0 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 26 Jan 2026 14:48:54 -0800 Subject: [PATCH 16/43] plan prompt (#9943) # External (non-OpenAI) Pull Request Requirements Before opening this Pull Request, please read the dedicated "Contributing" markdown file or your PR may be closed: https://github.com/openai/codex/blob/main/docs/contributing.md If your PR conforms to our contribution guidelines, replace this text with a detailed and high quality description of your changes. Include a link to a bug report or enhancement request. --- codex-rs/core/templates/collaboration_mode/plan.md | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/core/templates/collaboration_mode/plan.md b/codex-rs/core/templates/collaboration_mode/plan.md index b8e0af03f1..7736c97640 100644 --- a/codex-rs/core/templates/collaboration_mode/plan.md +++ b/codex-rs/core/templates/collaboration_mode/plan.md @@ -28,6 +28,7 @@ You SHOULD ask many questions, but each question must: - materially change the spec/plan, OR - confirm/lock an assumption, OR - choose between meaningful tradeoffs. +- not be answerable by non-mutating commands Batch questions (e.g., 4–10) per `request_user_input` call to keep momentum. ## Two kinds of unknowns (treat differently) From 313ee3003b5bd527f10df66e849073c9b843a9f9 Mon Sep 17 00:00:00 2001 From: David Gilbertson Date: Tue, 27 Jan 2026 10:11:27 +1100 Subject: [PATCH 17/43] fix: handle utf-8 in windows sandbox logs (#8647) Currently `apply_patch` will fail on Windows if the file contents happen to have a multi-byte character at the point where the `preview` function truncates. I've used the existing `take_bytes_at_char_boundary` helper and added a regression test (that fails without the fix). This is related to #4013 but doesn't fix it. --- codex-rs/Cargo.lock | 1 + codex-rs/windows-sandbox-rs/Cargo.toml | 1 + codex-rs/windows-sandbox-rs/src/logging.rs | 20 +++++++++++++++++++- 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d0b3a0ec37..836854b0eb 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2006,6 +2006,7 @@ dependencies = [ "chrono", "codex-protocol", "codex-utils-absolute-path", + "codex-utils-string", "dirs-next", "dunce", "pretty_assertions", diff --git a/codex-rs/windows-sandbox-rs/Cargo.toml b/codex-rs/windows-sandbox-rs/Cargo.toml index aa872035bc..f92c389e4b 100644 --- a/codex-rs/windows-sandbox-rs/Cargo.toml +++ b/codex-rs/windows-sandbox-rs/Cargo.toml @@ -25,6 +25,7 @@ chrono = { version = "0.4.42", default-features = false, features = [ "std", ] } codex-utils-absolute-path = { workspace = true } +codex-utils-string = { workspace = true } dunce = "1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/codex-rs/windows-sandbox-rs/src/logging.rs b/codex-rs/windows-sandbox-rs/src/logging.rs index 2e4de1d29a..f575ce3b8d 100644 --- a/codex-rs/windows-sandbox-rs/src/logging.rs +++ b/codex-rs/windows-sandbox-rs/src/logging.rs @@ -4,6 +4,8 @@ use std::path::Path; use std::path::PathBuf; use std::sync::OnceLock; +use codex_utils_string::take_bytes_at_char_boundary; + const LOG_COMMAND_PREVIEW_LIMIT: usize = 200; pub const LOG_FILE_NAME: &str = "sandbox.log"; @@ -22,7 +24,7 @@ fn preview(command: &[String]) -> String { if joined.len() <= LOG_COMMAND_PREVIEW_LIMIT { joined } else { - joined[..LOG_COMMAND_PREVIEW_LIMIT].to_string() + take_bytes_at_char_boundary(&joined, LOG_COMMAND_PREVIEW_LIMIT).to_string() } } @@ -72,3 +74,19 @@ pub fn log_note(msg: &str, base_dir: Option<&Path>) { let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f"); append_line(&format!("[{ts} {}] {}", exe_label(), msg), base_dir); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preview_does_not_panic_on_utf8_boundary() { + // Place a 4-byte emoji such that naive (byte-based) truncation would split it. + let prefix = "x".repeat(LOG_COMMAND_PREVIEW_LIMIT - 1); + let command = vec![format!("{prefix}😀")]; + let result = std::panic::catch_unwind(|| preview(&command)); + assert!(result.is_ok()); + let previewed = result.unwrap(); + assert!(previewed.len() <= LOG_COMMAND_PREVIEW_LIMIT); + } +} From a8f195828b677515fb7834c3946ce0c3393c746b Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 26 Jan 2026 15:16:29 -0800 Subject: [PATCH 18/43] Add composer config and shared menu surface helpers (#9891) Centralize built-in slash-command gating and extract shared menu-surface helpers. - Add bottom_pane::slash_commands and reuse it from composer + command popup. - Introduce ChatComposerConfig + shared menu surface rendering without changing default behavior. --- codex-rs/core/src/mcp/mod.rs | 1 + codex-rs/tui/src/bottom_pane/chat_composer.rs | 196 ++++++++++++------ codex-rs/tui/src/bottom_pane/command_popup.rs | 23 +- .../src/bottom_pane/list_selection_view.rs | 17 +- codex-rs/tui/src/bottom_pane/mod.rs | 1 + .../src/bottom_pane/selection_popup_common.rs | 29 +++ .../tui/src/bottom_pane/slash_commands.rs | 54 +++++ docs/tui-chat-composer.md | 25 +++ 8 files changed, 250 insertions(+), 96 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/slash_commands.rs diff --git a/codex-rs/core/src/mcp/mod.rs b/codex-rs/core/src/mcp/mod.rs index c3513bb061..1bb08a75be 100644 --- a/codex-rs/core/src/mcp/mod.rs +++ b/codex-rs/core/src/mcp/mod.rs @@ -97,6 +97,7 @@ fn codex_apps_mcp_server_config(config: &Config, auth: Option<&CodexAuth>) -> Mc tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, + scopes: None, } } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index c4fba97f65..ccf0fac5dd 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -109,6 +109,7 @@ use super::footer::toggle_shortcut_mode; use super::paste_burst::CharDecision; use super::paste_burst::PasteBurst; use super::skill_popup::SkillPopup; +use super::slash_commands; use crate::bottom_pane::paste_burst::FlushResult; use crate::bottom_pane::prompt_args::expand_custom_prompt; use crate::bottom_pane::prompt_args::expand_if_numeric_with_positional_args; @@ -120,7 +121,6 @@ use crate::render::Insets; use crate::render::RectExt; use crate::render::renderable::Renderable; use crate::slash_command::SlashCommand; -use crate::slash_command::built_in_slash_commands; use crate::style::user_message_style; use codex_common::fuzzy_match::fuzzy_match; use codex_protocol::custom_prompts::CustomPrompt; @@ -148,13 +148,6 @@ use std::path::PathBuf; use std::time::Duration; use std::time::Instant; -fn windows_degraded_sandbox_active() -> bool { - cfg!(target_os = "windows") - && codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED - && codex_core::get_platform_sandbox().is_some() - && !codex_core::is_windows_elevated_sandbox_enabled() -} - /// If the pasted content exceeds this number of characters, replace it with a /// placeholder in the UI. const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; @@ -197,6 +190,30 @@ enum PromptSelectionAction { }, } +/// Feature flags for reusing the chat composer in other bottom-pane surfaces. +/// +/// The default keeps today's behavior intact. Other call sites can opt out of +/// specific behaviors by constructing a config with those flags set to `false`. +#[derive(Clone, Copy, Debug)] +pub(crate) struct ChatComposerConfig { + /// Whether command/file/skill popups are allowed to appear. + pub(crate) popups_enabled: bool, + /// Whether `/...` input is parsed and dispatched as slash commands. + pub(crate) slash_commands_enabled: bool, + /// Whether pasting a file path can attach local images. + pub(crate) image_paste_enabled: bool, +} + +impl Default for ChatComposerConfig { + fn default() -> Self { + Self { + popups_enabled: true, + slash_commands_enabled: true, + image_paste_enabled: true, + } + } +} + pub(crate) struct ChatComposer { textarea: TextArea, textarea_state: RefCell, @@ -234,6 +251,7 @@ pub(crate) struct ChatComposer { /// When enabled, `Enter` submits immediately and `Tab` requests queuing behavior. steer_enabled: bool, collaboration_modes_enabled: bool, + config: ChatComposerConfig, collaboration_mode_indicator: Option, personality_command_enabled: bool, } @@ -261,6 +279,28 @@ impl ChatComposer { enhanced_keys_supported: bool, placeholder_text: String, disable_paste_burst: bool, + ) -> Self { + Self::new_with_config( + has_input_focus, + app_event_tx, + enhanced_keys_supported, + placeholder_text, + disable_paste_burst, + ChatComposerConfig::default(), + ) + } + + /// Construct a composer with explicit feature gating. + /// + /// This enables reuse in contexts like request-user-input where we want + /// the same visuals and editing behavior without slash commands or popups. + pub(crate) fn new_with_config( + has_input_focus: bool, + app_event_tx: AppEventSender, + enhanced_keys_supported: bool, + placeholder_text: String, + disable_paste_burst: bool, + config: ChatComposerConfig, ) -> Self { let use_shift_enter_hint = enhanced_keys_supported; @@ -296,6 +336,7 @@ impl ChatComposer { dismissed_skill_popup_token: None, steer_enabled: false, collaboration_modes_enabled: false, + config, collaboration_mode_indicator: None, personality_command_enabled: false, }; @@ -333,6 +374,18 @@ impl ChatComposer { self.personality_command_enabled = enabled; } + /// Centralized feature gating keeps config checks out of call sites. + fn popups_enabled(&self) -> bool { + self.config.popups_enabled + } + + fn slash_commands_enabled(&self) -> bool { + self.config.slash_commands_enabled + } + + fn image_paste_enabled(&self) -> bool { + self.config.image_paste_enabled + } fn layout_areas(&self, area: Rect) -> [Rect; 3] { let footer_props = self.footer_props(); let footer_hint_height = self @@ -417,7 +470,10 @@ impl ChatComposer { let placeholder = self.next_large_paste_placeholder(char_count); self.textarea.insert_element(&placeholder); self.pending_pastes.push((placeholder, pasted)); - } else if char_count > 1 && self.handle_paste_image_path(pasted.clone()) { + } else if char_count > 1 + && self.image_paste_enabled() + && self.handle_paste_image_path(pasted.clone()) + { self.textarea.insert_str(" "); } else { self.textarea.insert_str(&pasted); @@ -1634,14 +1690,17 @@ impl ChatComposer { text = text.trim().to_string(); text_elements = Self::trim_text_elements(&expanded_input, &text, text_elements); - if let Some((name, _rest, _rest_offset)) = parse_slash_name(&text) { + if self.slash_commands_enabled() + && let Some((name, _rest, _rest_offset)) = parse_slash_name(&text) + { let treat_as_plain_text = input_starts_with_space || name.contains('/'); if !treat_as_plain_text { - let is_builtin = Self::built_in_slash_commands_for_input( + let is_builtin = slash_commands::find_builtin_command( + name, self.collaboration_modes_enabled, self.personality_command_enabled, ) - .any(|(command_name, _)| command_name == name); + .is_some(); let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); let is_known_prompt = name .strip_prefix(&prompt_prefix) @@ -1670,26 +1729,28 @@ impl ChatComposer { } } - let expanded_prompt = - match expand_custom_prompt(&text, &text_elements, &self.custom_prompts) { - Ok(expanded) => expanded, - Err(err) => { - self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_error_event(err.user_message()), - ))); - self.set_text_content( - original_input.clone(), - original_text_elements, - original_local_image_paths, - ); - self.pending_pastes.clone_from(&original_pending_pastes); - self.textarea.set_cursor(original_input.len()); - return None; - } - }; - if let Some(expanded) = expanded_prompt { - text = expanded.text; - text_elements = expanded.text_elements; + if self.slash_commands_enabled() { + let expanded_prompt = + match expand_custom_prompt(&text, &text_elements, &self.custom_prompts) { + Ok(expanded) => expanded, + Err(err) => { + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_error_event(err.user_message()), + ))); + self.set_text_content( + original_input.clone(), + original_text_elements, + original_local_image_paths, + ); + self.pending_pastes.clone_from(&original_pending_pastes); + self.textarea.set_cursor(original_input.len()); + return None; + } + }; + if let Some(expanded) = expanded_prompt { + text = expanded.text; + text_elements = expanded.text_elements; + } } // Custom prompt expansion can remove or rewrite image placeholders, so prune any // attachments that no longer have a corresponding placeholder in the expanded text. @@ -1729,14 +1790,15 @@ impl ChatComposer { // If we're in a paste-like burst capture, treat Enter/Ctrl+Shift+Q as part of the burst // and accumulate it rather than submitting or inserting immediately. // Do not treat as paste inside a slash-command context. - let in_slash_context = matches!(self.active_popup, ActivePopup::Command(_)) - || self - .textarea - .text() - .lines() - .next() - .unwrap_or("") - .starts_with('/'); + let in_slash_context = self.slash_commands_enabled() + && (matches!(self.active_popup, ActivePopup::Command(_)) + || self + .textarea + .text() + .lines() + .next() + .unwrap_or("") + .starts_with('/')); if !self.disable_paste_burst && self.paste_burst.is_active() && !in_slash_context @@ -1803,14 +1865,17 @@ impl ChatComposer { /// Check if the first line is a bare slash command (no args) and dispatch it. /// Returns Some(InputResult) if a command was dispatched, None otherwise. fn try_dispatch_bare_slash_command(&mut self) -> Option { + if !self.slash_commands_enabled() { + return None; + } let first_line = self.textarea.text().lines().next().unwrap_or(""); if let Some((name, rest, _rest_offset)) = parse_slash_name(first_line) && rest.is_empty() - && let Some((_n, cmd)) = Self::built_in_slash_commands_for_input( + && let Some(cmd) = slash_commands::find_builtin_command( + name, self.collaboration_modes_enabled, self.personality_command_enabled, ) - .find(|(n, _)| *n == name) { self.textarea.set_text_clearing_elements(""); Some(InputResult::Command(cmd)) @@ -1822,6 +1887,9 @@ impl ChatComposer { /// Check if the input is a slash command with args (e.g., /review args) and dispatch it. /// Returns Some(InputResult) if a command was dispatched, None otherwise. fn try_dispatch_slash_command_with_args(&mut self) -> Option { + if !self.slash_commands_enabled() { + return None; + } let original_input = self.textarea.text().to_string(); let input_starts_with_space = original_input.starts_with(' '); @@ -1830,11 +1898,11 @@ impl ChatComposer { if let Some((name, rest, _rest_offset)) = parse_slash_name(&text) && !rest.is_empty() && !name.contains('/') - && let Some((_n, cmd)) = Self::built_in_slash_commands_for_input( + && let Some(cmd) = slash_commands::find_builtin_command( + name, self.collaboration_modes_enabled, self.personality_command_enabled, ) - .find(|(command_name, _)| *command_name == name) && cmd == SlashCommand::Review { self.textarea.set_text_clearing_elements(""); @@ -2188,6 +2256,10 @@ impl ChatComposer { } fn sync_popups(&mut self) { + if !self.popups_enabled() { + self.active_popup = ActivePopup::None; + return; + } let file_token = Self::current_at_token(&self.textarea); let browsing_history = self .history @@ -2200,7 +2272,8 @@ impl ChatComposer { } let skill_token = self.current_skill_token(); - let allow_command_popup = file_token.is_none() && skill_token.is_none(); + let allow_command_popup = + self.slash_commands_enabled() && file_token.is_none() && skill_token.is_none(); self.sync_command_popup(allow_command_popup); if matches!(self.active_popup, ActivePopup::Command(_)) { @@ -2261,24 +2334,24 @@ impl ChatComposer { /// prefix for any known command (built-in or custom prompt). /// Empty names only count when there is no extra content after the '/'. fn looks_like_slash_prefix(&self, name: &str, rest_after_name: &str) -> bool { + if !self.slash_commands_enabled() { + return false; + } if name.is_empty() { return rest_after_name.is_empty(); } - let builtin_match = Self::built_in_slash_commands_for_input( + if slash_commands::has_builtin_prefix( + name, self.collaboration_modes_enabled, self.personality_command_enabled, - ) - .any(|(cmd_name, _)| fuzzy_match(cmd_name, name).is_some()); - - if builtin_match { + ) { return true; } - let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); - self.custom_prompts - .iter() - .any(|p| fuzzy_match(&format!("{prompt_prefix}{}", p.name), name).is_some()) + self.custom_prompts.iter().any(|prompt| { + fuzzy_match(&format!("{PROMPTS_CMD_PREFIX}:{}", prompt.name), name).is_some() + }) } /// Synchronize `self.command_popup` with the current text in the @@ -2336,21 +2409,6 @@ impl ChatComposer { } } } - - fn built_in_slash_commands_for_input( - collaboration_modes_enabled: bool, - personality_command_enabled: bool, - ) -> impl Iterator { - let allow_elevate_sandbox = windows_degraded_sandbox_active(); - built_in_slash_commands() - .into_iter() - .filter(move |(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox) - .filter(move |(_, cmd)| collaboration_modes_enabled || *cmd != SlashCommand::Collab) - .filter(move |(_, cmd)| { - personality_command_enabled || *cmd != SlashCommand::Personality - }) - } - pub(crate) fn set_custom_prompts(&mut self, prompts: Vec) { self.custom_prompts = prompts.clone(); if let ActivePopup::Command(popup) = &mut self.active_popup { diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index d0805707fa..3c54dff0fe 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -6,21 +6,14 @@ use super::popup_consts::MAX_POPUP_ROWS; use super::scroll_state::ScrollState; use super::selection_popup_common::GenericDisplayRow; use super::selection_popup_common::render_rows; +use super::slash_commands; use crate::render::Insets; use crate::render::RectExt; use crate::slash_command::SlashCommand; -use crate::slash_command::built_in_slash_commands; use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; use std::collections::HashSet; -fn windows_degraded_sandbox_active() -> bool { - cfg!(target_os = "windows") - && codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED - && codex_core::get_platform_sandbox().is_some() - && !codex_core::is_windows_elevated_sandbox_enabled() -} - /// A selectable item in the popup: either a built-in command or a user prompt. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum CommandItem { @@ -44,15 +37,11 @@ pub(crate) struct CommandPopupFlags { impl CommandPopup { pub(crate) fn new(mut prompts: Vec, flags: CommandPopupFlags) -> Self { - let allow_elevate_sandbox = windows_degraded_sandbox_active(); - let builtins: Vec<(&'static str, SlashCommand)> = built_in_slash_commands() - .into_iter() - .filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox) - .filter(|(_, cmd)| flags.collaboration_modes_enabled || *cmd != SlashCommand::Collab) - .filter(|(_, cmd)| { - flags.personality_command_enabled || *cmd != SlashCommand::Personality - }) - .collect(); + // Keep built-in availability in sync with the composer. + let builtins = slash_commands::builtins_for_input( + flags.collaboration_modes_enabled, + flags.personality_command_enabled, + ); // Exclude prompts that collide with builtin command names and sort by name. let exclude: HashSet = builtins.iter().map(|(n, _)| (*n).to_string()).collect(); prompts.retain(|p| !exclude.contains(&p.name)); diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 7591b99326..095a095500 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -9,18 +9,15 @@ use ratatui::layout::Rect; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; -use ratatui::widgets::Block; use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; +use super::selection_popup_common::render_menu_surface; use super::selection_popup_common::wrap_styled_line; use crate::app_event_sender::AppEventSender; use crate::key_hint::KeyBinding; -use crate::render::Insets; -use crate::render::RectExt as _; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; -use crate::style::user_message_style; use super::CancellationEvent; use super::bottom_pane_view::BottomPaneView; @@ -476,16 +473,16 @@ impl Renderable for ListSelectionView { let [content_area, footer_area] = Layout::vertical([Constraint::Fill(1), Constraint::Length(footer_rows)]).areas(area); - Block::default() - .style(user_message_style()) - .render(content_area, buf); + let outer_content_area = content_area; + // Paint the shared menu surface and then layout inside the returned inset. + let content_area = render_menu_surface(outer_content_area, buf); let header_height = self .header // Subtract 4 for the padding on the left and right of the header. - .desired_height(content_area.width.saturating_sub(4)); + .desired_height(outer_content_area.width.saturating_sub(4)); let rows = self.build_rows(); - let rows_width = Self::rows_width(content_area.width); + let rows_width = Self::rows_width(outer_content_area.width); let rows_height = measure_rows_height( &rows, &self.state, @@ -498,7 +495,7 @@ impl Renderable for ListSelectionView { Constraint::Length(if self.is_searchable { 1 } else { 0 }), Constraint::Length(rows_height), ]) - .areas(content_area.inset(Insets::vh(1, 2))); + .areas(content_area); if header_area.height < header_height { let [header_area, elision_area] = diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index aea847d212..a435046cba 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -59,6 +59,7 @@ mod list_selection_view; mod prompt_args; mod skill_popup; mod skills_toggle_view; +mod slash_commands; pub(crate) use footer::CollaborationModeIndicator; pub(crate) use list_selection_view::SelectionViewParams; mod feedback_view; diff --git a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs index c9ca076d60..0708bb1592 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -7,11 +7,15 @@ use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; +use ratatui::widgets::Block; use ratatui::widgets::Widget; use unicode_width::UnicodeWidthChar; use unicode_width::UnicodeWidthStr; use crate::key_hint::KeyBinding; +use crate::render::Insets; +use crate::render::RectExt as _; +use crate::style::user_message_style; use super::scroll_state::ScrollState; @@ -27,6 +31,31 @@ pub(crate) struct GenericDisplayRow { pub wrap_indent: Option, // optional indent for wrapped lines } +const MENU_SURFACE_INSET_V: u16 = 1; +const MENU_SURFACE_INSET_H: u16 = 2; + +/// Apply the shared "menu surface" padding used by bottom-pane overlays. +/// +/// Rendering code should generally call [`render_menu_surface`] and then lay +/// out content inside the returned inset rect. +pub(crate) fn menu_surface_inset(area: Rect) -> Rect { + area.inset(Insets::vh(MENU_SURFACE_INSET_V, MENU_SURFACE_INSET_H)) +} + +/// Paint the shared menu background and return the inset content area. +/// +/// This keeps the surface treatment consistent across selection-style overlays +/// (for example `/model`, approvals, and request-user-input). +pub(crate) fn render_menu_surface(area: Rect, buf: &mut Buffer) -> Rect { + if area.is_empty() { + return area; + } + Block::default() + .style(user_message_style()) + .render(area, buf); + menu_surface_inset(area) +} + pub(crate) fn wrap_styled_line<'a>(line: &'a Line<'a>, width: u16) -> Vec> { use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs new file mode 100644 index 0000000000..55765a00da --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -0,0 +1,54 @@ +//! Shared helpers for filtering and matching built-in slash commands. +//! +//! The same sandbox- and feature-gating rules are used by both the composer +//! and the command popup. Centralizing them here keeps those call sites small +//! and ensures they stay in sync. +use codex_common::fuzzy_match::fuzzy_match; + +use crate::slash_command::SlashCommand; +use crate::slash_command::built_in_slash_commands; + +/// Whether the Windows degraded-sandbox elevation flow is currently allowed. +pub(crate) fn windows_degraded_sandbox_active() -> bool { + cfg!(target_os = "windows") + && codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED + && codex_core::get_platform_sandbox().is_some() + && !codex_core::is_windows_elevated_sandbox_enabled() +} + +/// Return the built-ins that should be visible/usable for the current input. +pub(crate) fn builtins_for_input( + collaboration_modes_enabled: bool, + personality_command_enabled: bool, +) -> Vec<(&'static str, SlashCommand)> { + let allow_elevate_sandbox = windows_degraded_sandbox_active(); + built_in_slash_commands() + .into_iter() + .filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox) + .filter(|(_, cmd)| collaboration_modes_enabled || *cmd != SlashCommand::Collab) + .filter(|(_, cmd)| personality_command_enabled || *cmd != SlashCommand::Personality) + .collect() +} + +/// Find a single built-in command by exact name, after applying the gating rules. +pub(crate) fn find_builtin_command( + name: &str, + collaboration_modes_enabled: bool, + personality_command_enabled: bool, +) -> Option { + builtins_for_input(collaboration_modes_enabled, personality_command_enabled) + .into_iter() + .find(|(command_name, _)| *command_name == name) + .map(|(_, cmd)| cmd) +} + +/// Whether any visible built-in fuzzily matches the provided prefix. +pub(crate) fn has_builtin_prefix( + name: &str, + collaboration_modes_enabled: bool, + personality_command_enabled: bool, +) -> bool { + builtins_for_input(collaboration_modes_enabled, personality_command_enabled) + .into_iter() + .any(|(command_name, _)| fuzzy_match(command_name, name).is_some()) +} diff --git a/docs/tui-chat-composer.md b/docs/tui-chat-composer.md index 6436426cbb..baab4ce0ed 100644 --- a/docs/tui-chat-composer.md +++ b/docs/tui-chat-composer.md @@ -49,6 +49,31 @@ The solution is to detect paste-like _bursts_ and buffer them into a single expl - After handling the key, `sync_popups()` runs so popup visibility/filters stay consistent with the latest text + cursor. +## Config gating for reuse + +`ChatComposer` now supports feature gating via `ChatComposerConfig` +(`codex-rs/tui/src/bottom_pane/chat_composer.rs`). The default config preserves current chat +behavior. + +Flags: + +- `popups_enabled` +- `slash_commands_enabled` +- `image_paste_enabled` + +Key effects when disabled: + +- When `popups_enabled` is `false`, `sync_popups()` forces `ActivePopup::None`. +- When `slash_commands_enabled` is `false`, the composer does not treat `/...` input as commands. +- When `slash_commands_enabled` is `false`, the composer does not expand custom prompts in + `prepare_submission_text`. +- When `slash_commands_enabled` is `false`, slash-context paste-burst exceptions are disabled. +- When `image_paste_enabled` is `false`, file-path paste image attachment is skipped. + +Built-in slash command availability is centralized in +`codex-rs/tui/src/bottom_pane/slash_commands.rs` and reused by both the composer and the command +popup so gating stays in sync. + ## Submission flow (Enter/Tab) There are multiple submission paths, but they share the same core rules: From 5ace350186be7f5a1c7172e5f3af1d0f4acfe09c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:48:45 -0800 Subject: [PATCH 19/43] chore(deps): bump tracing from 0.1.43 to 0.1.44 in /codex-rs (#9880) Bumps [tracing](https://github.com/tokio-rs/tracing) from 0.1.43 to 0.1.44.
Release notes

Sourced from tracing's releases.

tracing 0.1.44

Fixed

  • Fix record_all panic (#3432)

Changed

  • tracing-core: updated to 0.1.36 (#3440)

#3432: tokio-rs/tracing#3432 #3440: tokio-rs/tracing#3440

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=tracing&package-manager=cargo&previous-version=0.1.43&new-version=0.1.44)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- codex-rs/Cargo.lock | 14 +++++++------- codex-rs/Cargo.toml | 2 +- codex-rs/lmstudio/Cargo.toml | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 836854b0eb..56ffd29baa 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3716,7 +3716,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.1", "system-configuration", "tokio", "tower-service", @@ -5632,7 +5632,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.5.10", + "socket2 0.6.1", "thiserror 2.0.17", "tokio", "tracing", @@ -5669,7 +5669,7 @@ dependencies = [ "cfg_aliases 0.2.1", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.1", "tracing", "windows-sys 0.60.2", ] @@ -8001,9 +8001,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.43" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -8036,9 +8036,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.35" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index b39cfd5e15..a8ebb9be89 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -216,7 +216,7 @@ tokio-tungstenite = { version = "0.28.0", features = ["proxy", "rustls-tls-nativ tokio-util = "0.7.18" toml = "0.9.5" toml_edit = "0.24.0" -tracing = "0.1.43" +tracing = "0.1.44" tracing-appender = "0.2.3" tracing-subscriber = "0.3.22" tracing-test = "0.2.5" diff --git a/codex-rs/lmstudio/Cargo.toml b/codex-rs/lmstudio/Cargo.toml index cbe8d3313b..5f4849638a 100644 --- a/codex-rs/lmstudio/Cargo.toml +++ b/codex-rs/lmstudio/Cargo.toml @@ -14,7 +14,7 @@ codex-core = { path = "../core" } reqwest = { version = "0.12", features = ["json", "stream"] } serde_json = "1" tokio = { version = "1", features = ["rt"] } -tracing = { version = "0.1.43", features = ["log"] } +tracing = { version = "0.1.44", features = ["log"] } which = "8.0" [dev-dependencies] From 764712c1169a26bf4dc388f215f716dc9ae0c3b6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:51:21 -0800 Subject: [PATCH 20/43] chore(deps): bump tokio-test from 0.4.4 to 0.4.5 in /codex-rs (#9882) Bumps [tokio-test](https://github.com/tokio-rs/tokio) from 0.4.4 to 0.4.5.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=tokio-test&package-manager=cargo&previous-version=0.4.4&new-version=0.4.5)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- codex-rs/Cargo.lock | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 56ffd29baa..f6220700f2 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -361,7 +361,7 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.52.0", "wl-clipboard-rs", "x11rb", ] @@ -2906,7 +2906,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] @@ -3006,7 +3006,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", "rustix 1.0.8", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -3311,7 +3311,7 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.2.0", + "windows-link 0.1.3", "windows-result 0.3.4", ] @@ -4093,7 +4093,7 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -5671,7 +5671,7 @@ dependencies = [ "once_cell", "socket2 0.6.1", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] @@ -6366,7 +6366,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6379,7 +6379,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.9.4", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] @@ -7466,7 +7466,7 @@ dependencies = [ "getrandom 0.3.3", "once_cell", "rustix 1.0.8", - "windows-sys 0.61.1", + "windows-sys 0.52.0", ] [[package]] @@ -7797,12 +7797,10 @@ dependencies = [ [[package]] name = "tokio-test" -version = "0.4.4" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7" +checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545" dependencies = [ - "async-stream", - "bytes", "futures-core", "tokio", "tokio-stream", @@ -8750,7 +8748,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.48.0", ] [[package]] From 6418e653560d7a45f9694701f6f1a32d38ca8487 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:54:58 -0800 Subject: [PATCH 21/43] chore(deps): bump axum from 0.8.4 to 0.8.8 in /codex-rs (#9883) Bumps [axum](https://github.com/tokio-rs/axum) from 0.8.4 to 0.8.8.
Release notes

Sourced from axum's releases.

axum v0.8.8

  • Clarify documentation for Router::route_layer (#3567)

#3567: tokio-rs/axum#3567

axum v0.8.7

  • Relax implicit Send / Sync bounds on RouterAsService, RouterIntoService (#3555)
  • Make it easier to visually scan for default features (#3550)
  • Fix some documentation typos

#3550: tokio-rs/axum#3550 #3555: tokio-rs/axum#3555

axum v0.8.5

  • fixed: Reject JSON request bodies with trailing characters after the JSON document (#3453)
  • added: Implement OptionalFromRequest for Multipart (#3220)
  • added: Getter methods Location::{status_code, location}
  • added: Support for writing arbitrary binary data into server-sent events (#3425)]
  • added: middleware::ResponseAxumBodyLayer for mapping response body to axum::body::Body (#3469)
  • added: impl FusedStream for WebSocket (#3443)
  • changed: The sse module and Sse type no longer depend on the tokio feature (#3154)
  • changed: If the location given to one of Redirects constructors is not a valid header value, instead of panicking on construction, the IntoResponse impl now returns an HTTP 500, just like Json does when serialization fails (#3377)
  • changed: Update minimum rust version to 1.78 (#3412)

#3154: tokio-rs/axum#3154 #3220: tokio-rs/axum#3220 #3377: tokio-rs/axum#3377 #3412: tokio-rs/axum#3412 #3425: tokio-rs/axum#3425 #3443: tokio-rs/axum#3443 #3453: tokio-rs/axum#3453 #3469: tokio-rs/axum#3469

Commits
  • d07863f Release axum v0.8.8 and axum-extra v0.12.3
  • 287c674 axum-extra: Make typed-routing feature enable routing feature (#3514)
  • f5804aa SecondElementIs: Correct a small inconsistency (#3559)
  • f51f3ba axum-extra: Add trailing newline to pretty JSON response (#3526)
  • 816407a Fix integer underflow in try_range_response for empty files (#3566)
  • 78656eb docs: Clarify route_layer does not apply middleware to the fallback handler...
  • 4404f27 Release axum v0.8.7 and axum-extra v0.12.2
  • 8f1545a Fix typo in extractors guide (#3554)
  • 4fc3faa Relax implicit Send / Sync bounds (#3555)
  • a05920c Make it easier to visually scan for default features (#3550)
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=axum&package-manager=cargo&previous-version=0.8.4&new-version=0.8.8)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- codex-rs/Cargo.lock | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index f6220700f2..fcf2df0328 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -616,9 +616,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "axum" -version = "0.8.4" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ "axum-core", "bytes", @@ -634,8 +634,7 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rustversion", - "serde", + "serde_core", "serde_json", "serde_path_to_error", "sync_wrapper", @@ -647,9 +646,9 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.5.2" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", @@ -658,7 +657,6 @@ dependencies = [ "http-body-util", "mime", "pin-project-lite", - "rustversion", "sync_wrapper", "tower-layer", "tower-service", From 2e5aa809f474d28a2e20aa45cf994ecbc1b0266e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:55:19 -0800 Subject: [PATCH 22/43] chore(deps): bump globset from 0.4.16 to 0.4.18 in /codex-rs (#9884) Bumps [globset](https://github.com/BurntSushi/ripgrep) from 0.4.16 to 0.4.18.
Commits
  • 0b0e013 globset-0.4.18
  • cac9870 doc: update date in man page template
  • 24e88dc ignore/types: add ssa type
  • 5748f81 printer: use doc_cfg instead of doc_auto_cfg
  • d47663b searcher: fix regression with --line-buffered flag
  • 38d6302 printer: add Cursor hyperlink alias
  • b3dc4b0 globset: improve debug log
  • ca2e34f grep-0.4.0
  • a0d61a0 grep-printer-0.3.0
  • c22fc0f deps: bump to grep-searcher 0.1.15
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=globset&package-manager=cargo&previous-version=0.4.16&new-version=0.4.18)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- codex-rs/Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index fcf2df0328..75d72d360f 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3383,9 +3383,9 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "globset" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" dependencies = [ "aho-corasick", "bstr", From f29a0defa2329cf2477d80ccee67b3d6c86d0a2a Mon Sep 17 00:00:00 2001 From: Matt Ridley Date: Mon, 26 Jan 2026 23:55:44 +0000 Subject: [PATCH 23/43] fix: remove cli tooltip references to custom prompts (#9901) Custom prompts are now deprecated, however are still references in tooltips. Remove the relevant tips from the repository. Closes #9900 --- codex-rs/tui/tooltips.txt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/codex-rs/tui/tooltips.txt b/codex-rs/tui/tooltips.txt index 302a2b1ea5..31819c6628 100644 --- a/codex-rs/tui/tooltips.txt +++ b/codex-rs/tui/tooltips.txt @@ -10,8 +10,7 @@ Use /fork to branch the current chat into a new thread. Use /init to create an AGENTS.md with project-specific guidance. Use /mcp to list configured MCP tools. You can run any shell command from Codex using `!` (e.g. `!ls`) -Type / to open the command popup; Tab autocompletes slash commands and saved prompts. -You can define your own `/` commands with custom prompts. More info: https://developers.openai.com/codex/guides/slash-commands#create-your-own-slash-commands-with-custom-prompts +Type / to open the command popup; Tab autocompletes slash commands. When the composer is empty, press Esc to step back and edit your last message; Enter confirms. Press Tab to queue a message instead of sending it immediately; Enter always sends immediately. Paste an image with Ctrl+V to attach it to your next message. From 32b062d0e190943af7463f0f1aa257ce4977008a Mon Sep 17 00:00:00 2001 From: JBallin Date: Tue, 27 Jan 2026 02:21:09 +0200 Subject: [PATCH 24/43] fix: use `brew upgrade --cask codex` to avoid warnings and ambiguity (#9823) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #9822 ### Summary Make the Homebrew upgrade command explicit by using `brew upgrade --cask codex`. ### Motivation During the Codex self-update, Homebrew can emit an avoidable warning because the name `codex` resolves to a cask: ``` Warning: Formula codex was renamed to homebrew/cask/codex. ```` While the upgrade succeeds, this relies on implicit name resolution and produces unnecessary output during the update flow. ### Why `--cask` * Eliminates warning/noise for users * Explicitly matches how Codex is distributed via Homebrew * Avoids reliance on name resolution behavior * Makes the command more robust if a `codex` formula is ever introduced ### Context This restores the `--cask` flag that was removed in #6238 after being considered “not necessary” during review: [https://github.com/openai/codex/pull/6238#discussion_r2505947880](https://github.com/openai/codex/pull/6238#discussion_r2505947880). Co-authored-by: Eric Traut --- codex-rs/tui/src/update_action.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/tui/src/update_action.rs b/codex-rs/tui/src/update_action.rs index b5cf56a6b4..0bb6fa2caa 100644 --- a/codex-rs/tui/src/update_action.rs +++ b/codex-rs/tui/src/update_action.rs @@ -15,7 +15,7 @@ impl UpdateAction { match self { UpdateAction::NpmGlobalLatest => ("npm", &["install", "-g", "@openai/codex"]), UpdateAction::BunGlobalLatest => ("bun", &["install", "-g", "@openai/codex"]), - UpdateAction::BrewUpgrade => ("brew", &["upgrade", "codex"]), + UpdateAction::BrewUpgrade => ("brew", &["upgrade", "--cask", "codex"]), } } From 73bd84dee025f365bf7a0d8309e38bdb4004962e Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 27 Jan 2026 01:02:22 +0000 Subject: [PATCH 25/43] fix: try to fix freezes 2 (#9951) Fixes a TUI freeze caused by awaiting `mpsc::Sender::send()` that blocks the tokio thread, stopping the consumption runtime and creating a deadlock. This could happen if the server was producing enough chunks to fill the `mpsc` fast enough. To solve this we try on insert using a `try_send()` (not requiring an `await`) and delegate to a tokio task if this does not work This is a temporary solution as it can contain races for delta elements and a stronger design should come here --- codex-rs/tui/src/app.rs | 62 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index bc006a2928..7a87d396f0 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -93,6 +93,7 @@ use tokio::sync::Mutex; use tokio::sync::broadcast; use tokio::sync::mpsc; use tokio::sync::mpsc::error::TryRecvError; +use tokio::sync::mpsc::error::TrySendError; use tokio::sync::mpsc::unbounded_channel; use toml::Value as TomlValue; @@ -714,8 +715,23 @@ impl App { guard.active }; - if should_send && let Err(err) = sender.send(event).await { - tracing::warn!("thread {thread_id} event channel closed: {err}"); + if should_send { + // Never await a bounded channel send on the main TUI loop: if the receiver falls behind, + // `send().await` can block and the UI stops drawing. If the channel is full, wait in a + // spawned task instead. + match sender.try_send(event) { + Ok(()) => {} + Err(TrySendError::Full(event)) => { + tokio::spawn(async move { + if let Err(err) = sender.send(event).await { + tracing::warn!("thread {thread_id} event channel closed: {err}"); + } + }); + } + Err(TrySendError::Closed(_)) => { + tracing::warn!("thread {thread_id} event channel closed"); + } + } } Ok(()) } @@ -2400,6 +2416,7 @@ mod tests { use std::sync::Arc; use std::sync::atomic::AtomicBool; use tempfile::tempdir; + use tokio::time; #[test] fn normalize_harness_overrides_resolves_relative_add_dirs() -> Result<()> { @@ -2420,6 +2437,47 @@ mod tests { Ok(()) } + #[tokio::test] + async fn enqueue_thread_event_does_not_block_when_channel_full() -> Result<()> { + let mut app = make_test_app().await; + let thread_id = ThreadId::new(); + app.thread_event_channels + .insert(thread_id, ThreadEventChannel::new(1)); + app.set_thread_active(thread_id, true).await; + + let event = Event { + id: String::new(), + msg: EventMsg::ShutdownComplete, + }; + + app.enqueue_thread_event(thread_id, event.clone()).await?; + time::timeout( + Duration::from_millis(50), + app.enqueue_thread_event(thread_id, event), + ) + .await + .expect("enqueue_thread_event blocked on a full channel")?; + + let mut rx = app + .thread_event_channels + .get_mut(&thread_id) + .expect("missing thread channel") + .receiver + .take() + .expect("missing receiver"); + + time::timeout(Duration::from_millis(50), rx.recv()) + .await + .expect("timed out waiting for first event") + .expect("channel closed unexpectedly"); + time::timeout(Duration::from_millis(50), rx.recv()) + .await + .expect("timed out waiting for second event") + .expect("channel closed unexpectedly"); + + Ok(()) + } + async fn make_test_app() -> App { let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); From 47aa1f3b6affa3b29e7c94ba56bdecb299a1c095 Mon Sep 17 00:00:00 2001 From: Charley Cunningham Date: Mon, 26 Jan 2026 17:12:17 -0800 Subject: [PATCH 26/43] Reject request_user_input outside Plan/Pair (#9955) ## Context Previous work in https://github.com/openai/codex/pull/9560 only rejected `request_user_input` in Execute and Custom modes. Since then, additional modes (e.g., Code) were added, so the guard should be mode-agnostic. ## What changed - Switch the handler to an allowlist: only Plan and PairProgramming are allowed - Return the same error for any other mode (including Code) - Add a Code-mode rejection test alongside the existing Execute/Custom tests ## Why This prevents `request_user_input` from being used in modes where it is not intended, even as new modes are introduced. --- AGENTS.md | 1 + .../core/src/tools/handlers/request_user_input.rs | 14 ++++++++------ codex-rs/core/tests/suite/request_user_input.rs | 13 +++++++++++++ 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a0dcb98728..4b6f8992e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ In the codex-rs folder where the rust code lives: - Always collapse if statements per https://rust-lang.github.io/rust-clippy/master/index.html#collapsible_if - Always inline format! args when possible per https://rust-lang.github.io/rust-clippy/master/index.html#uninlined_format_args - Use method references over closures when possible per https://rust-lang.github.io/rust-clippy/master/index.html#redundant_closure_for_method_calls +- When possible, make `match` statements exhaustive and avoid wildcard arms. - When writing tests, prefer comparing the equality of entire objects over fields one by one. - When making a change that adds or changes an API, ensure that the documentation in the `docs/` folder is up to date if applicable. - If you change `ConfigToml` or nested config types, run `just write-config-schema` to update `codex-rs/core/config.schema.json`. diff --git a/codex-rs/core/src/tools/handlers/request_user_input.rs b/codex-rs/core/src/tools/handlers/request_user_input.rs index d2c55b08df..112dcae21e 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input.rs @@ -36,12 +36,14 @@ impl ToolHandler for RequestUserInputHandler { } }; - let disallowed_mode = match session.collaboration_mode().await.mode { - ModeKind::Execute => Some("Execute"), - ModeKind::Custom => Some("Custom"), - _ => None, - }; - if let Some(mode_name) = disallowed_mode { + let mode = session.collaboration_mode().await.mode; + if !matches!(mode, ModeKind::Plan | ModeKind::PairProgramming) { + let mode_name = match mode { + ModeKind::Code => "Code", + ModeKind::Execute => "Execute", + ModeKind::Custom => "Custom", + ModeKind::Plan | ModeKind::PairProgramming => unreachable!(), + }; return Err(FunctionCallError::RespondToModel(format!( "request_user_input is unavailable in {mode_name} mode" ))); diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 2d81fc9ad8..3872969610 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -286,6 +286,19 @@ async fn request_user_input_rejected_in_execute_mode() -> anyhow::Result<()> { .await } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn request_user_input_rejected_in_code_mode() -> anyhow::Result<()> { + assert_request_user_input_rejected("Code", |model| CollaborationMode { + mode: ModeKind::Code, + settings: Settings { + model, + reasoning_effort: None, + developer_instructions: None, + }, + }) + .await +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn request_user_input_rejected_in_custom_mode() -> anyhow::Result<()> { assert_request_user_input_rejected("Custom", |model| CollaborationMode { From 6a279f6d773c26d4f7e9880c03fd321f41d5231f Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 26 Jan 2026 17:13:25 -0800 Subject: [PATCH 27/43] Updated contribution guidelines (#9933) --- docs/contributing.md | 36 +++++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/docs/contributing.md b/docs/contributing.md index 8e73119910..d410123d2d 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -1,29 +1,47 @@ ## Contributing -This project is under active development and the code will likely change pretty significantly. +**External contributions are by invitation only** -**At the moment, we are generally accepting external contributions only for bugs fixes.** +At this time, the Codex team does not accept unsolicited code contributions. -If you want to add a new feature or change the behavior of an existing one, please open an issue proposing the feature or upvote an existing enhancement request. We will generally prioritize new features based on community feedback. New features must compose well with existing and upcoming features and fit into our roadmap. They must also be implemented consistently across all Codex surfaces (CLI, IDE extension, web, etc.). +If you would like to propose a new feature or a change in behavior, please open an issue describing the proposal or upvote an existing enhancement request. We prioritize new features based on community feedback, alignment with our roadmap, and consistency across all Codex surfaces (CLI, IDE extensions, web, etc.). -If you want to contribute a bug fix, please open a bug report first - or verify that there is an existing bug report that discusses the issue. All bug fix PRs should include a link to a bug report. +If you encounter a bug, please open a bug report or verify that an existing report already covers the issue. If you would like to help, we encourage you to contribute by sharing analysis, reproduction details, root-cause hypotheses, or a high-level outline of a potential fix directly in the issue thread. -**New contributions that don't go through this process may be closed** if they aren't aligned with our current roadmap or conflict with other priorities/upcoming features. +The Codex team may invite an external contributor to submit a pull request when: + +- the problem is well understood, +- the proposed approach aligns with the team’s intended solution, and +- the issue is deemed high-impact and high-priority. + +Pull requests that have not been explicitly invited by a member of the Codex team will be closed without review. + +**Why we do not generally accept external code contributions** + +In the past, the Codex team accepted external pull requests for bug fixes. While we appreciated the effort and engagement from the community, this model did not scale well. + +Many contributions were made without full visibility into the architectural context, system-level constraints, or near-term roadmap considerations that guide Codex development. Others focused on issues that were low priority or affected a very small subset of users. Reviewing and iterating on these PRs often took more time than implementing the fix directly, and diverted attention from higher-priority work. + +The most valuable contributions consistently came from community members who demonstrated deep understanding of a problem domain. That expertise is most helpful when shared early -- through detailed bug reports, analysis, and design discussion in issues. Identifying the right solution is typically the hard part; implementing it is comparatively straightforward with the help of Codex itself. + +For these reasons, we focus external contributions on discussion, analysis, and feedback, and reserve code changes for cases where a targeted invitation makes sense. ### Development workflow +If you are invited by a Codex team member to contribute a PR, here is the recommended development workflow. + - Create a _topic branch_ from `main` - e.g. `feat/interactive-prompt`. - Keep your changes focused. Multiple unrelated fixes should be opened as separate PRs. - Ensure your change is free of lint warnings and test failures. -### Writing high-impact code changes +### Guidance for invited code contributions 1. **Start with an issue.** Open a new one or comment on an existing discussion so we can agree on the solution before code is written. 2. **Add or update tests.** A bug fix should generally come with test coverage that fails before your change and passes afterwards. 100% coverage is not required, but aim for meaningful assertions. 3. **Document behavior.** If your change affects user-facing behavior, update the README, inline help (`codex --help`), or relevant example projects. 4. **Keep commits atomic.** Each commit should compile and the tests should pass. This makes reviews and potential rollbacks easier. -### Opening a pull request +### Opening a pull request (by invitation only) - Fill in the PR template (or include similar information) - **What? Why? How?** - Include a link to a bug report or enhancement request in the issue tracker @@ -34,7 +52,7 @@ If you want to contribute a bug fix, please open a bug report first - or verify ### Review process 1. One maintainer will be assigned as a primary reviewer. -2. If your PR adds a new feature that was not previously discussed and approved, we may close your PR (see [Contributing](#contributing)). +2. If your invited PR introduces scope or behavior that was not previously discussed and approved, we may close the PR. 3. We may ask for changes. Please do not take this personally. We value the work, but we also value consistency and long-term maintainability. 4. When there is consensus that the PR meets the bar, a maintainer will squash-and-merge. @@ -42,7 +60,7 @@ If you want to contribute a bug fix, please open a bug report first - or verify - **Be kind and inclusive.** Treat others with respect; we follow the [Contributor Covenant](https://www.contributor-covenant.org/). - **Assume good intent.** Written communication is hard - err on the side of generosity. -- **Teach & learn.** If you spot something confusing, open an issue or PR with improvements. +- **Teach & learn.** If you spot something confusing, open an issue or discussion with suggestions or clarifications. ### Getting help From 394b9674326132258dd3bf8656f400400bf82778 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 26 Jan 2026 17:21:41 -0800 Subject: [PATCH 28/43] Reuse ChatComposer in request_user_input overlay (#9892) Reuse the shared chat composer for notes and freeform answers in request_user_input. - Build the overlay composer with ChatComposerConfig::plain_text. - Wire paste-burst flushing + menu surface sizing through the bottom pane. --- .../tui/src/bottom_pane/bottom_pane_view.rs | 16 + codex-rs/tui/src/bottom_pane/chat_composer.rs | 24 ++ codex-rs/tui/src/bottom_pane/mod.rs | 65 +++- .../bottom_pane/request_user_input/layout.rs | 7 + .../src/bottom_pane/request_user_input/mod.rs | 325 ++++++++++++------ .../bottom_pane/request_user_input/render.rs | 180 +++------- ...t__tests__request_user_input_freeform.snap | 17 +- ...ut__tests__request_user_input_options.snap | 25 +- ..._request_user_input_scrolling_options.snap | 19 +- ...ests__request_user_input_tight_height.snap | 15 +- .../src/bottom_pane/selection_popup_common.rs | 5 + 11 files changed, 407 insertions(+), 291 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index b3be4fed1f..d143449226 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -27,6 +27,22 @@ pub(crate) trait BottomPaneView: Renderable { false } + /// Flush any pending paste-burst state. Return true if state changed. + /// + /// This lets a modal that reuses `ChatComposer` participate in the same + /// time-based paste burst flushing as the primary composer. + fn flush_paste_burst_if_due(&mut self) -> bool { + false + } + + /// Whether the view is currently holding paste-burst transient state. + /// + /// When `true`, the bottom pane will schedule a short delayed redraw to + /// give the burst time window a chance to flush. + fn is_in_paste_burst(&self) -> bool { + false + } + /// Try to handle approval request; return the original value if not /// consumed. fn try_consume_approval_request( diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index ccf0fac5dd..c283b10ab1 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -214,6 +214,20 @@ impl Default for ChatComposerConfig { } } +impl ChatComposerConfig { + /// A minimal preset for plain-text inputs embedded in other surfaces. + /// + /// This disables popups, slash commands, and image-path attachment behavior + /// so the composer behaves like a simple notes field. + pub(crate) const fn plain_text() -> Self { + Self { + popups_enabled: false, + slash_commands_enabled: false, + image_paste_enabled: false, + } + } +} + pub(crate) struct ChatComposer { textarea: TextArea, textarea_state: RefCell, @@ -669,6 +683,16 @@ impl ChatComposer { self.sync_popups(); } + /// Update the placeholder text without changing input enablement. + pub(crate) fn set_placeholder_text(&mut self, placeholder: String) { + self.placeholder_text = placeholder; + } + + /// Move the cursor to the end of the current text buffer. + pub(crate) fn move_cursor_to_end(&mut self) { + self.textarea.set_cursor(self.textarea.text().len()); + } + pub(crate) fn clear_for_ctrl_c(&mut self) -> Option { if self.is_empty() { return None; diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index a435046cba..d0236062f6 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -105,6 +105,7 @@ pub(crate) enum CancellationEvent { } pub(crate) use chat_composer::ChatComposer; +pub(crate) use chat_composer::ChatComposerConfig; pub(crate) use chat_composer::InputResult; use codex_protocol::custom_prompts::CustomPrompt; @@ -131,6 +132,8 @@ pub(crate) struct BottomPane { frame_requester: FrameRequester, has_input_focus: bool, + enhanced_keys_supported: bool, + disable_paste_burst: bool, is_task_running: bool, esc_backtrack_hint: bool, animations_enabled: bool, @@ -183,6 +186,8 @@ impl BottomPane { app_event_tx, frame_requester, has_input_focus, + enhanced_keys_supported, + disable_paste_burst, is_task_running: false, status: None, unified_exec_footer: UnifiedExecFooter::new(), @@ -251,19 +256,37 @@ impl BottomPane { /// Forward a key event to the active view or the composer. pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult { // If a modal/view is active, handle it here; otherwise forward to composer. - if let Some(view) = self.view_stack.last_mut() { - if key_event.code == KeyCode::Esc - && matches!(view.on_ctrl_c(), CancellationEvent::Handled) - && view.is_complete() - { + if !self.view_stack.is_empty() { + // We need three pieces of information after routing the key: + // whether Esc completed the view, whether the view finished for any + // reason, and whether a paste-burst timer should be scheduled. + let (ctrl_c_completed, view_complete, view_in_paste_burst) = { + let last_index = self.view_stack.len() - 1; + let view = &mut self.view_stack[last_index]; + let ctrl_c_completed = key_event.code == KeyCode::Esc + && matches!(view.on_ctrl_c(), CancellationEvent::Handled) + && view.is_complete(); + if ctrl_c_completed { + (true, true, false) + } else { + view.handle_key_event(key_event); + (false, view.is_complete(), view.is_in_paste_burst()) + } + }; + + if ctrl_c_completed { self.view_stack.pop(); self.on_active_view_complete(); - } else { - view.handle_key_event(key_event); - if view.is_complete() { - self.view_stack.clear(); - self.on_active_view_complete(); + if let Some(next_view) = self.view_stack.last() + && next_view.is_in_paste_burst() + { + self.request_redraw_in(ChatComposer::recommended_paste_flush_delay()); } + } else if view_complete { + self.view_stack.clear(); + self.on_active_view_complete(); + } else if view_in_paste_burst { + self.request_redraw_in(ChatComposer::recommended_paste_flush_delay()); } self.request_redraw(); InputResult::None @@ -629,7 +652,13 @@ impl BottomPane { request }; - let modal = RequestUserInputOverlay::new(request, self.app_event_tx.clone()); + let modal = RequestUserInputOverlay::new( + request, + self.app_event_tx.clone(), + self.has_input_focus, + self.enhanced_keys_supported, + self.disable_paste_burst, + ); self.pause_status_timer_for_modal(); self.set_composer_input_enabled( false, @@ -671,11 +700,23 @@ impl BottomPane { } pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool { + // Give the active view the first chance to flush paste-burst state so + // overlays that reuse the composer behave consistently. + if let Some(view) = self.view_stack.last_mut() + && view.flush_paste_burst_if_due() + { + return true; + } self.composer.flush_paste_burst_if_due() } pub(crate) fn is_in_paste_burst(&self) -> bool { - self.composer.is_in_paste_burst() + // A view can hold paste-burst state independently of the primary + // composer, so check it first. + self.view_stack + .last() + .is_some_and(|view| view.is_in_paste_burst()) + || self.composer.is_in_paste_burst() } pub(crate) fn on_history_entry_response( diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs b/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs index fd5066c994..7aa54e9532 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs @@ -96,6 +96,13 @@ impl RequestUserInputOverlay { .saturating_add(notes_title_height) .saturating_add(notes_input_height); options_height = remaining_content.saturating_sub(reserved); + if options_height > options_len { + // Expand the notes composer with any leftover rows so we + // do not leave a large blank gap between options and notes. + let extra_rows = options_height.saturating_sub(options_len); + options_height = options_len; + notes_input_height = notes_input_height.saturating_add(extra_rows); + } } } else { let max_notes = remaining.saturating_sub(footer_lines); diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index aaa6391770..c710b2b2c1 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -2,13 +2,13 @@ //! //! Core behaviors: //! - Each question can be answered by selecting one option and/or providing notes. -//! - When options exist, notes are stored per selected option and appended as extra answers. +//! - Notes are stored per question and appended as extra answers. //! - Typing while focused on options jumps into notes to keep freeform input fast. //! - Enter advances to the next question; the last question submits all answers. //! - Freeform-only questions submit an empty answer list when empty. -use std::cell::RefCell; use std::collections::HashMap; use std::collections::VecDeque; +use std::path::PathBuf; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -19,18 +19,23 @@ mod render; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::CancellationEvent; +use crate::bottom_pane::ChatComposer; +use crate::bottom_pane::ChatComposerConfig; +use crate::bottom_pane::InputResult; use crate::bottom_pane::bottom_pane_view::BottomPaneView; use crate::bottom_pane::scroll_state::ScrollState; -use crate::bottom_pane::textarea::TextArea; -use crate::bottom_pane::textarea::TextAreaState; +use crate::render::renderable::Renderable; use codex_core::protocol::Op; use codex_protocol::request_user_input::RequestUserInputAnswer; use codex_protocol::request_user_input::RequestUserInputEvent; use codex_protocol::request_user_input::RequestUserInputResponse; +use codex_protocol::user_input::TextElement; const NOTES_PLACEHOLDER: &str = "Add notes (optional)"; const ANSWER_PLACEHOLDER: &str = "Type your answer (optional)"; +// Keep in sync with ChatComposer's minimum composer height. +const MIN_COMPOSER_HEIGHT: u16 = 3; const SELECT_OPTION_PLACEHOLDER: &str = "Select an option to add notes (optional)"; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -39,18 +44,11 @@ enum Focus { Notes, } -struct NotesEntry { - text: TextArea, - state: RefCell, -} - -impl NotesEntry { - fn new() -> Self { - Self { - text: TextArea::new(), - state: RefCell::new(TextAreaState::default()), - } - } +#[derive(Default, Clone)] +struct ComposerDraft { + text: String, + text_elements: Vec, + local_image_paths: Vec, } struct AnswerState { @@ -58,10 +56,8 @@ struct AnswerState { selected: Option, // Scrollable cursor state for option navigation/highlight. option_state: ScrollState, - // Notes for freeform-only questions. - notes: NotesEntry, - // Per-option notes for option questions. - option_notes: Vec, + // Per-question notes draft. + draft: ComposerDraft, } pub(crate) struct RequestUserInputOverlay { @@ -69,6 +65,10 @@ pub(crate) struct RequestUserInputOverlay { request: RequestUserInputEvent, // Queue of incoming requests to process after the current one. queue: VecDeque, + // Reuse the shared chat composer so notes/freeform answers match the + // primary input styling and behavior. + composer: ChatComposer, + // One entry per question: selection state plus a stored notes draft. answers: Vec, current_idx: usize, focus: Focus, @@ -76,11 +76,30 @@ pub(crate) struct RequestUserInputOverlay { } impl RequestUserInputOverlay { - pub(crate) fn new(request: RequestUserInputEvent, app_event_tx: AppEventSender) -> Self { + pub(crate) fn new( + request: RequestUserInputEvent, + app_event_tx: AppEventSender, + has_input_focus: bool, + enhanced_keys_supported: bool, + disable_paste_burst: bool, + ) -> Self { + // Use the same composer widget, but disable popups/slash-commands and + // image-path attachment so it behaves like a focused notes field. + let mut composer = ChatComposer::new_with_config( + has_input_focus, + app_event_tx.clone(), + enhanced_keys_supported, + ANSWER_PLACEHOLDER.to_string(), + disable_paste_burst, + ChatComposerConfig::plain_text(), + ); + // The overlay renders its own footer hints, so keep the composer footer empty. + composer.set_footer_hint_override(Some(Vec::new())); let mut overlay = Self { app_event_tx, request, queue: VecDeque::new(), + composer, answers: Vec::new(), current_idx: 0, focus: Focus::Options, @@ -88,6 +107,7 @@ impl RequestUserInputOverlay { }; overlay.reset_for_request(); overlay.ensure_focus_available(); + overlay.restore_current_draft(); overlay } @@ -144,28 +164,40 @@ impl RequestUserInputOverlay { .map(|option| option.label.as_str()) } - fn current_notes_entry(&self) -> Option<&NotesEntry> { - let answer = self.current_answer()?; - if !self.has_options() { - return Some(&answer.notes); + fn capture_composer_draft(&self) -> ComposerDraft { + ComposerDraft { + text: self.composer.current_text_with_pending(), + text_elements: self.composer.text_elements(), + local_image_paths: self + .composer + .local_images() + .into_iter() + .map(|img| img.path) + .collect(), } - let idx = self - .selected_option_index() - .or(answer.option_state.selected_idx)?; - answer.option_notes.get(idx) } - fn current_notes_entry_mut(&mut self) -> Option<&mut NotesEntry> { - let has_options = self.has_options(); - let answer = self.current_answer_mut()?; - if !has_options { - return Some(&mut answer.notes); + fn save_current_draft(&mut self) { + let draft = self.capture_composer_draft(); + if let Some(answer) = self.current_answer_mut() { + answer.draft = draft; } - let idx = answer - .selected - .or(answer.option_state.selected_idx) - .or_else(|| answer.option_notes.is_empty().then_some(0))?; - answer.option_notes.get_mut(idx) + } + + fn restore_current_draft(&mut self) { + self.composer + .set_placeholder_text(self.notes_placeholder().to_string()); + self.composer.set_footer_hint_override(Some(Vec::new())); + let Some(answer) = self.current_answer() else { + self.composer + .set_text_content(String::new(), Vec::new(), Vec::new()); + self.composer.move_cursor_to_end(); + return; + }; + let draft = answer.draft.clone(); + self.composer + .set_text_content(draft.text, draft.text_elements, draft.local_image_paths); + self.composer.move_cursor_to_end(); } fn notes_placeholder(&self) -> &'static str { @@ -200,24 +232,23 @@ impl RequestUserInputOverlay { .iter() .map(|question| { let mut option_state = ScrollState::new(); - let mut option_notes = Vec::new(); if let Some(options) = question.options.as_ref() && !options.is_empty() { option_state.selected_idx = Some(0); - option_notes = (0..options.len()).map(|_| NotesEntry::new()).collect(); } AnswerState { selected: option_state.selected_idx, option_state, - notes: NotesEntry::new(), - option_notes, + draft: ComposerDraft::default(), } }) .collect(); self.current_idx = 0; self.focus = Focus::Options; + self.composer + .set_text_content(String::new(), Vec::new(), Vec::new()); } /// Move to the next/previous question, wrapping in either direction. @@ -226,9 +257,11 @@ impl RequestUserInputOverlay { if len == 0 { return; } + self.save_current_draft(); let offset = if next { 1 } else { len.saturating_sub(1) }; self.current_idx = (self.current_idx + offset) % len; self.ensure_focus_available(); + self.restore_current_draft(); } /// Synchronize selection state to the currently focused option. @@ -266,6 +299,7 @@ impl RequestUserInputOverlay { /// Build the response payload and dispatch it to the app. fn submit_answers(&mut self) { + self.save_current_draft(); let mut answers = HashMap::new(); for (idx, question) in self.request.questions.iter().enumerate() { let answer_state = &self.answers[idx]; @@ -278,15 +312,8 @@ impl RequestUserInputOverlay { } else { answer_state.selected }; - // Notes are appended as extra answers. When options exist, notes are per selected option. - let notes = if options.is_some_and(|opts| !opts.is_empty()) { - selected_idx - .and_then(|selected| answer_state.option_notes.get(selected)) - .map(|entry| entry.text.text().trim().to_string()) - .unwrap_or_default() - } else { - answer_state.notes.text.text().trim().to_string() - }; + // Notes are appended as extra answers. + let notes = answer_state.draft.text.trim().to_string(); let selected_label = selected_idx.and_then(|selected_idx| { question .options @@ -314,6 +341,7 @@ impl RequestUserInputOverlay { self.request = next; self.reset_for_request(); self.ensure_focus_available(); + self.restore_current_draft(); } else { self.done = true; } @@ -321,6 +349,7 @@ impl RequestUserInputOverlay { /// Count freeform-only questions that have no notes. fn unanswered_count(&self) -> usize { + let current_text = self.composer.current_text(); self.request .questions .iter() @@ -331,7 +360,12 @@ impl RequestUserInputOverlay { if options.is_some_and(|opts| !opts.is_empty()) { false } else { - answer.notes.text.text().trim().is_empty() + let notes = if *idx == self.current_index() { + current_text.as_str() + } else { + answer.draft.text.as_str() + }; + notes.trim().is_empty() } }) .count() @@ -339,12 +373,48 @@ impl RequestUserInputOverlay { /// Compute the preferred notes input height for the current question. fn notes_input_height(&self, width: u16) -> u16 { - let Some(entry) = self.current_notes_entry() else { - return 3; - }; - let usable_width = width.saturating_sub(2); - let text_height = entry.text.desired_height(usable_width).clamp(1, 6); - text_height.saturating_add(2).clamp(3, 8) + let min_height = MIN_COMPOSER_HEIGHT; + self.composer + .desired_height(width.max(1)) + .clamp(min_height, min_height.saturating_add(5)) + } + + fn apply_submission_to_draft(&mut self, text: String, text_elements: Vec) { + let local_image_paths = self + .composer + .local_images() + .into_iter() + .map(|img| img.path) + .collect::>(); + if let Some(answer) = self.current_answer_mut() { + answer.draft = ComposerDraft { + text: text.clone(), + text_elements: text_elements.clone(), + local_image_paths: local_image_paths.clone(), + }; + } + self.composer + .set_text_content(text, text_elements, local_image_paths); + self.composer.move_cursor_to_end(); + self.composer.set_footer_hint_override(Some(Vec::new())); + } + + fn handle_composer_input_result(&mut self, result: InputResult) -> bool { + match result { + InputResult::Submitted { + text, + text_elements, + } + | InputResult::Queued { + text, + text_elements, + } => { + self.apply_submission_to_draft(text, text_elements); + self.go_next_or_submit(); + true + } + _ => false, + } } } @@ -376,18 +446,19 @@ impl BottomPaneView for RequestUserInputOverlay { match self.focus { Focus::Options => { let options_len = self.options_len(); - let Some(answer) = self.current_answer_mut() else { - return; - }; // Keep selection synchronized as the user moves. match key_event.code { KeyCode::Up => { - answer.option_state.move_up_wrap(options_len); - answer.selected = answer.option_state.selected_idx; + if let Some(answer) = self.current_answer_mut() { + answer.option_state.move_up_wrap(options_len); + answer.selected = answer.option_state.selected_idx; + } } KeyCode::Down => { - answer.option_state.move_down_wrap(options_len); - answer.selected = answer.option_state.selected_idx; + if let Some(answer) = self.current_answer_mut() { + answer.option_state.move_down_wrap(options_len); + answer.selected = answer.option_state.selected_idx; + } } KeyCode::Char(' ') => { self.select_current_option(); @@ -400,41 +471,43 @@ impl BottomPaneView for RequestUserInputOverlay { // Any typing while in options switches to notes for fast freeform input. self.focus = Focus::Notes; self.ensure_selected_for_notes(); - if let Some(entry) = self.current_notes_entry_mut() { - entry.text.input(key_event); - } + let (result, _) = self.composer.handle_key_event(key_event); + self.handle_composer_input_result(result); } _ => {} } } Focus::Notes => { if matches!(key_event.code, KeyCode::Enter) { - self.go_next_or_submit(); + self.ensure_selected_for_notes(); + let (result, _) = self.composer.handle_key_event(key_event); + if !self.handle_composer_input_result(result) { + self.go_next_or_submit(); + } return; } if self.has_options() && matches!(key_event.code, KeyCode::Up | KeyCode::Down) { let options_len = self.options_len(); - let Some(answer) = self.current_answer_mut() else { - return; - }; match key_event.code { KeyCode::Up => { - answer.option_state.move_up_wrap(options_len); - answer.selected = answer.option_state.selected_idx; + if let Some(answer) = self.current_answer_mut() { + answer.option_state.move_up_wrap(options_len); + answer.selected = answer.option_state.selected_idx; + } } KeyCode::Down => { - answer.option_state.move_down_wrap(options_len); - answer.selected = answer.option_state.selected_idx; + if let Some(answer) = self.current_answer_mut() { + answer.option_state.move_down_wrap(options_len); + answer.selected = answer.option_state.selected_idx; + } } _ => {} } return; } - // Notes are per option when options exist. self.ensure_selected_for_notes(); - if let Some(entry) = self.current_notes_entry_mut() { - entry.text.input(key_event); - } + let (result, _) = self.composer.handle_key_event(key_event); + self.handle_composer_input_result(result); } } } @@ -453,25 +526,20 @@ impl BottomPaneView for RequestUserInputOverlay { if pasted.is_empty() { return false; } - if matches!(self.focus, Focus::Notes) { - self.ensure_selected_for_notes(); - if let Some(entry) = self.current_notes_entry_mut() { - entry.text.insert_str(&pasted); - return true; - } - return true; - } if matches!(self.focus, Focus::Options) { // Treat pastes the same as typing: switch into notes. self.focus = Focus::Notes; - self.ensure_selected_for_notes(); - if let Some(entry) = self.current_notes_entry_mut() { - entry.text.insert_str(&pasted); - return true; - } - return true; } - false + self.ensure_selected_for_notes(); + self.composer.handle_paste(pasted) + } + + fn flush_paste_burst_if_due(&mut self) -> bool { + self.composer.flush_paste_burst_if_due() + } + + fn is_in_paste_burst(&self) -> bool { + self.composer.is_in_paste_burst() } fn try_consume_user_input_request( @@ -571,6 +639,9 @@ mod tests { let mut overlay = RequestUserInputOverlay::new( request_event("turn-1", vec![question_with_options("q1", "First")]), tx, + true, + false, + false, ); overlay.try_consume_user_input_request(request_event( "turn-2", @@ -594,6 +665,9 @@ mod tests { let mut overlay = RequestUserInputOverlay::new( request_event("turn-1", vec![question_with_options("q1", "Pick one")]), tx, + true, + false, + false, ); overlay.submit_answers(); @@ -613,6 +687,9 @@ mod tests { let mut overlay = RequestUserInputOverlay::new( request_event("turn-1", vec![question_without_options("q1", "Notes")]), tx, + true, + false, + false, ); overlay.submit_answers(); @@ -631,6 +708,9 @@ mod tests { let mut overlay = RequestUserInputOverlay::new( request_event("turn-1", vec![question_with_options("q1", "Pick one")]), tx, + true, + false, + false, ); { @@ -639,10 +719,9 @@ mod tests { } overlay.select_current_option(); overlay - .current_notes_entry_mut() - .expect("notes entry missing") - .text - .insert_str("Notes for option 2"); + .composer + .set_text_content("Notes for option 2".to_string(), Vec::new(), Vec::new()); + overlay.composer.move_cursor_to_end(); overlay.submit_answers(); @@ -660,12 +739,39 @@ mod tests { ); } + #[test] + fn large_paste_is_preserved_when_switching_questions() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![ + question_without_options("q1", "First"), + question_without_options("q2", "Second"), + ], + ), + tx, + true, + false, + false, + ); + + let large = "x".repeat(1_500); + overlay.composer.handle_paste(large.clone()); + overlay.move_question(true); + + assert_eq!(overlay.answers[0].draft.text, large); + } + #[test] fn request_user_input_options_snapshot() { let (tx, _rx) = test_sender(); let overlay = RequestUserInputOverlay::new( request_event("turn-1", vec![question_with_options("q1", "Area")]), tx, + true, + false, + false, ); let area = Rect::new(0, 0, 64, 16); insta::assert_snapshot!( @@ -680,6 +786,9 @@ mod tests { let overlay = RequestUserInputOverlay::new( request_event("turn-1", vec![question_with_options("q1", "Area")]), tx, + true, + false, + false, ); let area = Rect::new(0, 0, 60, 8); insta::assert_snapshot!( @@ -724,6 +833,9 @@ mod tests { }], ), tx, + true, + false, + false, ); { let answer = overlay.current_answer_mut().expect("answer missing"); @@ -743,6 +855,9 @@ mod tests { let overlay = RequestUserInputOverlay::new( request_event("turn-1", vec![question_without_options("q1", "Goal")]), tx, + true, + false, + false, ); let area = Rect::new(0, 0, 64, 10); insta::assert_snapshot!( @@ -757,13 +872,15 @@ mod tests { let mut overlay = RequestUserInputOverlay::new( request_event("turn-1", vec![question_with_options("q1", "Pick one")]), tx, + true, + false, + false, ); overlay.focus = Focus::Notes; overlay - .current_notes_entry_mut() - .expect("notes entry missing") - .text - .insert_str("Notes"); + .composer + .set_text_content("Notes".to_string(), Vec::new(), Vec::new()); + overlay.composer.move_cursor_to_end(); overlay.handle_key_event(KeyEvent::from(KeyCode::Down)); diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/render.rs b/codex-rs/tui/src/bottom_pane/request_user_input/render.rs index 68b7eefdc3..368f174b57 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/render.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/render.rs @@ -3,12 +3,13 @@ use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Stylize; use ratatui::text::Line; -use ratatui::widgets::Clear; use ratatui::widgets::Paragraph; -use ratatui::widgets::StatefulWidgetRef; use ratatui::widgets::Widget; use crate::bottom_pane::selection_popup_common::GenericDisplayRow; +use crate::bottom_pane::selection_popup_common::menu_surface_inset; +use crate::bottom_pane::selection_popup_common::menu_surface_padding_height; +use crate::bottom_pane::selection_popup_common::render_menu_surface; use crate::bottom_pane::selection_popup_common::render_rows; use crate::key_hint; use crate::render::renderable::Renderable; @@ -17,18 +18,28 @@ use super::RequestUserInputOverlay; impl Renderable for RequestUserInputOverlay { fn desired_height(&self, width: u16) -> u16 { - let sections = self.layout_sections(Rect::new(0, 0, width, u16::MAX)); - let mut height = sections - .question_lines - .len() - .saturating_add(5) - .saturating_add(self.notes_input_height(width) as usize) - .saturating_add(sections.footer_lines as usize); + let outer = Rect::new(0, 0, width, u16::MAX); + let inner = menu_surface_inset(outer); + let inner_width = inner.width.max(1); + let sections = self.layout_sections(Rect::new(0, 0, inner_width, u16::MAX)); + let question_height = sections.question_lines.len(); + let notes_height = self.notes_input_height(inner_width) as usize; + let footer_height = sections.footer_lines as usize; + + // Tight minimum height: progress + header + question + (optional) titles/options + // + notes composer + footer + menu padding. + let mut height = question_height + .saturating_add(notes_height) + .saturating_add(footer_height) + .saturating_add(2); // progress + header if self.has_options() { - height = height.saturating_add(2); + height = height + .saturating_add(1) // answer title + .saturating_add(self.options_len()) + .saturating_add(1); // notes title } - height = height.max(8); - height as u16 + height = height.saturating_add(menu_surface_padding_height() as usize); + height.max(8) as u16 } fn render(&self, area: Rect, buf: &mut Buffer) { @@ -46,7 +57,13 @@ impl RequestUserInputOverlay { if area.width == 0 || area.height == 0 { return; } - let sections = self.layout_sections(area); + // Paint the same menu surface used by other bottom-pane overlays and + // then render the overlay content inside its inset area. + let content_area = render_menu_surface(area, buf); + if content_area.width == 0 || content_area.height == 0 { + return; + } + let sections = self.layout_sections(content_area); // Progress header keeps the user oriented across multiple questions. let progress_line = if self.question_count() > 0 { @@ -177,9 +194,9 @@ impl RequestUserInputOverlay { ); Paragraph::new(Line::from(warning.dim())).render( Rect { - x: area.x, + x: content_area.x, y: footer_y, - width: area.width, + width: content_area.width, height: 1, }, buf, @@ -218,9 +235,9 @@ impl RequestUserInputOverlay { ]); Paragraph::new(Line::from(hint_spans).dim()).render( Rect { - x: area.x, + x: content_area.x, y: hint_y, - width: area.width, + width: content_area.width, height: 1, }, buf, @@ -232,129 +249,24 @@ impl RequestUserInputOverlay { if !self.focus_is_notes() { return None; } - let sections = self.layout_sections(area); - let entry = self.current_notes_entry()?; - let input_area = sections.notes_area; - if input_area.width <= 2 || input_area.height == 0 { + let content_area = menu_surface_inset(area); + if content_area.width == 0 || content_area.height == 0 { return None; } - if input_area.height < 3 { - // Inline notes layout uses a prefix and a single-line text area. - let prefix = notes_prefix(); - let prefix_width = prefix.len() as u16; - if input_area.width <= prefix_width { - return None; - } - let textarea_rect = Rect { - x: input_area.x.saturating_add(prefix_width), - y: input_area.y, - width: input_area.width.saturating_sub(prefix_width), - height: 1, - }; - let state = *entry.state.borrow(); - return entry.text.cursor_pos_with_state(textarea_rect, state); + let sections = self.layout_sections(content_area); + let input_area = sections.notes_area; + if input_area.width == 0 || input_area.height == 0 { + return None; } - let text_area_height = input_area.height.saturating_sub(2); - let textarea_rect = Rect { - x: input_area.x.saturating_add(1), - y: input_area.y.saturating_add(1), - width: input_area.width.saturating_sub(2), - height: text_area_height, - }; - let state = *entry.state.borrow(); - entry.text.cursor_pos_with_state(textarea_rect, state) + self.composer.cursor_pos(input_area) } - /// Render the notes input box or inline notes field. + /// Render the notes composer. fn render_notes_input(&self, area: Rect, buf: &mut Buffer) { - let Some(entry) = self.current_notes_entry() else { - return; - }; - if area.width < 2 || area.height == 0 { + if area.width == 0 || area.height == 0 { return; } - if area.height < 3 { - // Inline notes field for tight layouts. - let prefix = notes_prefix(); - let prefix_width = prefix.len() as u16; - if area.width <= prefix_width { - Paragraph::new(Line::from(prefix.dim())).render(area, buf); - return; - } - Paragraph::new(Line::from(prefix.dim())).render( - Rect { - x: area.x, - y: area.y, - width: prefix_width, - height: 1, - }, - buf, - ); - let textarea_rect = Rect { - x: area.x.saturating_add(prefix_width), - y: area.y, - width: area.width.saturating_sub(prefix_width), - height: 1, - }; - let mut state = entry.state.borrow_mut(); - Clear.render(textarea_rect, buf); - StatefulWidgetRef::render_ref(&(&entry.text), textarea_rect, buf, &mut state); - if entry.text.text().is_empty() { - Paragraph::new(Line::from(self.notes_placeholder().dim())) - .render(textarea_rect, buf); - } - return; - } - // Draw a light ASCII frame around the notes area. - let top_border = format!("+{}+", "-".repeat(area.width.saturating_sub(2) as usize)); - let bottom_border = top_border.clone(); - Paragraph::new(Line::from(top_border)).render( - Rect { - x: area.x, - y: area.y, - width: area.width, - height: 1, - }, - buf, - ); - Paragraph::new(Line::from(bottom_border)).render( - Rect { - x: area.x, - y: area.y.saturating_add(area.height.saturating_sub(1)), - width: area.width, - height: 1, - }, - buf, - ); - for row in 1..area.height.saturating_sub(1) { - Line::from(vec![ - "|".into(), - " ".repeat(area.width.saturating_sub(2) as usize).into(), - "|".into(), - ]) - .render( - Rect { - x: area.x, - y: area.y.saturating_add(row), - width: area.width, - height: 1, - }, - buf, - ); - } - let text_area_height = area.height.saturating_sub(2); - let textarea_rect = Rect { - x: area.x.saturating_add(1), - y: area.y.saturating_add(1), - width: area.width.saturating_sub(2), - height: text_area_height, - }; - let mut state = entry.state.borrow_mut(); - Clear.render(textarea_rect, buf); - StatefulWidgetRef::render_ref(&(&entry.text), textarea_rect, buf, &mut state); - if entry.text.text().is_empty() { - Paragraph::new(Line::from(self.notes_placeholder().dim())).render(textarea_rect, buf); - } + self.composer.render(area, buf); } fn focus_is_options(&self) -> bool { @@ -369,7 +281,3 @@ impl RequestUserInputOverlay { !self.has_options() && self.focus_is_notes() } } - -fn notes_prefix() -> &'static str { - "Notes: " -} diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform.snap index b698776b13..dd5ced6fca 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform.snap +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform.snap @@ -2,11 +2,12 @@ source: tui/src/bottom_pane/request_user_input/mod.rs expression: "render_snapshot(&overlay, area)" --- -Question 1/1 -Goal -Share details. -+--------------------------------------------------------------+ -|Type your answer (optional) | -+--------------------------------------------------------------+ -Unanswered: 1 | Will submit as skipped -↑/↓ scroll | enter next question | esc interrupt + + Question 1/1 + Goal + Share details. + + › Type your answer (optional) + + Unanswered: 1 | Will submit as skipped + ↑/↓ scroll | enter next question | esc interrupt diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options.snap index bc3d7d55b2..37ed036478 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options.snap +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options.snap @@ -2,19 +2,18 @@ source: tui/src/bottom_pane/request_user_input/mod.rs expression: "render_snapshot(&overlay, area)" --- -Question 1/1 -Area -Choose an option. -Answer -(x) Option 1 First choice. -( ) Option 2 Second choice. -( ) Option 3 Third choice. + + Question 1/1 + Area + Choose an option. + Answer + (x) Option 1 First choice. + ( ) Option 2 Second choice. + ( ) Option 3 Third choice. + Notes for Option 1 (optional) + + › Add notes (optional) - -Notes for Option 1 (optional) -+--------------------------------------------------------------+ -|Add notes (optional) | -+--------------------------------------------------------------+ -Option 1 of 3 | ↑/↓ scroll | enter next question | esc interrupt + Option 1 of 3 | ↑/↓ scroll | enter next question | esc inter diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_scrolling_options.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_scrolling_options.snap index 22cb487413..f15d53e11e 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_scrolling_options.snap +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_scrolling_options.snap @@ -2,13 +2,12 @@ source: tui/src/bottom_pane/request_user_input/mod.rs expression: "render_snapshot(&overlay, area)" --- -Question 1/1 -Next Step -What would you like to do next? -( ) Discuss a code change (Recommended) Walk through a plan and - edit code together. -( ) Run tests Pick a crate and run its - tests. -( ) Review a diff Summarize or review current -Notes: Add notes (optional) -Option 4 of 5 | ↑/↓ scroll | enter next question | esc interrupt + + Question 1/1 + Next Step + What would you like to do next? + ( ) Run tests Pick a crate and run its tests. + ( ) Review a diff Summarize or review current changes. + (x) Refactor Tighten structure and remove dead code. + + Option 4 of 5 | ↑/↓ scroll | enter next question | esc interrupt diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_tight_height.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_tight_height.snap index e1d01df7af..7820e0d0f2 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_tight_height.snap +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_tight_height.snap @@ -2,11 +2,10 @@ source: tui/src/bottom_pane/request_user_input/mod.rs expression: "render_snapshot(&overlay, area)" --- -Question 1/1 -Area -Choose an option. -(x) Option 1 First choice. -( ) Option 2 Second choice. -( ) Option 3 Third choice. -Notes: Add notes (optional) -Option 1 of 3 | ↑/↓ scroll | enter next question | esc inter + + Question 1/1 + Area + Choose an option. + (x) Option 1 First choice. + + Option 1 of 3 | ↑/↓ scroll | enter next question | esc i diff --git a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs index 0708bb1592..89ef8b3b3f 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -42,6 +42,11 @@ pub(crate) fn menu_surface_inset(area: Rect) -> Rect { area.inset(Insets::vh(MENU_SURFACE_INSET_V, MENU_SURFACE_INSET_H)) } +/// Total vertical padding introduced by the menu surface treatment. +pub(crate) const fn menu_surface_padding_height() -> u16 { + MENU_SURFACE_INSET_V * 2 +} + /// Paint the shared menu background and return the inset content area. /// /// This keeps the surface treatment consistent across selection-style overlays From 5d13427ef47838ce999648798b6ce4bf5b072c1b Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 27 Jan 2026 01:26:55 +0000 Subject: [PATCH 29/43] NIT larger buffer (#9957) --- codex-rs/tui/src/app.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 7a87d396f0..5413fb1496 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -98,7 +98,7 @@ use tokio::sync::mpsc::unbounded_channel; use toml::Value as TomlValue; const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue."; -const THREAD_EVENT_CHANNEL_CAPACITY: usize = 1024; +const THREAD_EVENT_CHANNEL_CAPACITY: usize = 32768; #[derive(Debug, Clone)] pub struct AppExitInfo { From a641a6427c6a47a8bc7bfbebe4ecea9f394fb6c7 Mon Sep 17 00:00:00 2001 From: alexsong-oai Date: Mon, 26 Jan 2026 17:38:06 -0800 Subject: [PATCH 30/43] feat: load interface metadata from SKILL.json (#9953) --- codex-rs/core/src/skills/loader.rs | 196 +++++++++++++++++++++-------- 1 file changed, 143 insertions(+), 53 deletions(-) diff --git a/codex-rs/core/src/skills/loader.rs b/codex-rs/core/src/skills/loader.rs index 09c988a15b..2216390857 100644 --- a/codex-rs/core/src/skills/loader.rs +++ b/codex-rs/core/src/skills/loader.rs @@ -35,7 +35,7 @@ struct SkillFrontmatterMetadata { } #[derive(Debug, Default, Deserialize)] -struct SkillToml { +struct SkillMetadataFile { #[serde(default)] interface: Option, } @@ -51,6 +51,7 @@ struct Interface { } const SKILLS_FILENAME: &str = "SKILL.md"; +const SKILLS_JSON_FILENAME: &str = "SKILL.json"; const SKILLS_TOML_FILENAME: &str = "SKILL.toml"; const SKILLS_DIR_NAME: &str = "skills"; const MAX_NAME_LEN: usize = 64; @@ -370,62 +371,94 @@ fn parse_skill_file(path: &Path, scope: SkillScope) -> Result Option { - // Fail open: optional SKILL.toml metadata should not block loading SKILL.md. + // Fail open: optional interface metadata should not block loading SKILL.md. let skill_dir = skill_path.parent()?; - let interface_path = skill_dir.join(SKILLS_TOML_FILENAME); - if !interface_path.exists() { - return None; + let interface_paths = [ + (skill_dir.join(SKILLS_JSON_FILENAME), InterfaceFormat::Json), + (skill_dir.join(SKILLS_TOML_FILENAME), InterfaceFormat::Toml), + ]; + + for (interface_path, format) in interface_paths { + if !interface_path.exists() { + continue; + } + + let contents = match fs::read_to_string(&interface_path) { + Ok(contents) => contents, + Err(error) => { + tracing::warn!( + "ignoring {path}: failed to read {label}: {error}", + path = interface_path.display(), + label = format.label() + ); + continue; + } + }; + let parsed: SkillMetadataFile = match format.parse(&contents) { + Ok(parsed) => parsed, + Err(error) => { + tracing::warn!( + "ignoring {path}: invalid {label}: {error}", + path = interface_path.display(), + label = format.label() + ); + continue; + } + }; + let interface = parsed.interface?; + + let interface = SkillInterface { + display_name: resolve_str( + interface.display_name, + MAX_NAME_LEN, + "interface.display_name", + ), + short_description: resolve_str( + interface.short_description, + MAX_SHORT_DESCRIPTION_LEN, + "interface.short_description", + ), + icon_small: resolve_asset_path(skill_dir, "interface.icon_small", interface.icon_small), + icon_large: resolve_asset_path(skill_dir, "interface.icon_large", interface.icon_large), + brand_color: resolve_color_str(interface.brand_color, "interface.brand_color"), + default_prompt: resolve_str( + interface.default_prompt, + MAX_DEFAULT_PROMPT_LEN, + "interface.default_prompt", + ), + }; + let has_fields = interface.display_name.is_some() + || interface.short_description.is_some() + || interface.icon_small.is_some() + || interface.icon_large.is_some() + || interface.brand_color.is_some() + || interface.default_prompt.is_some(); + return if has_fields { Some(interface) } else { None }; } - let contents = match fs::read_to_string(&interface_path) { - Ok(contents) => contents, - Err(error) => { - tracing::warn!( - "ignoring {path}: failed to read SKILL.toml: {error}", - path = interface_path.display() - ); - return None; - } - }; - let parsed: SkillToml = match toml::from_str(&contents) { - Ok(parsed) => parsed, - Err(error) => { - tracing::warn!( - "ignoring {path}: invalid TOML: {error}", - path = interface_path.display() - ); - return None; - } - }; - let interface = parsed.interface?; + None +} - let interface = SkillInterface { - display_name: resolve_str( - interface.display_name, - MAX_NAME_LEN, - "interface.display_name", - ), - short_description: resolve_str( - interface.short_description, - MAX_SHORT_DESCRIPTION_LEN, - "interface.short_description", - ), - icon_small: resolve_asset_path(skill_dir, "interface.icon_small", interface.icon_small), - icon_large: resolve_asset_path(skill_dir, "interface.icon_large", interface.icon_large), - brand_color: resolve_color_str(interface.brand_color, "interface.brand_color"), - default_prompt: resolve_str( - interface.default_prompt, - MAX_DEFAULT_PROMPT_LEN, - "interface.default_prompt", - ), - }; - let has_fields = interface.display_name.is_some() - || interface.short_description.is_some() - || interface.icon_small.is_some() - || interface.icon_large.is_some() - || interface.brand_color.is_some() - || interface.default_prompt.is_some(); - if has_fields { Some(interface) } else { None } +#[derive(Clone, Copy)] +enum InterfaceFormat { + Json, + Toml, +} + +impl InterfaceFormat { + fn label(self) -> &'static str { + match self { + InterfaceFormat::Json => "SKILL.json", + InterfaceFormat::Toml => "SKILL.toml", + } + } + + fn parse(self, contents: &str) -> Result { + match self { + InterfaceFormat::Json => serde_json::from_str(contents).map_err(|err| err.to_string()), + InterfaceFormat::Toml => toml::from_str(contents).map_err(|err| err.to_string()), + } + } } fn resolve_asset_path( @@ -761,6 +794,12 @@ mod tests { path } + fn write_skill_interface_json_at(skill_dir: &Path, contents: &str) -> PathBuf { + let path = skill_dir.join(SKILLS_JSON_FILENAME); + fs::write(&path, contents).unwrap(); + path + } + #[tokio::test] async fn loads_skill_interface_metadata_happy_path() { let codex_home = tempfile::tempdir().expect("tempdir"); @@ -809,6 +848,57 @@ default_prompt = " default prompt " ); } + #[tokio::test] + async fn loads_skill_interface_metadata_from_json() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let skill_path = write_skill(&codex_home, "demo", "ui-skill", "from json"); + let skill_dir = skill_path.parent().expect("skill dir"); + let normalized_skill_dir = normalized(skill_dir); + + write_skill_interface_json_at( + skill_dir, + r##" +{ + "interface": { + "display_name": "UI Skill", + "short_description": " short desc ", + "icon_small": "./assets/small-400px.png", + "icon_large": "./assets/large-logo.svg", + "brand_color": "#3B82F6", + "default_prompt": " default prompt " + } +} +"##, + ); + + let cfg = make_config(&codex_home).await; + let outcome = load_skills(&cfg); + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "ui-skill".to_string(), + description: "from json".to_string(), + short_description: None, + interface: Some(SkillInterface { + display_name: Some("UI Skill".to_string()), + short_description: Some("short desc".to_string()), + icon_small: Some(normalized_skill_dir.join("assets/small-400px.png")), + icon_large: Some(normalized_skill_dir.join("assets/large-logo.svg")), + brand_color: Some("#3B82F6".to_string()), + default_prompt: Some("default prompt".to_string()), + }), + path: normalized(skill_path.as_path()), + scope: SkillScope::User, + }] + ); + } + #[tokio::test] async fn accepts_icon_paths_under_assets_dir() { let codex_home = tempfile::tempdir().expect("tempdir"); From c900de271a61d11bca34e7f20ef2189e233d2371 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 26 Jan 2026 17:58:05 -0800 Subject: [PATCH 31/43] Warn users on enabling underdevelopment features (#9954) image --- codex-rs/core/config.schema.json | 4 + codex-rs/core/src/codex.rs | 2 + codex-rs/core/src/config/mod.rs | 13 +++ codex-rs/core/src/features.rs | 57 ++++++++++++ codex-rs/core/tests/suite/mod.rs | 1 + .../tests/suite/unstable_features_warning.rs | 90 +++++++++++++++++++ 6 files changed, 167 insertions(+) create mode 100644 codex-rs/core/tests/suite/unstable_features_warning.rs diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 36d8f676cc..6b48dcb4a9 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1465,6 +1465,10 @@ ], "description": "User-level skill config entries keyed by SKILL.md path." }, + "suppress_unstable_features_warning": { + "description": "Suppress warnings about unstable (under development) features.", + "type": "boolean" + }, "tool_output_token_limit": { "description": "Token budget applied when storing tool/function outputs in the context manager.", "format": "uint", diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 6401605eec..0f696ea84d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -22,6 +22,7 @@ use crate::connectors; use crate::exec_policy::ExecPolicyManager; use crate::features::Feature; use crate::features::Features; +use crate::features::maybe_push_unstable_features_warning; use crate::models_manager::manager::ModelsManager; use crate::parse_command::parse_command; use crate::parse_turn_item; @@ -754,6 +755,7 @@ impl Session { }); } maybe_push_chat_wire_api_deprecation(&config, &mut post_session_configured_events); + maybe_push_unstable_features_warning(&config, &mut post_session_configured_events); let auth = auth.as_ref(); let otel_manager = OtelManager::new( diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 031f3c6f33..dbccb2bef1 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -316,6 +316,9 @@ pub struct Config { /// Centralized feature flags; source of truth for feature gating. pub features: Features, + /// When `true`, suppress warnings about unstable (under development) features. + pub suppress_unstable_features_warning: bool, + /// The active profile name used to derive this `Config` (if any). pub active_profile: Option, @@ -906,6 +909,9 @@ pub struct ConfigToml { #[schemars(schema_with = "crate::config::schema::features_schema")] pub features: Option, + /// Suppress warnings about unstable (under development) features. + pub suppress_unstable_features_warning: Option, + /// Settings for ghost snapshots (used for undo). #[serde(default)] pub ghost_snapshot: Option, @@ -1564,6 +1570,9 @@ impl Config { use_experimental_unified_exec_tool, ghost_snapshot, features, + suppress_unstable_features_warning: cfg + .suppress_unstable_features_warning + .unwrap_or(false), active_profile: active_profile_name, active_project, windows_wsl_setup_acknowledged: cfg.windows_wsl_setup_acknowledged.unwrap_or(false), @@ -3732,6 +3741,7 @@ model_verbosity = "high" use_experimental_unified_exec_tool: false, ghost_snapshot: GhostSnapshotConfig::default(), features: Features::with_defaults(), + suppress_unstable_features_warning: false, active_profile: Some("o3".to_string()), active_project: ProjectConfig { trust_level: None }, windows_wsl_setup_acknowledged: false, @@ -3814,6 +3824,7 @@ model_verbosity = "high" use_experimental_unified_exec_tool: false, ghost_snapshot: GhostSnapshotConfig::default(), features: Features::with_defaults(), + suppress_unstable_features_warning: false, active_profile: Some("gpt3".to_string()), active_project: ProjectConfig { trust_level: None }, windows_wsl_setup_acknowledged: false, @@ -3911,6 +3922,7 @@ model_verbosity = "high" use_experimental_unified_exec_tool: false, ghost_snapshot: GhostSnapshotConfig::default(), features: Features::with_defaults(), + suppress_unstable_features_warning: false, active_profile: Some("zdr".to_string()), active_project: ProjectConfig { trust_level: None }, windows_wsl_setup_acknowledged: false, @@ -3994,6 +4006,7 @@ model_verbosity = "high" use_experimental_unified_exec_tool: false, ghost_snapshot: GhostSnapshotConfig::default(), features: Features::with_defaults(), + suppress_unstable_features_warning: false, active_profile: Some("gpt5".to_string()), active_project: ProjectConfig { trust_level: None }, windows_wsl_setup_acknowledged: false, diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 07cc098bec..7fde52687c 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -5,14 +5,20 @@ //! booleans through multiple types, call sites consult a single `Features` //! container attached to `Config`. +use crate::config::CONFIG_TOML_FILE; +use crate::config::Config; use crate::config::ConfigToml; use crate::config::profile::ConfigProfile; +use crate::protocol::Event; +use crate::protocol::EventMsg; +use crate::protocol::WarningEvent; use codex_otel::OtelManager; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use std::collections::BTreeMap; use std::collections::BTreeSet; +use toml::Value as TomlValue; mod legacy; pub(crate) use legacy::LegacyFeatureToggles; @@ -466,3 +472,54 @@ pub const FEATURES: &[FeatureSpec] = &[ default_enabled: false, }, ]; + +/// Push a warning event if any under-development features are enabled. +pub fn maybe_push_unstable_features_warning( + config: &Config, + post_session_configured_events: &mut Vec, +) { + if config.suppress_unstable_features_warning { + return; + } + + let mut under_development_feature_keys = Vec::new(); + if let Some(table) = config + .config_layer_stack + .effective_config() + .get("features") + .and_then(TomlValue::as_table) + { + for (key, value) in table { + if value.as_bool() != Some(true) { + continue; + } + let Some(spec) = FEATURES.iter().find(|spec| spec.key == key.as_str()) else { + continue; + }; + if !config.features.enabled(spec.id) { + continue; + } + if matches!(spec.stage, Stage::UnderDevelopment) { + under_development_feature_keys.push(spec.key.to_string()); + } + } + } + + if under_development_feature_keys.is_empty() { + return; + } + + let under_development_feature_keys = under_development_feature_keys.join(", "); + let config_path = config + .codex_home + .join(CONFIG_TOML_FILE) + .display() + .to_string(); + let message = format!( + "Under-development features enabled: {under_development_feature_keys}. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` in {config_path}." + ); + post_session_configured_events.push(Event { + id: "".to_owned(), + msg: EventMsg::Warning(WarningEvent { message }), + }); +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index e0da93a69c..9bf4ef8ef2 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -74,6 +74,7 @@ mod tools; mod truncation; mod undo; mod unified_exec; +mod unstable_features_warning; mod user_notification; mod user_shell_cmd; mod view_image; diff --git a/codex-rs/core/tests/suite/unstable_features_warning.rs b/codex-rs/core/tests/suite/unstable_features_warning.rs new file mode 100644 index 0000000000..f148edc497 --- /dev/null +++ b/codex-rs/core/tests/suite/unstable_features_warning.rs @@ -0,0 +1,90 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use codex_core::AuthManager; +use codex_core::CodexAuth; +use codex_core::NewThread; +use codex_core::ThreadManager; +use codex_core::config::CONFIG_TOML_FILE; +use codex_core::features::Feature; +use codex_core::protocol::EventMsg; +use codex_core::protocol::InitialHistory; +use codex_core::protocol::WarningEvent; +use codex_utils_absolute_path::AbsolutePathBuf; +use core::time::Duration; +use core_test_support::load_default_config_for_test; +use core_test_support::wait_for_event; +use tempfile::TempDir; +use tokio::time::timeout; +use toml::toml; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn emits_warning_when_unstable_features_enabled_via_config() { + let home = TempDir::new().expect("tempdir"); + let mut config = load_default_config_for_test(&home).await; + config.features.enable(Feature::ChildAgentsMd); + let user_config_path = + AbsolutePathBuf::from_absolute_path(config.codex_home.join(CONFIG_TOML_FILE)) + .expect("absolute user config path"); + config.config_layer_stack = config.config_layer_stack.with_user_config( + &user_config_path, + toml! { features = { child_agents_md = true } }.into(), + ); + + let thread_manager = ThreadManager::with_models_provider( + CodexAuth::from_api_key("test"), + config.model_provider.clone(), + ); + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); + + let NewThread { + thread: conversation, + .. + } = thread_manager + .resume_thread_with_history(config, InitialHistory::New, auth_manager) + .await + .expect("spawn conversation"); + + let warning = wait_for_event(&conversation, |ev| matches!(ev, EventMsg::Warning(_))).await; + let EventMsg::Warning(WarningEvent { message }) = warning else { + panic!("expected warning event"); + }; + assert!(message.contains("child_agents_md")); + assert!(message.contains("Under-development features enabled")); + assert!(message.contains("suppress_unstable_features_warning = true")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn suppresses_warning_when_configured() { + let home = TempDir::new().expect("tempdir"); + let mut config = load_default_config_for_test(&home).await; + config.features.enable(Feature::ChildAgentsMd); + config.suppress_unstable_features_warning = true; + let user_config_path = + AbsolutePathBuf::from_absolute_path(config.codex_home.join(CONFIG_TOML_FILE)) + .expect("absolute user config path"); + config.config_layer_stack = config.config_layer_stack.with_user_config( + &user_config_path, + toml! { features = { child_agents_md = true } }.into(), + ); + + let thread_manager = ThreadManager::with_models_provider( + CodexAuth::from_api_key("test"), + config.model_provider.clone(), + ); + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); + + let NewThread { + thread: conversation, + .. + } = thread_manager + .resume_thread_with_history(config, InitialHistory::New, auth_manager) + .await + .expect("spawn conversation"); + + let warning = timeout( + Duration::from_millis(150), + wait_for_event(&conversation, |ev| matches!(ev, EventMsg::Warning(_))), + ) + .await; + assert!(warning.is_err()); +} From 998e88b12a822e90d8f7fbc0fe55d6722bdb8b59 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Mon, 26 Jan 2026 18:52:10 -0800 Subject: [PATCH 32/43] Use test_codex more (#9961) Reduces boilderplate. --- codex-rs/core/tests/common/test_codex.rs | 22 +- codex-rs/core/tests/suite/client.rs | 287 ++++++------------ codex-rs/core/tests/suite/compact.rs | 176 ++++------- .../core/tests/suite/compact_resume_fork.rs | 91 +++--- codex-rs/core/tests/suite/fork_thread.rs | 31 +- codex-rs/core/tests/suite/model_overrides.rs | 55 ++-- codex-rs/core/tests/suite/review.rs | 118 ++++--- codex-rs/core/tests/suite/user_shell_cmd.rs | 40 +-- 8 files changed, 302 insertions(+), 518 deletions(-) diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 3d867d592c..a247453775 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -57,6 +57,7 @@ pub struct TestCodexBuilder { config_mutators: Vec>, auth: CodexAuth, pre_build_hooks: Vec>, + home: Option>, } impl TestCodexBuilder { @@ -88,8 +89,16 @@ impl TestCodexBuilder { self } + pub fn with_home(mut self, home: Arc) -> Self { + self.home = Some(home); + self + } + pub async fn build(&mut self, server: &wiremock::MockServer) -> anyhow::Result { - let home = Arc::new(TempDir::new()?); + let home = match self.home.clone() { + Some(home) => home, + None => Arc::new(TempDir::new()?), + }; self.build_with_home(server, home, None).await } @@ -98,7 +107,10 @@ impl TestCodexBuilder { server: &StreamingSseServer, ) -> anyhow::Result { let base_url = server.uri(); - let home = Arc::new(TempDir::new()?); + let home = match self.home.clone() { + Some(home) => home, + None => Arc::new(TempDir::new()?), + }; self.build_with_home_and_base_url(format!("{base_url}/v1"), home, None) .await } @@ -108,7 +120,10 @@ impl TestCodexBuilder { server: &WebSocketTestServer, ) -> anyhow::Result { let base_url = format!("{}/v1", server.uri()); - let home = Arc::new(TempDir::new()?); + let home = match self.home.clone() { + Some(home) => home, + None => Arc::new(TempDir::new()?), + }; let base_url_clone = base_url.clone(); self.config_mutators.push(Box::new(move |config| { config.model_provider.base_url = Some(base_url_clone); @@ -432,5 +447,6 @@ pub fn test_codex() -> TestCodexBuilder { config_mutators: vec![], auth: CodexAuth::from_api_key("dummy"), pre_build_hooks: vec![], + home: None, } } diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 902609afc5..1ed398fe9a 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -257,31 +257,19 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { let resp_mock = mount_sse_once(&server, sse_completed("resp1")).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).await; - config.model_provider = model_provider; - // Also configure user instructions to ensure they are NOT delivered on resume. - config.user_instructions = Some("be nice".to_string()); - - let thread_manager = ThreadManager::with_models_provider_and_home( - CodexAuth::from_api_key("Test API Key"), - config.model_provider.clone(), - config.codex_home.clone(), - ); - let auth_manager = - codex_core::AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let NewThread { - thread: codex, - session_configured, - .. - } = thread_manager - .resume_thread_from_rollout(config, session_path.clone(), auth_manager) + let codex_home = Arc::new(TempDir::new().unwrap()); + let mut builder = test_codex() + .with_home(codex_home.clone()) + .with_config(|config| { + // Ensure user instructions are NOT delivered on resume. + config.user_instructions = Some("be nice".to_string()); + }); + let test = builder + .resume(&server, codex_home, session_path.clone()) .await .expect("resume conversation"); + let codex = test.codex.clone(); + let session_configured = test.session_configured; // 1) Assert initial_messages only includes existing EventMsg entries; response items are not converted let initial_msgs = session_configured @@ -367,30 +355,13 @@ async fn includes_conversation_id_and_model_headers_in_request() { let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; - let model_provider = ModelProviderInfo { - base_url: Some(format!("{}/v1", server.uri())), - ..built_in_model_providers()["openai"].clone() - }; - - // Init session - let codex_home = TempDir::new().unwrap(); - let mut config = load_default_config_for_test(&codex_home).await; - config.model_provider = model_provider; - - let thread_manager = ThreadManager::with_models_provider_and_home( - CodexAuth::from_api_key("Test API Key"), - config.model_provider.clone(), - config.codex_home.clone(), - ); - let NewThread { - thread: codex, - thread_id: session_id, - session_configured: _, - .. - } = thread_manager - .start_thread(config) + let mut builder = test_codex().with_auth(CodexAuth::from_api_key("Test API Key")); + let test = builder + .build(&server) .await .expect("create new conversation"); + let codex = test.codex.clone(); + let session_id = test.session_configured.session_id; codex .submit(Op::UserInput { @@ -425,26 +396,16 @@ async fn includes_base_instructions_override_in_request() { let server = MockServer::start().await; let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; - 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).await; - - config.base_instructions = Some("test instructions".to_string()); - config.model_provider = model_provider; - - let thread_manager = ThreadManager::with_models_provider_and_home( - CodexAuth::from_api_key("Test API Key"), - config.model_provider.clone(), - config.codex_home.clone(), - ); - let codex = thread_manager - .start_thread(config) + let mut builder = test_codex() + .with_auth(CodexAuth::from_api_key("Test API Key")) + .with_config(|config| { + config.base_instructions = Some("test instructions".to_string()); + }); + let codex = builder + .build(&server) .await .expect("create new conversation") - .thread; + .codex; codex .submit(Op::UserInput { @@ -479,29 +440,19 @@ async fn chatgpt_auth_sends_correct_request() { let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; - let model_provider = ModelProviderInfo { - base_url: Some(format!("{}/api/codex", server.uri())), - ..built_in_model_providers()["openai"].clone() - }; - - // Init session - let codex_home = TempDir::new().unwrap(); - let mut config = load_default_config_for_test(&codex_home).await; - config.model_provider = model_provider; - let thread_manager = ThreadManager::with_models_provider_and_home( - create_dummy_codex_auth(), - config.model_provider.clone(), - config.codex_home.clone(), - ); - let NewThread { - thread: codex, - thread_id, - session_configured: _, - .. - } = thread_manager - .start_thread(config) + let mut model_provider = built_in_model_providers()["openai"].clone(); + model_provider.base_url = Some(format!("{}/api/codex", server.uri())); + let mut builder = test_codex() + .with_auth(create_dummy_codex_auth()) + .with_config(move |config| { + config.model_provider = model_provider; + }); + let test = builder + .build(&server) .await .expect("create new conversation"); + let codex = test.codex.clone(); + let thread_id = test.session_configured.session_id; codex .submit(Op::UserInput { @@ -617,26 +568,16 @@ async fn includes_user_instructions_message_in_request() { let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; - 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).await; - config.model_provider = model_provider; - config.user_instructions = Some("be nice".to_string()); - - let thread_manager = ThreadManager::with_models_provider_and_home( - CodexAuth::from_api_key("Test API Key"), - config.model_provider.clone(), - config.codex_home.clone(), - ); - let codex = thread_manager - .start_thread(config) + let mut builder = test_codex() + .with_auth(CodexAuth::from_api_key("Test API Key")) + .with_config(|config| { + config.user_instructions = Some("be nice".to_string()); + }); + let codex = builder + .build(&server) .await .expect("create new conversation") - .thread; + .codex; codex .submit(Op::UserInput { @@ -689,12 +630,7 @@ async fn skills_append_to_instructions() { let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; - let model_provider = ModelProviderInfo { - base_url: Some(format!("{}/v1", server.uri())), - ..built_in_model_providers()["openai"].clone() - }; - - let codex_home = TempDir::new().unwrap(); + let codex_home = Arc::new(TempDir::new().unwrap()); let skill_dir = codex_home.path().join("skills/demo"); std::fs::create_dir_all(&skill_dir).expect("create skill dir"); std::fs::write( @@ -703,20 +639,18 @@ async fn skills_append_to_instructions() { ) .expect("write skill"); - let mut config = load_default_config_for_test(&codex_home).await; - config.model_provider = model_provider; - config.cwd = codex_home.path().to_path_buf(); - - let thread_manager = ThreadManager::with_models_provider_and_home( - CodexAuth::from_api_key("Test API Key"), - config.model_provider.clone(), - config.codex_home.clone(), - ); - let codex = thread_manager - .start_thread(config) + let codex_home_path = codex_home.path().to_path_buf(); + let mut builder = test_codex() + .with_home(codex_home.clone()) + .with_auth(CodexAuth::from_api_key("Test API Key")) + .with_config(move |config| { + config.cwd = codex_home_path; + }); + let codex = builder + .build(&server) .await .expect("create new conversation") - .thread; + .codex; codex .submit(Op::UserInput { @@ -1131,28 +1065,17 @@ async fn includes_developer_instructions_message_in_request() { let server = MockServer::start().await; let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; - - 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).await; - config.model_provider = model_provider; - config.user_instructions = Some("be nice".to_string()); - config.developer_instructions = Some("be useful".to_string()); - - let thread_manager = ThreadManager::with_models_provider_and_home( - CodexAuth::from_api_key("Test API Key"), - config.model_provider.clone(), - config.codex_home.clone(), - ); - let codex = thread_manager - .start_thread(config) + let mut builder = test_codex() + .with_auth(CodexAuth::from_api_key("Test API Key")) + .with_config(|config| { + config.user_instructions = Some("be nice".to_string()); + config.developer_instructions = Some("be useful".to_string()); + }); + let codex = builder + .build(&server) .await .expect("create new conversation") - .thread; + .codex; codex .submit(Op::UserInput { @@ -1390,20 +1313,16 @@ async fn token_count_includes_rate_limits_snapshot() { let mut provider = built_in_model_providers()["openai"].clone(); provider.base_url = Some(format!("{}/v1", server.uri())); - let home = TempDir::new().unwrap(); - let mut config = load_default_config_for_test(&home).await; - config.model_provider = provider; - - let thread_manager = ThreadManager::with_models_provider_and_home( - CodexAuth::from_api_key("test"), - config.model_provider.clone(), - config.codex_home.clone(), - ); - let codex = thread_manager - .start_thread(config) + let mut builder = test_codex() + .with_auth(CodexAuth::from_api_key("test")) + .with_config(move |config| { + config.model_provider = provider; + }); + let codex = builder + .build(&server) .await .expect("create conversation") - .thread; + .codex; codex .submit(Op::UserInput { @@ -1753,20 +1672,16 @@ async fn azure_overrides_assign_properties_used_for_responses_url() { }; // Init session - let codex_home = TempDir::new().unwrap(); - let mut config = load_default_config_for_test(&codex_home).await; - config.model_provider = provider; - - let thread_manager = ThreadManager::with_models_provider_and_home( - create_dummy_codex_auth(), - config.model_provider.clone(), - config.codex_home.clone(), - ); - let codex = thread_manager - .start_thread(config) + let mut builder = test_codex() + .with_auth(create_dummy_codex_auth()) + .with_config(move |config| { + config.model_provider = provider; + }); + let codex = builder + .build(&server) .await .expect("create new conversation") - .thread; + .codex; codex .submit(Op::UserInput { @@ -1837,20 +1752,16 @@ async fn env_var_overrides_loaded_auth() { }; // Init session - let codex_home = TempDir::new().unwrap(); - let mut config = load_default_config_for_test(&codex_home).await; - config.model_provider = provider; - - let thread_manager = ThreadManager::with_models_provider_and_home( - create_dummy_codex_auth(), - config.model_provider.clone(), - config.codex_home.clone(), - ); - let codex = thread_manager - .start_thread(config) + let mut builder = test_codex() + .with_auth(create_dummy_codex_auth()) + .with_config(move |config| { + config.model_provider = provider; + }); + let codex = builder + .build(&server) .await .expect("create new conversation") - .thread; + .codex; codex .submit(Op::UserInput { @@ -1905,26 +1816,12 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { let request_log = mount_sse_sequence(&server, vec![sse1.clone(), sse1.clone(), sse1]).await; - // Configure provider to point to mock server (Responses API) and use API key auth. - let model_provider = ModelProviderInfo { - base_url: Some(format!("{}/v1", server.uri())), - ..built_in_model_providers()["openai"].clone() - }; - - // Init session with isolated codex home. - let codex_home = TempDir::new().unwrap(); - let mut config = load_default_config_for_test(&codex_home).await; - config.model_provider = model_provider; - - let thread_manager = ThreadManager::with_models_provider_and_home( - CodexAuth::from_api_key("Test API Key"), - config.model_provider.clone(), - config.codex_home.clone(), - ); - let NewThread { thread: codex, .. } = thread_manager - .start_thread(config) + let mut builder = test_codex().with_auth(CodexAuth::from_api_key("Test API Key")); + let codex = builder + .build(&server) .await - .expect("create new conversation"); + .expect("create new conversation") + .codex; // Turn 1: user sends U1; wait for completion. codex diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 1ecac3afe0..f4706962a0 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1,8 +1,6 @@ #![allow(clippy::expect_used)] use codex_core::CodexAuth; use codex_core::ModelProviderInfo; -use codex_core::NewThread; -use codex_core::ThreadManager; use codex_core::built_in_model_providers; use codex_core::compact::SUMMARIZATION_PROMPT; use codex_core::compact::SUMMARY_PREFIX; @@ -17,7 +15,6 @@ use codex_core::protocol::SandboxPolicy; use codex_core::protocol::WarningEvent; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::user_input::UserInput; -use core_test_support::load_default_config_for_test; use core_test_support::responses::ev_local_shell_call; use core_test_support::responses::ev_reasoning_item; use core_test_support::skip_if_no_network; @@ -25,7 +22,6 @@ use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use core_test_support::wait_for_event_match; use std::collections::VecDeque; -use tempfile::TempDir; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -140,21 +136,14 @@ async fn summarize_context_three_requests_and_instructions() { // Build config pointing to the mock server and spawn Codex. let model_provider = non_openai_model_provider(&server); - let home = TempDir::new().unwrap(); - let mut config = load_default_config_for_test(&home).await; - config.model_provider = model_provider; - set_test_compact_prompt(&mut config); - config.model_auto_compact_token_limit = Some(200_000); - let thread_manager = ThreadManager::with_models_provider( - CodexAuth::from_api_key("dummy"), - config.model_provider.clone(), - ); - let NewThread { - thread: codex, - session_configured, - .. - } = thread_manager.start_thread(config).await.unwrap(); - let rollout_path = session_configured.rollout_path.expect("rollout path"); + let mut builder = test_codex().with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + config.model_auto_compact_token_limit = Some(200_000); + }); + let test = builder.build(&server).await.unwrap(); + let codex = test.codex.clone(); + let rollout_path = test.session_configured.rollout_path.expect("rollout path"); // 1) Normal user input – should hit server once. codex @@ -338,20 +327,15 @@ async fn manual_compact_uses_custom_prompt() { let custom_prompt = "Use this compact prompt instead"; let model_provider = non_openai_model_provider(&server); - let home = TempDir::new().unwrap(); - let mut config = load_default_config_for_test(&home).await; - config.model_provider = model_provider; - config.compact_prompt = Some(custom_prompt.to_string()); - - let thread_manager = ThreadManager::with_models_provider( - CodexAuth::from_api_key("dummy"), - config.model_provider.clone(), - ); - let codex = thread_manager - .start_thread(config) + let mut builder = test_codex().with_config(move |config| { + config.model_provider = model_provider; + config.compact_prompt = Some(custom_prompt.to_string()); + }); + let codex = builder + .build(&server) .await .expect("create conversation") - .thread; + .codex; codex.submit(Op::Compact).await.expect("trigger compact"); let warning_event = wait_for_event(&codex, |ev| matches!(ev, EventMsg::Warning(_))).await; @@ -414,16 +398,11 @@ async fn manual_compact_emits_api_and_local_token_usage_events() { mount_sse_once(&server, sse_compact).await; let model_provider = non_openai_model_provider(&server); - let home = TempDir::new().unwrap(); - let mut config = load_default_config_for_test(&home).await; - config.model_provider = model_provider; - set_test_compact_prompt(&mut config); - - let thread_manager = ThreadManager::with_models_provider( - CodexAuth::from_api_key("dummy"), - config.model_provider.clone(), - ); - let NewThread { thread: codex, .. } = thread_manager.start_thread(config).await.unwrap(); + let mut builder = test_codex().with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + }); + let codex = builder.build(&server).await.unwrap().codex; // Trigger manual compact and collect TokenCount events for the compact turn. codex.submit(Op::Compact).await.unwrap(); @@ -1039,16 +1018,12 @@ async fn auto_compact_runs_after_token_limit_hit() { let model_provider = non_openai_model_provider(&server); - let home = TempDir::new().unwrap(); - let mut config = load_default_config_for_test(&home).await; - config.model_provider = model_provider; - set_test_compact_prompt(&mut config); - config.model_auto_compact_token_limit = Some(200_000); - let thread_manager = ThreadManager::with_models_provider( - CodexAuth::from_api_key("dummy"), - config.model_provider.clone(), - ); - let codex = thread_manager.start_thread(config).await.unwrap().thread; + let mut builder = test_codex().with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + config.model_auto_compact_token_limit = Some(200_000); + }); + let codex = builder.build(&server).await.unwrap().codex; codex .submit(Op::UserInput { @@ -1379,20 +1354,14 @@ async fn auto_compact_persists_rollout_entries() { let model_provider = non_openai_model_provider(&server); - let home = TempDir::new().unwrap(); - let mut config = load_default_config_for_test(&home).await; - config.model_provider = model_provider; - set_test_compact_prompt(&mut config); - config.model_auto_compact_token_limit = Some(200_000); - let thread_manager = ThreadManager::with_models_provider( - CodexAuth::from_api_key("dummy"), - config.model_provider.clone(), - ); - let NewThread { - thread: codex, - session_configured, - .. - } = thread_manager.start_thread(config).await.unwrap(); + let mut builder = test_codex().with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + config.model_auto_compact_token_limit = Some(200_000); + }); + let test = builder.build(&server).await.unwrap(); + let codex = test.codex.clone(); + let session_configured = test.session_configured; codex .submit(Op::UserInput { @@ -1497,19 +1466,12 @@ async fn manual_compact_retries_after_context_window_error() { let model_provider = non_openai_model_provider(&server); - let home = TempDir::new().unwrap(); - let mut config = load_default_config_for_test(&home).await; - config.model_provider = model_provider; - set_test_compact_prompt(&mut config); - config.model_auto_compact_token_limit = Some(200_000); - let codex = ThreadManager::with_models_provider( - CodexAuth::from_api_key("dummy"), - config.model_provider.clone(), - ) - .start_thread(config) - .await - .unwrap() - .thread; + let mut builder = test_codex().with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + config.model_auto_compact_token_limit = Some(200_000); + }); + let codex = builder.build(&server).await.unwrap().codex; codex .submit(Op::UserInput { @@ -1632,18 +1594,11 @@ async fn manual_compact_twice_preserves_latest_user_messages() { let model_provider = non_openai_model_provider(&server); - let home = TempDir::new().unwrap(); - let mut config = load_default_config_for_test(&home).await; - config.model_provider = model_provider; - set_test_compact_prompt(&mut config); - let codex = ThreadManager::with_models_provider( - CodexAuth::from_api_key("dummy"), - config.model_provider.clone(), - ) - .start_thread(config) - .await - .unwrap() - .thread; + let mut builder = test_codex().with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + }); + let codex = builder.build(&server).await.unwrap().codex; codex .submit(Op::UserInput { @@ -1700,12 +1655,11 @@ async fn manual_compact_twice_preserves_latest_user_messages() { && item .get("content") .and_then(|v| v.as_array()) - .map(|arr| { + .is_some_and(|arr| { arr.iter().any(|entry| { entry.get("text").and_then(|v| v.as_str()) == Some(expected) }) }) - .unwrap_or(false) }) }; @@ -1843,16 +1797,12 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ let model_provider = non_openai_model_provider(&server); - let home = TempDir::new().unwrap(); - let mut config = load_default_config_for_test(&home).await; - config.model_provider = model_provider; - set_test_compact_prompt(&mut config); - config.model_auto_compact_token_limit = Some(200); - let thread_manager = ThreadManager::with_models_provider( - CodexAuth::from_api_key("dummy"), - config.model_provider.clone(), - ); - let codex = thread_manager.start_thread(config).await.unwrap().thread; + let mut builder = test_codex().with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + config.model_auto_compact_token_limit = Some(200); + }); + let codex = builder.build(&server).await.unwrap().codex; let mut auto_compact_lifecycle_events = Vec::new(); for user in [MULTI_AUTO_MSG, follow_up_user, final_user] { @@ -1954,21 +1904,13 @@ async fn auto_compact_triggers_after_function_call_over_95_percent_usage() { let model_provider = non_openai_model_provider(&server); - let home = TempDir::new().unwrap(); - let mut config = load_default_config_for_test(&home).await; - config.model_provider = model_provider; - set_test_compact_prompt(&mut config); - config.model_context_window = Some(context_window); - config.model_auto_compact_token_limit = Some(limit); - - let codex = ThreadManager::with_models_provider( - CodexAuth::from_api_key("dummy"), - config.model_provider.clone(), - ) - .start_thread(config) - .await - .unwrap() - .thread; + let mut builder = test_codex().with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + config.model_context_window = Some(context_window); + config.model_auto_compact_token_limit = Some(limit); + }); + let codex = builder.build(&server).await.unwrap().codex; codex .submit(Op::UserInput { diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index f6047ac733..d8757fb388 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -10,12 +10,8 @@ use super::compact::COMPACT_WARNING_MESSAGE; use super::compact::FIRST_REPLY; use super::compact::SUMMARY_TEXT; -use codex_core::CodexAuth; use codex_core::CodexThread; -use codex_core::ModelProviderInfo; -use codex_core::NewThread; use codex_core::ThreadManager; -use codex_core::built_in_model_providers; use codex_core::compact::SUMMARIZATION_PROMPT; use codex_core::config::Config; use codex_core::protocol::EventMsg; @@ -23,12 +19,12 @@ use codex_core::protocol::Op; use codex_core::protocol::WarningEvent; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use codex_protocol::user_input::UserInput; -use core_test_support::load_default_config_for_test; use core_test_support::responses::ResponseMock; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::mount_sse_once_match; use core_test_support::responses::sse; +use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; use serde_json::Value; @@ -99,8 +95,7 @@ fn extract_summary_message(request: &Value, summary_text: &str) -> Value { .and_then(|arr| arr.first()) .and_then(|entry| entry.get("text")) .and_then(Value::as_str) - .map(|text| text.contains(summary_text)) - .unwrap_or(false) + .is_some_and(|text| text.contains(summary_text)) }) }) .cloned() @@ -117,21 +112,18 @@ fn normalize_compact_prompts(requests: &mut [Value]) { { return true; } - let content = item - .get("content") - .and_then(Value::as_array) - .cloned() + let Some(content) = item.get("content").and_then(Value::as_array) else { + return false; + }; + let Some(first) = content.first() else { + return false; + }; + let text = first + .get("text") + .and_then(Value::as_str) .unwrap_or_default(); - if let Some(first) = content.first() { - let text = first - .get("text") - .and_then(Value::as_str) - .unwrap_or_default(); - let normalized_text = normalize_line_endings_str(text); - !(text.is_empty() || normalized_text == normalized_summary_prompt) - } else { - false - } + let normalized_text = normalize_line_endings_str(text); + !(text.is_empty() || normalized_text == normalized_summary_prompt) }); } } @@ -874,9 +866,7 @@ fn gather_request_bodies(request_log: &[ResponseMock]) -> Vec { .flat_map(ResponseMock::requests) .map(|request| request.body_json()) .collect::>(); - for body in &mut bodies { - normalize_line_endings(body); - } + bodies.iter_mut().for_each(normalize_line_endings); bodies } @@ -960,29 +950,19 @@ async fn mount_second_compact_flow(server: &MockServer) -> Vec { async fn start_test_conversation( server: &MockServer, model: Option<&str>, -) -> (TempDir, Config, ThreadManager, Arc) { - let model_provider = ModelProviderInfo { - name: "Non-OpenAI Model provider".into(), - base_url: Some(format!("{}/v1", server.uri())), - ..built_in_model_providers()["openai"].clone() - }; - let home = TempDir::new().expect("create temp dir"); - let mut config = load_default_config_for_test(&home).await; - config.model_provider = model_provider; - config.compact_prompt = Some(SUMMARIZATION_PROMPT.to_string()); - if let Some(model) = model { - config.model = Some(model.to_string()); - } - let manager = ThreadManager::with_models_provider( - CodexAuth::from_api_key("dummy"), - config.model_provider.clone(), - ); - let NewThread { thread, .. } = manager - .start_thread(config.clone()) - .await - .expect("create conversation"); - - (home, config, manager, thread) +) -> (Arc, Config, Arc, Arc) { + let base_url = format!("{}/v1", server.uri()); + let model = model.map(str::to_string); + let mut builder = test_codex().with_config(move |config| { + config.model_provider.name = "Non-OpenAI Model provider".to_string(); + config.model_provider.base_url = Some(base_url); + config.compact_prompt = Some(SUMMARIZATION_PROMPT.to_string()); + if let Some(model) = model { + config.model = Some(model); + } + }); + let test = builder.build(server).await.expect("create conversation"); + (test.home, test.config, test.thread_manager, test.codex) } async fn user_turn(conversation: &Arc, text: &str) { @@ -1021,13 +1001,14 @@ async fn resume_conversation( config: &Config, path: std::path::PathBuf, ) -> Arc { - let auth_manager = - codex_core::AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy")); - let NewThread { thread, .. } = manager + let auth_manager = codex_core::AuthManager::from_auth_for_testing( + codex_core::CodexAuth::from_api_key("dummy"), + ); + manager .resume_thread_from_rollout(config.clone(), path, auth_manager) .await - .expect("resume conversation"); - thread + .expect("resume conversation") + .thread } #[cfg(test)] @@ -1037,9 +1018,9 @@ async fn fork_thread( path: std::path::PathBuf, nth_user_message: usize, ) -> Arc { - let NewThread { thread, .. } = manager + manager .fork_thread(nth_user_message, config.clone(), path) .await - .expect("fork conversation"); - thread + .expect("fork conversation") + .thread } diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs index 8698991846..55474a68aa 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -1,8 +1,4 @@ -use codex_core::CodexAuth; -use codex_core::ModelProviderInfo; use codex_core::NewThread; -use codex_core::ThreadManager; -use codex_core::built_in_model_providers; use codex_core::parse_turn_item; use codex_core::protocol::EventMsg; use codex_core::protocol::Op; @@ -10,10 +6,9 @@ use codex_core::protocol::RolloutItem; use codex_core::protocol::RolloutLine; use codex_protocol::items::TurnItem; use codex_protocol::user_input::UserInput; -use core_test_support::load_default_config_for_test; use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; -use tempfile::TempDir; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; @@ -44,25 +39,11 @@ async fn fork_thread_twice_drops_to_first_message() { .mount(&server) .await; - // Configure Codex to use the mock server. - let model_provider = ModelProviderInfo { - base_url: Some(format!("{}/v1", server.uri())), - ..built_in_model_providers()["openai"].clone() - }; - - let home = TempDir::new().unwrap(); - let mut config = load_default_config_for_test(&home).await; - config.model_provider = model_provider.clone(); - let config_for_fork = config.clone(); - - let thread_manager = ThreadManager::with_models_provider( - CodexAuth::from_api_key("dummy"), - config.model_provider.clone(), - ); - let NewThread { thread: codex, .. } = thread_manager - .start_thread(config) - .await - .expect("create conversation"); + let mut builder = test_codex(); + let test = builder.build(&server).await.expect("create conversation"); + let codex = test.codex.clone(); + let thread_manager = test.thread_manager.clone(); + let config_for_fork = test.config.clone(); // Send three user messages; wait for three completed turns. for text in ["first", "second", "third"] { diff --git a/codex-rs/core/tests/suite/model_overrides.rs b/codex-rs/core/tests/suite/model_overrides.rs index d2190653ea..763e72c371 100644 --- a/codex-rs/core/tests/suite/model_overrides.rs +++ b/codex-rs/core/tests/suite/model_overrides.rs @@ -1,36 +1,28 @@ -use codex_core::CodexAuth; -use codex_core::ThreadManager; use codex_core::protocol::EventMsg; use codex_core::protocol::Op; use codex_protocol::openai_models::ReasoningEffort; -use core_test_support::load_default_config_for_test; +use core_test_support::responses::start_mock_server; +use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; -use tempfile::TempDir; const CONFIG_TOML: &str = "config.toml"; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn override_turn_context_does_not_persist_when_config_exists() { - let codex_home = TempDir::new().unwrap(); - let config_path = codex_home.path().join(CONFIG_TOML); + let server = start_mock_server().await; let initial_contents = "model = \"gpt-4o\"\n"; - tokio::fs::write(&config_path, initial_contents) - .await - .expect("seed config.toml"); - - let mut config = load_default_config_for_test(&codex_home).await; - config.model = Some("gpt-4o".to_string()); - - let thread_manager = ThreadManager::with_models_provider( - CodexAuth::from_api_key("Test API Key"), - config.model_provider.clone(), - ); - let codex = thread_manager - .start_thread(config) - .await - .expect("create conversation") - .thread; + let mut builder = test_codex() + .with_pre_build_hook(move |home| { + let config_path = home.join(CONFIG_TOML); + std::fs::write(config_path, initial_contents).expect("seed config.toml"); + }) + .with_config(|config| { + config.model = Some("gpt-4o".to_string()); + }); + let test = builder.build(&server).await.expect("create conversation"); + let codex = test.codex.clone(); + let config_path = test.home.path().join(CONFIG_TOML); codex .submit(Op::OverrideTurnContext { @@ -57,25 +49,16 @@ async fn override_turn_context_does_not_persist_when_config_exists() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn override_turn_context_does_not_create_config_file() { - let codex_home = TempDir::new().unwrap(); - let config_path = codex_home.path().join(CONFIG_TOML); + let server = start_mock_server().await; + let mut builder = test_codex(); + let test = builder.build(&server).await.expect("create conversation"); + let codex = test.codex.clone(); + let config_path = test.home.path().join(CONFIG_TOML); assert!( !config_path.exists(), "test setup should start without config" ); - let config = load_default_config_for_test(&codex_home).await; - - let thread_manager = ThreadManager::with_models_provider( - CodexAuth::from_api_key("Test API Key"), - config.model_provider.clone(), - ); - let codex = thread_manager - .start_thread(config) - .await - .expect("create conversation") - .thread; - codex .submit(Op::OverrideTurnContext { cwd: None, diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index df678f9e9d..de62bf462d 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -1,11 +1,7 @@ -use codex_core::CodexAuth; use codex_core::CodexThread; use codex_core::ContentItem; -use codex_core::ModelProviderInfo; use codex_core::REVIEW_PROMPT; use codex_core::ResponseItem; -use codex_core::ThreadManager; -use codex_core::built_in_model_providers; use codex_core::config::Config; use codex_core::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; use codex_core::protocol::EventMsg; @@ -21,11 +17,11 @@ use codex_core::protocol::RolloutItem; use codex_core::protocol::RolloutLine; use codex_core::review_format::render_review_output_text; use codex_protocol::user_input::UserInput; -use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id_from_str; use core_test_support::responses::ResponseMock; use core_test_support::responses::mount_sse_sequence; use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; use std::path::PathBuf; @@ -73,8 +69,8 @@ async fn review_op_emits_lifecycle_and_review_output() { let review_json_escaped = serde_json::to_string(&review_json).unwrap(); let sse_raw = sse_template.replace("__REVIEW__", &review_json_escaped); let (server, _request_log) = start_responses_server_with_sse(&sse_raw, 1).await; - let codex_home = TempDir::new().unwrap(); - let codex = new_conversation_for_server(&server, &codex_home, |_| {}).await; + let codex_home = Arc::new(TempDir::new().unwrap()); + let codex = new_conversation_for_server(&server, codex_home.clone(), |_| {}).await; // Submit review request. codex @@ -174,6 +170,7 @@ async fn review_op_emits_lifecycle_and_review_output() { "assistant review output contains user_action markup" ); + let _codex_home_guard = codex_home; server.verify().await; } @@ -194,8 +191,8 @@ async fn review_op_with_plain_text_emits_review_fallback() { {"type":"response.completed", "response": {"id": "__ID__"}} ]"#; let (server, _request_log) = start_responses_server_with_sse(sse_raw, 1).await; - let codex_home = TempDir::new().unwrap(); - let codex = new_conversation_for_server(&server, &codex_home, |_| {}).await; + let codex_home = Arc::new(TempDir::new().unwrap()); + let codex = new_conversation_for_server(&server, codex_home.clone(), |_| {}).await; codex .submit(Op::Review { @@ -226,6 +223,7 @@ async fn review_op_with_plain_text_emits_review_fallback() { assert_eq!(expected, review); let _complete = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + let _codex_home_guard = codex_home; server.verify().await; } @@ -254,8 +252,8 @@ async fn review_filters_agent_message_related_events() { {"type":"response.completed", "response": {"id": "__ID__"}} ]"#; let (server, _request_log) = start_responses_server_with_sse(sse_raw, 1).await; - let codex_home = TempDir::new().unwrap(); - let codex = new_conversation_for_server(&server, &codex_home, |_| {}).await; + let codex_home = Arc::new(TempDir::new().unwrap()); + let codex = new_conversation_for_server(&server, codex_home.clone(), |_| {}).await; codex .submit(Op::Review { @@ -295,6 +293,7 @@ async fn review_filters_agent_message_related_events() { .await; assert!(saw_entered && saw_exited, "missing review lifecycle events"); + let _codex_home_guard = codex_home; server.verify().await; } @@ -335,8 +334,8 @@ async fn review_does_not_emit_agent_message_on_structured_output() { let review_json_escaped = serde_json::to_string(&review_json).unwrap(); let sse_raw = sse_template.replace("__REVIEW__", &review_json_escaped); let (server, _request_log) = start_responses_server_with_sse(&sse_raw, 1).await; - let codex_home = TempDir::new().unwrap(); - let codex = new_conversation_for_server(&server, &codex_home, |_| {}).await; + let codex_home = Arc::new(TempDir::new().unwrap()); + let codex = new_conversation_for_server(&server, codex_home.clone(), |_| {}).await; codex .submit(Op::Review { @@ -375,6 +374,7 @@ async fn review_does_not_emit_agent_message_on_structured_output() { assert_eq!(1, agent_messages, "expected exactly one AgentMessage event"); assert!(saw_entered && saw_exited, "missing review lifecycle events"); + let _codex_home_guard = codex_home; server.verify().await; } @@ -389,9 +389,9 @@ async fn review_uses_custom_review_model_from_config() { {"type":"response.completed", "response": {"id": "__ID__"}} ]"#; let (server, request_log) = start_responses_server_with_sse(sse_raw, 1).await; - let codex_home = TempDir::new().unwrap(); + let codex_home = Arc::new(TempDir::new().unwrap()); // Choose a review model different from the main model; ensure it is used. - let codex = new_conversation_for_server(&server, &codex_home, |cfg| { + let codex = new_conversation_for_server(&server, codex_home.clone(), |cfg| { cfg.model = Some("gpt-4.1".to_string()); cfg.review_model = Some("gpt-5.1".to_string()); }) @@ -428,6 +428,7 @@ async fn review_uses_custom_review_model_from_config() { let body = request.body_json(); assert_eq!(body["model"].as_str().unwrap(), "gpt-5.1"); + let _codex_home_guard = codex_home; server.verify().await; } @@ -442,8 +443,8 @@ async fn review_uses_session_model_when_review_model_unset() { {"type":"response.completed", "response": {"id": "__ID__"}} ]"#; let (server, request_log) = start_responses_server_with_sse(sse_raw, 1).await; - let codex_home = TempDir::new().unwrap(); - let codex = new_conversation_for_server(&server, &codex_home, |cfg| { + let codex_home = Arc::new(TempDir::new().unwrap()); + let codex = new_conversation_for_server(&server, codex_home.clone(), |cfg| { cfg.model = Some("gpt-4.1".to_string()); cfg.review_model = None; }) @@ -478,6 +479,7 @@ async fn review_uses_session_model_when_review_model_unset() { let body = request.body_json(); assert_eq!(body["model"].as_str().unwrap(), "gpt-4.1"); + let _codex_home_guard = codex_home; server.verify().await; } @@ -497,12 +499,7 @@ async fn review_input_isolated_from_parent_history() { let (server, request_log) = start_responses_server_with_sse(sse_raw, 1).await; // Seed a parent session history via resume file with both user + assistant items. - let codex_home = TempDir::new().unwrap(); - let mut config = load_default_config_for_test(&codex_home).await; - config.model_provider = ModelProviderInfo { - base_url: Some(format!("{}/v1", server.uri())), - ..built_in_model_providers()["openai"].clone() - }; + let codex_home = Arc::new(TempDir::new().unwrap()); let session_file = codex_home.path().join("resume.jsonl"); { @@ -564,7 +561,8 @@ async fn review_input_isolated_from_parent_history() { .unwrap(); } let codex = - resume_conversation_for_server(&server, &codex_home, session_file.clone(), |_| {}).await; + resume_conversation_for_server(&server, codex_home.clone(), session_file.clone(), |_| {}) + .await; // Submit review request; it must start fresh (no parent history in `input`). let review_prompt = "Please review only this".to_string(); @@ -657,6 +655,7 @@ async fn review_input_isolated_from_parent_history() { "expected user interruption message in rollout" ); + let _codex_home_guard = codex_home; server.verify().await; } @@ -675,8 +674,8 @@ async fn review_history_surfaces_in_parent_session() { {"type":"response.completed", "response": {"id": "__ID__"}} ]"#; let (server, request_log) = start_responses_server_with_sse(sse_raw, 2).await; - let codex_home = TempDir::new().unwrap(); - let codex = new_conversation_for_server(&server, &codex_home, |_| {}).await; + let codex_home = Arc::new(TempDir::new().unwrap()); + let codex = new_conversation_for_server(&server, codex_home.clone(), |_| {}).await; // 1) Run a review turn that produces an assistant message (isolated in child). codex @@ -755,6 +754,7 @@ async fn review_history_surfaces_in_parent_session() { "review assistant output missing from parent turn input" ); + let _codex_home_guard = codex_home; server.verify().await; } @@ -807,9 +807,10 @@ async fn review_uses_overridden_cwd_for_base_branch_merge_base() { .trim() .to_string(); - let codex_home = TempDir::new().unwrap(); - let codex = new_conversation_for_server(&server, &codex_home, |config| { - config.cwd = initial_cwd.path().to_path_buf(); + let codex_home = Arc::new(TempDir::new().unwrap()); + let initial_cwd_path = initial_cwd.path().to_path_buf(); + let codex = new_conversation_for_server(&server, codex_home.clone(), move |config| { + config.cwd = initial_cwd_path; }) .await; @@ -859,6 +860,7 @@ async fn review_uses_overridden_cwd_for_base_branch_merge_base() { "expected review prompt to include merge-base sha {head_sha}" ); + let _codex_home_guard = codex_home; server.verify().await; } @@ -878,57 +880,47 @@ async fn start_responses_server_with_sse( #[expect(clippy::expect_used)] async fn new_conversation_for_server( server: &MockServer, - codex_home: &TempDir, + codex_home: Arc, mutator: F, ) -> Arc where - F: FnOnce(&mut Config), + F: FnOnce(&mut Config) + Send + 'static, { - let model_provider = ModelProviderInfo { - base_url: Some(format!("{}/v1", server.uri())), - ..built_in_model_providers()["openai"].clone() - }; - let mut config = load_default_config_for_test(codex_home).await; - config.model_provider = model_provider; - mutator(&mut config); - let thread_manager = ThreadManager::with_models_provider( - CodexAuth::from_api_key("Test API Key"), - config.model_provider.clone(), - ); - thread_manager - .start_thread(config) + let base_url = format!("{}/v1", server.uri()); + let mut builder = test_codex() + .with_home(codex_home) + .with_config(move |config| { + config.model_provider.base_url = Some(base_url.clone()); + mutator(config); + }); + builder + .build(server) .await .expect("create conversation") - .thread + .codex } /// Create a conversation resuming from a rollout file, configured to talk to the provided mock server. #[expect(clippy::expect_used)] async fn resume_conversation_for_server( server: &MockServer, - codex_home: &TempDir, + codex_home: Arc, resume_path: std::path::PathBuf, mutator: F, ) -> Arc where - F: FnOnce(&mut Config), + F: FnOnce(&mut Config) + Send + 'static, { - let model_provider = ModelProviderInfo { - base_url: Some(format!("{}/v1", server.uri())), - ..built_in_model_providers()["openai"].clone() - }; - let mut config = load_default_config_for_test(codex_home).await; - config.model_provider = model_provider; - mutator(&mut config); - let thread_manager = ThreadManager::with_models_provider( - CodexAuth::from_api_key("Test API Key"), - config.model_provider.clone(), - ); - let auth_manager = - codex_core::AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - thread_manager - .resume_thread_from_rollout(config, resume_path, auth_manager) + let base_url = format!("{}/v1", server.uri()); + let mut builder = test_codex() + .with_home(codex_home.clone()) + .with_config(move |config| { + config.model_provider.base_url = Some(base_url.clone()); + mutator(config); + }); + builder + .resume(server, codex_home, resume_path) .await .expect("resume conversation") - .thread + .codex } diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index 74cb1c55c8..45c91126d1 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -1,6 +1,4 @@ use anyhow::Context; -use codex_core::NewThread; -use codex_core::ThreadManager; use codex_core::features::Feature; use codex_core::protocol::EventMsg; use codex_core::protocol::ExecCommandEndEvent; @@ -10,7 +8,6 @@ use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; use codex_core::protocol::TurnAbortReason; use core_test_support::assert_regex_match; -use core_test_support::load_default_config_for_test; use core_test_support::responses; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -38,19 +35,17 @@ async fn user_shell_cmd_ls_and_cat_in_temp_dir() { .await .expect("write temp file"); - // Load config and pin cwd to the temp dir so ls/cat operate there. - let codex_home = TempDir::new().unwrap(); - let mut config = load_default_config_for_test(&codex_home).await; - config.cwd = cwd.path().to_path_buf(); - - let thread_manager = ThreadManager::with_models_provider( - codex_core::CodexAuth::from_api_key("dummy"), - config.model_provider.clone(), - ); - let NewThread { thread: codex, .. } = thread_manager - .start_thread(config) + // Pin cwd to the temp dir so ls/cat operate there. + let server = start_mock_server().await; + let cwd_path = cwd.path().to_path_buf(); + let mut builder = test_codex().with_config(move |config| { + config.cwd = cwd_path; + }); + let codex = builder + .build(&server) .await - .expect("create new conversation"); + .expect("create new conversation") + .codex; // 1) shell command should list the file let list_cmd = "ls".to_string(); @@ -97,16 +92,13 @@ async fn user_shell_cmd_ls_and_cat_in_temp_dir() { #[tokio::test] async fn user_shell_cmd_can_be_interrupted() { // Set up isolated config and conversation. - let codex_home = TempDir::new().unwrap(); - let config = load_default_config_for_test(&codex_home).await; - let thread_manager = ThreadManager::with_models_provider( - codex_core::CodexAuth::from_api_key("dummy"), - config.model_provider.clone(), - ); - let NewThread { thread: codex, .. } = thread_manager - .start_thread(config) + let server = start_mock_server().await; + let mut builder = test_codex(); + let codex = builder + .build(&server) .await - .expect("create new conversation"); + .expect("create new conversation") + .codex; // Start a long-running command and then interrupt it. let sleep_cmd = "sleep 5".to_string(); From 86adf53235f3885dace914a53b00d67daa4e76eb Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Mon, 26 Jan 2026 19:33:48 -0800 Subject: [PATCH 33/43] fix: handle all web_search actions and in progress invocations (#9960) ### Summary - Parse all `web_search` tool actions (`search`, `find_in_page`, `open_page`). - Previously we only parsed + displayed `search`, which made the TUI appear to pause when the other actions were being used. - Show in progress `web_search` calls as `Searching the web` - Previously we only showed completed tool calls image ### Tests Added + updated tests, tested locally ### Follow ups Update VSCode extension to display these as well --- .../app-server-protocol/src/protocol/v2.rs | 4 + codex-rs/codex-api/src/sse/responses.rs | 2 +- codex-rs/core/src/codex.rs | 4 +- codex-rs/core/src/event_mapping.rs | 117 +++++++++++++++--- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/web_search.rs | 24 ++++ codex-rs/core/tests/common/responses.rs | 5 +- codex-rs/core/tests/suite/client.rs | 4 +- codex-rs/core/tests/suite/items.rs | 23 ++-- .../src/event_processor_with_human_output.rs | 20 ++- .../src/event_processor_with_jsonl_output.rs | 34 ++++- codex-rs/exec/src/exec_events.rs | 3 + .../tests/event_processor_with_json_output.rs | 78 +++++++++++- codex-rs/protocol/src/items.rs | 5 +- codex-rs/protocol/src/models.rs | 45 +++++-- codex-rs/protocol/src/protocol.rs | 6 + codex-rs/tui/src/chatwidget.rs | 35 +++++- codex-rs/tui/src/history_cell.rs | 114 +++++++++++++++-- 18 files changed, 462 insertions(+), 62 deletions(-) create mode 100644 codex-rs/core/src/web_search.rs diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 699ad5f52a..e4b3b90589 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2621,6 +2621,7 @@ mod tests { use codex_protocol::items::TurnItem; use codex_protocol::items::UserMessageItem; use codex_protocol::items::WebSearchItem; + use codex_protocol::models::WebSearchAction; use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; use codex_protocol::user_input::UserInput as CoreUserInput; use pretty_assertions::assert_eq; @@ -2728,6 +2729,9 @@ mod tests { let search_item = TurnItem::WebSearch(WebSearchItem { id: "search-1".to_string(), query: "docs".to_string(), + action: WebSearchAction::Search { + query: Some("docs".to_string()), + }, }); assert_eq!( diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index f23975f8dd..ad94cf5ea1 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -291,7 +291,7 @@ pub fn process_responses_event( if let Ok(item) = serde_json::from_value::(item_val) { return Ok(Some(ResponseEvent::OutputItemAdded(item))); } - debug!("failed to parse ResponseItem from output_item.done"); + debug!("failed to parse ResponseItem from output_item.added"); } } "response.reasoning_summary_part.added" => { diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 0f696ea84d..5e535f5130 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3439,10 +3439,8 @@ async fn try_run_sampling_request( } ResponseEvent::OutputItemAdded(item) => { if let Some(turn_item) = handle_non_tool_response_item(&item).await { - let tracked_item = turn_item.clone(); sess.emit_turn_item_started(&turn_context, &turn_item).await; - - active_item = Some(tracked_item); + active_item = Some(turn_item); } } ResponseEvent::ServerReasoningIncluded(included) => { diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 55a1037542..32382aad71 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -21,6 +21,7 @@ use crate::instructions::SkillInstructions; use crate::instructions::UserInstructions; use crate::session_prefix::is_session_prefix; use crate::user_shell_command::is_user_shell_command_text; +use crate::web_search::web_search_action_detail; fn parse_user_message(message: &[ContentItem]) -> Option { if UserInstructions::is_user_instructions(message) @@ -127,14 +128,17 @@ pub fn parse_turn_item(item: &ResponseItem) -> Option { raw_content, })) } - ResponseItem::WebSearchCall { - id, - action: WebSearchAction::Search { query }, - .. - } => Some(TurnItem::WebSearch(WebSearchItem { - id: id.clone().unwrap_or_default(), - query: query.clone().unwrap_or_default(), - })), + ResponseItem::WebSearchCall { id, action, .. } => { + let (action, query) = match action { + Some(action) => (action.clone(), web_search_action_detail(action)), + None => (WebSearchAction::Other, String::new()), + }; + Some(TurnItem::WebSearch(WebSearchItem { + id: id.clone().unwrap_or_default(), + query, + action, + })) + } _ => None, } } @@ -144,6 +148,7 @@ mod tests { use super::parse_turn_item; use codex_protocol::items::AgentMessageContent; use codex_protocol::items::TurnItem; + use codex_protocol::items::WebSearchItem; use codex_protocol::models::ContentItem; use codex_protocol::models::ReasoningItemContent; use codex_protocol::models::ReasoningItemReasoningSummary; @@ -419,18 +424,102 @@ mod tests { let item = ResponseItem::WebSearchCall { id: Some("ws_1".to_string()), status: Some("completed".to_string()), - action: WebSearchAction::Search { + action: Some(WebSearchAction::Search { query: Some("weather".to_string()), - }, + }), }; let turn_item = parse_turn_item(&item).expect("expected web search turn item"); match turn_item { - TurnItem::WebSearch(search) => { - assert_eq!(search.id, "ws_1"); - assert_eq!(search.query, "weather"); - } + TurnItem::WebSearch(search) => assert_eq!( + search, + WebSearchItem { + id: "ws_1".to_string(), + query: "weather".to_string(), + action: WebSearchAction::Search { + query: Some("weather".to_string()), + }, + } + ), + other => panic!("expected TurnItem::WebSearch, got {other:?}"), + } + } + + #[test] + fn parses_web_search_open_page_call() { + let item = ResponseItem::WebSearchCall { + id: Some("ws_open".to_string()), + status: Some("completed".to_string()), + action: Some(WebSearchAction::OpenPage { + url: Some("https://example.com".to_string()), + }), + }; + + let turn_item = parse_turn_item(&item).expect("expected web search turn item"); + + match turn_item { + TurnItem::WebSearch(search) => assert_eq!( + search, + WebSearchItem { + id: "ws_open".to_string(), + query: "https://example.com".to_string(), + action: WebSearchAction::OpenPage { + url: Some("https://example.com".to_string()), + }, + } + ), + other => panic!("expected TurnItem::WebSearch, got {other:?}"), + } + } + + #[test] + fn parses_web_search_find_in_page_call() { + let item = ResponseItem::WebSearchCall { + id: Some("ws_find".to_string()), + status: Some("completed".to_string()), + action: Some(WebSearchAction::FindInPage { + url: Some("https://example.com".to_string()), + pattern: Some("needle".to_string()), + }), + }; + + let turn_item = parse_turn_item(&item).expect("expected web search turn item"); + + match turn_item { + TurnItem::WebSearch(search) => assert_eq!( + search, + WebSearchItem { + id: "ws_find".to_string(), + query: "'needle' in https://example.com".to_string(), + action: WebSearchAction::FindInPage { + url: Some("https://example.com".to_string()), + pattern: Some("needle".to_string()), + }, + } + ), + other => panic!("expected TurnItem::WebSearch, got {other:?}"), + } + } + + #[test] + fn parses_partial_web_search_call_without_action_as_other() { + let item = ResponseItem::WebSearchCall { + id: Some("ws_partial".to_string()), + status: Some("in_progress".to_string()), + action: None, + }; + + let turn_item = parse_turn_item(&item).expect("expected web search turn item"); + match turn_item { + TurnItem::WebSearch(search) => assert_eq!( + search, + WebSearchItem { + id: "ws_partial".to_string(), + query: String::new(), + action: WebSearchAction::Other, + } + ), other => panic!("expected TurnItem::WebSearch, got {other:?}"), } } diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index f662f41b21..010ba3874a 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -69,6 +69,7 @@ mod event_mapping; pub mod review_format; pub mod review_prompts; mod thread_manager; +pub mod web_search; pub use codex_protocol::protocol::InitialHistory; pub use thread_manager::NewThread; pub use thread_manager::ThreadManager; diff --git a/codex-rs/core/src/web_search.rs b/codex-rs/core/src/web_search.rs new file mode 100644 index 0000000000..458758b70e --- /dev/null +++ b/codex-rs/core/src/web_search.rs @@ -0,0 +1,24 @@ +use codex_protocol::models::WebSearchAction; + +pub fn web_search_action_detail(action: &WebSearchAction) -> String { + match action { + WebSearchAction::Search { query } => query.clone().unwrap_or_default(), + WebSearchAction::OpenPage { url } => url.clone().unwrap_or_default(), + WebSearchAction::FindInPage { url, pattern } => match (pattern, url) { + (Some(pattern), Some(url)) => format!("'{pattern}' in {url}"), + (Some(pattern), None) => format!("'{pattern}'"), + (None, Some(url)) => url.clone(), + (None, None) => String::new(), + }, + WebSearchAction::Other => String::new(), + } +} + +pub fn web_search_detail(action: Option<&WebSearchAction>, query: &str) -> String { + let detail = action.map(web_search_action_detail).unwrap_or_default(); + if detail.is_empty() { + query.to_string() + } else { + detail + } +} diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index 3ca45ca735..e36d9592f1 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -494,14 +494,13 @@ pub fn ev_reasoning_text_delta(delta: &str) -> Value { }) } -pub fn ev_web_search_call_added(id: &str, status: &str, query: &str) -> Value { +pub fn ev_web_search_call_added_partial(id: &str, status: &str) -> Value { serde_json::json!({ "type": "response.output_item.added", "item": { "type": "web_search_call", "id": id, - "status": status, - "action": {"type": "search", "query": query} + "status": status } }) } diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 1ed398fe9a..bdc2f311c0 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1211,9 +1211,9 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { prompt.input.push(ResponseItem::WebSearchCall { id: Some("web-search-id".into()), status: Some("completed".into()), - action: WebSearchAction::Search { + action: Some(WebSearchAction::Search { query: Some("weather".into()), - }, + }), }); prompt.input.push(ResponseItem::FunctionCall { id: Some("function-id".into()), diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index 60d0dbc75f..08ac598388 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -6,6 +6,7 @@ use codex_core::protocol::ItemCompletedEvent; use codex_core::protocol::ItemStartedEvent; use codex_core::protocol::Op; use codex_protocol::items::TurnItem; +use codex_protocol::models::WebSearchAction; use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; @@ -18,7 +19,7 @@ use core_test_support::responses::ev_reasoning_item_added; use core_test_support::responses::ev_reasoning_summary_text_delta; use core_test_support::responses::ev_reasoning_text_delta; use core_test_support::responses::ev_response_created; -use core_test_support::responses::ev_web_search_call_added; +use core_test_support::responses::ev_web_search_call_added_partial; use core_test_support::responses::ev_web_search_call_done; use core_test_support::responses::mount_sse_once; use core_test_support::responses::sse; @@ -208,8 +209,7 @@ async fn web_search_item_is_emitted() -> anyhow::Result<()> { let TestCodex { codex, .. } = test_codex().build(&server).await?; - let web_search_added = - ev_web_search_call_added("web-search-1", "in_progress", "weather seattle"); + let web_search_added = ev_web_search_call_added_partial("web-search-1", "in_progress"); let web_search_done = ev_web_search_call_done("web-search-1", "completed", "weather seattle"); let first_response = sse(vec![ @@ -230,11 +230,8 @@ async fn web_search_item_is_emitted() -> anyhow::Result<()> { }) .await?; - let started = wait_for_event_match(&codex, |ev| match ev { - EventMsg::ItemStarted(ItemStartedEvent { - item: TurnItem::WebSearch(item), - .. - }) => Some(item.clone()), + let begin = wait_for_event_match(&codex, |ev| match ev { + EventMsg::WebSearchBegin(event) => Some(event.clone()), _ => None, }) .await; @@ -247,8 +244,14 @@ async fn web_search_item_is_emitted() -> anyhow::Result<()> { }) .await; - assert_eq!(started.id, completed.id); - assert_eq!(completed.query, "weather seattle"); + assert_eq!(begin.call_id, "web-search-1"); + assert_eq!(completed.id, begin.call_id); + assert_eq!( + completed.action, + WebSearchAction::Search { + query: Some("weather seattle".to_string()), + } + ); 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 6275ceaf8d..fb7aa54ab4 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -32,6 +32,7 @@ use codex_core::protocol::TurnCompleteEvent; use codex_core::protocol::TurnDiffEvent; use codex_core::protocol::WarningEvent; use codex_core::protocol::WebSearchEndEvent; +use codex_core::web_search::web_search_detail; use codex_protocol::num_format::format_with_separators; use owo_colors::OwoColorize; use owo_colors::Style; @@ -370,8 +371,20 @@ impl EventProcessor for EventProcessorWithHumanOutput { } } } - EventMsg::WebSearchEnd(WebSearchEndEvent { call_id: _, query }) => { - ts_msg!(self, "🌐 Searched: {query}"); + EventMsg::WebSearchBegin(_) => { + ts_msg!(self, "🌐 Searching the web..."); + } + EventMsg::WebSearchEnd(WebSearchEndEvent { + call_id: _, + query, + action, + }) => { + let detail = web_search_detail(Some(&action), &query); + if detail.is_empty() { + ts_msg!(self, "🌐 Searched the web"); + } else { + ts_msg!(self, "🌐 Searched: {detail}"); + } } EventMsg::PatchApplyBegin(PatchApplyBeginEvent { call_id, @@ -737,8 +750,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { ); } EventMsg::ShutdownComplete => return CodexStatus::Shutdown, - EventMsg::WebSearchBegin(_) - | EventMsg::ExecApprovalRequest(_) + EventMsg::ExecApprovalRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) | EventMsg::TerminalInteraction(_) | EventMsg::ExecCommandOutputDelta(_) diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output.rs b/codex-rs/exec/src/event_processor_with_jsonl_output.rs index 779ec9292d..0dfce33cb6 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs @@ -49,6 +49,7 @@ use codex_core::protocol::CollabCloseBeginEvent; use codex_core::protocol::CollabCloseEndEvent; use codex_core::protocol::CollabWaitingBeginEvent; use codex_core::protocol::CollabWaitingEndEvent; +use codex_protocol::models::WebSearchAction; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; use serde_json::Value as JsonValue; @@ -66,6 +67,7 @@ pub struct EventProcessorWithJsonOutput { last_total_token_usage: Option, running_mcp_tool_calls: HashMap, running_collab_tool_calls: HashMap, + running_web_search_calls: HashMap, last_critical_error: Option, } @@ -107,6 +109,7 @@ impl EventProcessorWithJsonOutput { last_total_token_usage: None, running_mcp_tool_calls: HashMap::new(), running_collab_tool_calls: HashMap::new(), + running_web_search_calls: HashMap::new(), last_critical_error: None, } } @@ -138,7 +141,7 @@ impl EventProcessorWithJsonOutput { protocol::EventMsg::CollabCloseEnd(ev) => self.handle_collab_close_end(ev), protocol::EventMsg::PatchApplyBegin(ev) => self.handle_patch_apply_begin(ev), protocol::EventMsg::PatchApplyEnd(ev) => self.handle_patch_apply_end(ev), - protocol::EventMsg::WebSearchBegin(_) => Vec::new(), + protocol::EventMsg::WebSearchBegin(ev) => self.handle_web_search_begin(ev), protocol::EventMsg::WebSearchEnd(ev) => self.handle_web_search_end(ev), protocol::EventMsg::TokenCount(ev) => { if let Some(info) = &ev.info { @@ -195,11 +198,36 @@ impl EventProcessorWithJsonOutput { })] } - fn handle_web_search_end(&self, ev: &protocol::WebSearchEndEvent) -> Vec { + fn handle_web_search_begin(&mut self, ev: &protocol::WebSearchBeginEvent) -> Vec { + if self.running_web_search_calls.contains_key(&ev.call_id) { + return Vec::new(); + } + let item_id = self.get_next_item_id(); + self.running_web_search_calls + .insert(ev.call_id.clone(), item_id.clone()); let item = ThreadItem { - id: self.get_next_item_id(), + id: item_id, details: ThreadItemDetails::WebSearch(WebSearchItem { + id: ev.call_id.clone(), + query: String::new(), + action: WebSearchAction::Other, + }), + }; + + vec![ThreadEvent::ItemStarted(ItemStartedEvent { item })] + } + + fn handle_web_search_end(&mut self, ev: &protocol::WebSearchEndEvent) -> Vec { + let item_id = self + .running_web_search_calls + .remove(&ev.call_id) + .unwrap_or_else(|| self.get_next_item_id()); + let item = ThreadItem { + id: item_id, + details: ThreadItemDetails::WebSearch(WebSearchItem { + id: ev.call_id.clone(), query: ev.query.clone(), + action: ev.action.clone(), }), }; diff --git a/codex-rs/exec/src/exec_events.rs b/codex-rs/exec/src/exec_events.rs index e113a5c4b4..47c67a6dca 100644 --- a/codex-rs/exec/src/exec_events.rs +++ b/codex-rs/exec/src/exec_events.rs @@ -1,3 +1,4 @@ +use codex_protocol::models::WebSearchAction; use mcp_types::ContentBlock as McpContentBlock; use serde::Deserialize; use serde::Serialize; @@ -280,7 +281,9 @@ pub struct McpToolCallItem { /// A web search request. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] pub struct WebSearchItem { + pub id: String, pub query: String, + pub action: WebSearchAction, } /// An error notification. diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index cb2a283b30..105824c60d 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -20,6 +20,7 @@ use codex_core::protocol::PatchApplyEndEvent; use codex_core::protocol::SandboxPolicy; use codex_core::protocol::SessionConfiguredEvent; use codex_core::protocol::WarningEvent; +use codex_core::protocol::WebSearchBeginEvent; use codex_core::protocol::WebSearchEndEvent; use codex_exec::event_processor_with_jsonl_output::EventProcessorWithJsonOutput; use codex_exec::exec_events::AgentMessageItem; @@ -54,6 +55,7 @@ use codex_exec::exec_events::TurnStartedEvent; use codex_exec::exec_events::Usage; use codex_exec::exec_events::WebSearchItem; use codex_protocol::ThreadId; +use codex_protocol::models::WebSearchAction; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; @@ -124,11 +126,15 @@ fn task_started_produces_turn_started_event() { fn web_search_end_emits_item_completed() { let mut ep = EventProcessorWithJsonOutput::new(None); let query = "rust async await".to_string(); + let action = WebSearchAction::Search { + query: Some(query.clone()), + }; let out = ep.collect_thread_events(&event( "w1", EventMsg::WebSearchEnd(WebSearchEndEvent { call_id: "call-123".to_string(), query: query.clone(), + action: action.clone(), }), )); @@ -137,12 +143,82 @@ fn web_search_end_emits_item_completed() { vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item: ThreadItem { id: "item_0".to_string(), - details: ThreadItemDetails::WebSearch(WebSearchItem { query }), + details: ThreadItemDetails::WebSearch(WebSearchItem { + id: "call-123".to_string(), + query, + action, + }), }, })] ); } +#[test] +fn web_search_begin_emits_item_started() { + let mut ep = EventProcessorWithJsonOutput::new(None); + let out = ep.collect_thread_events(&event( + "w0", + EventMsg::WebSearchBegin(WebSearchBeginEvent { + call_id: "call-0".to_string(), + }), + )); + + assert_eq!(out.len(), 1); + let ThreadEvent::ItemStarted(ItemStartedEvent { item }) = &out[0] else { + panic!("expected ItemStarted"); + }; + assert!(item.id.starts_with("item_")); + assert_eq!( + item.details, + ThreadItemDetails::WebSearch(WebSearchItem { + id: "call-0".to_string(), + query: String::new(), + action: WebSearchAction::Other, + }) + ); +} + +#[test] +fn web_search_begin_then_end_reuses_item_id() { + let mut ep = EventProcessorWithJsonOutput::new(None); + let begin = ep.collect_thread_events(&event( + "w0", + EventMsg::WebSearchBegin(WebSearchBeginEvent { + call_id: "call-1".to_string(), + }), + )); + let ThreadEvent::ItemStarted(ItemStartedEvent { item: started_item }) = &begin[0] else { + panic!("expected ItemStarted"); + }; + let action = WebSearchAction::Search { + query: Some("rust async await".to_string()), + }; + let end = ep.collect_thread_events(&event( + "w1", + EventMsg::WebSearchEnd(WebSearchEndEvent { + call_id: "call-1".to_string(), + query: "rust async await".to_string(), + action: action.clone(), + }), + )); + let ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: completed_item, + }) = &end[0] + else { + panic!("expected ItemCompleted"); + }; + + assert_eq!(completed_item.id, started_item.id); + assert_eq!( + completed_item.details, + ThreadItemDetails::WebSearch(WebSearchItem { + id: "call-1".to_string(), + query: "rust async await".to_string(), + action, + }) + ); +} + #[test] fn plan_update_emits_todo_list_started_updated_and_completed() { let mut ep = EventProcessorWithJsonOutput::new(None); diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index 9276a759cc..12fa6a0f51 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -1,3 +1,4 @@ +use crate::models::WebSearchAction; use crate::protocol::AgentMessageEvent; use crate::protocol::AgentReasoningEvent; use crate::protocol::AgentReasoningRawContentEvent; @@ -49,10 +50,11 @@ pub struct ReasoningItem { pub raw_content: Vec, } -#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)] +#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] pub struct WebSearchItem { pub id: String, pub query: String, + pub action: WebSearchAction, } impl UserMessageItem { @@ -181,6 +183,7 @@ impl WebSearchItem { EventMsg::WebSearchEnd(WebSearchEndEvent { call_id: self.id.clone(), query: self.query.clone(), + action: self.action.clone(), }) } } diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 57241bb8b6..b0b8156916 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -157,7 +157,9 @@ pub enum ResponseItem { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] status: Option, - action: WebSearchAction, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + action: Option, }, // Generated by the harness but considered exactly as a model response. GhostSnapshot { @@ -1034,10 +1036,12 @@ mod tests { "query": "weather seattle" } }"#, - WebSearchAction::Search { + None, + Some(WebSearchAction::Search { query: Some("weather seattle".into()), - }, + }), Some("completed".into()), + true, ), ( r#"{ @@ -1048,10 +1052,12 @@ mod tests { "url": "https://example.com" } }"#, - WebSearchAction::OpenPage { + None, + Some(WebSearchAction::OpenPage { url: Some("https://example.com".into()), - }, + }), Some("open".into()), + true, ), ( r#"{ @@ -1063,26 +1069,43 @@ mod tests { "pattern": "installation" } }"#, - WebSearchAction::FindInPage { + None, + Some(WebSearchAction::FindInPage { url: Some("https://example.com/docs".into()), pattern: Some("installation".into()), - }, + }), Some("in_progress".into()), + true, + ), + ( + r#"{ + "type": "web_search_call", + "status": "in_progress", + "id": "ws_partial" + }"#, + Some("ws_partial".into()), + None, + Some("in_progress".into()), + false, ), ]; - for (json_literal, expected_action, expected_status) in cases { + for (json_literal, expected_id, expected_action, expected_status, expect_roundtrip) in cases + { let parsed: ResponseItem = serde_json::from_str(json_literal)?; let expected = ResponseItem::WebSearchCall { - id: None, + id: expected_id.clone(), status: expected_status.clone(), action: expected_action.clone(), }; assert_eq!(parsed, expected); let serialized = serde_json::to_value(&parsed)?; - let original_value: serde_json::Value = serde_json::from_str(json_literal)?; - assert_eq!(serialized, original_value); + let mut expected_serialized: serde_json::Value = serde_json::from_str(json_literal)?; + if !expect_roundtrip && let Some(obj) = expected_serialized.as_object_mut() { + obj.remove("id"); + } + assert_eq!(serialized, expected_serialized); } Ok(()) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index e9cb8147d7..c60b13d72f 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -24,6 +24,7 @@ use crate::message_history::HistoryEntry; use crate::models::BaseInstructions; use crate::models::ContentItem; use crate::models::ResponseItem; +use crate::models::WebSearchAction; use crate::num_format::format_with_separators; use crate::openai_models::ReasoningEffort as ReasoningEffortConfig; use crate::parse_command::ParsedCommand; @@ -1041,6 +1042,7 @@ impl HasLegacyEvent for ReasoningRawContentDeltaEvent { impl HasLegacyEvent for EventMsg { fn as_legacy_events(&self, show_raw_agent_reasoning: bool) -> Vec { match self { + EventMsg::ItemStarted(event) => event.as_legacy_events(show_raw_agent_reasoning), EventMsg::ItemCompleted(event) => event.as_legacy_events(show_raw_agent_reasoning), EventMsg::AgentMessageContentDelta(event) => { event.as_legacy_events(show_raw_agent_reasoning) @@ -1402,6 +1404,7 @@ pub struct WebSearchBeginEvent { pub struct WebSearchEndEvent { pub call_id: String, pub query: String, + pub action: WebSearchAction, } // Conversation kept for backward compatibility. @@ -2375,6 +2378,9 @@ mod tests { item: TurnItem::WebSearch(WebSearchItem { id: "search-1".into(), query: "find docs".into(), + action: WebSearchAction::Search { + query: Some("find docs".into()), + }, }), }; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 6f45ec25ce..9dc1bc4bfe 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -162,6 +162,7 @@ use crate::history_cell::AgentMessageCell; use crate::history_cell::HistoryCell; use crate::history_cell::McpToolCallCell; use crate::history_cell::PlainHistoryCell; +use crate::history_cell::WebSearchCell; use crate::key_hint; use crate::key_hint::KeyBinding; use crate::markdown::append_markdown; @@ -1490,13 +1491,43 @@ impl ChatWidget { self.defer_or_handle(|q| q.push_mcp_end(ev), |s| s.handle_mcp_end_now(ev2)); } - fn on_web_search_begin(&mut self, _ev: WebSearchBeginEvent) { + fn on_web_search_begin(&mut self, ev: WebSearchBeginEvent) { self.flush_answer_stream_with_separator(); + self.flush_active_cell(); + self.active_cell = Some(Box::new(history_cell::new_active_web_search_call( + ev.call_id, + String::new(), + self.config.animations, + ))); + self.bump_active_cell_revision(); + self.request_redraw(); } fn on_web_search_end(&mut self, ev: WebSearchEndEvent) { self.flush_answer_stream_with_separator(); - self.add_to_history(history_cell::new_web_search_call(ev.query)); + let WebSearchEndEvent { + call_id, + query, + action, + } = ev; + let mut handled = false; + if let Some(cell) = self + .active_cell + .as_mut() + .and_then(|cell| cell.as_any_mut().downcast_mut::()) + && cell.call_id() == call_id + { + cell.update(action.clone(), query.clone()); + cell.complete(); + self.bump_active_cell_revision(); + self.flush_active_cell(); + handled = true; + } + + if !handled { + self.add_to_history(history_cell::new_web_search_call(call_id, query, action)); + } + self.had_work_activity = true; } fn on_collab_event(&mut self, cell: PlainHistoryCell) { diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 94da257d9d..02a148df4c 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -43,6 +43,8 @@ use codex_core::protocol::FileChange; use codex_core::protocol::McpAuthStatus; use codex_core::protocol::McpInvocation; use codex_core::protocol::SessionConfiguredEvent; +use codex_core::web_search::web_search_detail; +use codex_protocol::models::WebSearchAction; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; @@ -1342,9 +1344,89 @@ pub(crate) fn new_active_mcp_tool_call( McpToolCallCell::new(call_id, invocation, animations_enabled) } -pub(crate) fn new_web_search_call(query: String) -> PrefixedWrappedHistoryCell { - let text: Text<'static> = Line::from(vec!["Searched".bold(), " ".into(), query.into()]).into(); - PrefixedWrappedHistoryCell::new(text, "• ".dim(), " ") +fn web_search_header(completed: bool) -> &'static str { + if completed { + "Searched" + } else { + "Searching the web" + } +} + +#[derive(Debug)] +pub(crate) struct WebSearchCell { + call_id: String, + query: String, + action: Option, + start_time: Instant, + completed: bool, + animations_enabled: bool, +} + +impl WebSearchCell { + pub(crate) fn new( + call_id: String, + query: String, + action: Option, + animations_enabled: bool, + ) -> Self { + Self { + call_id, + query, + action, + start_time: Instant::now(), + completed: false, + animations_enabled, + } + } + + pub(crate) fn call_id(&self) -> &str { + &self.call_id + } + + pub(crate) fn update(&mut self, action: WebSearchAction, query: String) { + self.action = Some(action); + self.query = query; + } + + pub(crate) fn complete(&mut self) { + self.completed = true; + } +} + +impl HistoryCell for WebSearchCell { + fn display_lines(&self, width: u16) -> Vec> { + let bullet = if self.completed { + "•".dim() + } else { + spinner(Some(self.start_time), self.animations_enabled) + }; + let header = web_search_header(self.completed); + let detail = web_search_detail(self.action.as_ref(), &self.query); + let text: Text<'static> = if detail.is_empty() { + Line::from(vec![header.bold()]).into() + } else { + Line::from(vec![header.bold(), " ".into(), detail.into()]).into() + }; + PrefixedWrappedHistoryCell::new(text, vec![bullet, " ".into()], " ").display_lines(width) + } +} + +pub(crate) fn new_active_web_search_call( + call_id: String, + query: String, + animations_enabled: bool, +) -> WebSearchCell { + WebSearchCell::new(call_id, query, None, animations_enabled) +} + +pub(crate) fn new_web_search_call( + call_id: String, + query: String, + action: WebSearchAction, +) -> WebSearchCell { + let mut cell = WebSearchCell::new(call_id, query, Some(action), false); + cell.complete(); + cell } /// If the first content is an image, return a new cell with the image. @@ -1837,6 +1919,7 @@ mod tests { use codex_core::config::types::McpServerConfig; use codex_core::config::types::McpServerTransportConfig; use codex_core::protocol::McpAuthStatus; + use codex_protocol::models::WebSearchAction; use codex_protocol::parse_command::ParsedCommand; use dirs::home_dir; use pretty_assertions::assert_eq; @@ -2060,8 +2143,12 @@ mod tests { #[test] fn web_search_history_cell_snapshot() { + let query = + "example search query with several generic words to exercise wrapping".to_string(); let cell = new_web_search_call( - "example search query with several generic words to exercise wrapping".to_string(), + "call-1".to_string(), + query.clone(), + WebSearchAction::Search { query: Some(query) }, ); let rendered = render_lines(&cell.display_lines(64)).join("\n"); @@ -2070,8 +2157,12 @@ mod tests { #[test] fn web_search_history_cell_wraps_with_indented_continuation() { + let query = + "example search query with several generic words to exercise wrapping".to_string(); let cell = new_web_search_call( - "example search query with several generic words to exercise wrapping".to_string(), + "call-1".to_string(), + query.clone(), + WebSearchAction::Search { query: Some(query) }, ); let rendered = render_lines(&cell.display_lines(64)); @@ -2086,7 +2177,12 @@ mod tests { #[test] fn web_search_history_cell_short_query_does_not_wrap() { - let cell = new_web_search_call("short query".to_string()); + let query = "short query".to_string(); + let cell = new_web_search_call( + "call-1".to_string(), + query.clone(), + WebSearchAction::Search { query: Some(query) }, + ); let rendered = render_lines(&cell.display_lines(64)); assert_eq!(rendered, vec!["• Searched short query".to_string()]); @@ -2094,8 +2190,12 @@ mod tests { #[test] fn web_search_history_cell_transcript_snapshot() { + let query = + "example search query with several generic words to exercise wrapping".to_string(); let cell = new_web_search_call( - "example search query with several generic words to exercise wrapping".to_string(), + "call-1".to_string(), + query.clone(), + WebSearchAction::Search { query: Some(query) }, ); let rendered = render_lines(&cell.transcript_lines(64)).join("\n"); From b7bba3614ef7a051f8e510092954367dfc85b9a0 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 26 Jan 2026 19:34:18 -0800 Subject: [PATCH 34/43] plan prompt v7 (#9966) # External (non-OpenAI) Pull Request Requirements Before opening this Pull Request, please read the dedicated "Contributing" markdown file or your PR may be closed: https://github.com/openai/codex/blob/main/docs/contributing.md If your PR conforms to our contribution guidelines, replace this text with a detailed and high quality description of your changes. Include a link to a bug report or enhancement request. --- codex-rs/core/templates/collaboration_mode/plan.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/templates/collaboration_mode/plan.md b/codex-rs/core/templates/collaboration_mode/plan.md index 7736c97640..5429c284f8 100644 --- a/codex-rs/core/templates/collaboration_mode/plan.md +++ b/codex-rs/core/templates/collaboration_mode/plan.md @@ -33,9 +33,10 @@ Batch questions (e.g., 4–10) per `request_user_input` call to keep momentum. ## Two kinds of unknowns (treat differently) 1) Discoverable facts (repo/system truth): explore first. - - Before asking, run ≥2 targeted searches (exact + variant) and check likely sources of truth (configs/manifests/entrypoints/schemas/types/constants). + - Before asking, run targeted searches and check likely sources of truth (configs/manifests/entrypoints/schemas/types/constants). - Ask only if: multiple plausible candidates; nothing found but you need a missing identifier/context; or ambiguity is actually product intent. - If asking, present concrete candidates (paths/service names) + recommend one. + - Never ask questions you can answer from your environment. i.e. where is this struct. 2) Preferences/tradeoffs (not discoverable): ask early. - Provide 2–4 mutually exclusive options + a recommended default. From b655a092bad1f1e96031d75ae9230dde7e796b64 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 26 Jan 2026 19:56:16 -0800 Subject: [PATCH 35/43] Improve plan mode prompt (#9968) # External (non-OpenAI) Pull Request Requirements Before opening this Pull Request, please read the dedicated "Contributing" markdown file or your PR may be closed: https://github.com/openai/codex/blob/main/docs/contributing.md If your PR conforms to our contribution guidelines, replace this text with a detailed and high quality description of your changes. Include a link to a bug report or enhancement request. --- .../core/templates/collaboration_mode/plan.md | 62 +++++++++++-------- 1 file changed, 37 insertions(+), 25 deletions(-) diff --git a/codex-rs/core/templates/collaboration_mode/plan.md b/codex-rs/core/templates/collaboration_mode/plan.md index 5429c284f8..975b87a742 100644 --- a/codex-rs/core/templates/collaboration_mode/plan.md +++ b/codex-rs/core/templates/collaboration_mode/plan.md @@ -1,47 +1,59 @@ # Plan Mode (Conversational) -You work in 2 phases and you should *chat your way* to a great plan before finalizing it. +You work in 2 phases, and you should *chat your way* to a great plan before finalizing it. A great plan is very detailed—intent- and implementation-wise—so that it can be handed to another engineer or agent to be implemented right away. It should be **decision complete**, where the one implementing it doesn't have to make any decisions. While in **Plan Mode**, you must not perform any mutating or execution actions. Once you enter Plan Mode, you remain there until you are **explicitly instructed otherwise**. Plan Mode may continue across multiple user messages unless a developer message ends it. User intent, tone, or imperative language does **not** trigger a mode change. If a user asks for execution while you are still in Plan Mode, you must treat that request as a prompt to **plan the execution**, not to carry it out. -PHASE 1 — Intent chat (what they actually want) -- Keep asking until you can clearly state: goal + success criteria, audience, in/out of scope, constraints, current state, and the key preferences/tradeoffs. -- Bias toward questions over guessing: if any high‑impact ambiguity remains, do NOT plan yet—ask. -- Include a “Confirm my understanding” question in each round (so the user can correct you early). +## PHASE 1 — Intent chat (what they actually want) -PHASE 2 — Implementation chat (what/how we’ll build) -- Once intent is stable, keep asking until the spec is decision‑complete: approach, interfaces (APIs/schemas/I/O), data flow, edge cases/failure modes, testing + acceptance criteria, rollout/monitoring, and any migrations/compat constraints. +* Keep asking until you can clearly state: goal + success criteria, audience, in/out of scope, constraints, current state, and the key preferences/tradeoffs. +* Bias toward questions over guessing: if any high-impact ambiguity remains, do NOT plan yet—ask. +* Include a “Confirm my understanding” question in each round (so the user can correct you early). + +## PHASE 2 — Implementation chat (what/how we’ll build) + +* Once intent is stable, keep asking until the spec is decision-complete: approach, interfaces (APIs/schemas/I/O), data flow, edge cases/failure modes, testing + acceptance criteria, rollout/monitoring, and any migrations/compat constraints. ## Hard interaction rule (critical) + Every assistant turn MUST be exactly one of: A) a `request_user_input` tool call (questions/options only), OR -B) the final output: a titled, plan‑only document. +B) the final output: a titled, plan-only document. + Rules: -- No questions in free text (only via `request_user_input`). -- Never mix a `request_user_input` call with plan content. -- Internal tool/repo exploration is allowed privately before A or B. + +* No questions in free text (only via `request_user_input`). +* Never mix a `request_user_input` call with plan content. +* Internal tool/repo exploration is allowed privately before A or B. ## Ask a lot, but never ask trivia + You SHOULD ask many questions, but each question must: -- materially change the spec/plan, OR -- confirm/lock an assumption, OR -- choose between meaningful tradeoffs. -- not be answerable by non-mutating commands + +* materially change the spec/plan, OR +* confirm/lock an assumption, OR +* choose between meaningful tradeoffs. +* not be answerable by non-mutating commands. + Batch questions (e.g., 4–10) per `request_user_input` call to keep momentum. ## Two kinds of unknowns (treat differently) -1) Discoverable facts (repo/system truth): explore first. - - Before asking, run targeted searches and check likely sources of truth (configs/manifests/entrypoints/schemas/types/constants). - - Ask only if: multiple plausible candidates; nothing found but you need a missing identifier/context; or ambiguity is actually product intent. - - If asking, present concrete candidates (paths/service names) + recommend one. - - Never ask questions you can answer from your environment. i.e. where is this struct. -2) Preferences/tradeoffs (not discoverable): ask early. - - Provide 2–4 mutually exclusive options + a recommended default. - - If unanswered, proceed with the recommended option and record it as an assumption in the final plan. +1. **Discoverable facts** (repo/system truth): explore first. + + * Before asking, run targeted searches and check likely sources of truth (configs/manifests/entrypoints/schemas/types/constants). + * Ask only if: multiple plausible candidates; nothing found but you need a missing identifier/context; or ambiguity is actually product intent. + * If asking, present concrete candidates (paths/service names) + recommend one. + * Never ask questions you can answer from your environment (e.g., “where is this struct”). + +2. **Preferences/tradeoffs** (not discoverable): ask early. + + * Provide 2–4 mutually exclusive options + a recommended default. + * If unanswered, proceed with the recommended option and record it as an assumption in the final plan. ## Finalization rule -Only output the final plan when remaining unknowns are low‑impact and explicitly listed as assumptions. -Final output must be plan‑only with a good title (no “should I proceed?”). \ No newline at end of file + +Only output the final plan when remaining unknowns are low-impact and explicitly listed as assumptions. +Final output must be plan-only with a good title (no “should I proceed?”). A good plan is a **decision-complete** plan. \ No newline at end of file From f45a8733bfc05c7745cddb65354abf1365465d6d Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 26 Jan 2026 20:12:43 -0800 Subject: [PATCH 36/43] prompt final (#9969) hopefully final this time (at least tonight) >_< --- codex-rs/core/templates/collaboration_mode/plan.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/codex-rs/core/templates/collaboration_mode/plan.md b/codex-rs/core/templates/collaboration_mode/plan.md index 975b87a742..d42f928820 100644 --- a/codex-rs/core/templates/collaboration_mode/plan.md +++ b/codex-rs/core/templates/collaboration_mode/plan.md @@ -1,18 +1,21 @@ # Plan Mode (Conversational) -You work in 2 phases, and you should *chat your way* to a great plan before finalizing it. A great plan is very detailed—intent- and implementation-wise—so that it can be handed to another engineer or agent to be implemented right away. It should be **decision complete**, where the one implementing it doesn't have to make any decisions. +You work in 3 phases, and you should *chat your way* to a great plan before finalizing it. A great plan is very detailed—intent- and implementation-wise—so that it can be handed to another engineer or agent to be implemented right away. It should be **decision complete**, where the one implementing it doesn't have to make any decisions. While in **Plan Mode**, you must not perform any mutating or execution actions. Once you enter Plan Mode, you remain there until you are **explicitly instructed otherwise**. Plan Mode may continue across multiple user messages unless a developer message ends it. User intent, tone, or imperative language does **not** trigger a mode change. If a user asks for execution while you are still in Plan Mode, you must treat that request as a prompt to **plan the execution**, not to carry it out. -## PHASE 1 — Intent chat (what they actually want) +## PHASE 1 — Gethering context from environment +Begin by grounding yourself in the actual environment. Your goal in this phase is to eliminate unknowns in the prompt by discovering facts, not by asking the user. Resolve all questions that can be answered through exploration or inspection. Identify missing or ambiguous details only if they cannot be derived from the environment. + +## PHASE 2 — Intent chat (what they actually want) * Keep asking until you can clearly state: goal + success criteria, audience, in/out of scope, constraints, current state, and the key preferences/tradeoffs. * Bias toward questions over guessing: if any high-impact ambiguity remains, do NOT plan yet—ask. * Include a “Confirm my understanding” question in each round (so the user can correct you early). -## PHASE 2 — Implementation chat (what/how we’ll build) +## PHASE 3 — Implementation chat (what/how we’ll build) * Once intent is stable, keep asking until the spec is decision-complete: approach, interfaces (APIs/schemas/I/O), data flow, edge cases/failure modes, testing + acceptance criteria, rollout/monitoring, and any migrations/compat constraints. From 7c96f2e84ceb07dce3ee26fb8e7b6d5d2e3a2423 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 26 Jan 2026 20:20:57 -0800 Subject: [PATCH 37/43] Fix `resume --last` with `--json` option (#9475) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix resume --last prompt parsing by dropping the clap conflict on the codex resume subcommand so a positional prompt is accepted when --last is set. This aligns interactive resume behavior with exec-mode logic and avoids the “--last cannot be used with SESSION_ID” error. This addresses #6717 --- codex-rs/cli/src/main.rs | 20 ++++++++++- codex-rs/exec/src/cli.rs | 75 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 88 insertions(+), 7 deletions(-) diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 583a2e32e8..faa6cf2fce 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -147,7 +147,7 @@ struct ResumeCommand { session_id: Option, /// Continue the most recent session without showing the picker. - #[arg(long = "last", default_value_t = false, conflicts_with = "session_id")] + #[arg(long = "last", default_value_t = false)] last: bool, /// Show all sessions (disables cwd filtering and shows CWD column). @@ -932,6 +932,24 @@ mod tests { finalize_fork_interactive(interactive, root_overrides, session_id, last, all, fork_cli) } + #[test] + fn exec_resume_last_accepts_prompt_positional() { + let cli = + MultitoolCli::try_parse_from(["codex", "exec", "--json", "resume", "--last", "2+2"]) + .expect("parse should succeed"); + + let Some(Subcommand::Exec(exec)) = cli.subcommand else { + panic!("expected exec subcommand"); + }; + let Some(codex_exec::Command::Resume(args)) = exec.command else { + panic!("expected exec resume"); + }; + + assert!(args.last); + assert_eq!(args.session_id, None); + assert_eq!(args.prompt.as_deref(), Some("2+2")); + } + fn app_server_from_args(args: &[&str]) -> AppServerCommand { let cli = MultitoolCli::try_parse_from(args).expect("parse"); let Subcommand::AppServer(app_server) = cli.subcommand.expect("app-server present") else { diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 3d2001ae61..56ff805fa0 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -1,3 +1,5 @@ +use clap::Args; +use clap::FromArgMatches; use clap::Parser; use clap::ValueEnum; use codex_common::CliConfigOverrides; @@ -108,20 +110,22 @@ pub enum Command { Review(ReviewArgs), } -#[derive(Parser, Debug)] -pub struct ResumeArgs { +#[derive(Args, Debug)] +struct ResumeArgsRaw { + // Note: This is the direct clap shape. We reinterpret the positional when --last is set + // so "codex resume --last " treats the positional as a prompt, not a session id. /// Conversation/session id (UUID). When provided, resumes this session. /// If omitted, use --last to pick the most recent recorded session. #[arg(value_name = "SESSION_ID")] - pub session_id: Option, + session_id: Option, /// Resume the most recent recorded session (newest) without specifying an id. #[arg(long = "last", default_value_t = false)] - pub last: bool, + last: bool, /// Show all sessions (disables cwd filtering). #[arg(long = "all", default_value_t = false)] - pub all: bool, + all: bool, /// Optional image(s) to attach to the prompt sent after resuming. #[arg( @@ -131,13 +135,72 @@ pub struct ResumeArgs { value_delimiter = ',', num_args = 1 )] - pub images: Vec, + images: Vec, /// Prompt to send after resuming the session. If `-` is used, read from stdin. #[arg(value_name = "PROMPT", value_hint = clap::ValueHint::Other)] + prompt: Option, +} + +#[derive(Debug)] +pub struct ResumeArgs { + /// Conversation/session id (UUID). When provided, resumes this session. + /// If omitted, use --last to pick the most recent recorded session. + pub session_id: Option, + + /// Resume the most recent recorded session (newest) without specifying an id. + pub last: bool, + + /// Show all sessions (disables cwd filtering). + pub all: bool, + + /// Optional image(s) to attach to the prompt sent after resuming. + pub images: Vec, + + /// Prompt to send after resuming the session. If `-` is used, read from stdin. pub prompt: Option, } +impl From for ResumeArgs { + fn from(raw: ResumeArgsRaw) -> Self { + // When --last is used without an explicit prompt, treat the positional as the prompt + // (clap can’t express this conditional positional meaning cleanly). + let (session_id, prompt) = if raw.last && raw.prompt.is_none() { + (None, raw.session_id) + } else { + (raw.session_id, raw.prompt) + }; + Self { + session_id, + last: raw.last, + all: raw.all, + images: raw.images, + prompt, + } + } +} + +impl Args for ResumeArgs { + fn augment_args(cmd: clap::Command) -> clap::Command { + ResumeArgsRaw::augment_args(cmd) + } + + fn augment_args_for_update(cmd: clap::Command) -> clap::Command { + ResumeArgsRaw::augment_args_for_update(cmd) + } +} + +impl FromArgMatches for ResumeArgs { + fn from_arg_matches(matches: &clap::ArgMatches) -> Result { + ResumeArgsRaw::from_arg_matches(matches).map(Self::from) + } + + fn update_from_arg_matches(&mut self, matches: &clap::ArgMatches) -> Result<(), clap::Error> { + *self = ResumeArgsRaw::from_arg_matches(matches).map(Self::from)?; + Ok(()) + } +} + #[derive(Parser, Debug)] pub struct ReviewArgs { /// Review staged, unstaged, and untracked changes. From 0c72d8fd6e4b9e8cd24bda865cf3d37af2e7f4d5 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 26 Jan 2026 20:27:57 -0800 Subject: [PATCH 38/43] prompt (#9970) # External (non-OpenAI) Pull Request Requirements Before opening this Pull Request, please read the dedicated "Contributing" markdown file or your PR may be closed: https://github.com/openai/codex/blob/main/docs/contributing.md If your PR conforms to our contribution guidelines, replace this text with a detailed and high quality description of your changes. Include a link to a bug report or enhancement request. --- codex-rs/core/templates/collaboration_mode/plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/core/templates/collaboration_mode/plan.md b/codex-rs/core/templates/collaboration_mode/plan.md index d42f928820..7b47e3c4c0 100644 --- a/codex-rs/core/templates/collaboration_mode/plan.md +++ b/codex-rs/core/templates/collaboration_mode/plan.md @@ -7,7 +7,7 @@ While in **Plan Mode**, you must not perform any mutating or execution actions. User intent, tone, or imperative language does **not** trigger a mode change. If a user asks for execution while you are still in Plan Mode, you must treat that request as a prompt to **plan the execution**, not to carry it out. ## PHASE 1 — Gethering context from environment -Begin by grounding yourself in the actual environment. Your goal in this phase is to eliminate unknowns in the prompt by discovering facts, not by asking the user. Resolve all questions that can be answered through exploration or inspection. Identify missing or ambiguous details only if they cannot be derived from the environment. +Begin by grounding yourself in the actual environment. Your goal in this phase is to eliminate unknowns in the prompt by discovering facts, not by asking the user. Resolve all questions that can be answered through exploration or inspection. Identify missing or ambiguous details only if they cannot be derived from the environment. Don't start asking until you resolve all unknowns possible via exploration. ## PHASE 2 — Intent chat (what they actually want) From 28bd7db14aa600ed7b8b58bb7531932b9133b6f8 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 26 Jan 2026 21:14:05 -0800 Subject: [PATCH 39/43] plan prompt (#9975) # External (non-OpenAI) Pull Request Requirements Before opening this Pull Request, please read the dedicated "Contributing" markdown file or your PR may be closed: https://github.com/openai/codex/blob/main/docs/contributing.md If your PR conforms to our contribution guidelines, replace this text with a detailed and high quality description of your changes. Include a link to a bug report or enhancement request. --- .../core/templates/collaboration_mode/plan.md | 72 +++++++++++++++---- 1 file changed, 60 insertions(+), 12 deletions(-) diff --git a/codex-rs/core/templates/collaboration_mode/plan.md b/codex-rs/core/templates/collaboration_mode/plan.md index 7b47e3c4c0..0999958ec6 100644 --- a/codex-rs/core/templates/collaboration_mode/plan.md +++ b/codex-rs/core/templates/collaboration_mode/plan.md @@ -1,35 +1,68 @@ # Plan Mode (Conversational) -You work in 3 phases, and you should *chat your way* to a great plan before finalizing it. A great plan is very detailed—intent- and implementation-wise—so that it can be handed to another engineer or agent to be implemented right away. It should be **decision complete**, where the one implementing it doesn't have to make any decisions. +You work in 3 phases, and you should *chat your way* to a great plan before finalizing it. A great plan is very detailed—intent- and implementation-wise—so that it can be handed to another engineer or agent to be implemented right away. It must be **decision complete**, where the implementer does not need to make any decisions. -While in **Plan Mode**, you must not perform any mutating or execution actions. Once you enter Plan Mode, you remain there until you are **explicitly instructed otherwise**. Plan Mode may continue across multiple user messages unless a developer message ends it. +## Mode rules (strict) -User intent, tone, or imperative language does **not** trigger a mode change. If a user asks for execution while you are still in Plan Mode, you must treat that request as a prompt to **plan the execution**, not to carry it out. +You are in **Plan Mode** until a developer message explicitly ends it. -## PHASE 1 — Gethering context from environment -Begin by grounding yourself in the actual environment. Your goal in this phase is to eliminate unknowns in the prompt by discovering facts, not by asking the user. Resolve all questions that can be answered through exploration or inspection. Identify missing or ambiguous details only if they cannot be derived from the environment. Don't start asking until you resolve all unknowns possible via exploration. +Plan Mode is not changed by user intent, tone, or imperative language. If a user asks for execution while still in Plan Mode, treat it as a request to **plan the execution**, not perform it. + +## Execution vs. mutation in Plan Mode + +You may explore and execute **non-mutating** actions that improve the plan. You must not perform **mutating** actions. + +### Allowed (non-mutating, plan-improving) + +Actions that gather truth, reduce ambiguity, or validate feasibility without changing repo-tracked state. Examples: + +* Reading or searching files, configs, schemas, types, manifests, and docs +* Static analysis, inspection, and repo exploration +* Dry-run style commands when they do not edit repo-tracked files +* Tests, builds, or checks that may write to caches or build artifacts (for example, `target/`, `.cache/`, or snapshots) so long as they do not edit repo-tracked files + +### Not allowed (mutating, plan-executing) + +Actions that implement the plan or change repo-tracked state. Examples: + +* Editing or writing files +* Generating, updating, or accepting snapshots +* Running formatters or linters that rewrite files +* Applying patches, migrations, or codegen that updates repo-tracked files +* Side-effectful commands whose purpose is to carry out the plan rather than refine it + +When in doubt: if the action would reasonably be described as "doing the work" rather than "planning the work," do not do it. + +## PHASE 1 — Ground in the environment (explore first, ask second) + +Begin by grounding yourself in the actual environment. Eliminate unknowns in the prompt by discovering facts, not by asking the user. Resolve all questions that can be answered through exploration or inspection. Identify missing or ambiguous details only if they cannot be derived from the environment. Silent exploration between turns is allowed and encouraged. + +Do not ask questions that can be answered from the repo or system (for example, "where is this struct?" or "which UI component should we use?" when exploration can make it clear). Only ask once you have exhausted reasonable non-mutating exploration. ## PHASE 2 — Intent chat (what they actually want) * Keep asking until you can clearly state: goal + success criteria, audience, in/out of scope, constraints, current state, and the key preferences/tradeoffs. * Bias toward questions over guessing: if any high-impact ambiguity remains, do NOT plan yet—ask. -* Include a “Confirm my understanding” question in each round (so the user can correct you early). ## PHASE 3 — Implementation chat (what/how we’ll build) -* Once intent is stable, keep asking until the spec is decision-complete: approach, interfaces (APIs/schemas/I/O), data flow, edge cases/failure modes, testing + acceptance criteria, rollout/monitoring, and any migrations/compat constraints. +* Once intent is stable, keep asking until the spec is decision complete: approach, interfaces (APIs/schemas/I/O), data flow, edge cases/failure modes, testing + acceptance criteria, rollout/monitoring, and any migrations/compat constraints. ## Hard interaction rule (critical) Every assistant turn MUST be exactly one of: A) a `request_user_input` tool call (questions/options only), OR -B) the final output: a titled, plan-only document. +B) a non-final status update with no questions and no plan content, OR +C) the final output: a titled, plan-only document. Rules: * No questions in free text (only via `request_user_input`). * Never mix a `request_user_input` call with plan content. -* Internal tool/repo exploration is allowed privately before A or B. +* Status updates must not include questions or plan content. +* Internal tool/repo exploration is allowed privately before A, B, or C. + +Status updates are required and should be frequent during exploration. Provide 1-2 sentence updates that summarize discoveries, assumption changes, or why you are changing direction. Provide an update before beginning a new area of exploration. ## Ask a lot, but never ask trivia @@ -40,7 +73,7 @@ You SHOULD ask many questions, but each question must: * choose between meaningful tradeoffs. * not be answerable by non-mutating commands. -Batch questions (e.g., 4–10) per `request_user_input` call to keep momentum. +Use the `request_user_input` tool only for decisions that materially change the plan, for confirming important assumptions, or for information that cannot be discovered via non-mutating exploration. ## Two kinds of unknowns (treat differently) @@ -53,10 +86,25 @@ Batch questions (e.g., 4–10) per `request_user_input` call to keep momentum. 2. **Preferences/tradeoffs** (not discoverable): ask early. + * These are intent or implementation preferences that cannot be derived from exploration. * Provide 2–4 mutually exclusive options + a recommended default. * If unanswered, proceed with the recommended option and record it as an assumption in the final plan. ## Finalization rule -Only output the final plan when remaining unknowns are low-impact and explicitly listed as assumptions. -Final output must be plan-only with a good title (no “should I proceed?”). A good plan is a **decision-complete** plan. \ No newline at end of file +Only output the final plan when it is decision complete and leaves no decisions to the implementer. + +The final plan must be plan-only and include: + +* A clear title +* Exact file paths to change +* Exact structures or shapes to introduce or modify +* Exact function, method, type, and variable names and signatures +* Step-by-step edits or patches described precisely +* Test cases and commands +* Acceptance criteria tied to observable outcomes +* Explicit assumptions and defaults chosen where needed + +Do not ask "should I proceed?" in the final output. + +Only produce the final answer when you are presenting the complete spec. From 0adcd8aa86137f7ba8e15e7c6111bf68161afa92 Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Mon, 26 Jan 2026 21:25:40 -0800 Subject: [PATCH 40/43] make cached web_search client-side default (#9974) [Experiment](https://console.statsig.com/50aWbk2p4R76rNX9lN5VUw/experiments/codex_web_search_rollout/summary) for default cached `web_search` completed; cached chosen as default. Update client to reflect that. --- codex-rs/core/src/client.rs | 12 ++-- codex-rs/core/src/codex.rs | 4 +- codex-rs/core/src/config/mod.rs | 33 +++++----- codex-rs/core/src/tasks/review.rs | 2 +- codex-rs/core/src/tools/spec.rs | 64 +++++++++---------- codex-rs/core/tests/responses_headers.rs | 2 +- codex-rs/core/tests/suite/model_tools.rs | 2 +- codex-rs/core/tests/suite/prompt_caching.rs | 2 +- .../core/tests/suite/web_search_cached.rs | 4 +- codex-rs/protocol/src/config_types.rs | 2 +- 10 files changed, 64 insertions(+), 63 deletions(-) diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index f415c3604a..6392b3e70c 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -655,13 +655,11 @@ fn build_responses_headers( let mut headers = experimental_feature_headers(config); headers.insert( WEB_SEARCH_ELIGIBLE_HEADER, - HeaderValue::from_static( - if matches!(config.web_search_mode, Some(WebSearchMode::Disabled)) { - "false" - } else { - "true" - }, - ), + HeaderValue::from_static(if config.web_search_mode == WebSearchMode::Disabled { + "false" + } else { + "true" + }), ); if let Some(turn_state) = turn_state && let Some(state) = turn_state.get() diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 5e535f5130..41e148e113 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -2824,7 +2824,7 @@ async fn spawn_review_thread( let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &review_model_info, features: &review_features, - web_search_mode: Some(review_web_search_mode), + web_search_mode: review_web_search_mode, }); let review_prompt = resolved.prompt.clone(); @@ -2836,7 +2836,7 @@ async fn spawn_review_thread( let mut per_turn_config = (*config).clone(); per_turn_config.model = Some(model.clone()); per_turn_config.features = review_features.clone(); - per_turn_config.web_search_mode = Some(review_web_search_mode); + per_turn_config.web_search_mode = review_web_search_mode; let otel_manager = parent_turn_context .client diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index dbccb2bef1..8744b756bf 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -304,8 +304,8 @@ pub struct Config { /// model info's default preference. pub include_apply_patch_tool: bool, - /// Explicit or feature-derived web search mode. - pub web_search_mode: Option, + /// Explicit or feature-derived web search mode. Defaults to cached. + pub web_search_mode: WebSearchMode, /// If set to `true`, used only the experimental unified exec tool. pub use_experimental_unified_exec_tool: bool, @@ -1205,17 +1205,17 @@ fn resolve_web_search_mode( config_toml: &ConfigToml, config_profile: &ConfigProfile, features: &Features, -) -> Option { +) -> WebSearchMode { if let Some(mode) = config_profile.web_search.or(config_toml.web_search) { - return Some(mode); + return mode; } if features.enabled(Feature::WebSearchCached) { - return Some(WebSearchMode::Cached); + return WebSearchMode::Cached; } if features.enabled(Feature::WebSearchRequest) { - return Some(WebSearchMode::Live); + return WebSearchMode::Live; } - None + WebSearchMode::Cached } impl Config { @@ -2264,12 +2264,15 @@ trust_level = "trusted" } #[test] - fn web_search_mode_uses_none_if_unset() { + fn web_search_mode_defaults_to_cached_if_unset() { let cfg = ConfigToml::default(); let profile = ConfigProfile::default(); let features = Features::with_defaults(); - assert_eq!(resolve_web_search_mode(&cfg, &profile, &features), None); + assert_eq!( + resolve_web_search_mode(&cfg, &profile, &features), + WebSearchMode::Cached + ); } #[test] @@ -2284,7 +2287,7 @@ trust_level = "trusted" assert_eq!( resolve_web_search_mode(&cfg, &profile, &features), - Some(WebSearchMode::Live) + WebSearchMode::Live ); } @@ -2300,7 +2303,7 @@ trust_level = "trusted" assert_eq!( resolve_web_search_mode(&cfg, &profile, &features), - Some(WebSearchMode::Disabled) + WebSearchMode::Disabled ); } @@ -3737,7 +3740,7 @@ model_verbosity = "high" forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: false, - web_search_mode: None, + web_search_mode: WebSearchMode::Cached, use_experimental_unified_exec_tool: false, ghost_snapshot: GhostSnapshotConfig::default(), features: Features::with_defaults(), @@ -3820,7 +3823,7 @@ model_verbosity = "high" forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: false, - web_search_mode: None, + web_search_mode: WebSearchMode::Cached, use_experimental_unified_exec_tool: false, ghost_snapshot: GhostSnapshotConfig::default(), features: Features::with_defaults(), @@ -3918,7 +3921,7 @@ model_verbosity = "high" forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: false, - web_search_mode: None, + web_search_mode: WebSearchMode::Cached, use_experimental_unified_exec_tool: false, ghost_snapshot: GhostSnapshotConfig::default(), features: Features::with_defaults(), @@ -4002,7 +4005,7 @@ model_verbosity = "high" forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: false, - web_search_mode: None, + web_search_mode: WebSearchMode::Cached, use_experimental_unified_exec_tool: false, ghost_snapshot: GhostSnapshotConfig::default(), features: Features::with_defaults(), diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index d156d3e0d5..89dbaf90fe 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -86,7 +86,7 @@ async fn start_review_conversation( let mut sub_agent_config = config.as_ref().clone(); // Carry over review-only feature restrictions so the delegate cannot // re-enable blocked tools (web search, view image). - sub_agent_config.web_search_mode = Some(WebSearchMode::Disabled); + sub_agent_config.web_search_mode = WebSearchMode::Disabled; // Set explicit review rubric for the sub-agent sub_agent_config.base_instructions = Some(crate::REVIEW_PROMPT.to_string()); diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 33010e58e1..64f4053bb5 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -27,7 +27,7 @@ use std::collections::HashMap; pub(crate) struct ToolsConfig { pub shell_type: ConfigShellToolType, pub apply_patch_tool_type: Option, - pub web_search_mode: Option, + pub web_search_mode: WebSearchMode, pub collab_tools: bool, pub collaboration_modes_tools: bool, pub experimental_supported_tools: Vec, @@ -36,7 +36,7 @@ pub(crate) struct ToolsConfig { pub(crate) struct ToolsConfigParams<'a> { pub(crate) model_info: &'a ModelInfo, pub(crate) features: &'a Features, - pub(crate) web_search_mode: Option, + pub(crate) web_search_mode: WebSearchMode, } impl ToolsConfig { @@ -1374,17 +1374,17 @@ pub(crate) fn build_specs( } match config.web_search_mode { - Some(WebSearchMode::Cached) => { + WebSearchMode::Cached => { builder.push_spec(ToolSpec::WebSearch { external_web_access: Some(false), }); } - Some(WebSearchMode::Live) => { + WebSearchMode::Live => { builder.push_spec(ToolSpec::WebSearch { external_web_access: Some(true), }); } - Some(WebSearchMode::Disabled) | None => {} + WebSearchMode::Disabled => {} } builder.push_spec_with_parallel_support(create_view_image_tool(), true); @@ -1546,7 +1546,7 @@ mod tests { let config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, - web_search_mode: Some(WebSearchMode::Live), + web_search_mode: WebSearchMode::Live, }); let (tools, _) = build_specs(&config, None, &[]).build(); @@ -1610,7 +1610,7 @@ mod tests { let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, - web_search_mode: Some(WebSearchMode::Cached), + web_search_mode: WebSearchMode::Cached, }); let (tools, _) = build_specs(&tools_config, None, &[]).build(); assert_contains_tool_names( @@ -1628,7 +1628,7 @@ mod tests { let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, - web_search_mode: Some(WebSearchMode::Cached), + web_search_mode: WebSearchMode::Cached, }); let (tools, _) = build_specs(&tools_config, None, &[]).build(); assert!( @@ -1640,7 +1640,7 @@ mod tests { let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, - web_search_mode: Some(WebSearchMode::Cached), + web_search_mode: WebSearchMode::Cached, }); let (tools, _) = build_specs(&tools_config, None, &[]).build(); assert_contains_tool_names(&tools, &["request_user_input"]); @@ -1649,7 +1649,7 @@ mod tests { fn assert_model_tools( model_slug: &str, features: &Features, - web_search_mode: Option, + web_search_mode: WebSearchMode, expected_tools: &[&str], ) { let config = test_config(); @@ -1673,7 +1673,7 @@ mod tests { let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, - web_search_mode: Some(WebSearchMode::Cached), + web_search_mode: WebSearchMode::Cached, }); let (tools, _) = build_specs(&tools_config, None, &[]).build(); @@ -1695,7 +1695,7 @@ mod tests { let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, - web_search_mode: Some(WebSearchMode::Live), + web_search_mode: WebSearchMode::Live, }); let (tools, _) = build_specs(&tools_config, None, &[]).build(); @@ -1715,7 +1715,7 @@ mod tests { assert_model_tools( "gpt-5-codex", &features, - Some(WebSearchMode::Cached), + WebSearchMode::Cached, &[ "shell_command", "list_mcp_resources", @@ -1737,7 +1737,7 @@ mod tests { assert_model_tools( "gpt-5.1-codex", &features, - Some(WebSearchMode::Cached), + WebSearchMode::Cached, &[ "shell_command", "list_mcp_resources", @@ -1760,7 +1760,7 @@ mod tests { assert_model_tools( "gpt-5-codex", &features, - Some(WebSearchMode::Live), + WebSearchMode::Live, &[ "exec_command", "write_stdin", @@ -1784,7 +1784,7 @@ mod tests { assert_model_tools( "gpt-5.1-codex", &features, - Some(WebSearchMode::Live), + WebSearchMode::Live, &[ "exec_command", "write_stdin", @@ -1807,7 +1807,7 @@ mod tests { assert_model_tools( "codex-mini-latest", &features, - Some(WebSearchMode::Cached), + WebSearchMode::Cached, &[ "local_shell", "list_mcp_resources", @@ -1828,7 +1828,7 @@ mod tests { assert_model_tools( "gpt-5.1-codex-mini", &features, - Some(WebSearchMode::Cached), + WebSearchMode::Cached, &[ "shell_command", "list_mcp_resources", @@ -1850,7 +1850,7 @@ mod tests { assert_model_tools( "gpt-5", &features, - Some(WebSearchMode::Cached), + WebSearchMode::Cached, &[ "shell", "list_mcp_resources", @@ -1871,7 +1871,7 @@ mod tests { assert_model_tools( "gpt-5.1", &features, - Some(WebSearchMode::Cached), + WebSearchMode::Cached, &[ "shell_command", "list_mcp_resources", @@ -1893,7 +1893,7 @@ mod tests { assert_model_tools( "exp-5.1", &features, - Some(WebSearchMode::Cached), + WebSearchMode::Cached, &[ "exec_command", "write_stdin", @@ -1917,7 +1917,7 @@ mod tests { assert_model_tools( "codex-mini-latest", &features, - Some(WebSearchMode::Live), + WebSearchMode::Live, &[ "exec_command", "write_stdin", @@ -1941,7 +1941,7 @@ mod tests { let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, - web_search_mode: Some(WebSearchMode::Live), + web_search_mode: WebSearchMode::Live, }); let (tools, _) = build_specs(&tools_config, Some(HashMap::new()), &[]).build(); @@ -1963,7 +1963,7 @@ mod tests { let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, - web_search_mode: Some(WebSearchMode::Cached), + web_search_mode: WebSearchMode::Cached, }); let (tools, _) = build_specs(&tools_config, None, &[]).build(); @@ -1982,7 +1982,7 @@ mod tests { let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, - web_search_mode: Some(WebSearchMode::Cached), + web_search_mode: WebSearchMode::Cached, }); let (tools, _) = build_specs(&tools_config, None, &[]).build(); @@ -2013,7 +2013,7 @@ mod tests { let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, - web_search_mode: Some(WebSearchMode::Live), + web_search_mode: WebSearchMode::Live, }); let (tools, _) = build_specs( &tools_config, @@ -2109,7 +2109,7 @@ mod tests { let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, - web_search_mode: Some(WebSearchMode::Cached), + web_search_mode: WebSearchMode::Cached, }); // Intentionally construct a map with keys that would sort alphabetically. @@ -2186,7 +2186,7 @@ mod tests { let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, - web_search_mode: Some(WebSearchMode::Cached), + web_search_mode: WebSearchMode::Cached, }); let (tools, _) = build_specs( @@ -2244,7 +2244,7 @@ mod tests { let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, - web_search_mode: Some(WebSearchMode::Cached), + web_search_mode: WebSearchMode::Cached, }); let (tools, _) = build_specs( @@ -2299,7 +2299,7 @@ mod tests { let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, - web_search_mode: Some(WebSearchMode::Cached), + web_search_mode: WebSearchMode::Cached, }); let (tools, _) = build_specs( @@ -2356,7 +2356,7 @@ mod tests { let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, - web_search_mode: Some(WebSearchMode::Cached), + web_search_mode: WebSearchMode::Cached, }); let (tools, _) = build_specs( @@ -2469,7 +2469,7 @@ Examples of valid command strings: let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, features: &features, - web_search_mode: Some(WebSearchMode::Cached), + web_search_mode: WebSearchMode::Cached, }); let (tools, _) = build_specs( &tools_config, diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index 22d9fa8b79..9e268a2c50 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -264,7 +264,7 @@ async fn responses_stream_includes_web_search_eligible_header_false_when_disable let test = test_codex() .with_config(|config| { - config.web_search_mode = Some(WebSearchMode::Disabled); + config.web_search_mode = WebSearchMode::Disabled; }) .build(&server) .await diff --git a/codex-rs/core/tests/suite/model_tools.rs b/codex-rs/core/tests/suite/model_tools.rs index b08b5281c8..0c8277e915 100644 --- a/codex-rs/core/tests/suite/model_tools.rs +++ b/codex-rs/core/tests/suite/model_tools.rs @@ -38,7 +38,7 @@ async fn collect_tool_identifiers_for_model(model: &str) -> Vec { .with_model(model) // Keep tool expectations stable when the default web_search mode changes. .with_config(|config| { - config.web_search_mode = Some(WebSearchMode::Cached); + config.web_search_mode = WebSearchMode::Cached; config.features.enable(Feature::CollaborationModes); }); let test = builder diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 686428b21a..32969bcf5a 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -92,7 +92,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { config.user_instructions = Some("be consistent and helpful".to_string()); config.model = Some("gpt-5.1-codex-max".to_string()); // Keep tool expectations stable when the default web_search mode changes. - config.web_search_mode = Some(WebSearchMode::Cached); + config.web_search_mode = WebSearchMode::Cached; config.features.enable(Feature::CollaborationModes); }) .build(&server) diff --git a/codex-rs/core/tests/suite/web_search_cached.rs b/codex-rs/core/tests/suite/web_search_cached.rs index 1a69d8b737..261efaf942 100644 --- a/codex-rs/core/tests/suite/web_search_cached.rs +++ b/codex-rs/core/tests/suite/web_search_cached.rs @@ -35,7 +35,7 @@ async fn web_search_mode_cached_sets_external_web_access_false_in_request_body() let mut builder = test_codex() .with_model("gpt-5-codex") .with_config(|config| { - config.web_search_mode = Some(WebSearchMode::Cached); + config.web_search_mode = WebSearchMode::Cached; }); let test = builder .build(&server) @@ -67,7 +67,7 @@ async fn web_search_mode_takes_precedence_over_legacy_flags_in_request_body() { .with_model("gpt-5-codex") .with_config(|config| { config.features.enable(Feature::WebSearchRequest); - config.web_search_mode = Some(WebSearchMode::Cached); + config.web_search_mode = WebSearchMode::Cached; }); let test = builder .build(&server) diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index ba6b12aadf..00b9bc0e80 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -94,8 +94,8 @@ pub enum Personality { #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] pub enum WebSearchMode { - #[default] Disabled, + #[default] Cached, Live, } From 4db6da32a3a2a70227a93a7119f81e7261a19f48 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 26 Jan 2026 21:30:09 -0800 Subject: [PATCH 41/43] tui: wrapping user input questions (#9971) --- .../core/templates/collaboration_mode/plan.md | 2 +- .../bottom_pane/request_user_input/layout.rs | 361 ++++++++++++++---- .../src/bottom_pane/request_user_input/mod.rs | 141 +++++++ .../bottom_pane/request_user_input/render.rs | 32 +- ..._request_user_input_scrolling_options.snap | 11 +- ...ests__request_user_input_tight_height.snap | 3 +- ...s__request_user_input_wrapped_options.snap | 21 + 7 files changed, 456 insertions(+), 115 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_wrapped_options.snap diff --git a/codex-rs/core/templates/collaboration_mode/plan.md b/codex-rs/core/templates/collaboration_mode/plan.md index 0999958ec6..4028ac8afe 100644 --- a/codex-rs/core/templates/collaboration_mode/plan.md +++ b/codex-rs/core/templates/collaboration_mode/plan.md @@ -62,7 +62,7 @@ Rules: * Status updates must not include questions or plan content. * Internal tool/repo exploration is allowed privately before A, B, or C. -Status updates are required and should be frequent during exploration. Provide 1-2 sentence updates that summarize discoveries, assumption changes, or why you are changing direction. Provide an update before beginning a new area of exploration. +Status updates should be frequent during exploration. Provide 1-2 sentence updates that summarize discoveries, assumption changes, or why you are changing direction. Use Parallel tools for exploration. ## Ask a lot, but never ask trivia diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs b/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs index 7aa54e9532..bb2b06dea5 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs @@ -19,100 +19,301 @@ pub(super) struct LayoutSections { impl RequestUserInputOverlay { /// Compute layout sections, collapsing notes and hints as space shrinks. pub(super) fn layout_sections(&self, area: Rect) -> LayoutSections { - let question_lines = self - .current_question() - .map(|q| { - textwrap::wrap(&q.question, area.width.max(1) as usize) - .into_iter() - .map(|line| line.to_string()) - .collect::>() - }) - .unwrap_or_default(); - let question_text_height = question_lines.len() as u16; let has_options = self.has_options(); - let mut notes_input_height = self.notes_input_height(area.width); - // Keep the question + options visible first; notes and hints collapse as space shrinks. - let footer_lines = if self.unanswered_count() > 0 { 2 } else { 1 }; - let mut notes_title_height = if has_options { 1 } else { 0 }; + let footer_pref = if self.unanswered_count() > 0 { 2 } else { 1 }; + let notes_pref_height = self.notes_input_height(area.width); + let mut question_lines = self.wrapped_question_lines(area.width); + let question_height = question_lines.len() as u16; + let ( + question_height, + progress_height, + answer_title_height, + notes_title_height, + notes_height, + options_height, + footer_lines, + ) = if has_options { + self.layout_with_options( + area.height, + area.width, + question_height, + notes_pref_height, + footer_pref, + &mut question_lines, + ) + } else { + self.layout_without_options( + area.height, + question_height, + notes_pref_height, + footer_pref, + &mut question_lines, + ) + }; + + let ( + progress_area, + header_area, + question_area, + answer_title_area, + options_area, + notes_title_area, + notes_area, + ) = self.build_layout_areas( + area, + progress_height, + question_height, + answer_title_height, + options_height, + notes_title_height, + notes_height, + ); + + LayoutSections { + progress_area, + header_area, + question_area, + answer_title_area, + question_lines, + options_area, + notes_title_area, + notes_area, + footer_lines, + } + } + + /// Layout calculation when options are present. + /// + /// Handles both tight layout (when space is constrained) and normal layout + /// (when there's sufficient space for all elements). + /// + /// Returns: (question_height, progress_height, answer_title_height, notes_title_height, notes_height, options_height, footer_lines) + fn layout_with_options( + &self, + available_height: u16, + width: u16, + question_height: u16, + notes_pref_height: u16, + footer_pref: u16, + question_lines: &mut Vec, + ) -> (u16, u16, u16, u16, u16, u16, u16) { + let options_required_height = self.options_required_height(width); + let min_options_height = 1u16; + let required = 1u16 + .saturating_add(question_height) + .saturating_add(options_required_height); + + if required > available_height { + self.layout_with_options_tight( + available_height, + question_height, + min_options_height, + question_lines, + ) + } else { + self.layout_with_options_normal( + available_height, + question_height, + options_required_height, + notes_pref_height, + footer_pref, + ) + } + } + + /// Tight layout for options case: allocate header + question + options first + /// and drop everything else when space is constrained. + fn layout_with_options_tight( + &self, + available_height: u16, + question_height: u16, + min_options_height: u16, + question_lines: &mut Vec, + ) -> (u16, u16, u16, u16, u16, u16, u16) { + let max_question_height = + available_height.saturating_sub(1u16.saturating_add(min_options_height)); + let adjusted_question_height = question_height.min(max_question_height); + question_lines.truncate(adjusted_question_height as usize); + let options_height = + available_height.saturating_sub(1u16.saturating_add(adjusted_question_height)); + + (adjusted_question_height, 0, 0, 0, 0, options_height, 0) + } + + /// Normal layout for options case: allocate space for all elements with + /// preference order: notes, footer, labels, then progress. + fn layout_with_options_normal( + &self, + available_height: u16, + question_height: u16, + options_required_height: u16, + notes_pref_height: u16, + footer_pref: u16, + ) -> (u16, u16, u16, u16, u16, u16, u16) { + let options_height = options_required_height; + let used = 1u16 + .saturating_add(question_height) + .saturating_add(options_height); + let mut remaining = available_height.saturating_sub(used); + + // Prefer notes next, then footer, then labels, with progress last. + let mut notes_height = notes_pref_height.min(remaining); + remaining = remaining.saturating_sub(notes_height); + + let footer_lines = footer_pref.min(remaining); + remaining = remaining.saturating_sub(footer_lines); + + let mut answer_title_height = 0; + if remaining > 0 { + answer_title_height = 1; + remaining = remaining.saturating_sub(1); + } + + let mut notes_title_height = 0; + if remaining > 0 { + notes_title_height = 1; + remaining = remaining.saturating_sub(1); + } + + let mut progress_height = 0; + if remaining > 0 { + progress_height = 1; + remaining = remaining.saturating_sub(1); + } + + // Expand the notes composer with any leftover rows. + notes_height = notes_height.saturating_add(remaining); + + ( + question_height, + progress_height, + answer_title_height, + notes_title_height, + notes_height, + options_height, + footer_lines, + ) + } + + /// Layout calculation when no options are present. + /// + /// Handles both tight layout (when space is constrained) and normal layout + /// (when there's sufficient space for all elements). + /// + /// Returns: (question_height, progress_height, answer_title_height, notes_title_height, notes_height, options_height, footer_lines) + fn layout_without_options( + &self, + available_height: u16, + question_height: u16, + notes_pref_height: u16, + footer_pref: u16, + question_lines: &mut Vec, + ) -> (u16, u16, u16, u16, u16, u16, u16) { + let required = 1u16.saturating_add(question_height); + if required > available_height { + self.layout_without_options_tight(available_height, question_height, question_lines) + } else { + self.layout_without_options_normal( + available_height, + question_height, + notes_pref_height, + footer_pref, + ) + } + } + + /// Tight layout for no-options case: truncate question to fit available space. + fn layout_without_options_tight( + &self, + available_height: u16, + question_height: u16, + question_lines: &mut Vec, + ) -> (u16, u16, u16, u16, u16, u16, u16) { + let max_question_height = available_height.saturating_sub(1); + let adjusted_question_height = question_height.min(max_question_height); + question_lines.truncate(adjusted_question_height as usize); + + (adjusted_question_height, 0, 0, 0, 0, 0, 0) + } + + /// Normal layout for no-options case: allocate space for notes, footer, and progress. + fn layout_without_options_normal( + &self, + available_height: u16, + question_height: u16, + notes_pref_height: u16, + footer_pref: u16, + ) -> (u16, u16, u16, u16, u16, u16, u16) { + let required = 1u16.saturating_add(question_height); + let mut remaining = available_height.saturating_sub(required); + let mut notes_height = notes_pref_height.min(remaining); + remaining = remaining.saturating_sub(notes_height); + + let footer_lines = footer_pref.min(remaining); + remaining = remaining.saturating_sub(footer_lines); + + let mut progress_height = 0; + if remaining > 0 { + progress_height = 1; + remaining = remaining.saturating_sub(1); + } + + notes_height = notes_height.saturating_add(remaining); + + ( + question_height, + progress_height, + 0, + 0, + notes_height, + 0, + footer_lines, + ) + } + + /// Build the final layout areas from computed heights. + fn build_layout_areas( + &self, + area: Rect, + progress_height: u16, + question_height: u16, + answer_title_height: u16, + options_height: u16, + notes_title_height: u16, + notes_height: u16, + ) -> ( + Rect, // progress_area + Rect, // header_area + Rect, // question_area + Rect, // answer_title_area + Rect, // options_area + Rect, // notes_title_area + Rect, // notes_area + ) { let mut cursor_y = area.y; let progress_area = Rect { x: area.x, y: cursor_y, width: area.width, - height: 1, + height: progress_height, }; - cursor_y = cursor_y.saturating_add(1); + cursor_y = cursor_y.saturating_add(progress_height); + let header_height = area.height.saturating_sub(progress_height).min(1); let header_area = Rect { x: area.x, y: cursor_y, width: area.width, - height: 1, + height: header_height, }; - cursor_y = cursor_y.saturating_add(1); + cursor_y = cursor_y.saturating_add(header_height); let question_area = Rect { x: area.x, y: cursor_y, width: area.width, - height: question_text_height, + height: question_height, }; - cursor_y = cursor_y.saturating_add(question_text_height); - // Remaining height after progress/header/question areas. - let remaining = area.height.saturating_sub(cursor_y.saturating_sub(area.y)); - let mut answer_title_height = if has_options { 1 } else { 0 }; - let mut options_height = 0; - if has_options { - let remaining_content = remaining.saturating_sub(footer_lines); - let options_len = self.options_len() as u16; - if remaining_content == 0 { - answer_title_height = 0; - notes_title_height = 0; - notes_input_height = 0; - options_height = 0; - } else { - let min_notes = 1u16; - let full_notes = 3u16; - // Prefer to keep all options visible, then allocate notes height. - if remaining_content - >= options_len + answer_title_height + notes_title_height + full_notes - { - let max_notes = remaining_content - .saturating_sub(options_len) - .saturating_sub(answer_title_height) - .saturating_sub(notes_title_height); - notes_input_height = notes_input_height.min(max_notes).max(full_notes); - } else if remaining_content > options_len + answer_title_height + min_notes { - notes_title_height = 0; - notes_input_height = min_notes; - } else { - // Tight layout: hide section titles and shrink notes to one line. - answer_title_height = 0; - notes_title_height = 0; - notes_input_height = min_notes; - } - - // Reserve notes/answer title area so options are scrollable if needed. - let reserved = answer_title_height - .saturating_add(notes_title_height) - .saturating_add(notes_input_height); - options_height = remaining_content.saturating_sub(reserved); - if options_height > options_len { - // Expand the notes composer with any leftover rows so we - // do not leave a large blank gap between options and notes. - let extra_rows = options_height.saturating_sub(options_len); - options_height = options_len; - notes_input_height = notes_input_height.saturating_add(extra_rows); - } - } - } else { - let max_notes = remaining.saturating_sub(footer_lines); - if max_notes == 0 { - notes_input_height = 0; - } else { - // When no options exist, notes are the primary input. - notes_input_height = notes_input_height.min(max_notes).max(3.min(max_notes)); - } - } + cursor_y = cursor_y.saturating_add(question_height); let answer_title_area = Rect { x: area.x, @@ -140,19 +341,17 @@ impl RequestUserInputOverlay { x: area.x, y: cursor_y, width: area.width, - height: notes_input_height, + height: notes_height, }; - LayoutSections { + ( progress_area, header_area, question_area, answer_title_area, - question_lines, options_area, notes_title_area, notes_area, - footer_lines, - } + ) } } diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index c710b2b2c1..1da955ec3b 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -24,6 +24,8 @@ use crate::bottom_pane::ChatComposerConfig; use crate::bottom_pane::InputResult; use crate::bottom_pane::bottom_pane_view::BottomPaneView; use crate::bottom_pane::scroll_state::ScrollState; +use crate::bottom_pane::selection_popup_common::GenericDisplayRow; +use crate::bottom_pane::selection_popup_common::measure_rows_height; use crate::render::renderable::Renderable; use codex_core::protocol::Op; @@ -164,6 +166,62 @@ impl RequestUserInputOverlay { .map(|option| option.label.as_str()) } + pub(super) fn wrapped_question_lines(&self, width: u16) -> Vec { + self.current_question() + .map(|q| { + textwrap::wrap(&q.question, width.max(1) as usize) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + }) + .unwrap_or_default() + } + + pub(super) fn option_rows(&self) -> Vec { + self.current_question() + .and_then(|question| question.options.as_ref()) + .map(|options| { + options + .iter() + .enumerate() + .map(|(idx, opt)| { + let selected = self + .current_answer() + .and_then(|answer| answer.selected) + .is_some_and(|sel| sel == idx); + let prefix = if selected { "(x)" } else { "( )" }; + GenericDisplayRow { + name: format!("{prefix} {}", opt.label), + description: Some(opt.description.clone()), + ..Default::default() + } + }) + .collect::>() + }) + .unwrap_or_default() + } + + pub(super) fn options_required_height(&self, width: u16) -> u16 { + if !self.has_options() { + return 0; + } + + let rows = self.option_rows(); + if rows.is_empty() { + return 1; + } + + let mut state = self + .current_answer() + .map(|answer| answer.option_state) + .unwrap_or_default(); + if state.selected_idx.is_none() { + state.selected_idx = Some(0); + } + + measure_rows_height(&rows, &state, rows.len(), width.max(1)) + } + fn capture_composer_draft(&self) -> ComposerDraft { ComposerDraft { text: self.composer.current_text_with_pending(), @@ -594,6 +652,35 @@ mod tests { } } + fn question_with_wrapped_options(id: &str, header: &str) -> RequestUserInputQuestion { + RequestUserInputQuestion { + id: id.to_string(), + header: header.to_string(), + question: "Choose the next step for this task.".to_string(), + is_other: false, + options: Some(vec![ + RequestUserInputQuestionOption { + label: "Discuss a code change".to_string(), + description: + "Walk through a plan, then implement it together with careful checks." + .to_string(), + }, + RequestUserInputQuestionOption { + label: "Run targeted tests".to_string(), + description: + "Pick the most relevant crate and validate the current behavior first." + .to_string(), + }, + RequestUserInputQuestionOption { + label: "Review the diff".to_string(), + description: + "Summarize the changes and highlight the most important risks and gaps." + .to_string(), + }, + ]), + } + } + fn question_without_options(id: &str, header: &str) -> RequestUserInputQuestion { RequestUserInputQuestion { id: id.to_string(), @@ -797,6 +884,60 @@ mod tests { ); } + #[test] + fn layout_allocates_all_wrapped_options_when_space_allows() { + let (tx, _rx) = test_sender(); + let overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![question_with_wrapped_options("q1", "Next Step")], + ), + tx, + true, + false, + false, + ); + + let width = 48u16; + let question_height = overlay.wrapped_question_lines(width).len() as u16; + let options_height = overlay.options_required_height(width); + let height = 1u16 + .saturating_add(question_height) + .saturating_add(options_height) + .saturating_add(4); + let sections = overlay.layout_sections(Rect::new(0, 0, width, height)); + + assert_eq!(sections.options_area.height, options_height); + } + + #[test] + fn request_user_input_wrapped_options_snapshot() { + let (tx, _rx) = test_sender(); + let overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![question_with_wrapped_options("q1", "Next Step")], + ), + tx, + true, + false, + false, + ); + + let width = 52u16; + let question_height = overlay.wrapped_question_lines(width).len() as u16; + let options_height = overlay.options_required_height(width); + let height = 1u16 + .saturating_add(question_height) + .saturating_add(options_height) + .saturating_add(4); + let area = Rect::new(0, 0, width, height); + insta::assert_snapshot!( + "request_user_input_wrapped_options", + render_snapshot(&overlay, area) + ); + } + #[test] fn request_user_input_scroll_options_snapshot() { let (tx, _rx) = test_sender(); diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/render.rs b/codex-rs/tui/src/bottom_pane/request_user_input/render.rs index 368f174b57..b64f704340 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/render.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/render.rs @@ -6,7 +6,6 @@ use ratatui::text::Line; use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; -use crate::bottom_pane::selection_popup_common::GenericDisplayRow; use crate::bottom_pane::selection_popup_common::menu_surface_inset; use crate::bottom_pane::selection_popup_common::menu_surface_padding_height; use crate::bottom_pane::selection_popup_common::render_menu_surface; @@ -21,21 +20,21 @@ impl Renderable for RequestUserInputOverlay { let outer = Rect::new(0, 0, width, u16::MAX); let inner = menu_surface_inset(outer); let inner_width = inner.width.max(1); - let sections = self.layout_sections(Rect::new(0, 0, inner_width, u16::MAX)); - let question_height = sections.question_lines.len(); + let question_height = self.wrapped_question_lines(inner_width).len(); + let options_height = self.options_required_height(inner_width) as usize; let notes_height = self.notes_input_height(inner_width) as usize; - let footer_height = sections.footer_lines as usize; + let footer_height = if self.unanswered_count() > 0 { 2 } else { 1 }; // Tight minimum height: progress + header + question + (optional) titles/options // + notes composer + footer + menu padding. let mut height = question_height + .saturating_add(options_height) .saturating_add(notes_height) .saturating_add(footer_height) .saturating_add(2); // progress + header if self.has_options() { height = height .saturating_add(1) // answer title - .saturating_add(self.options_len()) .saturating_add(1); // notes title } height = height.saturating_add(menu_surface_padding_height() as usize); @@ -113,28 +112,7 @@ impl RequestUserInputOverlay { } // Build rows with selection markers for the shared selection renderer. - let option_rows = self - .current_question() - .and_then(|question| question.options.as_ref()) - .map(|options| { - options - .iter() - .enumerate() - .map(|(idx, opt)| { - let selected = self - .current_answer() - .and_then(|answer| answer.selected) - .is_some_and(|sel| sel == idx); - let prefix = if selected { "(x)" } else { "( )" }; - GenericDisplayRow { - name: format!("{prefix} {}", opt.label), - description: Some(opt.description.clone()), - ..Default::default() - } - }) - .collect::>() - }) - .unwrap_or_default(); + let option_rows = self.option_rows(); if self.has_options() { let mut option_state = self diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_scrolling_options.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_scrolling_options.snap index f15d53e11e..ace8eb4537 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_scrolling_options.snap +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_scrolling_options.snap @@ -3,11 +3,12 @@ source: tui/src/bottom_pane/request_user_input/mod.rs expression: "render_snapshot(&overlay, area)" --- - Question 1/1 Next Step What would you like to do next? - ( ) Run tests Pick a crate and run its tests. - ( ) Review a diff Summarize or review current changes. - (x) Refactor Tighten structure and remove dead code. - + ( ) Discuss a code change (Recommended) Walk through a plan and + edit code together. + ( ) Run tests Pick a crate and run + its tests. + ( ) Review a diff Summarize or review + current changes. Option 4 of 5 | ↑/↓ scroll | enter next question | esc interrupt diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_tight_height.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_tight_height.snap index 7820e0d0f2..e8cd2bd22e 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_tight_height.snap +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_tight_height.snap @@ -3,9 +3,10 @@ source: tui/src/bottom_pane/request_user_input/mod.rs expression: "render_snapshot(&overlay, area)" --- - Question 1/1 Area Choose an option. (x) Option 1 First choice. + ( ) Option 2 Second choice. + ( ) Option 3 Third choice. Option 1 of 3 | ↑/↓ scroll | enter next question | esc i diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_wrapped_options.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_wrapped_options.snap new file mode 100644 index 0000000000..4ae9dd048f --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_wrapped_options.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/bottom_pane/request_user_input/mod.rs +expression: "render_snapshot(&overlay, area)" +--- + + Next Step + Choose the next step for this task. + (x) Discuss a code change Walk through a plan, + then implement it + together with careful + checks. + ( ) Run targeted tests Pick the most + relevant crate and + validate the current + behavior first. + ( ) Review the diff Summarize the changes + and highlight the + most important risks + and gaps. + + Option 1 of 3 | ↑/↓ scroll | enter next question From cabb2085cc05655a96d6e66d21a70c1ee37d06dd Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 26 Jan 2026 21:42:01 -0800 Subject: [PATCH 42/43] make plan prompt less detailed (#9977) This was too much to ask for --- codex-rs/core/templates/collaboration_mode/plan.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/codex-rs/core/templates/collaboration_mode/plan.md b/codex-rs/core/templates/collaboration_mode/plan.md index 4028ac8afe..4b5f450a50 100644 --- a/codex-rs/core/templates/collaboration_mode/plan.md +++ b/codex-rs/core/templates/collaboration_mode/plan.md @@ -100,9 +100,7 @@ The final plan must be plan-only and include: * Exact file paths to change * Exact structures or shapes to introduce or modify * Exact function, method, type, and variable names and signatures -* Step-by-step edits or patches described precisely -* Test cases and commands -* Acceptance criteria tied to observable outcomes +* Test cases * Explicit assumptions and defaults chosen where needed Do not ask "should I proceed?" in the final output. From 509ff1c643e0b45366fc5d77c78279ca29b71448 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 26 Jan 2026 22:30:24 -0800 Subject: [PATCH 43/43] Fixing main and make plan mode reasoning effort medium (#9980) It's overthinking so much on high and going over the context window. --- .../collaboration_mode_presets.rs | 2 +- .../bottom_pane/request_user_input/layout.rs | 55 +++++++++++-------- 2 files changed, 32 insertions(+), 25 deletions(-) diff --git a/codex-rs/core/src/models_manager/collaboration_mode_presets.rs b/codex-rs/core/src/models_manager/collaboration_mode_presets.rs index aff111b1ad..83059b912e 100644 --- a/codex-rs/core/src/models_manager/collaboration_mode_presets.rs +++ b/codex-rs/core/src/models_manager/collaboration_mode_presets.rs @@ -28,7 +28,7 @@ fn plan_preset() -> CollaborationModeMask { name: "Plan".to_string(), mode: Some(ModeKind::Plan), model: None, - reasoning_effort: Some(Some(ReasoningEffort::High)), + reasoning_effort: Some(Some(ReasoningEffort::Medium)), developer_instructions: Some(Some(COLLABORATION_MODE_PLAN.to_string())), } } diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs b/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs index bb2b06dea5..4a1817da89 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs @@ -62,12 +62,14 @@ impl RequestUserInputOverlay { notes_area, ) = self.build_layout_areas( area, - progress_height, - question_height, - answer_title_height, - options_height, - notes_title_height, - notes_height, + LayoutHeights { + progress_height, + question_height, + answer_title_height, + options_height, + notes_title_height, + notes_height, + }, ); LayoutSections { @@ -276,12 +278,7 @@ impl RequestUserInputOverlay { fn build_layout_areas( &self, area: Rect, - progress_height: u16, - question_height: u16, - answer_title_height: u16, - options_height: u16, - notes_title_height: u16, - notes_height: u16, + heights: LayoutHeights, ) -> ( Rect, // progress_area Rect, // header_area @@ -296,10 +293,10 @@ impl RequestUserInputOverlay { x: area.x, y: cursor_y, width: area.width, - height: progress_height, + height: heights.progress_height, }; - cursor_y = cursor_y.saturating_add(progress_height); - let header_height = area.height.saturating_sub(progress_height).min(1); + cursor_y = cursor_y.saturating_add(heights.progress_height); + let header_height = area.height.saturating_sub(heights.progress_height).min(1); let header_area = Rect { x: area.x, y: cursor_y, @@ -311,37 +308,37 @@ impl RequestUserInputOverlay { x: area.x, y: cursor_y, width: area.width, - height: question_height, + height: heights.question_height, }; - cursor_y = cursor_y.saturating_add(question_height); + cursor_y = cursor_y.saturating_add(heights.question_height); let answer_title_area = Rect { x: area.x, y: cursor_y, width: area.width, - height: answer_title_height, + height: heights.answer_title_height, }; - cursor_y = cursor_y.saturating_add(answer_title_height); + cursor_y = cursor_y.saturating_add(heights.answer_title_height); let options_area = Rect { x: area.x, y: cursor_y, width: area.width, - height: options_height, + height: heights.options_height, }; - cursor_y = cursor_y.saturating_add(options_height); + cursor_y = cursor_y.saturating_add(heights.options_height); let notes_title_area = Rect { x: area.x, y: cursor_y, width: area.width, - height: notes_title_height, + height: heights.notes_title_height, }; - cursor_y = cursor_y.saturating_add(notes_title_height); + cursor_y = cursor_y.saturating_add(heights.notes_title_height); let notes_area = Rect { x: area.x, y: cursor_y, width: area.width, - height: notes_height, + height: heights.notes_height, }; ( @@ -355,3 +352,13 @@ impl RequestUserInputOverlay { ) } } + +#[derive(Clone, Copy, Debug)] +struct LayoutHeights { + progress_height: u16, + question_height: u16, + answer_title_height: u16, + options_height: u16, + notes_title_height: u16, + notes_height: u16, +}