From 16d02ec77c6337ccea02a8c909e05bf3d905f887 Mon Sep 17 00:00:00 2001 From: jif Date: Thu, 4 Jun 2026 12:46:02 +0200 Subject: [PATCH 01/41] chore: calm down (#26367) Prompt update to address feedback --- codex-rs/core/src/config/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 5b79672b81..2c3cd4d6c3 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -199,6 +199,7 @@ All agents in the team, including the agents that you can assign tasks to, are e You can use `spawn_agent` to create a new agent, `followup_task` to give an existing agent a new task and trigger a turn, and `send_message` to pass a message to a running agent without triggering a turn. Child agents can also spawn their own sub-agents. You can decide how much context you want to propagate to your sub-agents with the `fork_turns` parameter. +Use multi-agent capabilities only when there is a real reason to split the work; handle trivial or simple tasks directly. You will receive messages in the analysis channel in the form: ``` @@ -215,6 +216,7 @@ You can spawn sub-agents to handle subtasks, and those sub-agents can spawn thei You can use `spawn_agent` to create a new agent, `followup_task` to give an existing agent a new task and trigger a turn, and `send_message` to pass a message to a running agent. Child agents can also spawn their own sub-agents. +Use multi-agent capabilities only when there is a real reason to split the work; handle trivial or simple tasks directly. When you provide a response in the final channel, that content is immediately delivered back to your parent agent. From d297616d3e6a27865fb327e7b4ea3d548f7fdb45 Mon Sep 17 00:00:00 2001 From: jif Date: Thu, 4 Jun 2026 14:44:45 +0200 Subject: [PATCH 02/41] Gate automatic idle turns in Plan mode (#26147) ## Why Goal idle continuation is extension-triggered model-visible work, so it should follow one core-owned rule for when automatic work may start. In particular, it should not jump ahead of queued user/client work, start while another task is active, or inject a continuation turn while the thread is in Plan mode. Keeping this policy in `try_start_turn_if_idle` avoids passing `collaboration_mode` or review-specific state through `ThreadLifecycleContributor::on_thread_idle`. Active `/review` is covered by the same active-task gate because Review turns are not steerable. ## What Changed - Teach `Session::try_start_turn_if_idle` to reject automatic idle turns in Plan mode, both before reserving an idle turn and after building the turn context. - Document `CodexThread::try_start_turn_if_idle` as the extension-facing gate for automatic idle work, including Plan-mode and active Review-task behavior. - Add focused coverage for Plan-mode rejection and active Review-task rejection without queuing synthetic input. ## Testing - `just test -p codex-core try_start_turn_if_idle` --- codex-rs/core/src/codex_thread.rs | 56 ++++++++++++++++++- codex-rs/core/src/lib.rs | 2 + codex-rs/core/src/session/inject.rs | 52 ++++++++++++++--- codex-rs/core/src/session/tests.rs | 86 ++++++++++++++++++++++++++++- codex-rs/ext/goal/src/runtime.rs | 8 ++- 5 files changed, 192 insertions(+), 12 deletions(-) diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 5035796767..ac28a58ec3 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -74,6 +74,46 @@ pub struct ThreadConfigSnapshot { pub thread_source: Option, } +/// Explains why `CodexThread::try_start_turn_if_idle` rejected an automatic +/// idle turn. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TryStartTurnIfIdleRejectionReason { + /// User/client-triggered mailbox work is already queued and must take + /// priority over extension-initiated idle work. + PendingTriggerTurn, + /// The thread is in Plan mode, where automatic idle work must not start a + /// new model turn. + PlanMode, + /// Another turn or task is active, or the idle reservation was lost before + /// the automatic turn could start. + Busy, +} + +/// Rejection returned when an extension asks to start automatic idle work but +/// the thread is not eligible to run it. +#[derive(Debug)] +pub struct TryStartTurnIfIdleError { + reason: TryStartTurnIfIdleRejectionReason, + input: Vec, +} + +impl TryStartTurnIfIdleError { + pub(crate) fn new(reason: TryStartTurnIfIdleRejectionReason, input: Vec) -> Self { + Self { reason, input } + } + + /// Returns the stable reason the automatic idle turn was rejected. + pub fn reason(&self) -> TryStartTurnIfIdleRejectionReason { + self.reason + } + + /// Consumes the rejection and returns the original model-visible input + /// unchanged, so callers can retry, drop, or log it explicitly. + pub fn into_input(self) -> Vec { + self.input + } +} + impl ThreadConfigSnapshot { pub fn sandbox_policy(&self) -> SandboxPolicy { codex_sandboxing::compatibility_sandbox_policy_for_permission_profile( @@ -276,11 +316,23 @@ impl CodexThread { self.codex.session.inject_if_running(items).await } - /// Starts a regular turn with model-visible items only if the thread is idle. + /// Starts an automatic regular turn with model-visible items only when idle + /// work is allowed for this thread. + /// + /// This is the required entry point for extensions that want to launch + /// model-visible work from `ThreadLifecycleContributor::on_thread_idle`. + /// The call succeeds only if no user/client-triggered turn is queued, no + /// task is currently active, and the thread is not in Plan mode. Active + /// Review tasks are rejected by the active-task check because Review turns + /// are not steerable. + /// + /// On rejection, the returned error includes a stable reason and carries + /// the original `items` unchanged so the caller can decide whether to drop + /// them, retry later, or log why no automatic turn was started. pub async fn try_start_turn_if_idle( &self, items: Vec, - ) -> Result<(), Vec> { + ) -> Result<(), TryStartTurnIfIdleError> { self.codex.session.try_start_turn_if_idle(items).await } diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 31ac529556..d3031ad222 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -22,6 +22,8 @@ mod config_lock; pub use codex_thread::CodexThread; pub use codex_thread::CodexThreadSettingsOverrides; pub use codex_thread::ThreadConfigSnapshot; +pub use codex_thread::TryStartTurnIfIdleError; +pub use codex_thread::TryStartTurnIfIdleRejectionReason; pub use session::turn_context::TurnContext; mod agent; mod attestation; diff --git a/codex-rs/core/src/session/inject.rs b/codex-rs/core/src/session/inject.rs index 64d4aaade1..3f73cd96d0 100644 --- a/codex-rs/core/src/session/inject.rs +++ b/codex-rs/core/src/session/inject.rs @@ -1,9 +1,12 @@ use super::input_queue::TurnInput; use super::session::Session; use super::turn_context::TurnContext; +use crate::codex_thread::TryStartTurnIfIdleError; +use crate::codex_thread::TryStartTurnIfIdleRejectionReason; use crate::state::ActiveTurn; use crate::state::TurnState; use crate::tasks::RegularTask; +use codex_protocol::config_types::ModeKind; use codex_protocol::models::ResponseItem; use std::sync::Arc; @@ -32,22 +35,40 @@ impl Session { } } - /// Starts a regular turn with the provided items only if the session is idle. + /// Starts a regular turn with the provided items only if automatic idle work + /// is allowed for the current session state. + /// + /// This is the shared gate for extension-initiated idle work. It refuses to + /// start a turn when user/client-triggered work is queued, any task is still + /// active, or the session is currently in Plan mode. Active Review tasks are + /// covered by the active-task check because Review turns are not steerable. pub(crate) async fn try_start_turn_if_idle( self: &Arc, input: Vec, - ) -> Result<(), Vec> { + ) -> Result<(), TryStartTurnIfIdleError> { if input.is_empty() { return Ok(()); } if self.input_queue.has_trigger_turn_mailbox_items().await { - return Err(input); + return Err(TryStartTurnIfIdleError::new( + TryStartTurnIfIdleRejectionReason::PendingTriggerTurn, + input, + )); + } + if self.collaboration_mode().await.mode == ModeKind::Plan { + return Err(TryStartTurnIfIdleError::new( + TryStartTurnIfIdleRejectionReason::PlanMode, + input, + )); } let turn_state = { let mut active_turn = self.active_turn.lock().await; if active_turn.is_some() { - return Err(input); + return Err(TryStartTurnIfIdleError::new( + TryStartTurnIfIdleRejectionReason::Busy, + input, + )); } let active_turn = active_turn.get_or_insert_with(ActiveTurn::default); Arc::clone(&active_turn.turn_state) @@ -56,18 +77,32 @@ impl Session { if self.input_queue.has_trigger_turn_mailbox_items().await { self.clear_reserved_idle_turn(&turn_state).await; self.maybe_start_turn_for_pending_work().await; - return Err(input); + return Err(TryStartTurnIfIdleError::new( + TryStartTurnIfIdleRejectionReason::PendingTriggerTurn, + input, + )); } let turn_context = self .new_default_turn_with_sub_id(uuid::Uuid::new_v4().to_string()) .await; + if turn_context.collaboration_mode.mode == ModeKind::Plan { + self.clear_reserved_idle_turn(&turn_state).await; + self.maybe_start_turn_for_pending_work().await; + return Err(TryStartTurnIfIdleError::new( + TryStartTurnIfIdleRejectionReason::PlanMode, + input, + )); + } self.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref()) .await; if self.input_queue.has_trigger_turn_mailbox_items().await { self.clear_reserved_idle_turn(&turn_state).await; self.maybe_start_turn_for_pending_work().await; - return Err(input); + return Err(TryStartTurnIfIdleError::new( + TryStartTurnIfIdleRejectionReason::PendingTriggerTurn, + input, + )); } let still_reserved = { let active_turn = self.active_turn.lock().await; @@ -77,7 +112,10 @@ impl Session { }; if !still_reserved { self.clear_reserved_idle_turn(&turn_state).await; - return Err(input); + return Err(TryStartTurnIfIdleError::new( + TryStartTurnIfIdleRejectionReason::Busy, + input, + )); } self.input_queue diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 4d4709e65d..1643574206 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1,5 +1,6 @@ use super::turn_context::TurnEnvironment; use super::*; +use crate::codex_thread::TryStartTurnIfIdleRejectionReason; use crate::config::ConfigBuilder; use crate::config::ConfigOverrides; use crate::config::test_config; @@ -8770,7 +8771,90 @@ async fn try_start_turn_if_idle_rejects_active_turn_without_injecting() { .await .expect_err("active turn should reject idle-only input"); - assert_eq!(vec![item], err); + assert_eq!(TryStartTurnIfIdleRejectionReason::Busy, err.reason()); + assert_eq!(vec![item], err.into_input()); + assert_eq!( + Vec::::new(), + sess.input_queue.get_pending_input(&sess.active_turn).await + ); + + sess.abort_all_tasks(TurnAbortReason::Interrupted).await; +} + +#[tokio::test] +async fn try_start_turn_if_idle_rejects_plan_mode_without_injecting() { + let (sess, _tc, _rx) = make_session_and_context_with_rx().await; + let mut collaboration_mode = sess.collaboration_mode().await; + collaboration_mode.mode = ModeKind::Plan; + { + let mut state = sess.state.lock().await; + state.session_configuration.collaboration_mode = collaboration_mode; + } + + let item = user_message("synthetic idle input"); + let err = sess + .try_start_turn_if_idle(vec![item.clone()]) + .await + .expect_err("plan mode should reject automatic idle input"); + + assert_eq!(TryStartTurnIfIdleRejectionReason::PlanMode, err.reason()); + assert_eq!(vec![item], err.into_input()); + assert!(sess.active_turn.lock().await.is_none()); + assert_eq!( + Vec::::new(), + sess.input_queue.get_pending_input(&sess.active_turn).await + ); +} + +#[tokio::test] +async fn try_start_turn_if_idle_rejects_pending_trigger_turn_without_injecting() { + let (sess, _tc, _rx) = make_session_and_context_with_rx().await; + sess.input_queue + .enqueue_mailbox_communication(InterAgentCommunication::new( + AgentPath::root(), + AgentPath::root(), + Vec::new(), + "pending trigger".to_string(), + /*trigger_turn*/ true, + )) + .await; + + let item = user_message("synthetic idle input"); + let err = sess + .try_start_turn_if_idle(vec![item.clone()]) + .await + .expect_err("pending trigger-turn mail should reject automatic idle input"); + + assert_eq!( + TryStartTurnIfIdleRejectionReason::PendingTriggerTurn, + err.reason() + ); + assert_eq!(vec![item], err.into_input()); + assert!(sess.active_turn.lock().await.is_none()); + assert!(sess.input_queue.has_trigger_turn_mailbox_items().await); +} + +#[tokio::test] +async fn try_start_turn_if_idle_rejects_active_review_turn_without_injecting() { + let (sess, tc, _rx) = make_session_and_context_with_rx().await; + sess.spawn_task( + Arc::clone(&tc), + Vec::new(), + NeverEndingTask { + kind: TaskKind::Review, + listen_to_cancellation_token: true, + }, + ) + .await; + + let item = user_message("synthetic idle input"); + let err = sess + .try_start_turn_if_idle(vec![item.clone()]) + .await + .expect_err("active review turn should reject automatic idle input"); + + assert_eq!(TryStartTurnIfIdleRejectionReason::Busy, err.reason()); + assert_eq!(vec![item], err.into_input()); assert_eq!( Vec::::new(), sess.input_queue.get_pending_input(&sess.active_turn).await diff --git a/codex-rs/ext/goal/src/runtime.rs b/codex-rs/ext/goal/src/runtime.rs index de818dabec..b58d1244e6 100644 --- a/codex-rs/ext/goal/src/runtime.rs +++ b/codex-rs/ext/goal/src/runtime.rs @@ -307,8 +307,12 @@ impl GoalRuntimeHandle { return Ok(()); }; - if thread.try_start_turn_if_idle(vec![item]).await.is_err() { - tracing::debug!("skipping goal continuation because the thread is no longer idle"); + if let Err(err) = thread.try_start_turn_if_idle(vec![item]).await { + let reason = err.reason(); + tracing::debug!( + ?reason, + "skipping goal continuation because automatic idle work was rejected" + ); } let current_turn_is_goal_active = self From d46a98d31a9c32949580e6e3ce65b85772f3470d Mon Sep 17 00:00:00 2001 From: jif Date: Thu, 4 Jun 2026 15:28:06 +0200 Subject: [PATCH 03/41] Bridge host-loaded skills into the skills extension (#26172) ## Why The skills extension needs to become the path that exposes local host skills without losing the behavior already owned by core skill loading. Host skill discovery is not just `$CODEX_HOME/skills`: it also includes config layers, bundled-skill settings, plugin roots, runtime extra roots, and the filesystem for the selected primary environment. Rather than making the extension reload host skills and risk drifting from that authoritative load, this PR bridges the already-loaded per-turn skills outcome into the extension. That lets the extension advertise host skills and inject explicit `$skill` prompts while preserving the same roots, disabled/hidden state, rendered paths, and environment-backed file reads that the legacy path uses. ## What Changed - Adds `HostLoadedSkills` in `core-skills` to wrap the turn's `SkillLoadOutcome` and read `SKILL.md` through the filesystem that loaded that skill. - Stores `HostLoadedSkills` in turn extension data for normal turns and review turns, so the skills extension can consume the loaded host catalog without reloading it. - Adds `HostSkillProvider` under `ext/skills/src/provider/host.rs`, mapping host-loaded skill metadata into the skills-extension catalog/read contract. - Registers the host provider by default from `codex_skills_extension::install()`. - Preserves host skill metadata such as dependencies, disabled state, hidden-from-prompt policy, and slash-normalized display paths. - Passes host-loaded skills through `SkillListQuery` and `SkillReadRequest` so explicit skill invocation reads only resources from the loaded host catalog. - Adds integration coverage for a real legacy `$CODEX_HOME/skills/.../SKILL.md` skill being listed and injected through the installed extension. ## Testing - Added `installed_extension_loads_host_skills_from_legacy_roots` in `ext/skills/tests/skills_extension.rs`. - `just test -p codex-skills-extension` --- codex-rs/core-skills/src/injection.rs | 30 ++++ codex-rs/core-skills/src/lib.rs | 1 + codex-rs/core-skills/src/model.rs | 32 +++- codex-rs/core/src/session/review.rs | 10 +- codex-rs/core/src/session/turn.rs | 15 +- codex-rs/core/src/session/turn_context.rs | 2 + .../ext/extension-api/src/contributors.rs | 1 - codex-rs/ext/skills/src/extension.rs | 29 +++- codex-rs/ext/skills/src/lib.rs | 1 + codex-rs/ext/skills/src/provider.rs | 13 +- codex-rs/ext/skills/src/provider/host.rs | 134 +++++++++++++++ codex-rs/ext/skills/tests/skills_extension.rs | 155 +++++++++++++++--- 12 files changed, 390 insertions(+), 33 deletions(-) create mode 100644 codex-rs/ext/skills/src/provider/host.rs diff --git a/codex-rs/core-skills/src/injection.rs b/codex-rs/core-skills/src/injection.rs index 45979fa5c4..a358ddc637 100644 --- a/codex-rs/core-skills/src/injection.rs +++ b/codex-rs/core-skills/src/injection.rs @@ -28,6 +28,32 @@ pub struct SkillInjection { pub contents: String, } +/// Host skill prompts that have already been injected by an extension for this +/// turn. +/// +/// Core uses this to keep the legacy skill-injection path from sending the same +/// host `SKILL.md` body again while the skills extension is being wired in. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct InjectedHostSkillPrompts { + paths: HashSet, +} + +impl InjectedHostSkillPrompts { + pub fn insert_path(&mut self, path: impl Into) { + let path = path.into(); + self.paths.insert(normalize_host_skill_path(&path)); + self.paths.insert(path); + } + + pub fn is_empty(&self) -> bool { + self.paths.is_empty() + } + + pub fn contains_path(&self, path: &str) -> bool { + self.paths.contains(path) || self.paths.contains(&normalize_host_skill_path(path)) + } +} + pub async fn build_skill_injections( mentioned_skills: &[SkillMetadata], loaded_skills: Option<&SkillLoadOutcome>, @@ -85,6 +111,10 @@ pub async fn build_skill_injections( result } +fn normalize_host_skill_path(path: &str) -> String { + normalize_skill_path(path).replace('\\', "/") +} + fn emit_skill_injected_metric( otel: Option<&SessionTelemetry>, skill: &SkillMetadata, diff --git a/codex-rs/core-skills/src/lib.rs b/codex-rs/core-skills/src/lib.rs index e4a61697d1..0390302afd 100644 --- a/codex-rs/core-skills/src/lib.rs +++ b/codex-rs/core-skills/src/lib.rs @@ -15,6 +15,7 @@ pub use invocation_utils::detect_implicit_skill_invocation_for_command; pub use manager::SkillsLoadInput; pub use manager::SkillsManager; pub use mention_counts::build_skill_name_counts; +pub use model::HostLoadedSkills; pub use model::SkillError; pub use model::SkillLoadOutcome; pub use model::SkillMetadata; diff --git a/codex-rs/core-skills/src/model.rs b/codex-rs/core-skills/src/model.rs index fc8e9f5917..a9cc24306a 100644 --- a/codex-rs/core-skills/src/model.rs +++ b/codex-rs/core-skills/src/model.rs @@ -1,9 +1,11 @@ use std::collections::HashMap; use std::collections::HashSet; use std::fmt; +use std::io; use std::sync::Arc; use codex_exec_server::ExecutorFileSystem; +use codex_exec_server::LOCAL_FS; use codex_protocol::protocol::Product; use codex_protocol::protocol::SkillScope; use codex_utils_absolute_path::AbsolutePathBuf; @@ -23,7 +25,7 @@ pub struct SkillMetadata { } impl SkillMetadata { - fn allow_implicit_invocation(&self) -> bool { + pub fn allows_implicit_invocation(&self) -> bool { self.policy .as_ref() .and_then(|policy| policy.allow_implicit_invocation) @@ -103,7 +105,7 @@ impl SkillLoadOutcome { } pub fn is_skill_allowed_for_implicit_invocation(&self, skill: &SkillMetadata) -> bool { - self.is_skill_enabled(skill) && skill.allow_implicit_invocation() + self.is_skill_enabled(skill) && skill.allows_implicit_invocation() } pub fn allowed_skills_for_implicit_invocation(&self) -> Vec { @@ -129,6 +131,32 @@ impl SkillLoadOutcome { } } +/// Host-loaded skills for one turn, including the filesystem mapping needed to +/// read skill bodies through the environment that loaded them. +#[derive(Debug, Clone)] +pub struct HostLoadedSkills { + outcome: Arc, +} + +impl HostLoadedSkills { + pub fn new(outcome: Arc) -> Self { + Self { outcome } + } + + pub fn outcome(&self) -> &SkillLoadOutcome { + self.outcome.as_ref() + } + + pub async fn read_skill_text(&self, skill: &SkillMetadata) -> io::Result { + let fs = self + .outcome + .file_system_for_skill(skill) + .unwrap_or_else(|| Arc::clone(&LOCAL_FS)); + fs.read_file_text(&skill.path_to_skills_md, /*sandbox*/ None) + .await + } +} + #[derive(Clone, Default)] pub(crate) struct SkillFileSystemsByPath { values: Arc>>, diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index 5a26cce2e3..de864ebb08 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -1,4 +1,5 @@ use super::*; +use codex_core_skills::HostLoadedSkills; use codex_protocol::openai_models::ToolMode; use std::sync::atomic::AtomicBool; @@ -100,6 +101,13 @@ pub(super) async fn spawn_review_thread( parent_turn_context.network.is_some(), )); + let extension_data = Arc::new(codex_extension_api::ExtensionData::new( + review_turn_id.clone(), + )); + extension_data.insert(HostLoadedSkills::new( + parent_turn_context.turn_skills.outcome.clone(), + )); + let review_turn_context = TurnContext { sub_id: review_turn_id.clone(), trace_id: current_span_trace_id(), @@ -143,7 +151,7 @@ pub(super) async fn spawn_review_thread( dynamic_tools: parent_turn_context.dynamic_tools.clone(), truncation_policy: model_info.truncation_policy.into(), turn_metadata_state, - extension_data: Arc::new(codex_extension_api::ExtensionData::new(review_turn_id)), + extension_data, turn_skills: TurnSkillsContext::new(parent_turn_context.turn_skills.outcome.clone()), turn_timing_state: Arc::new(TurnTimingState::default()), server_model_warning_emitted: AtomicBool::new(false), diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 42d609561e..2765de76fb 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -69,6 +69,7 @@ use codex_analytics::InvocationType; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_async_utils::OrCancelExt; +use codex_core_skills::injection::InjectedHostSkillPrompts; use codex_extension_api::TurnInputContext; use codex_extension_api::TurnInputEnvironment; use codex_features::Feature; @@ -535,6 +536,9 @@ async fn build_skills_and_plugins( ) .await; + let injected_host_skill_prompts = turn_context + .extension_data + .get::(); let SkillInjections { items: skill_injections, warnings: skill_warnings, @@ -591,7 +595,16 @@ async fn build_skills_and_plugins( .track_plugin_used(tracking.clone(), plugin); } - let mut injection_items = skill_items; + let mut injection_items: Vec = match injected_host_skill_prompts { + Some(injected_host_skill_prompts) => skill_injections + .iter() + .filter(|skill| !injected_host_skill_prompts.contains_path(&skill.path)) + .map(|skill| { + ContextualUserFragment::into(crate::context::SkillInstructions::from(skill)) + }) + .collect(), + None => skill_items, + }; injection_items.extend(plugin_items); injection_items.extend(extension_injection_items); Some((injection_items, explicitly_enabled_connectors)) diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index a6fcfca787..fea422cb4b 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -2,6 +2,7 @@ use super::*; use crate::SkillLoadOutcome; use crate::config::GhostSnapshotConfig; use crate::environment_selection::ResolvedTurnEnvironments; +use codex_core_skills::HostLoadedSkills; use codex_model_provider::SharedModelProvider; use codex_model_provider::create_model_provider; use codex_protocol::SessionId; @@ -526,6 +527,7 @@ impl Session { )); let (current_date, timezone) = local_time_context(); let extension_data = Arc::new(codex_extension_api::ExtensionData::new(sub_id.clone())); + extension_data.insert(HostLoadedSkills::new(Arc::clone(&skills_outcome))); TurnContext { sub_id, trace_id: current_span_trace_id(), diff --git a/codex-rs/ext/extension-api/src/contributors.rs b/codex-rs/ext/extension-api/src/contributors.rs index 14ccb68909..8706e8ee7a 100644 --- a/codex-rs/ext/extension-api/src/contributors.rs +++ b/codex-rs/ext/extension-api/src/contributors.rs @@ -89,7 +89,6 @@ pub trait TurnLifecycleContributor: Send + Sync { async fn on_turn_error(&self, _input: TurnErrorInput<'_>) {} } -/// WARNING: DO NOT USE YET /// Extension contribution that can add turn-local model input. /// /// Implementations should resolve only the model-visible input they own and diff --git a/codex-rs/ext/skills/src/extension.rs b/codex-rs/ext/skills/src/extension.rs index 85ff513acf..3961d7d85d 100644 --- a/codex-rs/ext/skills/src/extension.rs +++ b/codex-rs/ext/skills/src/extension.rs @@ -1,7 +1,9 @@ use std::sync::Arc; use codex_core::config::Config; +use codex_core_skills::HostLoadedSkills; use codex_core_skills::SkillInstructions; +use codex_core_skills::injection::InjectedHostSkillPrompts; use codex_core_skills::injection::SkillInjection; use codex_extension_api::ConfigContributor; use codex_extension_api::ContextualUserFragment; @@ -20,6 +22,7 @@ use crate::catalog::SkillAuthority; use crate::catalog::SkillCatalogEntry; use crate::catalog::SkillReadResult; use crate::catalog::SkillSourceKind; +use crate::provider::HostSkillProvider; use crate::provider::SkillListQuery; use crate::provider::SkillReadRequest; use crate::render::available_skills_fragment; @@ -78,6 +81,7 @@ impl TurnInputContributor for SkillsExtension { }; let config = thread_state.config(); + let host_loaded_skills = turn_store.get::(); let query = SkillListQuery { turn_id: input.turn_id.clone(), executor_authorities: input @@ -90,6 +94,7 @@ impl TurnInputContributor for SkillsExtension { ) }) .collect(), + host: host_loaded_skills.clone(), include_host_skills: true, include_bundled_skills: config.bundled_skills_enabled, include_remote_skills: true, @@ -109,8 +114,12 @@ impl TurnInputContributor for SkillsExtension { let mut warnings = catalog.warnings.clone(); let mut main_prompts_injected = false; + let mut injected_host_skill_prompts = InjectedHostSkillPrompts::default(); for entry in &selected_entries { - match self.read_main_prompt(entry).await { + match self + .read_main_prompt(entry, host_loaded_skills.clone()) + .await + { Ok(read_result) => { let (contents, truncated) = truncate_main_prompt_contents(read_result.contents.as_str()); @@ -129,6 +138,9 @@ impl TurnInputContributor for SkillsExtension { }; fragments.push(Box::new(SkillInstructions::from(&injection))); main_prompts_injected = true; + if entry.authority.kind == SkillSourceKind::Host { + injected_host_skill_prompts.insert_path(entry.main_prompt.0.clone()); + } } Err(message) => { let warning = format!("Failed to load skill `{}`: {message}", entry.name); @@ -144,18 +156,26 @@ impl TurnInputContributor for SkillsExtension { warnings, main_prompts_injected, }); + if !injected_host_skill_prompts.is_empty() { + turn_store.insert(injected_host_skill_prompts); + } fragments } } impl SkillsExtension { - async fn read_main_prompt(&self, entry: &SkillCatalogEntry) -> Result { + async fn read_main_prompt( + &self, + entry: &SkillCatalogEntry, + host_loaded_skills: Option>, + ) -> Result { self.providers .read(SkillReadRequest { authority: entry.authority.clone(), package: entry.id.clone(), resource: entry.main_prompt.clone(), + host: host_loaded_skills, }) .await .map_err(|err| err.message) @@ -170,7 +190,10 @@ impl SkillsExtension { } pub fn install(registry: &mut ExtensionRegistryBuilder) { - install_with_providers(registry, SkillProviders::default()); + install_with_providers( + registry, + SkillProviders::new().with_host_provider(Arc::new(HostSkillProvider::new())), + ); } pub fn install_with_providers( diff --git a/codex-rs/ext/skills/src/lib.rs b/codex-rs/ext/skills/src/lib.rs index b5e224d49b..3a28c5fbdf 100644 --- a/codex-rs/ext/skills/src/lib.rs +++ b/codex-rs/ext/skills/src/lib.rs @@ -8,5 +8,6 @@ mod state; pub use extension::install; pub use extension::install_with_providers; +pub use provider::HostSkillProvider; pub use sources::SkillProviderSource; pub use sources::SkillProviders; diff --git a/codex-rs/ext/skills/src/provider.rs b/codex-rs/ext/skills/src/provider.rs index f9c1b41fea..a968774afd 100644 --- a/codex-rs/ext/skills/src/provider.rs +++ b/codex-rs/ext/skills/src/provider.rs @@ -1,5 +1,10 @@ use std::future::Future; use std::pin::Pin; +use std::sync::Arc; + +mod host; + +use codex_core_skills::HostLoadedSkills; use crate::catalog::SkillAuthority; use crate::catalog::SkillCatalog; @@ -9,20 +14,24 @@ use crate::catalog::SkillReadResult; use crate::catalog::SkillResourceId; use crate::catalog::SkillSearchResult; -#[derive(Clone, Debug, PartialEq, Eq)] +pub use host::HostSkillProvider; + +#[derive(Clone, Debug)] pub struct SkillListQuery { pub turn_id: String, pub executor_authorities: Vec, + pub host: Option>, pub include_host_skills: bool, pub include_bundled_skills: bool, pub include_remote_skills: bool, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug)] pub struct SkillReadRequest { pub authority: SkillAuthority, pub package: SkillPackageId, pub resource: SkillResourceId, + pub host: Option>, } #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/codex-rs/ext/skills/src/provider/host.rs b/codex-rs/ext/skills/src/provider/host.rs new file mode 100644 index 0000000000..27293aa1d5 --- /dev/null +++ b/codex-rs/ext/skills/src/provider/host.rs @@ -0,0 +1,134 @@ +use codex_core_skills::SkillLoadOutcome; +use codex_core_skills::SkillMetadata; + +use crate::catalog::SkillAuthority; +use crate::catalog::SkillCatalog; +use crate::catalog::SkillCatalogEntry; +use crate::catalog::SkillPackageId; +use crate::catalog::SkillProviderError; +use crate::catalog::SkillReadResult; +use crate::catalog::SkillResourceId; +use crate::catalog::SkillSearchResult; +use crate::catalog::SkillSourceKind; +use crate::provider::SkillListQuery; +use crate::provider::SkillProvider; +use crate::provider::SkillProviderFuture; +use crate::provider::SkillReadRequest; +use crate::provider::SkillSearchRequest; + +const HOST_AUTHORITY_ID: &str = "host"; + +/// Host-owned skill provider backed by the already-loaded turn skills. +/// +/// The provider intentionally does not reload or cache host skills. Core owns +/// skill loading, including plugin roots, runtime extra roots, and the primary +/// environment filesystem. This adapter only maps that loaded outcome into the +/// skills-extension catalog/read contract. +#[derive(Clone, Default)] +pub struct HostSkillProvider; + +impl HostSkillProvider { + pub fn new() -> Self { + Self + } +} + +impl SkillProvider for HostSkillProvider { + fn list(&self, query: SkillListQuery) -> SkillProviderFuture<'_, SkillCatalog> { + Box::pin(async move { + let Some(host_loaded_skills) = query.host else { + return Err(SkillProviderError::new( + "host skill provider requires loaded host skills", + )); + }; + + Ok(catalog_from_outcome(host_loaded_skills.outcome())) + }) + } + + fn read(&self, request: SkillReadRequest) -> SkillProviderFuture<'_, SkillReadResult> { + Box::pin(async move { + let Some(host_loaded_skills) = request.host else { + return Err(SkillProviderError::new( + "host skill provider requires loaded host skills", + )); + }; + let Some(skill) = host_loaded_skills.outcome().skills.iter().find(|skill| { + let skill_path = skill.path_to_skills_md.to_string_lossy(); + skill_path == request.resource.0.as_str() + || skill_path.replace('\\', "/") == request.resource.0 + }) else { + return Err(SkillProviderError::new(format!( + "host skill resource is not loaded: {}", + request.resource.0 + ))); + }; + + let contents = host_loaded_skills + .read_skill_text(skill) + .await + .map_err(|err| { + SkillProviderError::new(format!( + "failed to read host skill resource {}: {err}", + request.resource.0 + )) + })?; + + Ok(SkillReadResult { + resource: request.resource, + contents, + }) + }) + } + + fn search(&self, _request: SkillSearchRequest) -> SkillProviderFuture<'_, SkillSearchResult> { + Box::pin(async { Ok(SkillSearchResult::default()) }) + } +} + +fn catalog_from_outcome(outcome: &SkillLoadOutcome) -> SkillCatalog { + let mut catalog = SkillCatalog { + entries: Vec::new(), + warnings: outcome + .errors + .iter() + .map(|err| { + format!( + "Failed to load skill at {}: {}", + err.path.display(), + err.message + ) + }) + .collect(), + }; + + for (skill, enabled) in outcome.skills_with_enabled() { + catalog.push_entry(catalog_entry_from_skill(skill, enabled)); + } + + catalog +} + +fn catalog_entry_from_skill(skill: &SkillMetadata, enabled: bool) -> SkillCatalogEntry { + let skill_path = skill.path_to_skills_md.to_string_lossy().into_owned(); + let display_path = skill_path.replace('\\', "/"); + let mut entry = SkillCatalogEntry::new( + SkillPackageId(skill_path.clone()), + SkillAuthority::new(SkillSourceKind::Host, HOST_AUTHORITY_ID), + skill.name.clone(), + skill.description.clone(), + SkillResourceId(skill_path), + ) + .with_short_description(skill.short_description.clone()) + .with_display_path(display_path) + .with_dependencies(skill.dependencies.clone()); + + if !enabled { + entry = entry.disabled(); + } + if !skill.allows_implicit_invocation() { + entry = entry.hidden_from_prompt(); + } + + entry +} diff --git a/codex-rs/ext/skills/tests/skills_extension.rs b/codex-rs/ext/skills/tests/skills_extension.rs index acdb5587be..1e2a2f0b60 100644 --- a/codex-rs/ext/skills/tests/skills_extension.rs +++ b/codex-rs/ext/skills/tests/skills_extension.rs @@ -1,9 +1,15 @@ +use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; use codex_core::config::Config; +use codex_core::config::ConfigBuilder; +use codex_core_skills::HostLoadedSkills; +use codex_core_skills::SkillsLoadInput; +use codex_core_skills::SkillsManager; +use codex_core_skills::injection::InjectedHostSkillPrompts; use codex_extension_api::ExtensionData; use codex_extension_api::ExtensionRegistryBuilder; use codex_extension_api::ThreadStartInput; @@ -21,6 +27,7 @@ use codex_skills_extension::catalog::SkillReadResult; use codex_skills_extension::catalog::SkillResourceId; use codex_skills_extension::catalog::SkillSearchResult; use codex_skills_extension::catalog::SkillSourceKind; +use codex_skills_extension::install; use codex_skills_extension::install_with_providers; use codex_skills_extension::provider::SkillListQuery; use codex_skills_extension::provider::SkillProvider; @@ -33,6 +40,93 @@ type TestResult = Result<(), Box>; static NEXT_CODEX_HOME_ID: AtomicUsize = AtomicUsize::new(0); +#[tokio::test] +async fn installed_extension_loads_host_skills_from_legacy_roots() -> TestResult { + let codex_home = test_codex_home(); + let skill_path = codex_home.join("skills").join("demo").join("SKILL.md"); + std::fs::create_dir_all( + skill_path + .parent() + .ok_or("skill path should have a parent")?, + )?; + std::fs::write( + &skill_path, + "---\nname: demo\ndescription: Demo skill.\n---\n# Demo\n\nUse the demo skill.\n", + )?; + let config = ConfigBuilder::default() + .codex_home(codex_home.clone()) + .fallback_cwd(Some(codex_home.clone())) + .build() + .await?; + + let mut builder = ExtensionRegistryBuilder::new(); + install(&mut builder); + let registry = builder.build(); + let session_store = ExtensionData::new("session"); + let thread_store = ExtensionData::new("thread"); + let session_source = SessionSource::Cli; + registry.thread_lifecycle_contributors()[0] + .on_thread_start(ThreadStartInput { + config: &config, + session_source: &session_source, + persistent_thread_state_available: true, + session_store: &session_store, + thread_store: &thread_store, + }) + .await; + + let manager = SkillsManager::new(config.codex_home.clone(), config.bundled_skills_enabled()); + let input = SkillsLoadInput::new( + config.cwd.clone(), + Vec::new(), + config.config_layer_stack.clone(), + config.bundled_skills_enabled(), + ); + let loaded_skills = Arc::new(manager.skills_for_config(&input, /*fs*/ None).await); + let skill_path_string = loaded_skills + .skills + .iter() + .find(|skill| skill.name == "demo") + .ok_or("demo skill should load")? + .path_to_skills_md + .to_string_lossy() + .into_owned(); + let skill_prompt_path = skill_path_string.replace('\\', "/"); + let turn_store = ExtensionData::new("turn-1"); + turn_store.insert(HostLoadedSkills::new(Arc::clone(&loaded_skills))); + + let fragments = registry.turn_input_contributors()[0] + .contribute( + TurnInputContext { + turn_id: "turn-1".to_string(), + user_input: vec![UserInput::Text { + text: "$demo".to_string(), + text_elements: Vec::new(), + }], + environments: Vec::new(), + }, + &session_store, + &thread_store, + &turn_store, + ) + .await; + + assert_eq!(2, fragments.len()); + assert!(fragments[0].render().contains("demo")); + assert!(fragments[0].render().contains(&skill_prompt_path)); + assert_eq!("user", fragments[1].role()); + assert!(fragments[1].render().contains("demo")); + assert!(fragments[1].render().contains("# Demo")); + assert!(fragments[1].render().contains(&skill_prompt_path)); + let injected_host_skill_prompts = turn_store + .get::() + .ok_or("host skill prompt marker should be set")?; + assert!(injected_host_skill_prompts.contains_path(&skill_path_string)); + + std::fs::remove_dir_all(codex_home)?; + Ok(()) +} + #[tokio::test] async fn installed_extension_injects_available_catalog_and_selected_entrypoint() -> TestResult { let host_read_requests = Arc::new(Mutex::new(Vec::new())); @@ -115,15 +209,12 @@ async fn installed_extension_injects_available_catalog_and_selected_entrypoint() assert!(fragments[1].render().contains("lint-fix")); assert!(fragments[1].render().contains("# Lint Fix")); assert_eq!( - vec![SkillReadRequest { - authority: SkillAuthority::new(SkillSourceKind::Host, "host"), - package: SkillPackageId("host/lint-fix".to_string()), - resource: SkillResourceId("lint-fix/SKILL.md".to_string()), - }], - host_read_requests - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .clone() + vec![( + SkillAuthority::new(SkillSourceKind::Host, "host"), + SkillPackageId("host/lint-fix".to_string()), + SkillResourceId("lint-fix/SKILL.md".to_string()), + )], + read_request_keys(&host_read_requests) ); assert!( remote_read_requests @@ -220,15 +311,12 @@ async fn prompt_hidden_skill_can_still_be_invoked() -> TestResult { assert!(!catalog_fragment.contains("hidden-skill")); assert!(fragments[1].render().contains("hidden-skill")); assert_eq!( - vec![SkillReadRequest { - authority: SkillAuthority::new(SkillSourceKind::Host, "host"), - package: SkillPackageId("host/hidden-skill".to_string()), - resource: SkillResourceId("hidden-skill/SKILL.md".to_string()), - }], - read_requests - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .clone() + vec![( + SkillAuthority::new(SkillSourceKind::Host, "host"), + SkillPackageId("host/hidden-skill".to_string()), + SkillResourceId("hidden-skill/SKILL.md".to_string()), + )], + read_request_keys(&read_requests) ); Ok(()) @@ -287,14 +375,35 @@ fn test_entry( } async fn default_config() -> std::io::Result { - let id = NEXT_CODEX_HOME_ID.fetch_add(1, Ordering::Relaxed); - let codex_home = std::env::temp_dir().join(format!( - "codex-skills-extension-test-{}-{id}", - std::process::id(), - )); + let codex_home = test_codex_home(); std::fs::create_dir_all(&codex_home)?; let config = Config::load_default_with_cli_overrides_for_codex_home(codex_home.clone(), vec![]).await?; std::fs::remove_dir_all(codex_home)?; Ok(config) } + +fn test_codex_home() -> PathBuf { + let id = NEXT_CODEX_HOME_ID.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "codex-skills-extension-test-{}-{id}", + std::process::id(), + )) +} + +fn read_request_keys( + requests: &Arc>>, +) -> Vec<(SkillAuthority, SkillPackageId, SkillResourceId)> { + requests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .map(|request| { + ( + request.authority.clone(), + request.package.clone(), + request.resource.clone(), + ) + }) + .collect() +} From 7da4af622ffc68d7b687e61ba8f19163af50fab8 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 4 Jun 2026 09:53:08 -0400 Subject: [PATCH 04/41] Optimize unbounded byte scans with memchr (#26265) ## Summary This PR adds `memchr` for some low-hanging performance improvements (namely, in MCP stdio, Ollama streaming, and full message-history newline counts). Codex produced the following release benchmarks: | Operation | Before | After | Speedup | | --- | ---: | ---: | ---: | | MCP 1 MiB chunked line | 2.172 s | 3.984 ms | 545x | | Ollama 1 MiB chunked line | 1.673 s | 2.790 ms | 600x | | Count newlines in 10 MiB history | 132.83 ms | 20.05 ms | 6.6x | With a "real" MCP setup (`ExecutorStdioServerLauncher` started a Python MCP server, completed `initialize`, requested `tools/list`, and deserialized a 1 MiB tool description over newline-delimited stdio), it's about 16x faster end-to-end: | Branch | 50 calls | Per call | | --- | ---: | ---: | | `main` | 862.53 ms | 17.25 ms | | this branch | 53.89 ms | 1.08 ms | `memchr` is already in our dependency tree and extremely widely used for this kind of optimized scanning. --- codex-rs/Cargo.lock | 3 + codex-rs/Cargo.toml | 1 + codex-rs/message-history/Cargo.toml | 1 + codex-rs/message-history/src/lib.rs | 6 +- codex-rs/message-history/src/tests.rs | 22 +++++ codex-rs/ollama/Cargo.toml | 1 + codex-rs/ollama/src/client.rs | 52 +++++++++++- codex-rs/ollama/src/lib.rs | 1 + codex-rs/ollama/src/line_buffer.rs | 32 ++++++++ codex-rs/ollama/src/line_buffer_tests.rs | 42 ++++++++++ codex-rs/rmcp-client/Cargo.toml | 1 + .../src/executor_process_transport.rs | 81 +++++++++++++------ .../src/executor_process_transport_tests.rs | 72 +++++++++++++++++ 13 files changed, 284 insertions(+), 31 deletions(-) create mode 100644 codex-rs/ollama/src/line_buffer.rs create mode 100644 codex-rs/ollama/src/line_buffer_tests.rs create mode 100644 codex-rs/rmcp-client/src/executor_process_transport_tests.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index e9c171d778..f5e8b56230 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3293,6 +3293,7 @@ name = "codex-message-history" version = "0.0.0" dependencies = [ "codex-config", + "memchr", "pretty_assertions", "serde", "serde_json", @@ -3407,6 +3408,7 @@ dependencies = [ "codex-core", "codex-model-provider-info", "futures", + "memchr", "pretty_assertions", "reqwest 0.12.28", "semver", @@ -3576,6 +3578,7 @@ dependencies = [ "codex-utils-pty", "futures", "keyring", + "memchr", "oauth2", "pretty_assertions", "reqwest 0.13.4", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 59d161958d..15e0e2c010 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -318,6 +318,7 @@ libc = "0.2.182" log = "0.4" lru = "0.16.3" maplit = "1.0.2" +memchr = "2.7.6" mime_guess = "2.0.5" multimap = "0.10.0" notify = "8.2.0" diff --git a/codex-rs/message-history/Cargo.toml b/codex-rs/message-history/Cargo.toml index b67933d1d7..176b957909 100644 --- a/codex-rs/message-history/Cargo.toml +++ b/codex-rs/message-history/Cargo.toml @@ -14,6 +14,7 @@ workspace = true [dependencies] codex-config = { workspace = true } +memchr = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tokio = { workspace = true, features = ["fs", "io-util", "rt"] } diff --git a/codex-rs/message-history/src/lib.rs b/codex-rs/message-history/src/lib.rs index 0d85cb8fe3..18a6d791ee 100644 --- a/codex-rs/message-history/src/lib.rs +++ b/codex-rs/message-history/src/lib.rs @@ -26,6 +26,7 @@ use std::io::Write; use std::path::Path; use std::path::PathBuf; +use memchr::memchr_iter; use serde::Deserialize; use serde::Serialize; @@ -43,6 +44,7 @@ use std::os::unix::fs::PermissionsExt; /// Filename that stores the message history inside `~/.codex`. const HISTORY_FILENAME: &str = "history.jsonl"; +const HISTORY_READ_BUFFER_SIZE: usize = 8192; /// When history exceeds the hard cap, trim it down to this fraction of `max_bytes`. const HISTORY_SOFT_CAP_RATIO: f64 = 0.8; @@ -330,13 +332,13 @@ async fn history_metadata_for_file(path: &Path) -> (u64, usize) { }; // Count newline bytes. - let mut buf = [0u8; 8192]; + let mut buf = [0u8; HISTORY_READ_BUFFER_SIZE]; let mut count = 0usize; loop { match file.read(&mut buf).await { Ok(0) => break, Ok(n) => { - count += buf[..n].iter().filter(|&&b| b == b'\n').count(); + count += memchr_iter(b'\n', &buf[..n]).count(); } Err(_) => return (log_id, 0), } diff --git a/codex-rs/message-history/src/tests.rs b/codex-rs/message-history/src/tests.rs index 88f0b7e007..f4c7e66e1c 100644 --- a/codex-rs/message-history/src/tests.rs +++ b/codex-rs/message-history/src/tests.rs @@ -41,6 +41,28 @@ async fn lookup_reads_history_entries() { assert_eq!(second_entry, entries[1]); } +#[tokio::test] +async fn history_metadata_counts_newlines_across_read_boundaries() { + let temp_dir = TempDir::new().expect("create temp dir"); + let history_path = temp_dir.path().join(HISTORY_FILENAME); + let mut contents = vec![b'x'; 3 * HISTORY_READ_BUFFER_SIZE + 1]; + let newline_offsets = [ + 0, + HISTORY_READ_BUFFER_SIZE - 1, + HISTORY_READ_BUFFER_SIZE, + 2 * HISTORY_READ_BUFFER_SIZE, + contents.len() - 2, + ]; + for offset in newline_offsets { + contents[offset] = b'\n'; + } + std::fs::write(&history_path, contents).expect("write history file"); + + let (_, count) = history_metadata_for_file(&history_path).await; + + assert_eq!(count, newline_offsets.len()); +} + #[tokio::test] async fn lookup_uses_stable_log_id_after_appends() { let temp_dir = TempDir::new().expect("create temp dir"); diff --git a/codex-rs/ollama/Cargo.toml b/codex-rs/ollama/Cargo.toml index 5d30fbda28..42e32159f2 100644 --- a/codex-rs/ollama/Cargo.toml +++ b/codex-rs/ollama/Cargo.toml @@ -18,6 +18,7 @@ bytes = { workspace = true } codex-core = { workspace = true } codex-model-provider-info = { workspace = true } futures = { workspace = true } +memchr = { workspace = true } reqwest = { workspace = true, features = ["json", "stream"] } semver = { workspace = true } serde_json = { workspace = true } diff --git a/codex-rs/ollama/src/client.rs b/codex-rs/ollama/src/client.rs index 96601d0862..232c146836 100644 --- a/codex-rs/ollama/src/client.rs +++ b/codex-rs/ollama/src/client.rs @@ -1,4 +1,3 @@ -use bytes::BytesMut; use futures::StreamExt; use futures::stream::BoxStream; use semver::Version; @@ -6,6 +5,7 @@ use serde_json::Value as JsonValue; use std::collections::VecDeque; use std::io; +use crate::line_buffer::LineBuffer; use crate::parser::pull_events_from_value; use crate::pull::PullEvent; use crate::pull::PullProgressReporter; @@ -174,7 +174,7 @@ impl OllamaClient { } let mut stream = resp.bytes_stream(); - let mut buf = BytesMut::new(); + let mut buf = LineBuffer::default(); let _pending: VecDeque = VecDeque::new(); // Using an async stream adaptor backed by unfold-like manual loop. @@ -183,8 +183,7 @@ impl OllamaClient { match chunk { Ok(bytes) => { buf.extend_from_slice(&bytes); - while let Some(pos) = buf.iter().position(|b| *b == b'\n') { - let line = buf.split_to(pos + 1); + while let Some(line) = buf.take_line() { if let Ok(text) = std::str::from_utf8(&line) { let text = text.trim(); if text.is_empty() { continue; } @@ -263,6 +262,7 @@ impl OllamaClient { #[cfg(test)] mod tests { use super::*; + use assert_matches::assert_matches; use pretty_assertions::assert_eq; // Happy-path tests using a mock HTTP server; skip if sandbox network is disabled. @@ -333,6 +333,50 @@ mod tests { assert_eq!(version, Some(Version::new(0, 14, 1))); } + #[tokio::test] + async fn test_pull_model_stream_parses_large_json_lines() { + if std::env::var(codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { + tracing::info!( + "{} set; skipping test_pull_model_stream_parses_large_json_lines", + codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR + ); + return; + } + + let server = wiremock::MockServer::start().await; + let body = format!( + "{}\n{}\n", + serde_json::json!({ + "status": "pulling layers", + "padding": "x".repeat(128 * 1024), + }), + serde_json::json!({"status": "complete"}), + ); + wiremock::Mock::given(wiremock::matchers::method("POST")) + .and(wiremock::matchers::path("/api/pull")) + .respond_with( + wiremock::ResponseTemplate::new(200).set_body_raw(body, "application/x-ndjson"), + ) + .mount(&server) + .await; + + let client = OllamaClient::from_host_root(server.uri()); + let events = client + .pull_model_stream("test-model") + .await + .expect("start pull stream") + .collect::>() + .await; + + assert_matches!( + events.as_slice(), + [ + PullEvent::Status(pulling), + PullEvent::Status(complete), + ] if pulling == "pulling layers" && complete == "complete" + ); + } + #[tokio::test] async fn test_probe_server_happy_path_openai_compat_and_native() { if std::env::var(codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { diff --git a/codex-rs/ollama/src/lib.rs b/codex-rs/ollama/src/lib.rs index 75800845b4..d0de830bcc 100644 --- a/codex-rs/ollama/src/lib.rs +++ b/codex-rs/ollama/src/lib.rs @@ -1,4 +1,5 @@ mod client; +mod line_buffer; mod parser; mod pull; mod url; diff --git a/codex-rs/ollama/src/line_buffer.rs b/codex-rs/ollama/src/line_buffer.rs new file mode 100644 index 0000000000..fb3d4b98f9 --- /dev/null +++ b/codex-rs/ollama/src/line_buffer.rs @@ -0,0 +1,32 @@ +use bytes::BytesMut; +use memchr::memchr; + +#[derive(Default)] +#[cfg_attr(test, derive(Debug, PartialEq, Eq))] +pub(crate) struct LineBuffer { + bytes: BytesMut, + /// Prefix already scanned and known not to contain a newline. + scanned_len: usize, +} + +impl LineBuffer { + pub(crate) fn extend_from_slice(&mut self, bytes: &[u8]) { + self.bytes.extend_from_slice(bytes); + } + + pub(crate) fn take_line(&mut self) -> Option { + let Some(relative_index) = memchr(b'\n', &self.bytes[self.scanned_len..]) else { + self.scanned_len = self.bytes.len(); + return None; + }; + + let newline_index = self.scanned_len + relative_index; + let line = self.bytes.split_to(newline_index + 1); + self.scanned_len = 0; + Some(line) + } +} + +#[cfg(test)] +#[path = "line_buffer_tests.rs"] +mod tests; diff --git a/codex-rs/ollama/src/line_buffer_tests.rs b/codex-rs/ollama/src/line_buffer_tests.rs new file mode 100644 index 0000000000..58de07e864 --- /dev/null +++ b/codex-rs/ollama/src/line_buffer_tests.rs @@ -0,0 +1,42 @@ +use bytes::BytesMut; +use pretty_assertions::assert_eq; + +use super::LineBuffer; + +#[test] +fn searches_only_new_bytes_after_partial_line() { + let mut buffer = LineBuffer::default(); + + buffer.extend_from_slice(b"partial"); + assert_eq!(buffer.take_line(), None); + assert_eq!( + buffer, + LineBuffer { + bytes: BytesMut::from(&b"partial"[..]), + scanned_len: 7, + } + ); + + buffer.extend_from_slice(b" line"); + assert_eq!(buffer.take_line(), None); + assert_eq!( + buffer, + LineBuffer { + bytes: BytesMut::from(&b"partial line"[..]), + scanned_len: 12, + } + ); + + buffer.extend_from_slice(b"\nnext"); + assert_eq!( + buffer.take_line(), + Some(BytesMut::from(&b"partial line\n"[..])) + ); + assert_eq!( + buffer, + LineBuffer { + bytes: BytesMut::from(&b"next"[..]), + scanned_len: 0, + } + ); +} diff --git a/codex-rs/rmcp-client/Cargo.toml b/codex-rs/rmcp-client/Cargo.toml index 63c6d74e4a..e3417de70e 100644 --- a/codex-rs/rmcp-client/Cargo.toml +++ b/codex-rs/rmcp-client/Cargo.toml @@ -26,6 +26,7 @@ codex-utils-home-dir = { workspace = true } bytes = { workspace = true } futures = { workspace = true, default-features = false, features = ["std"] } keyring = { workspace = true, features = ["crypto-rust"] } +memchr = { workspace = true } oauth2 = "5" reqwest = { version = "0.13", default-features = false, features = [ "json", diff --git a/codex-rs/rmcp-client/src/executor_process_transport.rs b/codex-rs/rmcp-client/src/executor_process_transport.rs index 41f0b7660d..cff4f517ed 100644 --- a/codex-rs/rmcp-client/src/executor_process_transport.rs +++ b/codex-rs/rmcp-client/src/executor_process_transport.rs @@ -20,11 +20,11 @@ use std::future::Future; use std::io; -use std::mem::take; use std::sync::Arc; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; +use bytes::BytesMut; use codex_exec_server::ExecOutputStream; use codex_exec_server::ExecProcess; use codex_exec_server::ExecProcessEvent; @@ -32,6 +32,7 @@ use codex_exec_server::ExecProcessEventReceiver; use codex_exec_server::ProcessId; use codex_exec_server::ProcessOutputChunk; use codex_exec_server::WriteStatus; +use memchr::memchr; use rmcp::service::RoleClient; use rmcp::service::RxJsonRpcMessage; use rmcp::service::TxJsonRpcMessage; @@ -46,6 +47,42 @@ use tracing::warn; static PROCESS_COUNTER: AtomicUsize = AtomicUsize::new(1); +#[derive(Default)] +#[cfg_attr(test, derive(Debug, PartialEq, Eq))] +struct LineBuffer { + bytes: BytesMut, + /// Prefix already scanned and known not to contain a newline. + scanned_len: usize, +} + +impl LineBuffer { + fn extend_from_slice(&mut self, bytes: &[u8]) { + self.bytes.extend_from_slice(bytes); + } + + fn take_line(&mut self) -> Option { + let Some(relative_index) = memchr(b'\n', &self.bytes[self.scanned_len..]) else { + self.scanned_len = self.bytes.len(); + return None; + }; + + let newline_index = self.scanned_len + relative_index; + let mut line = self.bytes.split_to(newline_index + 1); + line.truncate(newline_index); + self.scanned_len = 0; + Some(line) + } + + fn take_remaining(&mut self) -> Option { + if self.bytes.is_empty() { + return None; + } + + self.scanned_len = 0; + Some(self.bytes.split()) + } +} + // Remote public implementation. /// A client-side rmcp transport backed by an executor-managed process. @@ -73,10 +110,10 @@ pub(super) struct ExecutorProcessTransport { /// Buffered child stdout bytes that have not yet formed a complete /// newline-delimited JSON-RPC message. - stdout: Vec, + stdout: LineBuffer, /// Buffered stderr bytes for diagnostic logging. - stderr: Vec, + stderr: LineBuffer, /// Whether the executor has reported process closure or a terminal /// subscription failure. Once closed, any remaining partial stdout line is @@ -105,8 +142,8 @@ impl ExecutorProcessTransport { process, events, program_name, - stdout: Vec::new(), - stderr: Vec::new(), + stdout: LineBuffer::default(), + stderr: LineBuffer::default(), closed: false, terminated: false, last_seq: 0, @@ -288,15 +325,10 @@ impl ExecutorProcessTransport { // so EOF after a complete JSON object behaves like local rmcp's // `decode_eof` handling. loop { - let line_end = self.stdout.iter().position(|byte| *byte == b'\n'); - let line = match (line_end, allow_partial && !self.stdout.is_empty()) { - (Some(index), _) => { - let mut line = self.stdout.drain(..=index).collect::>(); - line.pop(); - line - } - (None, true) => self.stdout.drain(..).collect(), - (None, false) => return None, + let line = match self.stdout.take_line() { + Some(line) => line, + None if allow_partial => self.stdout.take_remaining()?, + None => return None, }; let line = Self::trim_trailing_carriage_return(line); match from_slice::>(&line) { @@ -315,12 +347,8 @@ impl ExecutorProcessTransport { // Keep stderr line-oriented in logs so a chatty MCP server does not // produce one log record per byte chunk. self.stderr.extend_from_slice(bytes); - while let Some(index) = self.stderr.iter().position(|byte| *byte == b'\n') { - let mut line = self.stderr.drain(..=index).collect::>(); - line.pop(); - if line.last() == Some(&b'\r') { - line.pop(); - } + while let Some(line) = self.stderr.take_line() { + let line = Self::trim_trailing_carriage_return(line); info!( "MCP server stderr ({}): {}", self.program_name, @@ -330,10 +358,9 @@ impl ExecutorProcessTransport { } fn flush_stderr(&mut self) { - if self.stderr.is_empty() { + let Some(line) = self.stderr.take_remaining() else { return; - } - let line = take(&mut self.stderr); + }; info!( "MCP server stderr ({}): {}", self.program_name, @@ -341,14 +368,18 @@ impl ExecutorProcessTransport { ); } - fn trim_trailing_carriage_return(mut line: Vec) -> Vec { + fn trim_trailing_carriage_return(mut line: BytesMut) -> BytesMut { if line.last() == Some(&b'\r') { - line.pop(); + line.truncate(line.len() - 1); } line } } +#[cfg(test)] +#[path = "executor_process_transport_tests.rs"] +mod tests; + impl Drop for ExecutorProcessTransport { fn drop(&mut self) { if self.terminated { diff --git a/codex-rs/rmcp-client/src/executor_process_transport_tests.rs b/codex-rs/rmcp-client/src/executor_process_transport_tests.rs new file mode 100644 index 0000000000..aeef11a764 --- /dev/null +++ b/codex-rs/rmcp-client/src/executor_process_transport_tests.rs @@ -0,0 +1,72 @@ +use bytes::BytesMut; +use pretty_assertions::assert_eq; + +use super::LineBuffer; + +#[test] +fn searches_only_new_bytes_after_partial_line() { + let mut buffer = LineBuffer::default(); + + buffer.extend_from_slice(b"partial"); + assert_eq!(buffer.take_line(), None); + assert_eq!( + buffer, + LineBuffer { + bytes: BytesMut::from(&b"partial"[..]), + scanned_len: 7, + } + ); + + buffer.extend_from_slice(b" line"); + assert_eq!(buffer.take_line(), None); + assert_eq!( + buffer, + LineBuffer { + bytes: BytesMut::from(&b"partial line"[..]), + scanned_len: 12, + } + ); + + buffer.extend_from_slice(b"\nnext"); + assert_eq!( + buffer.take_line(), + Some(BytesMut::from(&b"partial line"[..])) + ); + assert_eq!( + buffer, + LineBuffer { + bytes: BytesMut::from(&b"next"[..]), + scanned_len: 0, + } + ); +} + +#[test] +fn splits_multiple_lines_and_retains_partial_tail() { + let mut buffer = LineBuffer::default(); + buffer.extend_from_slice(b"first\nsecond\npartial"); + + assert_eq!(buffer.take_line(), Some(BytesMut::from(&b"first"[..]))); + assert_eq!(buffer.take_line(), Some(BytesMut::from(&b"second"[..]))); + assert_eq!(buffer.take_line(), None); + assert_eq!( + buffer, + LineBuffer { + bytes: BytesMut::from(&b"partial"[..]), + scanned_len: 7, + } + ); +} + +#[test] +fn takes_unterminated_remaining_bytes_at_eof() { + let mut buffer = LineBuffer::default(); + buffer.extend_from_slice(b"remaining"); + assert_eq!(buffer.take_line(), None); + + assert_eq!( + buffer.take_remaining(), + Some(BytesMut::from(&b"remaining"[..])) + ); + assert_eq!(buffer, LineBuffer::default()); +} From d81fcdf8ef3f2857db1a51b83a012638a7ecf0fb Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Thu, 4 Jun 2026 10:12:08 -0500 Subject: [PATCH 05/41] Reduce SQLite contention from OpenTelemetry SDK debug logs (#26396) ## Summary - skip `opentelemetry_sdk` DEBUG and TRACE events before formatting or queueing them for the SQLite log sink - preserve INFO, WARN, and ERROR events from the SDK, along with TRACE events from application targets - add a persistence-level regression test for the target and level policy ## Why OpenTelemetry's batch log processor emits internal `BatchLogProcessor.ExportingDueToTimer` meta-events every second per Codex process. In measured high-fanout `logs_2.sqlite` databases, low-level `opentelemetry_sdk` events accounted for over 30% of retained rows (30-60% on the machines of people I asked to check). Persisting this SDK bookkeeping across many processes adds substantial write volume and contention without representing application activity. ## Validation - `just test -p codex-state` (132/132 tests passed, plus bench smoke) - `just fix -p codex-state` - `just fmt` --- codex-rs/state/src/log_db.rs | 15 +++++++ codex-rs/state/src/log_db_filter_tests.rs | 53 +++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 codex-rs/state/src/log_db_filter_tests.rs diff --git a/codex-rs/state/src/log_db.rs b/codex-rs/state/src/log_db.rs index 6024d6bdaa..71d2afbc01 100644 --- a/codex-rs/state/src/log_db.rs +++ b/codex-rs/state/src/log_db.rs @@ -189,6 +189,17 @@ where fn on_event(&self, event: &Event<'_>, ctx: tracing_subscriber::layer::Context<'_, S>) { let metadata = event.metadata(); + // The SDK emits DEBUG timer meta-events every second per process; these + // were over 30% of retained logs in measured high-fanout Codex environments. + if metadata.target() == "opentelemetry_sdk" + && matches!( + *metadata.level(), + tracing::Level::TRACE | tracing::Level::DEBUG + ) + { + return; + } + let mut visitor = MessageVisitor::default(); event.record(&mut visitor); let thread_id = visitor @@ -451,6 +462,10 @@ impl Visit for MessageVisitor { } } +#[cfg(test)] +#[path = "log_db_filter_tests.rs"] +mod filter_tests; + #[cfg(test)] mod tests { use std::io; diff --git a/codex-rs/state/src/log_db_filter_tests.rs b/codex-rs/state/src/log_db_filter_tests.rs new file mode 100644 index 0000000000..36ee27aba8 --- /dev/null +++ b/codex-rs/state/src/log_db_filter_tests.rs @@ -0,0 +1,53 @@ +use pretty_assertions::assert_eq; +use tracing_subscriber::filter::Targets; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use uuid::Uuid; + +use super::*; + +#[tokio::test] +async fn sqlite_sink_drops_low_level_opentelemetry_sdk_logs() { + let codex_home = + std::env::temp_dir().join(format!("codex-state-log-db-filter-{}", Uuid::new_v4())); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string()) + .await + .expect("initialize runtime"); + let layer = start(runtime.clone()); + + let guard = tracing_subscriber::registry() + .with( + layer + .clone() + .with_filter(Targets::new().with_default(tracing::Level::TRACE)), + ) + .set_default(); + + tracing::trace!(target: "opentelemetry_sdk", "dropped-trace"); + tracing::debug!(target: "opentelemetry_sdk", "dropped-debug"); + tracing::info!(target: "opentelemetry_sdk", "retained-info"); + tracing::trace!(target: "codex_state", "retained-trace"); + + layer.flush().await; + drop(guard); + + let logs = runtime + .query_logs(&crate::LogQuery::default()) + .await + .expect("query logs after flush"); + assert_eq!( + logs.iter() + .map(|row| ( + row.level.as_str(), + row.target.as_str(), + row.message.as_deref() + )) + .collect::>(), + vec![ + ("INFO", "opentelemetry_sdk", Some("retained-info")), + ("TRACE", "codex_state", Some("retained-trace")), + ] + ); + + let _ = tokio::fs::remove_dir_all(codex_home).await; +} From 4ae7930f58c980c1064f5c2f7a2e38b1058e0410 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 4 Jun 2026 11:21:40 -0400 Subject: [PATCH 06/41] Load plugin hooks without other plugin capabilities (#26272) ## Summary `hooks/list` only consumes plugin hook declarations, but previously loaded every enabled plugin's skills, MCP configuration, apps, and capability summary before discarding them. In a local benchmark, this reduced `hooks/list` latency by over 100ms (e.g., from 594 to 467ms on startup, and 168 to 16ms when making a `hooks/list` call later in the same TUI session). This is on the critical path to rendering the TUI, so every 10s of ms should be eyed skeptically (IMO). This change adds a hook-specific plugin loading path that preserves plugin enablement, remote/local conflict resolution, deterministic ordering, manifest resolution, and hook-loading warnings while skipping unrelated capabilities. (I think there's room for a more general design here that allows you to project the capabilities you need at load-time, but that seems unnecessary right now.) --- codex-rs/app-server/src/request_processors.rs | 1 - .../request_processors/catalog_processor.rs | 10 +- codex-rs/core-plugins/src/lib.rs | 1 + codex-rs/core-plugins/src/loader.rs | 151 +++++++++++------ codex-rs/core-plugins/src/loader_tests.rs | 153 ++++++++++++++++++ codex-rs/core-plugins/src/manager.rs | 20 +++ codex-rs/core-plugins/src/manager_tests.rs | 47 ++++++ 7 files changed, 330 insertions(+), 53 deletions(-) diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 36008ce0b9..2f3c87d868 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -303,7 +303,6 @@ use codex_core::windows_sandbox::WindowsSandboxSetupRequest; use codex_core::windows_sandbox::sandbox_setup_is_complete; use codex_core_plugins::PluginInstallError as CorePluginInstallError; use codex_core_plugins::PluginInstallRequest; -use codex_core_plugins::PluginLoadOutcome; use codex_core_plugins::PluginReadRequest; use codex_core_plugins::PluginUninstallError as CorePluginUninstallError; use codex_core_plugins::loader::load_plugin_apps; diff --git a/codex-rs/app-server/src/request_processors/catalog_processor.rs b/codex-rs/app-server/src/request_processors/catalog_processor.rs index 6f17e4614f..bbba0bae62 100644 --- a/codex-rs/app-server/src/request_processors/catalog_processor.rs +++ b/codex-rs/app-server/src/request_processors/catalog_processor.rs @@ -645,20 +645,20 @@ impl CatalogRequestProcessor { .await; let plugins_enabled = config.features.enabled(Feature::Plugins) && workspace_codex_plugins_enabled; - let plugin_outcome = if plugins_enabled { + let plugin_hooks = if plugins_enabled { let plugins_input = config.plugins_config_input(); plugins_manager - .plugins_for_layer_stack(&config.config_layer_stack, &plugins_input) + .plugin_hooks_for_layer_stack(&config.config_layer_stack, &plugins_input) .await } else { - PluginLoadOutcome::default() + codex_core_plugins::PluginHookLoadOutcome::default() }; let hooks = codex_hooks::list_hooks(codex_hooks::HooksConfig { feature_enabled: config.features.enabled(Feature::CodexHooks), bypass_hook_trust: config.bypass_hook_trust, config_layer_stack: Some(config.config_layer_stack), - plugin_hook_sources: plugin_outcome.effective_plugin_hook_sources(), - plugin_hook_load_warnings: plugin_outcome.effective_plugin_hook_warnings(), + plugin_hook_sources: plugin_hooks.hook_sources, + plugin_hook_load_warnings: plugin_hooks.hook_load_warnings, ..Default::default() }); data.push(codex_app_server_protocol::HooksListEntry { diff --git a/codex-rs/core-plugins/src/lib.rs b/codex-rs/core-plugins/src/lib.rs index 4728a8c6f8..98cb73e127 100644 --- a/codex-rs/core-plugins/src/lib.rs +++ b/codex-rs/core-plugins/src/lib.rs @@ -26,6 +26,7 @@ pub type PluginLoadOutcome = codex_plugin::PluginLoadOutcome, + pub hook_load_warnings: Vec, +} + +enum PluginLoadScope<'a> { + AllCapabilities { + restriction_product: Option, + skill_config_rules: &'a SkillConfigRules, + }, + HooksOnly, +} + #[derive(Clone, Copy, PartialEq, Eq)] enum NonCuratedCacheRefreshMode { IfVersionChanged, @@ -117,6 +132,26 @@ pub async fn load_plugins_from_layer_stack( prefer_remote_curated_conflicts: bool, ) -> PluginLoadOutcome { let skill_config_rules = skill_config_rules_from_stack(config_layer_stack); + load_plugins_from_layer_stack_with_scope( + config_layer_stack, + extra_plugins, + store, + prefer_remote_curated_conflicts, + PluginLoadScope::AllCapabilities { + restriction_product, + skill_config_rules: &skill_config_rules, + }, + ) + .await +} + +async fn load_plugins_from_layer_stack_with_scope( + config_layer_stack: &ConfigLayerStack, + extra_plugins: HashMap, + store: &PluginStore, + prefer_remote_curated_conflicts: bool, + scope: PluginLoadScope<'_>, +) -> PluginLoadOutcome { let configured_plugins = merge_configured_plugins_with_remote_installed( configured_plugins_from_stack(config_layer_stack), extra_plugins, @@ -129,14 +164,7 @@ pub async fn load_plugins_from_layer_stack( let mut plugins = Vec::with_capacity(configured_plugins.len()); let mut seen_mcp_server_names = HashMap::::new(); for (configured_name, plugin) in configured_plugins { - let loaded_plugin = load_plugin( - configured_name.clone(), - &plugin, - store, - restriction_product, - &skill_config_rules, - ) - .await; + let loaded_plugin = load_plugin(configured_name.clone(), &plugin, store, &scope).await; for name in loaded_plugin.mcp_servers.keys() { if let Some(previous_plugin) = seen_mcp_server_names.insert(name.clone(), configured_name.clone()) @@ -155,6 +183,27 @@ pub async fn load_plugins_from_layer_stack( PluginLoadOutcome::from_plugins(plugins) } +/// Load hooks from enabled plugins without loading their skills, MCP servers, or apps. +pub async fn load_plugin_hooks_from_layer_stack( + config_layer_stack: &ConfigLayerStack, + extra_plugins: HashMap, + store: &PluginStore, + prefer_remote_curated_conflicts: bool, +) -> PluginHookLoadOutcome { + let outcome = load_plugins_from_layer_stack_with_scope( + config_layer_stack, + extra_plugins, + store, + prefer_remote_curated_conflicts, + PluginLoadScope::HooksOnly, + ) + .await; + PluginHookLoadOutcome { + hook_sources: outcome.effective_plugin_hook_sources(), + hook_load_warnings: outcome.effective_plugin_hook_warnings(), + } +} + fn merge_configured_plugins_with_remote_installed( mut configured_plugins: HashMap, extra_plugins: HashMap, @@ -557,8 +606,7 @@ async fn load_plugin( config_name: String, plugin: &PluginConfig, store: &PluginStore, - restriction_product: Option, - skill_config_rules: &SkillConfigRules, + scope: &PluginLoadScope<'_>, ) -> LoadedPlugin { let plugin_id = PluginId::parse(&config_name); let active_plugin_root = plugin_id @@ -616,46 +664,55 @@ async fn load_plugin( }; let manifest_paths = &manifest.paths; - loaded_plugin.manifest_name = manifest - .interface - .as_ref() - .and_then(|interface| interface.display_name.as_deref()) - .map(str::trim) - .filter(|display_name| !display_name.is_empty()) - .map(str::to_string) - .or_else(|| Some(manifest.name.clone())); - loaded_plugin.manifest_description = manifest.description.clone(); - loaded_plugin.skill_roots = plugin_skill_roots(&plugin_root, manifest_paths); - let resolved_skills = load_plugin_skills( - &plugin_root, - &loaded_plugin_id, - manifest_paths, - restriction_product, - skill_config_rules, - ) - .await; - let has_enabled_skills = resolved_skills.has_enabled_skills(); - loaded_plugin.disabled_skill_paths = resolved_skills.disabled_skill_paths; - loaded_plugin.has_enabled_skills = has_enabled_skills; - let mut mcp_servers = HashMap::new(); - for mcp_config_path in plugin_mcp_config_paths(plugin_root.as_path(), manifest_paths) { - let plugin_mcp = load_mcp_servers_from_file(plugin_root.as_path(), &mcp_config_path).await; - for (name, mut config) in plugin_mcp.mcp_servers { - if let Some(policy) = plugin.mcp_servers.get(&name) { - apply_plugin_mcp_server_policy(&mut config, policy); - } - if mcp_servers.insert(name.clone(), config).is_some() { - warn!( - plugin = %plugin_root.display(), - path = %mcp_config_path.display(), - server = name, - "plugin MCP file overwrote an earlier server definition" - ); + match scope { + PluginLoadScope::AllCapabilities { + restriction_product, + skill_config_rules, + } => { + loaded_plugin.manifest_name = manifest + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()) + .map(str::trim) + .filter(|display_name| !display_name.is_empty()) + .map(str::to_string) + .or_else(|| Some(manifest.name.clone())); + loaded_plugin.manifest_description = manifest.description.clone(); + loaded_plugin.skill_roots = plugin_skill_roots(&plugin_root, manifest_paths); + let resolved_skills = load_plugin_skills( + &plugin_root, + &loaded_plugin_id, + manifest_paths, + *restriction_product, + skill_config_rules, + ) + .await; + let has_enabled_skills = resolved_skills.has_enabled_skills(); + loaded_plugin.disabled_skill_paths = resolved_skills.disabled_skill_paths; + loaded_plugin.has_enabled_skills = has_enabled_skills; + let mut mcp_servers = HashMap::new(); + for mcp_config_path in plugin_mcp_config_paths(plugin_root.as_path(), manifest_paths) { + let plugin_mcp = + load_mcp_servers_from_file(plugin_root.as_path(), &mcp_config_path).await; + for (name, mut config) in plugin_mcp.mcp_servers { + if let Some(policy) = plugin.mcp_servers.get(&name) { + apply_plugin_mcp_server_policy(&mut config, policy); + } + if mcp_servers.insert(name.clone(), config).is_some() { + warn!( + plugin = %plugin_root.display(), + path = %mcp_config_path.display(), + server = name, + "plugin MCP file overwrote an earlier server definition" + ); + } + } } + loaded_plugin.mcp_servers = mcp_servers; + loaded_plugin.apps = load_plugin_apps(plugin_root.as_path()).await; } + PluginLoadScope::HooksOnly => {} } - loaded_plugin.mcp_servers = mcp_servers; - loaded_plugin.apps = load_plugin_apps(plugin_root.as_path()).await; let (hook_sources, hook_load_warnings) = load_plugin_hooks( &plugin_root, &loaded_plugin_id, diff --git a/codex-rs/core-plugins/src/loader_tests.rs b/codex-rs/core-plugins/src/loader_tests.rs index a360d54ccb..2e89776b49 100644 --- a/codex-rs/core-plugins/src/loader_tests.rs +++ b/codex-rs/core-plugins/src/loader_tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::manifest::load_plugin_manifest; +use crate::test_support::write_file; use codex_config::ConfigLayerEntry; use codex_config::ConfigLayerSource; use codex_config::ConfigRequirements; @@ -65,6 +66,158 @@ fn configured_plugins_from_stack_merges_user_layers() { ); } +#[tokio::test] +async fn hooks_only_scope_shares_plugin_resolution_without_loading_other_capabilities() { + let temp_dir = TempDir::new().expect("tempdir"); + let plugin_root = temp_dir.path().join("plugins/cache/test/valid/local"); + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"valid"}"#, + ); + write_file( + &plugin_root.join("skills/example/SKILL.md"), + "---\nname: example\ndescription: example skill\n---\n", + ); + write_file( + &plugin_root.join(".mcp.json"), + r#"{"mcpServers":{"example":{"command":"echo"}}}"#, + ); + write_file( + &plugin_root.join(".app.json"), + r#"{"apps":{"example":{"id":"connector_example"}}}"#, + ); + write_file( + &plugin_root.join("hooks/hooks.json"), + r#"{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "echo startup" + } + ] + } + ] + } +}"#, + ); + + let disabled_root = temp_dir.path().join("plugins/cache/test/disabled/local"); + write_file( + &disabled_root.join(".codex-plugin/plugin.json"), + r#"{"name":"disabled"}"#, + ); + write_file( + &disabled_root.join("hooks/hooks.json"), + r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"echo disabled"}]}]}}"#, + ); + + let malformed_root = temp_dir.path().join("plugins/cache/test/malformed/local"); + write_file( + &malformed_root.join(".codex-plugin/plugin.json"), + "not valid json", + ); + + let warning_root = temp_dir.path().join("plugins/cache/test/warning/local"); + write_file( + &warning_root.join(".codex-plugin/plugin.json"), + r#"{"name":"warning"}"#, + ); + write_file(&warning_root.join("hooks/hooks.json"), "not valid json"); + + let stack = ConfigLayerStack::new( + vec![user_layer( + user_config_path(&temp_dir, "config.toml"), + r#" +[plugins."valid@test"] +enabled = true + +[plugins."disabled@test"] +enabled = false + +[plugins.invalid] +enabled = true + +[plugins."malformed@test"] +enabled = true + +[plugins."missing@test"] +enabled = true + +[plugins."warning@test"] +enabled = true +"#, + )], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("valid config layer stack"); + let store = PluginStore::new(temp_dir.path().to_path_buf()); + + let full = load_plugins_from_layer_stack( + &stack, + HashMap::new(), + &store, + Some(Product::Codex), + /*prefer_remote_curated_conflicts*/ false, + ) + .await; + let hooks_only = load_plugins_from_layer_stack_with_scope( + &stack, + HashMap::new(), + &store, + /*prefer_remote_curated_conflicts*/ false, + PluginLoadScope::HooksOnly, + ) + .await; + + let validation_state = |outcome: &PluginLoadOutcome| { + outcome + .plugins() + .iter() + .map(|plugin| { + ( + plugin.config_name.clone(), + plugin.enabled, + plugin.root.clone(), + plugin.error.clone(), + ) + }) + .collect::>() + }; + assert_eq!(validation_state(&hooks_only), validation_state(&full)); + assert_eq!( + hooks_only.effective_plugin_hook_sources(), + full.effective_plugin_hook_sources() + ); + assert_eq!( + hooks_only.effective_plugin_hook_warnings(), + full.effective_plugin_hook_warnings() + ); + + let full_valid = full + .plugins() + .iter() + .find(|plugin| plugin.config_name == "valid@test") + .expect("full load should include valid plugin"); + assert!(full_valid.manifest_name.is_some()); + assert!(!full_valid.skill_roots.is_empty()); + assert!(!full_valid.mcp_servers.is_empty()); + assert!(!full_valid.apps.is_empty()); + + let hooks_only_valid = hooks_only + .plugins() + .iter() + .find(|plugin| plugin.config_name == "valid@test") + .expect("hooks-only load should include valid plugin"); + assert_eq!(hooks_only_valid.manifest_name, None); + assert!(hooks_only_valid.skill_roots.is_empty()); + assert!(hooks_only_valid.mcp_servers.is_empty()); + assert!(hooks_only_valid.apps.is_empty()); +} + #[test] fn plugin_mcp_file_supports_mcp_servers_object_format() { let parsed = serde_json::from_str::( diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 8022c6c86d..08f668d130 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -2,11 +2,13 @@ use super::PluginLoadOutcome; use super::startup_remote_sync::start_startup_remote_plugin_sync_once; use crate::OPENAI_CURATED_MARKETPLACE_NAME; use crate::installed_marketplaces::installed_marketplace_roots_from_layer_stack; +use crate::loader::PluginHookLoadOutcome; use crate::loader::configured_curated_plugin_ids_from_codex_home; use crate::loader::curated_plugin_cache_version; use crate::loader::installed_plugin_telemetry_metadata; use crate::loader::load_plugin_apps; use crate::loader::load_plugin_hooks; +use crate::loader::load_plugin_hooks_from_layer_stack; use crate::loader::load_plugin_mcp_servers; use crate::loader::load_plugin_skills; use crate::loader::load_plugins_from_layer_stack; @@ -543,6 +545,24 @@ impl PluginsManager { .await } + /// Resolve plugin hooks for a config layer stack without loading other plugin capabilities. + pub async fn plugin_hooks_for_layer_stack( + &self, + config_layer_stack: &ConfigLayerStack, + config: &PluginsConfigInput, + ) -> PluginHookLoadOutcome { + if !config.plugins_enabled { + return PluginHookLoadOutcome::default(); + } + load_plugin_hooks_from_layer_stack( + config_layer_stack, + self.remote_installed_plugin_configs(), + &self.store, + config.remote_plugin_enabled, + ) + .await + } + /// Resolve plugin skill roots for a config layer stack without touching the plugins cache. pub async fn effective_skill_roots_for_layer_stack( &self, diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 905d120a8e..d6c13b1fe0 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -3858,3 +3858,50 @@ async fn load_plugins_ignores_project_config_files() { assert_eq!(outcome, PluginLoadOutcome::default()); } + +#[tokio::test] +async fn plugin_hooks_for_layer_stack_loads_configured_plugin_hooks() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + write_plugin( + codex_home.path().join("plugins/cache/test").as_path(), + "sample/local", + "sample", + ); + write_file( + &plugin_root.join("hooks/hooks.json"), + r#"{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "echo startup" + } + ] + } + ] + } +}"#, + ); + write_file( + &codex_home.path().join(CONFIG_TOML_FILE), + &plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true), + ); + let config = load_config(codex_home.path(), codex_home.path()).await; + + let outcome = PluginsManager::new(codex_home.path().to_path_buf()) + .plugin_hooks_for_layer_stack(&config.config_layer_stack, &config) + .await; + + assert_eq!(outcome.hook_sources.len(), 1); + assert_eq!( + outcome.hook_sources[0].source_relative_path, + "hooks/hooks.json" + ); + assert_eq!(outcome.hook_load_warnings, Vec::::new()); +} From 68db0bb5ec67635d3052ddae2ecb35cdea48263e Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 4 Jun 2026 09:16:03 -0700 Subject: [PATCH 07/41] Simplify Codex CLI README (#26313) ## Summary The codex-rs README was left over from before we moved the docs into the developer site. Its contents were very much out of date, and we received some bug reports about it. --- codex-rs/README.md | 100 +-------------------------------------------- 1 file changed, 2 insertions(+), 98 deletions(-) diff --git a/codex-rs/README.md b/codex-rs/README.md index 18bffd9f64..8c2ae94040 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -1,99 +1,3 @@ -# Codex CLI (Rust Implementation) +# Codex CLI -We provide Codex CLI as a standalone executable to ensure a zero-dependency install. - -## Installing Codex - -Today, the easiest way to install Codex is via `npm`: - -```shell -npm i -g @openai/codex -codex -``` - -You can also install via Homebrew (`brew install --cask codex`) or download a platform-specific release directly from our [GitHub Releases](https://github.com/openai/codex/releases). - -## Documentation quickstart - -- First run with Codex? Start with [`docs/getting-started.md`](../docs/getting-started.md) (links to the walkthrough for prompts, keyboard shortcuts, and session management). -- Want deeper control? See [`docs/config.md`](../docs/config.md) and [`docs/install.md`](../docs/install.md). - -## What's new in the Rust CLI - -The Rust implementation is now the maintained Codex CLI and serves as the default experience. It includes a number of features that the legacy TypeScript CLI never supported. - -### Config - -Codex supports a rich set of configuration options. Note that the Rust CLI uses `config.toml` instead of `config.json`. See [`docs/config.md`](../docs/config.md) for details. - -### Model Context Protocol Support - -#### MCP client - -Codex CLI functions as an MCP client that allows the Codex CLI and IDE extension to connect to MCP servers on startup. See the [`configuration documentation`](../docs/config.md#connecting-to-mcp-servers) for details. - -#### MCP server (experimental) - -Codex can be launched as an MCP _server_ by running `codex mcp-server`. This allows _other_ MCP clients to use Codex as a tool for another agent. - -Use the [`@modelcontextprotocol/inspector`](https://github.com/modelcontextprotocol/inspector) to try it out: - -```shell -npx @modelcontextprotocol/inspector codex mcp-server -``` - -Use `codex mcp` to add/list/get/remove MCP server launchers defined in `config.toml`, and `codex mcp-server` to run the MCP server directly. - -### Notifications - -You can enable notifications by configuring a script that is run whenever the agent finishes a turn. The [notify documentation](../docs/config.md#notify) includes a detailed example that explains how to get desktop notifications via [terminal-notifier](https://github.com/julienXX/terminal-notifier) on macOS. When Codex detects that it is running under WSL 2 inside Windows Terminal (`WT_SESSION` is set), the TUI automatically falls back to native Windows toast notifications so approval prompts and completed turns surface even though Windows Terminal does not implement OSC 9. - -### `codex exec` to run Codex programmatically/non-interactively - -To run Codex non-interactively, run `codex exec PROMPT` (you can also pass the prompt via `stdin`) and Codex will work on your task until it decides that it is done and exits. If you provide both a prompt argument and piped stdin, Codex appends stdin as a `` block after the prompt so patterns like `echo "my output" | codex exec "Summarize this concisely"` work naturally. Output is printed to the terminal directly. You can set the `RUST_LOG` environment variable to see more about what's going on. -Use `codex exec --ephemeral ...` to run without persisting session rollout files to disk. - -### Experimenting with the Codex Sandbox - -To test to see what happens when a command is run under the sandbox provided by Codex, use the `sandbox` subcommand in Codex CLI: - -``` -# Uses the sandbox implementation for the current host OS: -# Seatbelt on macOS, the Linux sandbox on Linux, and Windows restricted token on Windows. -codex sandbox [COMMAND]... - -# macOS-only diagnostic option -codex sandbox --log-denials [COMMAND]... -``` - -`codex sandbox` also accepts `--profile NAME` (`-p NAME`) to layer -`$CODEX_HOME/NAME.config.toml` onto the base user config for the sandboxed -command. - -### Selecting a sandbox policy via `--sandbox` - -The Rust CLI exposes a dedicated `--sandbox` (`-s`) flag that lets you pick the sandbox policy **without** having to reach for the generic `-c/--config` option: - -```shell -# Run Codex with the default, read-only sandbox -codex --sandbox read-only - -# Allow the agent to write within the current workspace while still blocking network access -codex --sandbox workspace-write - -# Danger! Disable sandboxing entirely (only do this if you are already running in a container or other isolated env) -codex --sandbox danger-full-access -``` - -In `workspace-write`, Codex also includes `~/.codex/memories` in its writable roots so memory maintenance does not require an extra approval. - -## Code Organization - -This folder is the root of a Cargo workspace. It contains quite a bit of experimental code, but here are the key crates: - -- [`core/`](./core) contains the business logic for Codex. Ultimately, we hope this becomes a library crate that is generally useful for building other Rust/native applications that use Codex. -- [`exec/`](./exec) "headless" CLI for use in automation. -- [`tui/`](./tui) CLI that launches a fullscreen TUI built with [Ratatui](https://ratatui.rs/). -- [`cli/`](./cli) CLI multitool that provides the aforementioned CLIs via subcommands. - -If you want to contribute or inspect behavior in detail, start by reading the module-level `README.md` files under each crate and run the project workspace from the top-level `codex-rs` directory so shared config, features, and build scripts stay aligned. +[**Codex CLI Documentation**](https://developers.openai.com/codex/cli) From 12e8764a9c61dfc8cc8ba2520a2b7cf3e0500edd Mon Sep 17 00:00:00 2001 From: Won Park Date: Thu, 4 Jun 2026 09:39:20 -0700 Subject: [PATCH 08/41] Add saved image path hint to standalone image generation (#25947) ## Why Standalone image generation returns image bytes to the model, but the model also needs the host artifact path to reference the generated file in follow-up work. ## What changed - Append the default saved-image path hint alongside the generated image tool output. - Reuse the existing core image-generation hint text. - Pass the thread ID and Codex home directory needed to compute the artifact path. - Add app-server and extension coverage for the model-visible hint. ## Validation - `just fmt` - `just bazel-lock-check` - `just test -p codex-app-server standalone_image_generation_returns_saved_path_hint_to_model` --- codex-rs/Cargo.lock | 1 + .../tests/suite/v2/imagegen_extension.rs | 19 +++++-- codex-rs/code-mode/src/description.rs | 1 + codex-rs/code-mode/src/runtime/callbacks.rs | 52 ++++++++++++++++++ codex-rs/code-mode/src/runtime/globals.rs | 3 ++ codex-rs/code-mode/src/service.rs | 38 +++++++++++++ .../context/image_generation_instructions.rs | 26 +++++++-- codex-rs/core/src/context/mod.rs | 1 + codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/stream_events_utils.rs | 3 +- codex-rs/ext/image-generation/Cargo.toml | 1 + .../image-generation/imagegen_description.md | 1 + .../ext/image-generation/src/extension.rs | 14 +++-- codex-rs/ext/image-generation/src/tests.rs | 52 +++++++++++++++++- codex-rs/ext/image-generation/src/tool.rs | 54 +++++++++++++++++-- 15 files changed, 249 insertions(+), 18 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index f5e8b56230..1e79865d43 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3052,6 +3052,7 @@ dependencies = [ "codex-model-provider-info", "codex-protocol", "codex-tools", + "codex-utils-absolute-path", "http 1.4.0", "pretty_assertions", "schemars 0.8.22", diff --git a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs index 3469bf44c1..b6593bc820 100644 --- a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs +++ b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs @@ -44,7 +44,7 @@ const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60); const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); #[tokio::test] -async fn standalone_image_generation_persists_image_and_returns_it_to_model() -> Result<()> { +async fn standalone_image_generation_returns_saved_path_hint_to_model() -> Result<()> { let call_id = "image-run-1"; let server = responses::start_mock_server().await; mount_image_response(&server).await; @@ -124,7 +124,13 @@ async fn standalone_image_generation_persists_image_and_returns_it_to_model() -> "detail": "high", }) ); - assert_eq!(output["output"].as_array().map(Vec::len), Some(1)); + let output_hint = output["output"][1]["text"] + .as_str() + .context("image output should include model-visible path hint")?; + assert!( + output_hint.contains(&saved_path.display().to_string()), + "output hint should identify the path core saved" + ); assert!( !requests[1] .message_input_texts("developer") @@ -199,7 +205,7 @@ const result = await tools.image_gen__imagegen({ action: "generate", prompt: "paint a blue whale", }); -image(result); +generatedImage(result); "#, ), responses::ev_completed("resp-1"), @@ -246,7 +252,12 @@ image(result); "detail": "high", }) ); - assert_eq!(output["output"].as_array().map(Vec::len), Some(2)); + assert!( + output["output"][2]["text"] + .as_str() + .is_some_and(|text| text.contains("Generated images are saved")) + ); + assert_eq!(output["output"].as_array().map(Vec::len), Some(3)); Ok(()) } diff --git a/codex-rs/code-mode/src/description.rs b/codex-rs/code-mode/src/description.rs index 50fcc034ba..7e0801ed25 100644 --- a/codex-rs/code-mode/src/description.rs +++ b/codex-rs/code-mode/src/description.rs @@ -25,6 +25,7 @@ const EXEC_DESCRIPTION_TEMPLATE: &str = r#"Run JavaScript code to orchestrate/co - `exit()`: Immediately ends the current script successfully (like an early return from the top level). - `text(value: string | number | boolean | undefined | null)`: Appends a text item. Non-string values are stringified with `JSON.stringify(...)` when possible. - `image(imageUrlOrItem: string | { image_url: string; detail?: "auto" | "low" | "high" | "original" | null } | ImageContent, detail?: "auto" | "low" | "high" | "original" | null)`: Appends an image item. `image_url` can be an HTTPS URL or a base64-encoded `data:` URL. To forward an MCP tool image, pass an individual `ImageContent` block from `result.content`, for example `image(result.content[0])`. MCP image blocks may request detail with `_meta: { "codex/imageDetail": "original" }`. When provided, the second `detail` argument overrides any detail embedded in the first argument. +- `generatedImage(result: { image_url: string; output_hint?: string })`: Appends an image-generation result and its optional output hint. - `store(key: string, value: any)`: stores a serializable value under a string key for later `exec` calls in the same session. - `load(key: string)`: returns the stored value for a string key, or `undefined` if it is missing. - `notify(value: string | number | boolean | undefined | null)`: immediately injects an extra `custom_tool_call_output` for the current `exec` call. Values are stringified like `text(...)`. diff --git a/codex-rs/code-mode/src/runtime/callbacks.rs b/codex-rs/code-mode/src/runtime/callbacks.rs index 7d8a28662a..dde63617ec 100644 --- a/codex-rs/code-mode/src/runtime/callbacks.rs +++ b/codex-rs/code-mode/src/runtime/callbacks.rs @@ -129,6 +129,58 @@ pub(super) fn image_callback( retval.set(v8::undefined(scope).into()); } +pub(super) fn generated_image_callback( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, + mut retval: v8::ReturnValue, +) { + let value = if args.length() == 0 { + v8::undefined(scope).into() + } else { + args.get(0) + }; + let output_hint = match generated_image_output_hint(scope, value) { + Ok(output_hint) => output_hint, + Err(error_text) => { + throw_type_error(scope, &error_text); + return; + } + }; + let image_item = match normalize_output_image(scope, value, /*detail_override*/ None) { + Ok(image_item) => image_item, + Err(()) => return, + }; + if let Some(state) = scope.get_slot::() { + let _ = state.event_tx.send(RuntimeEvent::ContentItem(image_item)); + if let Some(text) = output_hint { + let _ = state.event_tx.send(RuntimeEvent::ContentItem( + FunctionCallOutputContentItem::InputText { text }, + )); + } + } + retval.set(v8::undefined(scope).into()); +} + +fn generated_image_output_hint( + scope: &mut v8::PinScope<'_, '_>, + value: v8::Local<'_, v8::Value>, +) -> Result, String> { + let object = v8::Local::::try_from(value) + .map_err(|_| "generatedImage expects an image generation result object".to_string())?; + let key = v8::String::new(scope, "output_hint") + .ok_or_else(|| "failed to allocate generatedImage helper keys".to_string())?; + let output_hint = object + .get(scope, key.into()) + .ok_or_else(|| "failed to read generatedImage output_hint".to_string())?; + if output_hint.is_undefined() { + return Ok(None); + } + if !output_hint.is_string() { + return Err("generatedImage output_hint must be a string when provided".to_string()); + } + Ok(Some(output_hint.to_rust_string_lossy(scope))) +} + pub(super) fn store_callback( scope: &mut v8::PinScope<'_, '_>, args: v8::FunctionCallbackArguments, diff --git a/codex-rs/code-mode/src/runtime/globals.rs b/codex-rs/code-mode/src/runtime/globals.rs index 2ec6953f09..fe3df4c958 100644 --- a/codex-rs/code-mode/src/runtime/globals.rs +++ b/codex-rs/code-mode/src/runtime/globals.rs @@ -1,6 +1,7 @@ use super::RuntimeState; use super::callbacks::clear_timeout_callback; use super::callbacks::exit_callback; +use super::callbacks::generated_image_callback; use super::callbacks::image_callback; use super::callbacks::load_callback; use super::callbacks::notify_callback; @@ -23,6 +24,7 @@ pub(super) fn install_globals(scope: &mut v8::PinScope<'_, '_>) -> Result<(), St let set_timeout = helper_function(scope, "setTimeout", set_timeout_callback)?; let text = helper_function(scope, "text", text_callback)?; let image = helper_function(scope, "image", image_callback)?; + let generated_image = helper_function(scope, "generatedImage", generated_image_callback)?; let store = helper_function(scope, "store", store_callback)?; let load = helper_function(scope, "load", load_callback)?; let notify = helper_function(scope, "notify", notify_callback)?; @@ -35,6 +37,7 @@ pub(super) fn install_globals(scope: &mut v8::PinScope<'_, '_>) -> Result<(), St set_global(scope, global, "setTimeout", set_timeout.into())?; set_global(scope, global, "text", text.into())?; set_global(scope, global, "image", image.into())?; + set_global(scope, global, "generatedImage", generated_image.into())?; set_global(scope, global, "store", store.into())?; set_global(scope, global, "load", load.into())?; set_global(scope, global, "notify", notify.into())?; diff --git a/codex-rs/code-mode/src/service.rs b/codex-rs/code-mode/src/service.rs index 6589e6ec5a..b348d1d35f 100644 --- a/codex-rs/code-mode/src/service.rs +++ b/codex-rs/code-mode/src/service.rs @@ -1563,6 +1563,44 @@ image({ ); } + #[tokio::test] + async fn generated_image_helper_appends_image_and_output_hint() { + let service = CodeModeService::new(); + + let response = execute( + &service, + ExecuteRequest { + source: r#" +generatedImage({ + image_url: "https://example.com/image.jpg", + output_hint: "generated image save hint", +}); +"# + .to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![ + FunctionCallOutputContentItem::InputImage { + image_url: "https://example.com/image.jpg".to_string(), + detail: Some(crate::DEFAULT_IMAGE_DETAIL), + }, + FunctionCallOutputContentItem::InputText { + text: "generated image save hint".to_string(), + }, + ], + error_text: None, + } + ); + } + #[tokio::test] async fn image_helper_second_arg_overrides_explicit_object_detail() { let service = CodeModeService::new(); diff --git a/codex-rs/core/src/context/image_generation_instructions.rs b/codex-rs/core/src/context/image_generation_instructions.rs index 52ce1ca4e9..6986a7c64f 100644 --- a/codex-rs/core/src/context/image_generation_instructions.rs +++ b/codex-rs/core/src/context/image_generation_instructions.rs @@ -1,6 +1,27 @@ use super::ContextualUserFragment; use std::fmt::Display; +/// Maximum size of the extension's model-facing generated-image path hint. +const MAX_IMAGE_GENERATION_OUTPUT_HINT_BYTES: usize = 1024; + +/// Returns the extension's model-facing hint, or omits it if the path makes it too large. +pub fn extension_image_generation_output_hint( + image_output_dir: impl Display, + image_output_path: impl Display, +) -> Option { + let hint = image_generation_hint(image_output_dir, image_output_path); + (hint.len() <= MAX_IMAGE_GENERATION_OUTPUT_HINT_BYTES).then_some(hint) +} + +fn image_generation_hint( + image_output_dir: impl Display, + image_output_path: impl Display, +) -> String { + format!( + "Generated images are saved to {image_output_dir} as {image_output_path} by default.\nIf you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it." + ) +} + #[derive(Debug, Clone, PartialEq)] pub(crate) struct ImageGenerationInstructions { image_output_dir: String, @@ -30,9 +51,6 @@ impl ContextualUserFragment for ImageGenerationInstructions { } fn body(&self) -> String { - format!( - "Generated images are saved to {} as {} by default.\nIf you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it.", - self.image_output_dir, self.image_output_path - ) + image_generation_hint(&self.image_output_dir, &self.image_output_path) } } diff --git a/codex-rs/core/src/context/mod.rs b/codex-rs/core/src/context/mod.rs index 5115bda6d4..78fc70fd49 100644 --- a/codex-rs/core/src/context/mod.rs +++ b/codex-rs/core/src/context/mod.rs @@ -44,6 +44,7 @@ pub(crate) use environment_context::EnvironmentContext; pub(crate) use guardian_followup_review_reminder::GuardianFollowupReviewReminder; pub(crate) use hook_additional_context::HookAdditionalContext; pub(crate) use image_generation_instructions::ImageGenerationInstructions; +pub use image_generation_instructions::extension_image_generation_output_hint; pub use internal_model_context::InternalContextSource; pub use internal_model_context::InternalModelContextFragment; pub use internal_model_context::InvalidInternalContextSource; diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index d3031ad222..10b3511d5d 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -99,6 +99,7 @@ pub(crate) use skills::manager; pub(crate) use skills::maybe_emit_implicit_skill_invocation; pub(crate) use skills::skills_load_input_from_config; mod stream_events_utils; +pub use stream_events_utils::image_generation_artifact_path; pub mod test_support; mod unified_exec; pub mod windows_sandbox; diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index 20fc6787ac..81beea5be1 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -38,7 +38,8 @@ use tracing::warn; const GENERATED_IMAGE_ARTIFACTS_DIR: &str = "generated_images"; -pub(crate) fn image_generation_artifact_path( +/// Returns the host-owned default artifact path for a generated image. +pub fn image_generation_artifact_path( codex_home: &AbsolutePathBuf, session_id: &str, call_id: &str, diff --git a/codex-rs/ext/image-generation/Cargo.toml b/codex-rs/ext/image-generation/Cargo.toml index 995f1786bc..3f22bd3fff 100644 --- a/codex-rs/ext/image-generation/Cargo.toml +++ b/codex-rs/ext/image-generation/Cargo.toml @@ -23,6 +23,7 @@ codex-model-provider = { workspace = true } codex-model-provider-info = { workspace = true } codex-protocol = { workspace = true } codex-tools = { workspace = true } +codex-utils-absolute-path = { workspace = true } http = { workspace = true } schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/codex-rs/ext/image-generation/imagegen_description.md b/codex-rs/ext/image-generation/imagegen_description.md index 7ae6ecb5fa..1e5a46390f 100644 --- a/codex-rs/ext/image-generation/imagegen_description.md +++ b/codex-rs/ext/image-generation/imagegen_description.md @@ -4,6 +4,7 @@ The `image_gen.imagegen` tool enables image generation from descriptions and edi - The user wants to modify an attached or previously generated image with specific changes, including adding or removing elements, altering colors, improving quality/resolution, or transforming the style (e.g., cartoon, oil painting). Guidelines: +- In code mode, pass the result to `generatedImage(result)`. - Set `action` to `generate` when the user asks for a brand new image. - Set `action` to `edit` when the user asks to modify an existing image from the conversation history. - Directly generate the image without reconfirmation or clarification. diff --git a/codex-rs/ext/image-generation/src/extension.rs b/codex-rs/ext/image-generation/src/extension.rs index f7a9f2406f..6f6a7c7b1b 100644 --- a/codex-rs/ext/image-generation/src/extension.rs +++ b/codex-rs/ext/image-generation/src/extension.rs @@ -13,6 +13,7 @@ use codex_features::Feature; use codex_login::AuthManager; use codex_model_provider::create_model_provider; use codex_model_provider_info::ModelProviderInfo; +use codex_utils_absolute_path::AbsolutePathBuf; use crate::backend::CodexImagesBackend; use crate::tool::ImageGenerationTool; @@ -26,6 +27,7 @@ struct ImageGenerationExtension { struct ImageGenerationExtensionConfig { enabled: bool, provider: ModelProviderInfo, + codex_home: AbsolutePathBuf, } impl From<&Config> for ImageGenerationExtensionConfig { @@ -35,6 +37,7 @@ impl From<&Config> for ImageGenerationExtensionConfig { enabled: config.features.enabled(Feature::ImageGenExt) && config.model_provider.is_openai(), provider: config.model_provider.clone(), + codex_home: config.codex_home.clone(), } } } @@ -76,9 +79,14 @@ impl ToolContributor for ImageGenerationExtension { return Vec::new(); } - vec![Arc::new(ImageGenerationTool::new(CodexImagesBackend::new( - create_model_provider(config.provider.clone(), Some(self.auth_manager.clone())), - )))] + vec![Arc::new(ImageGenerationTool::new( + CodexImagesBackend::new(create_model_provider( + config.provider.clone(), + Some(self.auth_manager.clone()), + )), + config.codex_home.clone(), + thread_store.level_id().to_string(), + ))] } } diff --git a/codex-rs/ext/image-generation/src/tests.rs b/codex-rs/ext/image-generation/src/tests.rs index 44f2d80ebe..6a974e2d51 100644 --- a/codex-rs/ext/image-generation/src/tests.rs +++ b/codex-rs/ext/image-generation/src/tests.rs @@ -3,6 +3,7 @@ use codex_api::ImageEditRequest; use codex_api::ImageGenerationRequest; use codex_api::ImageQuality; use codex_api::ImageUrl; +use codex_core::context::extension_image_generation_output_hint; use codex_extension_api::ToolOutput; use codex_extension_api::ToolPayload; use codex_extension_api::ToolSpec; @@ -54,9 +55,58 @@ fn generate_uses_fixed_request_defaults() { } #[test] -fn generated_output_returns_image_input() { +fn generated_output_returns_image_input_and_output_hint() { + let output_hint = + extension_image_generation_output_hint("/tmp", "/tmp/call-1.png").expect("hint should fit"); let output = GeneratedImageOutput { result: RESULT.to_string(), + output_hint: Some(output_hint.clone()), + }; + + let ResponseInputItem::FunctionCallOutput { + output: response_output, + .. + } = output.to_response_item("call-1", &function_payload()) + else { + panic!("imagegen should return function tool output"); + }; + let FunctionCallOutputBody::ContentItems(content_items) = response_output.body else { + panic!("imagegen output should contain generated image bytes"); + }; + assert_eq!( + content_items, + vec![ + FunctionCallOutputContentItem::InputImage { + image_url: format!("data:image/png;base64,{RESULT}"), + detail: Some(DEFAULT_IMAGE_DETAIL), + }, + FunctionCallOutputContentItem::InputText { text: output_hint }, + ] + ); +} + +#[test] +fn generated_output_returns_generated_image_helper_input_in_code_mode() { + let output = GeneratedImageOutput { + result: RESULT.to_string(), + output_hint: Some("generated image save hint".to_string()), + }; + + assert_eq!( + output.code_mode_result(&function_payload()), + serde_json::json!({ + "image_url": format!("data:image/png;base64,{RESULT}"), + "output_hint": "generated image save hint", + }) + ); +} + +#[test] +fn generated_output_omits_oversized_output_hint() { + let long_path = "x".repeat(1024); + let output = GeneratedImageOutput { + result: RESULT.to_string(), + output_hint: extension_image_generation_output_hint("/tmp", long_path), }; let ResponseInputItem::FunctionCallOutput { diff --git a/codex-rs/ext/image-generation/src/tool.rs b/codex-rs/ext/image-generation/src/tool.rs index e4e8e13135..9c73e96b23 100644 --- a/codex-rs/ext/image-generation/src/tool.rs +++ b/codex-rs/ext/image-generation/src/tool.rs @@ -3,6 +3,8 @@ use codex_api::ImageEditRequest; use codex_api::ImageGenerationRequest; use codex_api::ImageQuality; use codex_api::ImageUrl; +use codex_core::context::extension_image_generation_output_hint; +use codex_core::image_generation_artifact_path; use codex_extension_api::ExtensionTurnItem; use codex_extension_api::FunctionCallError; use codex_extension_api::ToolCall; @@ -25,6 +27,7 @@ use codex_tools::ResponsesApiNamespaceTool; use codex_tools::ResponsesApiTool; use codex_tools::ToolExposure; use codex_tools::default_namespace_description; +use codex_utils_absolute_path::AbsolutePathBuf; use schemars::JsonSchema; use schemars::r#gen::SchemaSettings; use serde::Deserialize; @@ -42,12 +45,22 @@ const IMAGEGEN_DESCRIPTION: &str = include_str!("../imagegen_description.md"); #[derive(Clone)] pub(crate) struct ImageGenerationTool { backend: CodexImagesBackend, + codex_home: AbsolutePathBuf, + thread_id: String, } impl ImageGenerationTool { /// Creates an image-generation tool backed by an image API executor. - pub(crate) fn new(backend: CodexImagesBackend) -> Self { - Self { backend } + pub(crate) fn new( + backend: CodexImagesBackend, + codex_home: AbsolutePathBuf, + thread_id: String, + ) -> Self { + Self { + backend, + codex_home, + thread_id, + } } } @@ -116,7 +129,17 @@ impl ToolExecutor for ImageGenerationTool { saved_path: None, })) .await; - Ok(Box::new(GeneratedImageOutput { result })) + let output_path = + image_generation_artifact_path(&self.codex_home, &self.thread_id, &call.call_id); + let output_dir = output_path + .parent() + .unwrap_or_else(|| self.codex_home.clone()); + let output_hint = + extension_image_generation_output_hint(output_dir.display(), output_path.display()); + Ok(Box::new(GeneratedImageOutput { + result, + output_hint, + })) } } @@ -297,6 +320,7 @@ fn imagegen_tool_spec() -> ToolSpec { struct GeneratedImageOutput { result: String, + output_hint: Option, } impl ToolOutput for GeneratedImageOutput { @@ -310,12 +334,32 @@ impl ToolOutput for GeneratedImageOutput { true } - /// Returns generated bytes for model follow-up. + /// Returns the object consumed by the code-mode `generatedImage()` helper. + fn code_mode_result(&self, _payload: &ToolPayload) -> Value { + let mut result = Map::from_iter([( + "image_url".to_string(), + Value::String(format!("data:image/png;base64,{}", self.result)), + )]); + if let Some(output_hint) = &self.output_hint { + result.insert( + "output_hint".to_string(), + Value::String(output_hint.clone()), + ); + } + Value::Object(result) + } + + /// Returns generated bytes and persisted-artifact context for model follow-up. fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem { - let content = vec![FunctionCallOutputContentItem::InputImage { + let mut content = vec![FunctionCallOutputContentItem::InputImage { image_url: format!("data:image/png;base64,{}", self.result), detail: Some(DEFAULT_IMAGE_DETAIL), }]; + if let Some(output_hint) = &self.output_hint { + content.push(FunctionCallOutputContentItem::InputText { + text: output_hint.clone(), + }); + } ResponseInputItem::FunctionCallOutput { call_id: call_id.to_string(), output: FunctionCallOutputPayload { From cbf62f64cbfdca160e7303536ec6ea464d371281 Mon Sep 17 00:00:00 2001 From: stefanstokic-oai Date: Thu, 4 Jun 2026 13:13:05 -0400 Subject: [PATCH 09/41] Bound external agent session detection work (#26291) ## Why External agent migration detection parsed and hashed every JSONL session file. For users with many large conversations, launching migration could consume substantial CPU and disk resources. Detection only needs the most recent sessions for the migration UI, so full-content work should be bounded. ## What - Use file modification metadata to select the 50 most recent eligible sessions before parsing JSONL content. - Skip unchanged imported sessions using metadata stored in the import ledger. - Preserve content hashing when metadata indicates a session may have changed. - Stream SHA-256 calculation through a 64 KiB buffer instead of loading an entire session into memory. - Continue detecting older sessions in subsequent batches after newer sessions are imported. ## Validation - `RUST_MIN_STACK=8388608 cargo nextest run --no-fail-fast -p codex-external-agent-sessions` - 20 tests passed. - Benchmarked release builds against 250 valid JSONL sessions totaling 501 MiB: - Median detection time decreased from 1,138.8 ms to 47.0 ms. - CPU instructions decreased by 95.8%. - Both versions returned the expected 50 sessions. The benchmark used warm filesystem caches and measured the reduction in parsing, hashing, and CPU work. --- .../external-agent-sessions/src/detect.rs | 209 +++++++++++++++--- .../external-agent-sessions/src/ledger.rs | 112 ++++++++-- 2 files changed, 271 insertions(+), 50 deletions(-) diff --git a/codex-rs/external-agent-sessions/src/detect.rs b/codex-rs/external-agent-sessions/src/detect.rs index 27613eed18..f42c90766b 100644 --- a/codex-rs/external-agent-sessions/src/detect.rs +++ b/codex-rs/external-agent-sessions/src/detect.rs @@ -1,7 +1,10 @@ use crate::ExternalAgentSessionMigration; use crate::ledger::load_import_ledger; +use crate::ledger::save_import_ledger; use crate::now_unix_seconds; use crate::summarize_session; +use std::cmp::Reverse; +use std::collections::BinaryHeap; use std::fs; use std::io; use std::path::Path; @@ -10,12 +13,6 @@ use std::time::Duration; const SESSION_IMPORT_MAX_COUNT: usize = 50; const SESSION_IMPORT_MAX_AGE: Duration = Duration::from_secs(30 * 24 * 60 * 60); -#[derive(Debug)] -struct SessionCandidate { - latest_timestamp: i64, - migration: ExternalAgentSessionMigration, -} - pub fn detect_recent_sessions( external_agent_home: &Path, codex_home: &Path, @@ -26,8 +23,9 @@ pub fn detect_recent_sessions( } let now = now_unix_seconds(); - let ledger = load_import_ledger(codex_home)?; - let mut candidates = Vec::new(); + let mut ledger = load_import_ledger(codex_home)?; + let source_states = ledger.source_states(); + let mut file_candidates = BinaryHeap::with_capacity(SESSION_IMPORT_MAX_COUNT + 1); for project_entry in fs::read_dir(projects_root)? { let Ok(project_entry) = project_entry else { continue; @@ -47,44 +45,67 @@ pub fn detect_recent_sessions( if path.extension().and_then(|value| value.to_str()) != Some("jsonl") { continue; } - let Ok(Some(summary)) = summarize_session(&path) else { + let Ok(metadata) = entry.metadata() else { continue; }; - let Ok(has_been_imported) = ledger.contains_current_source(&path) else { + let Ok(modified_at) = metadata.modified() else { continue; }; - if has_been_imported { + let Ok(modified_at) = modified_at.duration_since(std::time::UNIX_EPOCH) else { + continue; + }; + if (modified_at.as_secs() as i64) + < now.saturating_sub(SESSION_IMPORT_MAX_AGE.as_secs() as i64) + { continue; } - if !is_recent_enough(now, summary.latest_timestamp) { + let Ok(modified_at_nanos) = i64::try_from(modified_at.as_nanos()) else { + continue; + }; + let Ok(source_path) = fs::canonicalize(&path) else { + continue; + }; + if let Some(state) = source_states.get(source_path.as_path()) + && (state.source_modified_at == Some(modified_at_nanos) + || state.source_modified_at.is_none() + && modified_at.as_secs() as i64 <= state.imported_at) + { continue; } - let migration = summary.migration; - if !migration.cwd.is_dir() { - continue; + file_candidates.push((Reverse(modified_at_nanos), path)); + if file_candidates.len() > SESSION_IMPORT_MAX_COUNT { + file_candidates.pop(); } - candidates.push(SessionCandidate { - latest_timestamp: summary.latest_timestamp, - migration, - }); } } - candidates.sort_by(|left, right| { - right - .latest_timestamp - .cmp(&left.latest_timestamp) - .then_with(|| left.migration.path.cmp(&right.migration.path)) - }); - candidates.truncate(SESSION_IMPORT_MAX_COUNT); - Ok(candidates - .into_iter() - .map(|candidate| candidate.migration) - .collect()) -} + drop(source_states); + let file_candidates = file_candidates.into_sorted_vec(); + let mut migrations = Vec::new(); + let mut ledger_changed = false; + for (modified_at, path) in file_candidates { + match ledger.refresh_current_source(&path, modified_at.0) { + Ok(false) => {} + Ok(true) => { + ledger_changed = true; + continue; + } + Err(_) => continue, + } + let Ok(Some(summary)) = summarize_session(&path) else { + continue; + }; + let migration = summary.migration; + if !migration.cwd.is_dir() { + continue; + } + migrations.push(migration); + } + if ledger_changed { + save_import_ledger(codex_home, &ledger)?; + } -fn is_recent_enough(now: i64, latest_timestamp: i64) -> bool { - latest_timestamp >= now.saturating_sub(SESSION_IMPORT_MAX_AGE.as_secs() as i64) + Ok(migrations) } #[cfg(test)] @@ -93,7 +114,10 @@ mod tests { use crate::ledger::record_imported_session; use codex_protocol::ThreadId; use serde_json::Value as JsonValue; + use std::fs::FileTimes; + use std::fs::OpenOptions; use std::path::Path; + use std::time::SystemTime; use tempfile::TempDir; #[test] @@ -207,11 +231,11 @@ mod tests { } #[test] - fn ignores_old_sessions() { + fn uses_file_modification_time_for_recency() { let root = TempDir::new().expect("tempdir"); let external_agent_home = root.path().join(".external"); let project_root = root.path().join("repo"); - write_session( + let session_path = write_session( &external_agent_home, &project_root, "session.jsonl", @@ -223,6 +247,34 @@ mod tests { )], ); + let sessions = detect_recent_sessions(&external_agent_home, root.path()).expect("detect"); + + assert_eq!( + sessions, + vec![ExternalAgentSessionMigration { + path: session_path, + cwd: project_root, + title: Some("hello".to_string()), + }] + ); + } + + #[test] + fn ignores_sessions_with_old_file_modification_time() { + let root = TempDir::new().expect("tempdir"); + let external_agent_home = root.path().join(".external"); + let project_root = root.path().join("repo"); + let session_path = write_session( + &external_agent_home, + &project_root, + "session.jsonl", + &[record("user", "hello", &project_root)], + ); + set_modified_at( + &session_path, + SystemTime::UNIX_EPOCH + Duration::from_secs(/*secs*/ 1), + ); + assert!( detect_recent_sessions(&external_agent_home, root.path()) .expect("detect") @@ -230,6 +282,84 @@ mod tests { ); } + #[test] + fn detects_sessions_in_batches() { + let root = TempDir::new().expect("tempdir"); + let external_agent_home = root.path().join(".external"); + let project_root = root.path().join("repo"); + let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + let modified_at = SystemTime::now(); + let mut expected = Vec::new(); + for index in 0..=SESSION_IMPORT_MAX_COUNT { + let file_name = format!("{index:02}-session.jsonl"); + let title = format!("session {index}"); + let path = write_session( + &external_agent_home, + &project_root, + &file_name, + &[record_at("user", &title, &project_root, ×tamp)], + ); + set_modified_at( + &path, + modified_at - Duration::from_secs(/*secs*/ index as u64), + ); + expected.push(ExternalAgentSessionMigration { + path, + cwd: project_root.clone(), + title: Some(title), + }); + } + let oldest_session = expected.pop().expect("oldest session"); + let mut all_sessions = expected.clone(); + all_sessions.push(oldest_session.clone()); + + let sessions = detect_recent_sessions(&external_agent_home, root.path()).expect("detect"); + + assert_eq!(sessions, expected); + for session in sessions { + record_imported_session(root.path(), &session.path, ThreadId::new()) + .expect("record import"); + } + + let sessions = detect_recent_sessions(&external_agent_home, root.path()).expect("detect"); + + assert_eq!(sessions, vec![oldest_session.clone()]); + for session in sessions { + record_imported_session(root.path(), &session.path, ThreadId::new()) + .expect("record import"); + } + + let changed_at = SystemTime::now() + + Duration::from_secs(/*secs*/ SESSION_IMPORT_MAX_COUNT as u64 + 1); + for (index, session) in all_sessions.iter().enumerate() { + let title = session.title.as_deref().expect("session title"); + std::fs::write( + &session.path, + jsonl(&[ + record("user", title, &project_root), + record("assistant", "updated", &project_root), + ]), + ) + .expect("update session"); + set_modified_at( + &session.path, + changed_at - Duration::from_secs(/*secs*/ index as u64), + ); + } + + let sessions = detect_recent_sessions(&external_agent_home, root.path()).expect("detect"); + + assert_eq!(sessions, expected); + for session in sessions { + record_imported_session(root.path(), &session.path, ThreadId::new()) + .expect("record import"); + } + + let sessions = detect_recent_sessions(&external_agent_home, root.path()).expect("detect"); + + assert_eq!(sessions, vec![oldest_session]); + } + #[test] fn skips_already_imported_current_session_versions() { let root = TempDir::new().expect("tempdir"); @@ -300,6 +430,15 @@ mod tests { session_path } + fn set_modified_at(path: &Path, modified_at: SystemTime) { + OpenOptions::new() + .write(true) + .open(path) + .expect("open session") + .set_times(FileTimes::new().set_modified(modified_at)) + .expect("set session modified time"); + } + fn record(role: &str, text: &str, cwd: &Path) -> JsonValue { let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); record_at(role, text, cwd, ×tamp) diff --git a/codex-rs/external-agent-sessions/src/ledger.rs b/codex-rs/external-agent-sessions/src/ledger.rs index 84679581ca..45ff97bcf0 100644 --- a/codex-rs/external-agent-sessions/src/ledger.rs +++ b/codex-rs/external-agent-sessions/src/ledger.rs @@ -4,12 +4,16 @@ use serde::Deserialize; use serde::Serialize; use sha2::Digest; use sha2::Sha256; +use std::collections::HashMap; use std::fs; +use std::fs::File; use std::io; +use std::io::Read; use std::path::Path; use std::path::PathBuf; const SESSION_IMPORT_LEDGER_FILE: &str = "external_agent_session_imports.json"; +const SESSION_HASH_BUFFER_SIZE: usize = 64 * 1024; #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub(super) struct ImportedExternalAgentSessionLedger { @@ -22,6 +26,14 @@ struct ImportedExternalAgentSessionRecord { content_sha256: String, imported_thread_id: ThreadId, imported_at: i64, + #[serde(default)] + source_modified_at: Option, +} + +#[derive(Debug, Clone, Copy)] +pub(super) struct ImportedSourceState { + pub source_modified_at: Option, + pub imported_at: i64, } pub fn has_current_session_been_imported( @@ -39,30 +51,82 @@ pub fn record_imported_session( let mut ledger = load_import_ledger(codex_home)?; let source_path = canonical_source_path(source_path)?; let content_sha256 = session_content_sha256(&source_path)?; - if ledger - .records - .iter() - .any(|record| record.source_path == source_path && record.content_sha256 == content_sha256) - { - return Ok(()); + let source_modified_at = session_modified_at(&source_path)?; + if let Some(index) = ledger.records.iter().rposition(|record| { + record.source_path == source_path && record.content_sha256 == content_sha256 + }) { + let mut record = ledger.records.remove(index); + record.imported_thread_id = imported_thread_id; + record.imported_at = now_unix_seconds(); + record.source_modified_at = source_modified_at; + ledger.records.push(record); + } else { + ledger.records.push(ImportedExternalAgentSessionRecord { + source_path, + content_sha256, + imported_thread_id, + imported_at: now_unix_seconds(), + source_modified_at, + }); } - ledger.records.push(ImportedExternalAgentSessionRecord { - source_path, - content_sha256, - imported_thread_id, - imported_at: now_unix_seconds(), - }); save_import_ledger(codex_home, &ledger) } impl ImportedExternalAgentSessionLedger { + pub(super) fn source_states(&self) -> HashMap<&Path, ImportedSourceState> { + let mut states = HashMap::new(); + for record in &self.records { + states.insert( + record.source_path.as_path(), + ImportedSourceState { + source_modified_at: record.source_modified_at, + imported_at: record.imported_at, + }, + ); + } + states + } + pub(super) fn contains_current_source(&self, source_path: &Path) -> io::Result { let source_path = canonical_source_path(source_path)?; + if !self + .records + .iter() + .any(|record| record.source_path == source_path) + { + return Ok(false); + } let content_sha256 = session_content_sha256(&source_path)?; Ok(self.records.iter().any(|record| { record.source_path == source_path && record.content_sha256 == content_sha256 })) } + + pub(super) fn refresh_current_source( + &mut self, + source_path: &Path, + source_modified_at: i64, + ) -> io::Result { + let source_path = canonical_source_path(source_path)?; + if !self + .records + .iter() + .any(|record| record.source_path == source_path) + { + return Ok(false); + } + let content_sha256 = session_content_sha256(&source_path)?; + let Some(index) = self.records.iter().rposition(|record| { + record.source_path == source_path && record.content_sha256 == content_sha256 + }) else { + return Ok(false); + }; + let mut record = self.records.remove(index); + record.imported_at = now_unix_seconds(); + record.source_modified_at = Some(source_modified_at); + self.records.push(record); + Ok(true) + } } pub(super) fn load_import_ledger( @@ -84,7 +148,7 @@ pub(super) fn load_import_ledger( }) } -fn save_import_ledger( +pub(super) fn save_import_ledger( codex_home: &Path, ledger: &ImportedExternalAgentSessionLedger, ) -> io::Result<()> { @@ -103,6 +167,24 @@ fn canonical_source_path(path: &Path) -> io::Result { } fn session_content_sha256(path: &Path) -> io::Result { - let contents = fs::read(path)?; - Ok(format!("{:x}", Sha256::digest(contents))) + let mut file = File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = [0; SESSION_HASH_BUFFER_SIZE]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + let digest = hasher.finalize(); + Ok(format!("{digest:x}")) +} + +fn session_modified_at(path: &Path) -> io::Result> { + Ok(fs::metadata(path)? + .modified()? + .duration_since(std::time::UNIX_EPOCH) + .ok() + .and_then(|duration| i64::try_from(duration.as_nanos()).ok())) } From cdc1d592df7f066c141025cc8ae80bb3202580b6 Mon Sep 17 00:00:00 2001 From: mpc-oai Date: Thu, 4 Jun 2026 12:20:32 -0500 Subject: [PATCH 10/41] Expose configured marketplace source in plugin list JSON (#26417) ## Summary - Follow-up to #25330 - Add `marketplaceSource` to `codex plugin list --json` entries for configured marketplaces - Keep the existing per-plugin `source` field unchanged; this still reports the local plugin source path - Include only the configured marketplace `sourceType` and `source` from `config.toml` - Keep human-readable output unchanged - Add CLI coverage for configured local and git marketplace sources Example: ```json { "source": { "source": "local", "path": "/path/to/.codex/.tmp/marketplaces/debug/plugins/sample" }, "marketplaceSource": { "sourceType": "git", "source": "https://example.com/acme/agent-skills.git" } } ``` ## Validation - `just fmt` - `just fix -p codex-cli` - `just test -p codex-cli plugin_list` --- codex-rs/cli/src/plugin_cmd.rs | 58 +++++++++++++++++++++++++- codex-rs/cli/tests/plugin_cli.rs | 70 ++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 2 deletions(-) diff --git a/codex-rs/cli/src/plugin_cmd.rs b/codex-rs/cli/src/plugin_cmd.rs index 9ed2c1be6e..c850556e4d 100644 --- a/codex-rs/cli/src/plugin_cmd.rs +++ b/codex-rs/cli/src/plugin_cmd.rs @@ -20,6 +20,7 @@ use codex_plugin::PluginId; use codex_plugin::validate_plugin_segment; use codex_utils_cli::CliConfigOverrides; use serde::Serialize; +use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; @@ -177,9 +178,14 @@ pub async fn run_plugin_list( .is_none_or(|name| marketplace.name == *name) }) .collect::>(); + let marketplace_sources = configured_marketplace_sources(&plugins_input); if args.json { - let output = JsonPluginListOutput::from_marketplaces(marketplaces, args.available); + let output = JsonPluginListOutput::from_marketplaces( + marketplaces, + args.available, + &marketplace_sources, + ); println!("{}", serde_json::to_string_pretty(&output)?); return Ok(()); } @@ -269,13 +275,19 @@ impl JsonPluginListOutput { fn from_marketplaces( marketplaces: Vec, include_available: bool, + marketplace_sources: &HashMap, ) -> Self { let mut installed = Vec::new(); let mut available = Vec::new(); for marketplace in marketplaces { + let marketplace_source = marketplace_sources.get(&marketplace.name).cloned(); for plugin in marketplace.plugins { - let entry = JsonPluginListEntry::from_configured_plugin(&marketplace.name, plugin); + let entry = JsonPluginListEntry::from_configured_plugin( + &marketplace.name, + marketplace_source.clone(), + plugin, + ); if entry.installed { installed.push(entry); } else if include_available { @@ -301,6 +313,8 @@ struct JsonPluginListEntry { installed: bool, enabled: bool, source: JsonPluginSource, + #[serde(skip_serializing_if = "Option::is_none")] + marketplace_source: Option, install_policy: &'static str, auth_policy: &'static str, } @@ -308,6 +322,7 @@ struct JsonPluginListEntry { impl JsonPluginListEntry { fn from_configured_plugin( marketplace_name: &str, + marketplace_source: Option, plugin: codex_core_plugins::ConfiguredMarketplacePlugin, ) -> Self { let version = plugin.installed_version.or(plugin.local_version); @@ -319,6 +334,7 @@ impl JsonPluginListEntry { installed: plugin.installed, enabled: plugin.enabled, source: JsonPluginSource::from_marketplace_source(plugin.source), + marketplace_source, install_policy: install_policy_label(plugin.policy.installation), auth_policy: auth_policy_label(plugin.policy.authentication), } @@ -375,6 +391,44 @@ impl JsonPluginSource { } } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonMarketplaceSource { + source_type: String, + source: String, +} + +fn configured_marketplace_sources( + plugins_input: &PluginsConfigInput, +) -> HashMap { + let Some(user_config) = plugins_input.config_layer_stack.effective_user_config() else { + return HashMap::new(); + }; + let Some(marketplaces) = user_config + .get("marketplaces") + .and_then(toml::Value::as_table) + else { + return HashMap::new(); + }; + + marketplaces + .iter() + .filter_map(|(marketplace_name, marketplace)| { + let source_type = marketplace + .get("source_type") + .and_then(toml::Value::as_str)?; + let source = marketplace.get("source").and_then(toml::Value::as_str)?; + Some(( + marketplace_name.clone(), + JsonMarketplaceSource { + source_type: source_type.to_string(), + source: source.to_string(), + }, + )) + }) + .collect() +} + fn install_policy_label(policy: MarketplacePluginInstallPolicy) -> &'static str { match policy { MarketplacePluginInstallPolicy::NotAvailable => "NOT_AVAILABLE", diff --git a/codex-rs/cli/tests/plugin_cli.rs b/codex-rs/cli/tests/plugin_cli.rs index 4ce3617211..2625e0b68b 100644 --- a/codex-rs/cli/tests/plugin_cli.rs +++ b/codex-rs/cli/tests/plugin_cli.rs @@ -2,6 +2,7 @@ use anyhow::Result; use codex_config::CONFIG_TOML_FILE; use codex_config::MarketplaceConfigUpdate; use codex_config::record_user_marketplace; +use codex_utils_absolute_path::canonicalize_existing_preserving_symlinks; use predicates::prelude::PredicateBooleanExt; use predicates::str::contains; use pretty_assertions::assert_eq; @@ -495,6 +496,7 @@ async fn plugin_list_prints_plugins_in_a_table() -> Result<()> { async fn plugin_list_json_prints_available_plugins_when_requested() -> Result<()> { let (codex_home, source) = setup_local_marketplace()?; let plugin_path = source.path().join("plugins").join("sample"); + let source_path = source.path().to_string_lossy().into_owned(); let assert = codex_command(codex_home.path())? .args(["plugin", "list", "--available", "--json"]) @@ -519,6 +521,69 @@ async fn plugin_list_json_prints_available_plugins_when_requested() -> Result<() "source": "local", "path": plugin_path.display().to_string(), }, + "marketplaceSource": { + "sourceType": "local", + "source": source_path, + }, + "installPolicy": "AVAILABLE", + "authPolicy": "ON_INSTALL", + }, + ], + }) + ); + + Ok(()) +} + +#[tokio::test] +async fn plugin_list_json_includes_configured_git_marketplace_source() -> Result<()> { + let codex_home = TempDir::new()?; + let marketplace_root = codex_home + .path() + .join(".tmp") + .join("marketplaces") + .join("debug"); + write_plugins_enabled_config(codex_home.path())?; + write_marketplace_source(&marketplace_root)?; + let update = MarketplaceConfigUpdate { + last_updated: "2026-06-04T08:39:49Z", + last_revision: Some("abc123"), + source_type: "git", + source: "https://example.com/acme/agent-skills.git", + ref_name: None, + sparse_paths: &[], + }; + record_user_marketplace(codex_home.path(), "debug", &update)?; + let plugin_path = marketplace_root.join("plugins").join("sample"); + let normalized_plugin_path = canonicalize_existing_preserving_symlinks(&plugin_path)?; + + let assert = codex_command(codex_home.path())? + .args(["plugin", "list", "--available", "--json"]) + .assert() + .success(); + let stdout = assert.get_output().stdout.as_slice(); + let actual: serde_json::Value = serde_json::from_slice(stdout)?; + + assert_eq!( + actual, + json!({ + "installed": [], + "available": [ + { + "pluginId": "sample@debug", + "name": "sample", + "marketplaceName": "debug", + "version": "1.2.3", + "installed": false, + "enabled": false, + "source": { + "source": "local", + "path": normalized_plugin_path.display().to_string(), + }, + "marketplaceSource": { + "sourceType": "git", + "source": "https://example.com/acme/agent-skills.git", + }, "installPolicy": "AVAILABLE", "authPolicy": "ON_INSTALL", }, @@ -533,6 +598,7 @@ async fn plugin_list_json_prints_available_plugins_when_requested() -> Result<() async fn plugin_list_json_prints_installed_plugins() -> Result<()> { let (codex_home, source) = setup_local_marketplace()?; let plugin_path = source.path().join("plugins").join("sample"); + let source_path = source.path().to_string_lossy().into_owned(); codex_command(codex_home.path())? .args(["plugin", "add", "sample@debug"]) @@ -561,6 +627,10 @@ async fn plugin_list_json_prints_installed_plugins() -> Result<()> { "source": "local", "path": plugin_path.display().to_string(), }, + "marketplaceSource": { + "sourceType": "local", + "source": source_path, + }, "installPolicy": "AVAILABLE", "authPolicy": "ON_INSTALL", }, From 881cf191d72da0edfc90cbc9fd5b3c30cb6eb9bc Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 4 Jun 2026 11:05:54 -0700 Subject: [PATCH 11/41] app-server: support -c config overrides (#26436) ## Why The standalone `codex-app-server` binary already routed a `CliConfigOverrides` value into app-server startup, but its own clap args did not expose the shared `-c/--config` option. That meant `codex-app-server -c key=value` was rejected before the existing config override path could run, unlike the main `codex` CLI. ## What Changed - Flatten `CliConfigOverrides` into `AppServerArgs` in `codex-rs/app-server/src/main.rs`. - Pass parsed overrides to `run_main_with_transport_options` instead of always using `CliConfigOverrides::default()`. - Add a binary parser test covering both `-c` and `--config` for the standalone app-server. ## Verification - `just test -p codex-app-server app_server_accepts_cli_config_overrides` The broader `just test -p codex-app-server` run was also attempted. It compiled and ran 812 tests, with 796 passing, but failed in this local sandbox on unrelated `sandbox-exec: sandbox_apply: Operation not permitted` command-exec/turn integration paths and a skills watcher timeout. --- codex-rs/app-server/src/main.rs | 10 +++++++- codex-rs/app-server/src/main_tests.rs | 37 +++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 codex-rs/app-server/src/main_tests.rs diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs index 2435c532e8..2412f8c3cf 100644 --- a/codex-rs/app-server/src/main.rs +++ b/codex-rs/app-server/src/main.rs @@ -19,6 +19,9 @@ const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_C #[derive(Debug, Parser)] #[command(version)] struct AppServerArgs { + #[command(flatten)] + config_overrides: CliConfigOverrides, + /// Transport endpoint URL. Supported values: `stdio://` (default), /// `unix://`, `unix://PATH`, `ws://IP:PORT`, `off`. #[arg( @@ -58,6 +61,7 @@ struct AppServerArgs { fn main() -> anyhow::Result<()> { arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move { let AppServerArgs { + config_overrides, listen, session_source, auth, @@ -84,7 +88,7 @@ fn main() -> anyhow::Result<()> { run_main_with_transport_options( arg0_paths, - CliConfigOverrides::default(), + config_overrides, loader_overrides, strict_config, /*default_analytics_enabled*/ false, @@ -123,3 +127,7 @@ fn managed_config_path_from_debug_env() -> Option { None } + +#[cfg(test)] +#[path = "main_tests.rs"] +mod tests; diff --git a/codex-rs/app-server/src/main_tests.rs b/codex-rs/app-server/src/main_tests.rs new file mode 100644 index 0000000000..57d0e5217c --- /dev/null +++ b/codex-rs/app-server/src/main_tests.rs @@ -0,0 +1,37 @@ +use super::AppServerArgs; +use clap::Parser; +use pretty_assertions::assert_eq; +use toml::Value as TomlValue; + +#[test] +fn app_server_accepts_cli_config_overrides() { + let args = AppServerArgs::try_parse_from([ + "codex-app-server", + "-c", + "model=\"gpt-5-codex\"", + "--config", + "sandbox_mode=\"read-only\"", + "--listen", + "off", + ]) + .expect("parse app-server args"); + + let parsed_overrides = args + .config_overrides + .parse_overrides() + .expect("parse config overrides"); + + assert_eq!( + parsed_overrides, + vec![ + ( + "model".to_string(), + TomlValue::String("gpt-5-codex".to_string()), + ), + ( + "sandbox_mode".to_string(), + TomlValue::String("read-only".to_string()), + ), + ] + ); +} From c8fdc74b421fb904e11c07886a3a7dc22fa56ed6 Mon Sep 17 00:00:00 2001 From: stefanstokic-oai Date: Thu, 4 Jun 2026 14:16:03 -0400 Subject: [PATCH 12/41] external-agent-migration: avoid mixed MCP transport configs (#26435) ## Why MCP migration could recursively merge an imported server into an existing same-named Codex server. When one definition used stdio and the other used HTTP, this produced an invalid mixed configuration containing both `command` and `url`. ## What changed - Merge MCP configuration at the server level instead of field by field. - Preserve an existing same-named Codex MCP server unchanged. - Report only MCP servers that would actually be added during detection. - Add regression coverage for mixed command/HTTP source configurations. - Use neutral fixture names and reserved `example.com` URLs. ## Test plan - `just test -p codex-app-server repo_mcp` - 5 tests passed. - `just test -p codex-external-agent-migration mcp_migration_prefers_command_transport_for_mixed_server_config` - 1 test passed. --- .../src/config/external_agent_config.rs | 48 ++++++- .../src/config/external_agent_config_tests.rs | 118 ++++++++++++++++++ codex-rs/external-agent-migration/src/lib.rs | 43 +++++++ 3 files changed, 203 insertions(+), 6 deletions(-) diff --git a/codex-rs/app-server/src/config/external_agent_config.rs b/codex-rs/app-server/src/config/external_agent_config.rs index 18c276f07d..eca927fc23 100644 --- a/codex-rs/app-server/src/config/external_agent_config.rs +++ b/codex-rs/app-server/src/config/external_agent_config.rs @@ -316,9 +316,8 @@ impl ExternalAgentConfigService { Some(self.external_agent_home.as_path()), mcp_settings.as_ref(), )?; - let mcp_server_names = migrated_mcp_server_names(&migrated_mcp); + let mut mcp_server_names = migrated_mcp_server_names(&migrated_mcp); if !is_empty_toml_table(&migrated_mcp) { - let mut should_include = true; if target_config.exists() { let existing_raw = fs::read_to_string(&target_config)?; let mut existing = if existing_raw.trim().is_empty() { @@ -328,10 +327,10 @@ impl ExternalAgentConfigService { invalid_data_error(format!("invalid existing config.toml: {err}")) })? }; - should_include = merge_missing_toml_values(&mut existing, &migrated_mcp)?; + mcp_server_names = merge_missing_mcp_servers(&mut existing, &migrated_mcp)?; } - if should_include { + if !mcp_server_names.is_empty() { items.push(ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::McpServerConfig, description: format!( @@ -341,7 +340,7 @@ impl ExternalAgentConfigService { ), cwd: cwd.clone(), details: Some(MigrationDetails { - mcp_servers: named_migrations(mcp_server_names.clone()), + mcp_servers: named_migrations(mcp_server_names), ..Default::default() }), }); @@ -863,7 +862,7 @@ impl ExternalAgentConfigService { toml::from_str::(&existing_raw) .map_err(|err| invalid_data_error(format!("invalid existing config.toml: {err}")))? }; - if merge_missing_toml_values(&mut existing, &migrated)? { + if !merge_missing_mcp_servers(&mut existing, &migrated)?.is_empty() { write_toml_file(&target_config, &existing)?; } Ok(()) @@ -1538,6 +1537,43 @@ fn merge_missing_toml_values(existing: &mut TomlValue, incoming: &TomlValue) -> } } +fn merge_missing_mcp_servers( + existing: &mut TomlValue, + incoming: &TomlValue, +) -> io::Result> { + let existing_root = existing + .as_table_mut() + .ok_or_else(|| invalid_data_error("expected existing config to be a TOML table"))?; + let incoming_root = incoming + .as_table() + .ok_or_else(|| invalid_data_error("expected migrated MCP config to be a TOML table"))?; + let Some(incoming_servers) = incoming_root.get("mcp_servers") else { + return Ok(Vec::new()); + }; + let incoming_servers = incoming_servers + .as_table() + .ok_or_else(|| invalid_data_error("expected migrated MCP servers to be a TOML table"))?; + let Some(existing_servers) = existing_root.get_mut("mcp_servers") else { + existing_root.insert( + "mcp_servers".to_string(), + TomlValue::Table(incoming_servers.clone()), + ); + return Ok(incoming_servers.keys().cloned().collect()); + }; + let Some(existing_servers) = existing_servers.as_table_mut() else { + return Ok(Vec::new()); + }; + + let mut merged_server_names = Vec::new(); + for (server_name, incoming_server) in incoming_servers { + if !existing_servers.contains_key(server_name) { + existing_servers.insert(server_name.clone(), incoming_server.clone()); + merged_server_names.push(server_name.clone()); + } + } + Ok(merged_server_names) +} + fn write_toml_file(path: &Path, value: &TomlValue) -> io::Result<()> { let serialized = toml::to_string_pretty(value) .map_err(|err| invalid_data_error(format!("failed to serialize config.toml: {err}")))?; diff --git a/codex-rs/app-server/src/config/external_agent_config_tests.rs b/codex-rs/app-server/src/config/external_agent_config_tests.rs index 47f93a5f8b..8c120fb51b 100644 --- a/codex-rs/app-server/src/config/external_agent_config_tests.rs +++ b/codex-rs/app-server/src/config/external_agent_config_tests.rs @@ -638,6 +638,124 @@ Research with Codex carefully.""" assert_eq!(agent, expected_agent); } +#[tokio::test] +async fn import_repo_mcp_preserves_existing_same_named_server() { + let root = TempDir::new().expect("create tempdir"); + let repo_root = root.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).expect("create git dir"); + fs::write( + repo_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "mixedTransport": { + "command": "mcp-remote-proxy", + "args": [ + "https://example.com/mixed-transport", + "--transport", + "http" + ], + "url": "https://example.com/mixed-transport" + } + } + }"#, + ) + .expect("write mcp"); + fs::create_dir_all(repo_root.join(".codex")).expect("create codex dir"); + let existing_config = r#"[mcp_servers.mixedTransport] +url = "https://example.com/mixed-transport" +"#; + fs::write( + repo_root.join(".codex").join("config.toml"), + existing_config, + ) + .expect("write config"); + + let service = service_for_paths( + root.path().join(EXTERNAL_AGENT_DIR), + root.path().join(".codex"), + ); + assert_eq!( + service + .detect(ExternalAgentConfigDetectOptions { + include_home: false, + cwds: Some(vec![repo_root.clone()]), + }) + .await + .expect("detect"), + Vec::::new() + ); + + service + .import(vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::McpServerConfig, + description: String::new(), + cwd: Some(repo_root.clone()), + details: None, + }]) + .await + .expect("import"); + + assert_eq!( + fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"), + existing_config + ); +} + +#[tokio::test] +async fn detect_repo_mcp_lists_only_missing_servers() { + let root = TempDir::new().expect("create tempdir"); + let repo_root = root.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).expect("create git dir"); + fs::write( + repo_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "docs": {"command": "docs-server"}, + "mixedTransport": {"command": "mcp-remote-proxy"} + } + }"#, + ) + .expect("write mcp"); + fs::create_dir_all(repo_root.join(".codex")).expect("create codex dir"); + fs::write( + repo_root.join(".codex").join("config.toml"), + r#"[mcp_servers.mixedTransport] +url = "https://example.com/mixed-transport" +"#, + ) + .expect("write config"); + + let items = service_for_paths( + root.path().join(EXTERNAL_AGENT_DIR), + root.path().join(".codex"), + ) + .detect(ExternalAgentConfigDetectOptions { + include_home: false, + cwds: Some(vec![repo_root.clone()]), + }) + .await + .expect("detect"); + + assert_eq!( + items, + vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::McpServerConfig, + description: format!( + "Migrate MCP servers from {} into {}", + repo_root.display(), + repo_root.join(".codex").join("config.toml").display() + ), + cwd: Some(repo_root), + details: Some(MigrationDetails { + mcp_servers: vec![NamedMigration { + name: "docs".to_string(), + }], + ..Default::default() + }), + }] + ); +} + #[tokio::test] async fn import_home_migrates_supported_config_fields_skills_and_agents_md() { let (_root, external_agent_home, codex_home) = fixture_paths(); diff --git a/codex-rs/external-agent-migration/src/lib.rs b/codex-rs/external-agent-migration/src/lib.rs index d7b8801796..6ee0b38a24 100644 --- a/codex-rs/external-agent-migration/src/lib.rs +++ b/codex-rs/external-agent-migration/src/lib.rs @@ -1451,6 +1451,49 @@ mod tests { ); } + #[test] + fn mcp_migration_prefers_command_transport_for_mixed_server_config() { + let root = tempfile::TempDir::new().expect("tempdir"); + fs::write( + root.path().join(".mcp.json"), + r#"{ + "mcpServers": { + "mixedTransport": { + "command": "mcp-remote-proxy", + "args": [ + "https://example.com/mixed-transport", + "--transport", + "http" + ], + "url": "https://example.com/mixed-transport" + } + } + }"#, + ) + .expect("write mcp"); + + assert_eq!( + build_mcp_config_from_external( + root.path(), + /*external_agent_home*/ None, + /*settings*/ None, + ) + .unwrap(), + toml::from_str( + r#" +[mcp_servers.mixedTransport] +command = "mcp-remote-proxy" +args = [ + "https://example.com/mixed-transport", + "--transport", + "http", +] +"# + ) + .unwrap() + ); + } + #[test] fn mcp_migration_skips_unsupported_transports() { let root = tempfile::TempDir::new().expect("tempdir"); From 9e41f8ddbe087ae6b9bdfb8d5821ab2f09f8c5fe Mon Sep 17 00:00:00 2001 From: kbazzi Date: Thu, 4 Jun 2026 11:24:12 -0700 Subject: [PATCH 13/41] [codex-analytics] emit forked thread id on initialization (#26248) ## Why - Thread initialization analytics do not identify the source thread for forked threads. - The session viewer needs this lineage to construct thread trees. - Depends on openai/openai#987854. Do not release this change before that backend schema change is deployed. ## What Changed - Adds optional `forked_from_thread_id` to `codex_thread_initialized`. - Populates it from the existing thread fork lineage for app-server and in-process subagent initialization paths. - Keeps it null for non-forked threads. ## Verification - `just fmt` - `just test -p codex-analytics` - `just test -p codex-app-server thread_fork_tracks_thread_initialized_analytics` --- .../analytics/src/analytics_client_tests.rs | 23 +++++- codex-rs/analytics/src/events.rs | 2 + codex-rs/analytics/src/facts.rs | 1 + codex-rs/analytics/src/reducer.rs | 2 + .../thread_processor_tests.rs | 1 + .../app-server/tests/suite/v2/thread_fork.rs | 7 ++ codex-rs/core/src/codex_thread.rs | 1 + codex-rs/core/src/session/mod.rs | 3 + codex-rs/core/src/session/session.rs | 1 + codex-rs/core/src/session/tests.rs | 79 +++++++++++++++++++ 10 files changed, 119 insertions(+), 1 deletion(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 59e3f50467..7177952c37 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -1347,6 +1347,7 @@ fn thread_initialized_event_serializes_expected_shape() { initialization_mode: ThreadInitializationMode::New, subagent_source: None, parent_thread_id: None, + forked_from_thread_id: None, created_at: 1, }, }); @@ -1379,6 +1380,7 @@ fn thread_initialized_event_serializes_expected_shape() { "initialization_mode": "new", "subagent_source": null, "parent_thread_id": null, + "forked_from_thread_id": null, "created_at": 1 } }) @@ -2429,6 +2431,7 @@ fn subagent_thread_started_review_serializes_expected_shape() { session_id: "session-root".to_string(), thread_id: "thread-review".to_string(), parent_thread_id: None, + forked_from_thread_id: None, product_client_id: "codex-tui".to_string(), client_name: "codex-tui".to_string(), client_version: "1.0.0".to_string(), @@ -2461,18 +2464,26 @@ fn subagent_thread_started_review_serializes_expected_shape() { assert_eq!(payload["event_params"]["initialization_mode"], "new"); assert_eq!(payload["event_params"]["subagent_source"], "review"); assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!( + payload["event_params"]["forked_from_thread_id"], + json!(null) + ); } #[test] -fn subagent_thread_started_thread_spawn_serializes_parent_thread_id() { +fn subagent_thread_started_thread_spawn_serializes_thread_lineage() { let parent_thread_id = codex_protocol::ThreadId::from_string("11111111-1111-1111-1111-111111111111") .expect("valid thread id"); + let forked_from_thread_id = + codex_protocol::ThreadId::from_string("22222222-2222-4222-8222-222222222222") + .expect("valid thread id"); let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( SubAgentThreadStartedInput { session_id: "session-root".to_string(), thread_id: "thread-spawn".to_string(), parent_thread_id: Some(parent_thread_id.to_string()), + forked_from_thread_id: Some(forked_from_thread_id.to_string()), product_client_id: "codex-tui".to_string(), client_name: "codex-tui".to_string(), client_version: "1.0.0".to_string(), @@ -2497,6 +2508,10 @@ fn subagent_thread_started_thread_spawn_serializes_parent_thread_id() { payload["event_params"]["parent_thread_id"], "11111111-1111-1111-1111-111111111111" ); + assert_eq!( + payload["event_params"]["forked_from_thread_id"], + "22222222-2222-4222-8222-222222222222" + ); assert_eq!(payload["event_params"]["session_id"], "session-root"); } @@ -2507,6 +2522,7 @@ fn subagent_thread_started_memory_consolidation_serializes_expected_shape() { session_id: "session-root".to_string(), thread_id: "thread-memory".to_string(), parent_thread_id: None, + forked_from_thread_id: None, product_client_id: "codex-tui".to_string(), client_name: "codex-tui".to_string(), client_version: "1.0.0".to_string(), @@ -2533,6 +2549,7 @@ fn subagent_thread_started_other_serializes_expected_shape() { session_id: "session-root".to_string(), thread_id: "thread-guardian".to_string(), parent_thread_id: None, + forked_from_thread_id: None, product_client_id: "codex-tui".to_string(), client_name: "codex-tui".to_string(), client_version: "1.0.0".to_string(), @@ -2558,6 +2575,7 @@ fn subagent_thread_started_other_serializes_explicit_parent_thread_id() { session_id: "session-root".to_string(), thread_id: "thread-guardian".to_string(), parent_thread_id: Some(parent_thread_id.to_string()), + forked_from_thread_id: None, product_client_id: "codex-tui".to_string(), client_name: "codex-tui".to_string(), client_version: "1.0.0".to_string(), @@ -2588,6 +2606,7 @@ async fn subagent_thread_started_publishes_without_initialize() { session_id: "session-root".to_string(), thread_id: "thread-review".to_string(), parent_thread_id: None, + forked_from_thread_id: None, product_client_id: "codex-tui".to_string(), client_name: "codex-tui".to_string(), client_version: "1.0.0".to_string(), @@ -2662,6 +2681,7 @@ async fn subagent_thread_started_inherits_parent_connection_for_new_thread() { session_id: "session-root".to_string(), thread_id: "thread-review".to_string(), parent_thread_id: Some(parent_thread_id.to_string()), + forked_from_thread_id: None, product_client_id: "parent-client".to_string(), client_name: "parent-client".to_string(), client_version: "1.0.0".to_string(), @@ -2732,6 +2752,7 @@ async fn subagent_tool_items_inherit_parent_connection_metadata() { session_id: "session-root".to_string(), thread_id: "thread-subagent".to_string(), parent_thread_id: Some("thread-1".to_string()), + forked_from_thread_id: None, product_client_id: "codex-tui".to_string(), client_name: "codex-tui".to_string(), client_version: "1.0.0".to_string(), diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 5367e651e4..bdc2c996dc 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -157,6 +157,7 @@ pub(crate) struct ThreadInitializedEventParams { pub(crate) initialization_mode: ThreadInitializationMode, pub(crate) subagent_source: Option, pub(crate) parent_thread_id: Option, + pub(crate) forked_from_thread_id: Option, pub(crate) created_at: u64, } @@ -1060,6 +1061,7 @@ pub(crate) fn subagent_thread_started_event_request( initialization_mode: ThreadInitializationMode::New, subagent_source: Some(subagent_source_name(&input.subagent_source)), parent_thread_id: input.parent_thread_id, + forked_from_thread_id: input.forked_from_thread_id, created_at: input.created_at, }; ThreadInitializedEvent { diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index b056108507..1dff5de89d 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -348,6 +348,7 @@ pub struct SubAgentThreadStartedInput { pub session_id: String, pub thread_id: String, pub parent_thread_id: Option, + pub forked_from_thread_id: Option, pub product_client_id: String, pub client_name: String, pub client_version: String, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 5b41178530..877000928f 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -1268,6 +1268,7 @@ impl AnalyticsReducer { let session_id = thread.session_id; let thread_id = thread.id; let parent_thread_id = thread.parent_thread_id; + let forked_from_thread_id = thread.forked_from_id; let Some(connection_state) = self.connections.get(&connection_id) else { return; }; @@ -1299,6 +1300,7 @@ impl AnalyticsReducer { initialization_mode, subagent_source: thread_metadata.subagent_source.clone(), parent_thread_id: thread_metadata.parent_thread_id, + forked_from_thread_id, created_at: u64::try_from(thread.created_at).unwrap_or_default(), }, }, diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index 6be022aa6f..c0a909e577 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -701,6 +701,7 @@ mod thread_processor_behavior_tests { }, }, session_source: SessionSource::Cli, + forked_from_thread_id: None, parent_thread_id: None, thread_source: None, }; diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index ff9f655630..a335b441ff 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -490,6 +490,13 @@ async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> { "forked", "user", ); + assert_eq!( + event["event_params"]["forked_from_thread_id"], + thread + .forked_from_id + .as_deref() + .expect("forked thread has a source thread") + ); Ok(()) } diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index ac28a58ec3..2ed8a9ae65 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -70,6 +70,7 @@ pub struct ThreadConfigSnapshot { pub personality: Option, pub collaboration_mode: CollaborationMode, pub session_source: SessionSource, + pub forked_from_thread_id: Option, pub parent_thread_id: Option, pub thread_source: Option, } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 3ea33a6bde..294ab2f211 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -3364,6 +3364,9 @@ pub(crate) fn emit_subagent_session_started( session_id: session_id.to_string(), thread_id: thread_id.to_string(), parent_thread_id: parent_thread_id.map(|thread_id| thread_id.to_string()), + forked_from_thread_id: thread_config + .forked_from_thread_id + .map(|thread_id| thread_id.to_string()), product_client_id: client_name.clone(), client_name, client_version, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 07d249613a..756f87831a 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -185,6 +185,7 @@ impl SessionConfiguration { personality: self.personality, collaboration_mode: self.collaboration_mode.clone(), session_source: self.session_source.clone(), + forked_from_thread_id: self.forked_from_thread_id, parent_thread_id: self.parent_thread_id, thread_source: self.thread_source, } diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 1643574206..93dc1e109f 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3831,6 +3831,85 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati } } +#[tokio::test] +async fn emit_subagent_session_started_includes_fork_lineage_from_session_configuration() { + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/analytics-events/events")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let analytics_events_client = AnalyticsEventsClient::new( + auth_manager, + server.uri(), + /*analytics_enabled*/ Some(true), + ); + + let parent_thread_id = ThreadId::new(); + let forked_from_thread_id = ThreadId::new(); + let child_thread_id = ThreadId::new(); + let mut session_configuration = make_session_configuration_for_tests().await; + session_configuration.forked_from_thread_id = Some(forked_from_thread_id); + + emit_subagent_session_started( + &analytics_events_client, + AppServerClientMetadata { + client_name: Some("codex-tui".to_string()), + client_version: Some("1.0.0".to_string()), + }, + SessionId::from(child_thread_id), + child_thread_id, + Some(parent_thread_id), + session_configuration.thread_config_snapshot(), + SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + }, + ); + + let event = timeout(Duration::from_secs(1), async { + 'wait_for_event: loop { + if let Some(requests) = server.received_requests().await { + for request in requests { + let payload: serde_json::Value = + serde_json::from_slice(&request.body).expect("valid analytics payload"); + if let Some(event) = payload["events"].as_array().and_then(|events| { + events + .iter() + .find(|event| event["event_type"] == "codex_thread_initialized") + }) { + break 'wait_for_event event.clone(); + } + } + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("subagent initialization analytics should be emitted"); + + assert_eq!( + event["event_params"]["parent_thread_id"], + parent_thread_id.to_string() + ); + assert_eq!( + event["event_params"]["forked_from_thread_id"], + forked_from_thread_id.to_string() + ); +} + fn turn_environments_for_tests( environment: &Arc, cwd: &codex_utils_absolute_path::AbsolutePathBuf, From 8b1238856b0839cfdb345ee2af9f02e4ee2959f9 Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Thu, 4 Jun 2026 11:40:18 -0700 Subject: [PATCH 14/41] core: allow excluding tool namespaces from code mode (#26320) ## Why Research and training setups need to control which tool namespaces appear inside code mode's nested `tools` surface without disabling those tools entirely. This makes it possible to train against a deliberately reduced nested-tool setup while preserving the normal direct and deferred tool paths. ## What - Extend `features.code_mode` to accept structured configuration while preserving the existing boolean syntax. - Add an exact `excluded_tool_namespaces` list under `[features.code_mode]`: ```toml [features.code_mode] enabled = true excluded_tool_namespaces = ["mcp__codex_apps", "multi_agent_v1"] ``` - Filter matching canonical `ToolName` namespaces when constructing code mode's nested router and code-mode-specific direct tool descriptions. - Keep excluded tools registered, directly exposed in mixed code mode, and discoverable through top-level `tool_search` when otherwise eligible. - Derive deferred nested-tool guidance after namespace filtering so the `exec` description does not advertise excluded-only deferred tools. - Preserve the boolean/table representation when materializing config locks and update the generated config schema. ## Testing - `just test -p codex-features` - `just test -p codex-config` - `just test -p codex-core load_config_resolves_code_mode_config` - `just test -p codex-core lock_contains_prompts_and_materializes_features` - `just test -p codex-core excluded_deferred_namespaces_do_not_enable_nested_tool_guidance` - `just test -p codex-core code_mode_excludes_configured_nested_tool_namespaces` - `cargo check -p codex-thread-manager-sample` --- codex-rs/config/src/schema.rs | 9 +++ codex-rs/core/config.schema.json | 30 +++++++- codex-rs/core/src/config/config_tests.rs | 26 +++++++ codex-rs/core/src/config/mod.rs | 29 ++++++++ codex-rs/core/src/session/config_lock.rs | 8 ++ codex-rs/core/src/tools/spec_plan.rs | 45 +++++++---- codex-rs/core/src/tools/spec_plan_tests.rs | 36 +++++++++ codex-rs/core/tests/suite/code_mode.rs | 87 ++++++++++++++++++++++ codex-rs/features/src/feature_configs.rs | 20 +++++ codex-rs/features/src/lib.rs | 11 ++- codex-rs/thread-manager-sample/src/main.rs | 1 + 11 files changed, 286 insertions(+), 16 deletions(-) diff --git a/codex-rs/config/src/schema.rs b/codex-rs/config/src/schema.rs index e0d2c70671..6b012494d9 100644 --- a/codex-rs/config/src/schema.rs +++ b/codex-rs/config/src/schema.rs @@ -25,6 +25,15 @@ pub fn features_schema(schema_gen: &mut SchemaGenerator) -> Schema { if feature.id == codex_features::Feature::Artifact { continue; } + if feature.id == codex_features::Feature::CodeMode { + validation.properties.insert( + feature.key.to_string(), + schema_gen.subschema_for::>(), + ); + continue; + } if feature.id == codex_features::Feature::MultiAgentV2 { validation.properties.insert( feature.key.to_string(), diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index b281b44bb7..a584eae263 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -355,6 +355,22 @@ }, "type": "object" }, + "CodeModeConfigToml": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "excluded_tool_namespaces": { + "description": "Exact tool namespaces to omit from the code-mode nested tool surface.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, "ConfigProfile": { "additionalProperties": false, "description": "Collection of common configuration options that a user can define as a unit in `config.toml`.", @@ -410,7 +426,7 @@ "type": "boolean" }, "code_mode": { - "type": "boolean" + "$ref": "#/definitions/FeatureToml_for_CodeModeConfigToml" }, "code_mode_only": { "type": "boolean" @@ -830,6 +846,16 @@ } ] }, + "FeatureToml_for_CodeModeConfigToml": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/CodeModeConfigToml" + } + ] + }, "FeatureToml_for_MultiAgentV2ConfigToml": { "anyOf": [ { @@ -4533,7 +4559,7 @@ "type": "boolean" }, "code_mode": { - "type": "boolean" + "$ref": "#/definitions/FeatureToml_for_CodeModeConfigToml" }, "code_mode_only": { "type": "boolean" diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index dac8286749..ba622e8944 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -476,6 +476,32 @@ async fn load_config_resolves_experimental_request_user_input_enabled() -> std:: Ok(()) } +#[tokio::test] +async fn load_config_resolves_code_mode_config() -> std::io::Result<()> { + let codex_home = tempdir()?; + let config_toml: ConfigToml = toml::from_str( + r#" +[features.code_mode] +enabled = true +excluded_tool_namespaces = ["mcp__codex_apps", "multi_agent_v1"] +"#, + ) + .expect("TOML deserialization should succeed"); + let config = Config::load_from_base_config_with_overrides( + config_toml, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await?; + + assert_eq!( + config.code_mode.excluded_tool_namespaces, + vec!["mcp__codex_apps".to_string(), "multi_agent_v1".to_string()] + ); + assert!(config.features.enabled(Feature::CodeMode)); + Ok(()) +} + #[test] fn rejects_provider_auth_with_env_key() { let err = toml::from_str::( diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 2c3cd4d6c3..e198443b16 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -58,6 +58,7 @@ use codex_core_plugins::PluginsConfigInput; use codex_exec_server::ExecutorFileSystem; use codex_exec_server::LOCAL_FS; use codex_features::AppsMcpPathOverrideConfigToml; +use codex_features::CodeModeConfigToml; use codex_features::Feature; use codex_features::FeatureConfigSource; use codex_features::FeatureOverrides; @@ -977,6 +978,9 @@ pub struct Config { /// Whether to register the experimental request_user_input tool. pub experimental_request_user_input_enabled: bool, + /// Configuration for the experimental code-mode tool surface. + pub code_mode: CodeModeConfig, + /// If set to `true`, used only the experimental unified exec tool. pub use_experimental_unified_exec_tool: bool, @@ -1029,6 +1033,11 @@ pub struct Config { pub otel: codex_config::types::OtelConfig, } +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct CodeModeConfig { + pub excluded_tool_namespaces: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct MultiAgentV2Config { pub max_concurrent_threads_per_session: usize, @@ -2290,6 +2299,17 @@ fn resolve_experimental_request_user_input_enabled(config_toml: &ConfigToml) -> .is_none_or(|config| config.enabled) } +fn resolve_code_mode_config(config_toml: &ConfigToml) -> CodeModeConfig { + let base = code_mode_toml_config(config_toml.features.as_ref()); + + CodeModeConfig { + excluded_tool_namespaces: base + .and_then(|config| config.excluded_tool_namespaces.as_ref()) + .cloned() + .unwrap_or_default(), + } +} + fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config { let base = multi_agent_v2_toml_config(config_toml.features.as_ref()); let default = MultiAgentV2Config::default(); @@ -2372,6 +2392,13 @@ fn resolve_optional_prompt_text( } } +fn code_mode_toml_config(features: Option<&FeaturesToml>) -> Option<&CodeModeConfigToml> { + match features?.code_mode.as_ref()? { + FeatureToml::Enabled(_) => None, + FeatureToml::Config(config) => Some(config), + } +} + fn multi_agent_v2_toml_config(features: Option<&FeaturesToml>) -> Option<&MultiAgentV2ConfigToml> { match features?.multi_agent_v2.as_ref()? { FeatureToml::Enabled(_) => None, @@ -3013,6 +3040,7 @@ impl Config { let web_search_config = resolve_web_search_config(&cfg); let experimental_request_user_input_enabled = resolve_experimental_request_user_input_enabled(&cfg); + let code_mode = resolve_code_mode_config(&cfg); let multi_agent_v2 = resolve_multi_agent_v2_config(&cfg); let apps_mcp_path_override = if features.enabled(Feature::AppsMcpPathOverride) { let base = apps_mcp_path_override_toml_config(cfg.features.as_ref()); @@ -3554,6 +3582,7 @@ impl Config { web_search_mode: constrained_web_search_mode.value, web_search_config, experimental_request_user_input_enabled, + code_mode, use_experimental_unified_exec_tool, background_terminal_max_timeout, ghost_snapshot, diff --git a/codex-rs/core/src/session/config_lock.rs b/codex-rs/core/src/session/config_lock.rs index 85815f8533..5d608ec900 100644 --- a/codex-rs/core/src/session/config_lock.rs +++ b/codex-rs/core/src/session/config_lock.rs @@ -251,6 +251,14 @@ mod tests { spec.key ); } + assert_eq!( + features.code_mode, + Some(FeatureToml::Enabled( + sc.original_config_do_not_use + .features + .enabled(Feature::CodeMode) + )) + ); let multi_agent_v2 = features .multi_agent_v2 diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 48111fb578..eae0644b83 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -207,7 +207,12 @@ fn build_model_visible_specs_and_registry( if exposure.is_direct() && !is_hidden_by_code_mode_only(turn_context, &tool_name, exposure) { let spec = runtime.spec(); - specs.push(spec_for_model_request(turn_context, exposure, spec)); + specs.push(spec_for_model_request( + turn_context, + exposure, + &tool_name, + spec, + )); } } specs.extend(hosted_specs); @@ -226,12 +231,14 @@ fn build_model_visible_specs_and_registry( fn spec_for_model_request( turn_context: &TurnContext, exposure: ToolExposure, + tool_name: &ToolName, spec: ToolSpec, ) -> ToolSpec { if matches!( turn_context.tool_mode, ToolMode::CodeMode | ToolMode::CodeModeOnly ) && exposure != ToolExposure::DirectModelOnly + && !is_excluded_from_code_mode(turn_context, tool_name) && codex_code_mode::is_code_mode_nested_tool(spec.name()) { codex_tools::augment_tool_spec_for_code_mode(spec) @@ -405,10 +412,19 @@ fn is_hidden_by_code_mode_only( )) } +fn is_excluded_from_code_mode(turn_context: &TurnContext, tool_name: &ToolName) -> bool { + tool_name.namespace.as_ref().is_some_and(|namespace| { + turn_context + .config + .code_mode + .excluded_tool_namespaces + .contains(namespace) + }) +} + fn build_code_mode_executors( turn_context: &TurnContext, executors: &[Arc], - deferred_tools_available: bool, ) -> Vec> { if !matches!( turn_context.tool_mode, @@ -419,6 +435,8 @@ fn build_code_mode_executors( let mut code_mode_nested_tool_specs = Vec::new(); let mut exec_prompt_tool_specs = Vec::new(); + let mut deferred_tools_available = false; + let deferred_tools_guidance_enabled = search_tool_enabled(turn_context); for executor in executors { let exposure = executor.exposure(); if exposure == ToolExposure::DirectModelOnly { @@ -428,9 +446,19 @@ fn build_code_mode_executors( if exposure == ToolExposure::Hidden { continue; } + + if is_excluded_from_code_mode(turn_context, &executor.tool_name()) { + continue; + } + let spec = executor.spec(); - if exposure != ToolExposure::Deferred { + if exposure == ToolExposure::Deferred { + // Only show deferred-tool guidance when supported and an included spec is usable by code mode. + deferred_tools_available |= deferred_tools_guidance_enabled + && !collect_code_mode_exec_prompt_tool_definitions(std::iter::once(&spec)) + .is_empty(); + } else { exec_prompt_tool_specs.push(spec.clone()); } code_mode_nested_tool_specs.push(spec); @@ -839,16 +867,7 @@ fn prepend_code_mode_executors( planned_tools: &mut PlannedTools, ) { let turn_context = context.turn_context; - let deferred_tools_available = search_tool_enabled(turn_context) - && planned_tools - .runtimes() - .iter() - .any(|executor| executor.exposure() == ToolExposure::Deferred); - let code_mode_executors = build_code_mode_executors( - turn_context, - planned_tools.runtimes(), - deferred_tools_available, - ); + let code_mode_executors = build_code_mode_executors(turn_context, planned_tools.runtimes()); planned_tools.runtimes.splice(0..0, code_mode_executors); } diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 02a7a21f63..bf5b1c01bb 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -938,6 +938,42 @@ async fn code_mode_only_exposes_code_executor_and_hides_nested_tools() { ); } +#[tokio::test] +async fn excluded_deferred_namespaces_do_not_enable_nested_tool_guidance() { + let plan = probe_with( + |turn| { + set_features(turn, &[Feature::CodeMode, Feature::CodeModeOnly]); + set_feature(turn, Feature::Collab, /*enabled*/ false); + turn.model_info.supports_search_tool = true; + update_config(turn, |config| { + config.code_mode.excluded_tool_namespaces = vec!["excluded".to_string()]; + }); + }, + ToolPlanInputs { + dynamic_tools: vec![dynamic_tool( + Some("excluded"), + "lookup", + /*defer_loading*/ true, + )], + ..ToolPlanInputs::default() + }, + ) + .await; + + let ToolSpec::Freeform(exec) = plan.visible_spec(codex_code_mode::PUBLIC_TOOL_NAME) else { + panic!("expected code mode exec tool"); + }; + assert!( + !exec + .description + .contains("Some deferred nested tools may be omitted") + ); + plan.assert_registered_contains(&[ + &ToolName::namespaced("excluded", "lookup").to_string(), + "tool_search", + ]); +} + #[tokio::test] async fn multi_agent_feature_selects_one_agent_tool_family() { let v1 = probe(|turn| { diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index e33f964aa5..ffe23bff56 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -3165,6 +3165,93 @@ text( Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_mode_excludes_configured_nested_tool_namespaces() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let mut builder = test_codex().with_config(|config| { + let _ = config.features.enable(Feature::CodeMode); + config.code_mode.excluded_tool_namespaces = vec!["excluded".to_string()]; + }); + let base_test = builder.build(&server).await?; + let new_thread = base_test + .thread_manager + .start_thread_with_tools( + base_test.config.clone(), + vec![DynamicToolSpec { + namespace: Some("excluded".to_string()), + name: "lookup".to_string(), + description: "An excluded dynamic tool.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": {}, + "additionalProperties": false, + }), + defer_loading: false, + }], + ) + .await?; + let mut test = base_test; + test.codex = new_thread.thread; + test.session_configured = new_thread.session_configured; + + let first_mock = responses::mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_custom_tool_call( + "call-1", + "exec", + r#" +text(JSON.stringify({ + excludedType: typeof tools.excluded__lookup, + excludedMetadata: ALL_TOOLS.some(({ name }) => name === "excluded__lookup"), + allowedType: typeof tools.update_plan, + allowedMetadata: ALL_TOOLS.some(({ name }) => name === "update_plan"), +})); +"#, + ), + ev_completed("resp-1"), + ]), + ) + .await; + let second_mock = responses::mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ) + .await; + + test.submit_turn("use exec to inspect nested tool namespaces") + .await?; + + assert!( + tool_names(&first_mock.single_request().body_json()).contains(&"excluded".to_string()), + "excluded namespace should remain directly exposed in mixed code mode" + ); + let request = second_mock.single_request(); + let (output, success) = custom_tool_output_body_and_success(&request, "call-1"); + assert_ne!( + success, + Some(false), + "exec configured namespace exclusion failed unexpectedly: {output}" + ); + assert_eq!( + serde_json::from_str::(&output)?, + serde_json::json!({ + "excludedType": "undefined", + "excludedMetadata": false, + "allowedType": "function", + "allowedMetadata": true, + }) + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn code_mode_can_print_content_only_mcp_tool_result_fields() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index 6106431d77..b7f666c21d 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -4,6 +4,26 @@ use serde::Deserialize; use serde::Serialize; use std::collections::BTreeMap; +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CodeModeConfigToml { + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + /// Exact tool namespaces to omit from the code-mode nested tool surface. + #[serde(skip_serializing_if = "Option::is_none")] + pub excluded_tool_namespaces: Option>, +} + +impl FeatureConfig for CodeModeConfigToml { + fn enabled(&self) -> Option { + self.enabled + } + + fn set_enabled(&mut self, enabled: bool) { + self.enabled = Some(enabled); + } +} + #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct MultiAgentV2ConfigToml { diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index ce1ee6940e..a1f5a25c00 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -17,6 +17,7 @@ use toml::Table; mod feature_configs; mod legacy; pub use feature_configs::AppsMcpPathOverrideConfigToml; +pub use feature_configs::CodeModeConfigToml; pub use feature_configs::MultiAgentV2ConfigToml; pub use feature_configs::NetworkProxyConfigToml; pub use feature_configs::NetworkProxyDomainPermissionToml; @@ -600,6 +601,8 @@ pub fn is_known_feature_key(key: &str) -> bool { /// Deserializable features table for TOML. #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)] pub struct FeaturesToml { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub code_mode: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub multi_agent_v2: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -620,6 +623,9 @@ impl Features { impl FeaturesToml { pub fn entries(&self) -> BTreeMap { let mut entries = self.entries.clone(); + if let Some(enabled) = self.code_mode.as_ref().and_then(FeatureToml::enabled) { + entries.insert(Feature::CodeMode.key().to_string(), enabled); + } if let Some(enabled) = self.multi_agent_v2.as_ref().and_then(FeatureToml::enabled) { entries.insert(Feature::MultiAgentV2.key().to_string(), enabled); } @@ -638,6 +644,7 @@ impl FeaturesToml { pub fn materialize_resolved_enabled(&mut self, features: &Features) { let Self { + code_mode, multi_agent_v2, apps_mcp_path_override, network_proxy, @@ -648,7 +655,9 @@ impl FeaturesToml { } for spec in FEATURES { let enabled = features.enabled(spec.id); - if spec.id == Feature::MultiAgentV2 { + if spec.id == Feature::CodeMode { + materialize_resolved_feature_enabled(code_mode, enabled); + } else if spec.id == Feature::MultiAgentV2 { materialize_resolved_feature_enabled(multi_agent_v2, enabled); } else if spec.id == Feature::AppsMcpPathOverride { materialize_resolved_feature_enabled(apps_mcp_path_override, enabled); diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 9305a6f668..47e0fc3911 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -266,6 +266,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R web_search_mode: Constrained::allow_any(WebSearchMode::Disabled), web_search_config: None, experimental_request_user_input_enabled: true, + code_mode: Default::default(), use_experimental_unified_exec_tool: false, background_terminal_max_timeout: 300_000, ghost_snapshot: GhostSnapshotConfig::default(), From c3fcb0e7457dc53aa4422ae4356e1fcc2a206ce6 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Thu, 4 Jun 2026 12:24:26 -0700 Subject: [PATCH 15/41] Use Azure artifact signing environment secrets (#25945) ## Why Windows release signing should read Azure signing credentials from the `azure-artifact-signing` environment instead of the old repo-level `AZURE_TRUSTED_SIGNING_*` names. The smoke runs confirmed the environment secrets resolve with the new `AZURE_ARTIFACT_SIGNING_*` names once the Windows signing job is attached to that environment. ## What Changed - Put the real Windows signing job in the `azure-artifact-signing` environment. - Switch the Windows signing action inputs from `AZURE_TRUSTED_SIGNING_*` to `AZURE_ARTIFACT_SIGNING_*`. - Drop the obsolete `workflow_call.secrets` declarations for the old repo-level secret names; the caller continues to use `secrets: inherit`. - Remove the temporary branch-trigger and Windows-only smoke-test workflow changes before finalizing this PR. ## Validation - `git diff --check -- .github/workflows/rust-release.yml .github/workflows/rust-release-windows.yml` - `ruby -e 'require "yaml"; ARGV.each { |f| YAML.load_file(f); puts "yaml ok: #{f}" }' .github/workflows/rust-release.yml .github/workflows/rust-release-windows.yml` --- .github/workflows/rust-release-windows.yml | 28 +++++++--------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index 89bcd33ea1..b4c2b84c0a 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -6,19 +6,6 @@ on: release-lto: required: true type: string - secrets: - AZURE_TRUSTED_SIGNING_CLIENT_ID: - required: true - AZURE_TRUSTED_SIGNING_TENANT_ID: - required: true - AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID: - required: true - AZURE_TRUSTED_SIGNING_ENDPOINT: - required: true - AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: - required: true - AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: - required: true # Cargo's libgit2 transport has been flaky when fetching git dependencies with # nested submodules. Prefer the system git CLI across every Cargo invocation. @@ -150,6 +137,9 @@ jobs: - build-windows-binaries name: Build - ${{ matrix.runner }} - ${{ matrix.target }} runs-on: ${{ matrix.runs_on }} + environment: + name: azure-artifact-signing + deployment: false timeout-minutes: 90 permissions: contents: read @@ -211,12 +201,12 @@ jobs: with: target: ${{ matrix.target }} binaries: ${{ env.WINDOWS_BINARIES }} - client-id: ${{ secrets.AZURE_TRUSTED_SIGNING_CLIENT_ID }} - tenant-id: ${{ secrets.AZURE_TRUSTED_SIGNING_TENANT_ID }} - subscription-id: ${{ secrets.AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID }} - endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} - account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} - certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + client-id: ${{ secrets.AZURE_ARTIFACT_SIGNING_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_ARTIFACT_SIGNING_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_ARTIFACT_SIGNING_SUBSCRIPTION_ID }} + endpoint: ${{ secrets.AZURE_ARTIFACT_SIGNING_ENDPOINT }} + account-name: ${{ secrets.AZURE_ARTIFACT_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ secrets.AZURE_ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME }} - name: Stage artifacts shell: bash From e64b469bbcf2c2a6bb575ef0a58368f02147e899 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Thu, 4 Jun 2026 12:43:07 -0700 Subject: [PATCH 16/41] Route AGENTS.md loading through environment filesystems (#26205) ## Why Workspace-specific `AGENTS.md` loading needs to use the selected environment filesystem so remote workspaces and child agents read instructions from their actual environment instead of the host filesystem. The app-server should report the same instruction sources the initialized thread actually loaded, rather than independently rescanning configuration and filesystem state. ## What changed - Introduce `LoadedAgentsMd` to retain ordered user, project, and internal instructions with their provenance. - Load and canonicalize workspace `AGENTS.md` paths through the primary `EnvironmentManager` environment, then render the loaded instructions when constructing turn context. - Expose cached loaded instruction sources from initialized threads and use them for app-server start, resume, and fork responses. - Preserve global `CODEX_HOME` loading and separator behavior while excluding empty project files that did not supply model-visible instructions. - Add integration coverage for CLI injection, selected-environment provenance and rendering, empty environment selection, and cached sources on loaded-thread resume. ## Validation - `just test -p codex-core agents_md` - `just test -p codex-core selected_environment_sources_match_model_visible_instructions` - `just test -p codex-exec agents_md` - `just test -p codex-app-server instruction_sources` - `just test -p codex-app-server --status-level fail` --- .../request_processors/thread_processor.rs | 20 +- .../tests/suite/v2/thread_resume.rs | 76 ++++++ .../app-server/tests/suite/v2/thread_start.rs | 77 ++++++ codex-rs/core-api/src/lib.rs | 1 + codex-rs/core/src/agents_md.rs | 204 ++++++++++----- codex-rs/core/src/agents_md_tests.rs | 242 ++++++++++++++++-- codex-rs/core/src/codex_thread.rs | 5 + codex-rs/core/src/config/config_tests.rs | 26 +- codex-rs/core/src/config/mod.rs | 6 +- codex-rs/core/src/guardian/review_session.rs | 3 +- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/session/mod.rs | 24 +- codex-rs/core/src/session/session.rs | 6 +- codex-rs/core/src/session/turn_context.rs | 6 +- .../src/tools/handlers/multi_agents_tests.rs | 6 +- codex-rs/core/tests/suite/agents_md.rs | 53 ++++ codex-rs/core/tests/suite/client.rs | 7 +- .../core/tests/suite/compact_remote_parity.rs | 5 +- codex-rs/core/tests/suite/prompt_caching.rs | 29 ++- .../core/tests/suite/prompt_debug_tests.rs | 5 +- codex-rs/exec/tests/suite/agents_md.rs | 74 ++++++ codex-rs/exec/tests/suite/mod.rs | 1 + 22 files changed, 740 insertions(+), 137 deletions(-) create mode 100644 codex-rs/exec/tests/suite/agents_md.rs diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 265832f9b7..6b4df61aa1 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -659,12 +659,6 @@ impl ThreadRequestProcessor { .map(|response| Some(response.into())) } - async fn instruction_sources_from_config(config: &Config) -> Vec { - codex_core::AgentsMdManager::new(config) - .instruction_sources(LOCAL_FS.as_ref()) - .await - } - async fn load_thread( &self, thread_id: &str, @@ -1043,7 +1037,6 @@ impl ThreadRequestProcessor { .map_err(|err| config_load_error(&err))?; } - let instruction_sources = Self::instruction_sources_from_config(&config).await; let environments = environments.unwrap_or_else(|| { listener_task_context .thread_manager @@ -1113,6 +1106,7 @@ impl ThreadRequestProcessor { ) .await?; + let instruction_sources = thread.instruction_sources().await; let config_snapshot = thread .config_snapshot() .instrument(tracing::info_span!( @@ -2524,13 +2518,12 @@ impl ThreadRequestProcessor { } }; - let instruction_sources = Self::instruction_sources_from_config(&config).await; let response_history = thread_history.clone(); match self .thread_manager .resume_thread_with_history( - config.clone(), + config, thread_history, self.auth_manager.clone(), self.request_trace_context(&request_id).await, @@ -2553,6 +2546,7 @@ impl ThreadRequestProcessor { self.outgoing.send_error(request_id, err).await; return Ok(()); } + let instruction_sources = codex_thread.instruction_sources().await; let SessionConfiguredEvent { rollout_path, .. } = session_configured; let Some(rollout_path) = rollout_path else { let error = @@ -2853,10 +2847,7 @@ impl ThreadRequestProcessor { /*include_turns*/ false, ); thread_summary.session_id = existing_thread.session_configured().session_id.to_string(); - let mut config_for_instruction_sources = self.config.as_ref().clone(); - config_for_instruction_sources.cwd = config_snapshot.cwd.clone(); - let instruction_sources = - Self::instruction_sources_from_config(&config_for_instruction_sources).await; + let instruction_sources = existing_thread.instruction_sources().await; let listener_command_tx = { let thread_state = thread_state.lock().await; @@ -3233,7 +3224,6 @@ impl ThreadRequestProcessor { .map_err(|err| config_load_error(&err))?; let fallback_model_provider = config.model_provider_id.clone(); - let instruction_sources = Self::instruction_sources_from_config(&config).await; let NewThread { thread_id, @@ -3284,6 +3274,8 @@ impl ThreadRequestProcessor { .map_err(|err| core_thread_write_error("inherit source thread name", err))?; } + let instruction_sources = forked_thread.instruction_sources().await; + // Auto-attach a conversation listener when forking a thread. log_listener_attach_result( self.ensure_conversation_listener( diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index d18a405164..4e052041ad 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -256,6 +256,82 @@ async fn thread_resume_with_empty_path_uses_running_thread_id() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_resume_running_thread_uses_cached_instruction_sources() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let workspace = TempDir::new()?; + let project_agents = workspace.path().join("AGENTS.md"); + std::fs::write(&project_agents, "project instructions")?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().display().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, + instruction_sources, + .. + } = to_response::(start_resp)?; + let project_agents = AbsolutePathBuf::try_from(std::fs::canonicalize(project_agents)?)?; + assert_eq!(instruction_sources, vec![project_agents.clone()]); + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + client_user_message_id: None, + input: vec![UserInput::Text { + text: "materialize rollout".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + std::fs::remove_file(project_agents.as_path())?; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id, + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { + instruction_sources, + .. + } = to_response::(resume_resp)?; + + assert_eq!(instruction_sources, vec![project_agents]); + + Ok(()) +} + #[tokio::test] async fn turn_start_updates_runtime_workspace_roots_for_loaded_thread() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 7a7ed5522a..208af14299 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -359,6 +359,83 @@ async fn thread_start_response_includes_loaded_instruction_sources() -> Result<( Ok(()) } +#[tokio::test] +async fn thread_start_response_excludes_empty_project_instruction_source() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + let global_agents_path = codex_home.path().join("AGENTS.md"); + std::fs::write(&global_agents_path, "global instructions")?; + let workspace = TempDir::new()?; + let project_agents_path = workspace.path().join("AGENTS.md"); + std::fs::write(project_agents_path, "")?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().display().to_string()), + ..Default::default() + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ThreadStartResponse { + instruction_sources, + .. + } = to_response::(response)?; + + let instruction_sources = instruction_sources + .into_iter() + .map(normalize_path_for_comparison) + .collect::>(); + let expected_instruction_sources = vec![normalize_path_for_comparison(std::fs::canonicalize( + global_agents_path, + )?)]; + + assert_eq!(instruction_sources, expected_instruction_sources); + + Ok(()) +} + +#[tokio::test] +async fn thread_start_without_selected_environment_excludes_instruction_sources() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + std::fs::write(codex_home.path().join("AGENTS.md"), "global instructions")?; + let workspace = TempDir::new()?; + std::fs::write(workspace.path().join("AGENTS.md"), "project instructions")?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().display().to_string()), + environments: Some(Vec::new()), + ..Default::default() + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ThreadStartResponse { + instruction_sources, + .. + } = to_response::(response)?; + + assert!(instruction_sources.is_empty()); + + Ok(()) +} + #[cfg(windows)] fn normalize_path_for_comparison(path: impl AsRef) -> PathBuf { let path = path.as_ref(); diff --git a/codex-rs/core-api/src/lib.rs b/codex-rs/core-api/src/lib.rs index e87ee82f30..d1c7c7a18b 100644 --- a/codex-rs/core-api/src/lib.rs +++ b/codex-rs/core-api/src/lib.rs @@ -26,6 +26,7 @@ pub use codex_config::types::TuiPetAnchor; pub use codex_config::types::UriBasedFileOpener; pub use codex_core::CodexThread; pub use codex_core::ForkSnapshot; +pub use codex_core::LoadedAgentsMd; pub use codex_core::McpManager; pub use codex_core::NewThread; pub use codex_core::StartThreadOptions; diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index 8048552480..bc3740a46f 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -23,11 +23,9 @@ use codex_config::merge_toml_values; use codex_config::project_root_markers_from_config; use codex_exec_server::Environment; use codex_exec_server::ExecutorFileSystem; -use codex_exec_server::LOCAL_FS; use codex_features::Feature; use codex_prompts::HIERARCHICAL_AGENTS_MESSAGE; use codex_utils_absolute_path::AbsolutePathBuf; -use dunce::canonicalize as normalize_path; use std::io; use toml::Value as TomlValue; use tracing::error; @@ -37,8 +35,8 @@ pub const DEFAULT_AGENTS_MD_FILENAME: &str = "AGENTS.md"; /// Preferred local override for AGENTS.md instructions. pub const LOCAL_AGENTS_MD_FILENAME: &str = "AGENTS.override.md"; -/// When both `Config::instructions` and AGENTS.md docs are present, they will -/// be concatenated with the following separator. +/// When both user and project AGENTS.md docs are present, they will be +/// concatenated with the following separator. const AGENTS_MD_SEPARATOR: &str = "\n\n--- project-doc ---\n\n"; /// Resolves AGENTS.md files into model-visible user instructions and source @@ -47,11 +45,6 @@ pub struct AgentsMdManager<'a> { config: &'a Config, } -pub(crate) struct LoadedAgentsMd { - pub(crate) contents: String, - pub(crate) path: AbsolutePathBuf, -} - impl<'a> AgentsMdManager<'a> { pub fn new(config: &'a Config) -> Self { Self { config } @@ -81,10 +74,7 @@ impl<'a> AgentsMdManager<'a> { let contents = String::from_utf8_lossy(&data); let trimmed = contents.trim(); if !trimmed.is_empty() { - return Some(LoadedAgentsMd { - contents: trimmed.to_string(), - path, - }); + return Some(LoadedAgentsMd::new_user(trimmed.to_string(), path)); } } None @@ -94,10 +84,10 @@ impl<'a> AgentsMdManager<'a> { /// single model-visible instruction string. pub(crate) async fn user_instructions( &self, - environment: Option<&Environment>, + environment: &Environment, startup_warnings: &mut Vec, - ) -> Option { - let fs = environment?.get_filesystem(); + ) -> Option { + let fs = environment.get_filesystem(); self.user_instructions_with_fs(fs.as_ref(), startup_warnings) .await } @@ -106,22 +96,13 @@ impl<'a> AgentsMdManager<'a> { &self, fs: &dyn ExecutorFileSystem, startup_warnings: &mut Vec, - ) -> Option { + ) -> Option { let agents_md_docs = self.read_agents_md(fs, startup_warnings).await; - let mut output = String::new(); - - if let Some(instructions) = self.config.user_instructions.clone() { - output.push_str(&instructions); - } + let mut loaded = self.config.user_instructions.clone().unwrap_or_default(); match agents_md_docs { - Ok(Some(docs)) => { - if !output.is_empty() { - output.push_str(AGENTS_MD_SEPARATOR); - } - output.push_str(&docs); - } + Ok(Some(docs)) => loaded.entries.extend(docs.entries), Ok(None) => {} Err(e) => { error!("error trying to find AGENTS.md docs: {e:#}"); @@ -129,50 +110,26 @@ impl<'a> AgentsMdManager<'a> { }; if self.config.features.enabled(Feature::ChildAgentsMd) { - if !output.is_empty() { - output.push_str("\n\n"); - } - output.push_str(HIERARCHICAL_AGENTS_MESSAGE); + loaded.entries.push(InstructionEntry { + contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(), + provenance: InstructionProvenance::Internal, + }); } - if !output.is_empty() { - Some(output) - } else { - None - } - } - - /// Returns all instruction source files included in the current config. - pub async fn instruction_sources(&self, fs: &dyn ExecutorFileSystem) -> Vec { - let mut global_instruction_warnings = Vec::new(); - let mut paths = Self::load_global_instructions( - LOCAL_FS.as_ref(), - Some(&self.config.codex_home), - &mut global_instruction_warnings, - ) - .await - .map(|loaded| vec![loaded.path]) - .unwrap_or_default(); - match self.agents_md_paths(fs).await { - Ok(agents_md_paths) => paths.extend(agents_md_paths), - Err(err) => { - tracing::warn!(error = %err, "failed to discover AGENTS.md docs for instruction sources"); - } - } - paths + (!loaded.is_empty()).then_some(loaded) } /// Attempt to locate and load AGENTS.md documentation. /// - /// On success returns `Ok(Some(contents))` where `contents` is the - /// concatenation of all discovered docs. If no documentation file is found - /// the function returns `Ok(None)`. Unexpected I/O failures bubble up as - /// `Err` so callers can decide how to handle them. + /// On success returns `Ok(Some(loaded))` where `loaded` contains every + /// discovered doc. If no documentation file is found the function returns + /// `Ok(None)`. Unexpected I/O failures bubble up as `Err` so callers can + /// decide how to handle them. async fn read_agents_md( &self, fs: &dyn ExecutorFileSystem, startup_warnings: &mut Vec, - ) -> io::Result> { + ) -> io::Result> { let max_total = self.config.project_doc_max_bytes; if max_total == 0 { @@ -185,7 +142,7 @@ impl<'a> AgentsMdManager<'a> { } let mut remaining: u64 = max_total as u64; - let mut parts: Vec = Vec::new(); + let mut loaded = LoadedAgentsMd::default(); for p in paths { if remaining == 0 { @@ -221,15 +178,18 @@ impl<'a> AgentsMdManager<'a> { let text = String::from_utf8_lossy(&data).to_string(); if !text.trim().is_empty() { - parts.push(text); + loaded.entries.push(InstructionEntry { + contents: text, + provenance: InstructionProvenance::Project(p), + }); remaining = remaining.saturating_sub(data.len() as u64); } } - if parts.is_empty() { + if loaded.is_empty() { Ok(None) } else { - Ok(Some(parts.join("\n\n"))) + Ok(Some(loaded)) } } @@ -247,8 +207,8 @@ impl<'a> AgentsMdManager<'a> { } let mut dir = self.config.cwd.clone(); - if let Ok(canon) = normalize_path(&dir) { - dir = AbsolutePathBuf::try_from(canon)?; + if let Ok(canonical_dir) = fs.canonicalize(&dir, /*sandbox*/ None).await { + dir = canonical_dir; } let mut merged = TomlValue::Table(toml::map::Map::new()); @@ -348,6 +308,114 @@ impl<'a> AgentsMdManager<'a> { } } +/// Model-visible instructions loaded from AGENTS.md files and internal +/// guidance. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct LoadedAgentsMd { + /// Ordered instructions and their provenance. + entries: Vec, +} + +impl LoadedAgentsMd { + /// Creates loaded instructions containing one user-level AGENTS.md entry. + pub fn new_user(contents: String, path: AbsolutePathBuf) -> Self { + if contents.trim().is_empty() { + return Self::default(); + } + Self { + entries: vec![InstructionEntry { + contents, + provenance: InstructionProvenance::User(path), + }], + } + } + + /// Creates source-less user instructions for tests. + /// + /// This cannot be gated with `#[cfg(test)]` because integration tests + /// compile `codex-core` as a normal dependency without that configuration. + pub fn from_text_for_testing(contents: impl Into) -> Self { + let contents = contents.into(); + if contents.trim().is_empty() { + return Self::default(); + } + Self { + entries: vec![InstructionEntry { + contents, + provenance: InstructionProvenance::Internal, + }], + } + } + + fn is_empty(&self) -> bool { + self.entries + .iter() + .all(|entry| entry.contents.trim().is_empty()) + } + + /// Returns the concatenated model-visible instruction text. + pub fn text(&self) -> String { + let mut output = String::new(); + let mut previous_provenance: Option<&InstructionProvenance> = None; + for entry in &self.entries { + if let Some(previous_provenance) = previous_provenance { + // The project-doc marker tells the model where workspace-scoped + // instructions begin, so it is only needed on the transition + // from user or internal instructions to project instructions. + let separator = match (previous_provenance, &entry.provenance) { + ( + InstructionProvenance::User(_) | InstructionProvenance::Internal, + InstructionProvenance::Project(_), + ) => AGENTS_MD_SEPARATOR, + _ => "\n\n", + }; + output.push_str(separator); + } + output.push_str(&entry.contents); + previous_provenance = Some(&entry.provenance); + } + output + } + + /// Returns the AGENTS.md files that supplied instruction entries. + pub fn sources(&self) -> impl Iterator { + self.entries + .iter() + .filter_map(|entry| entry.provenance.path()) + } +} + +/// One model-visible instruction and its provenance. +#[derive(Clone, Debug, PartialEq, Eq)] +struct InstructionEntry { + /// Model-visible instruction text. + contents: String, + + /// Origin of the instruction. + provenance: InstructionProvenance, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum InstructionProvenance { + /// User-level instructions, normally loaded from CODEX_HOME. + User(AbsolutePathBuf), + + /// Workspace instructions discovered from project AGENTS.md files. + Project(AbsolutePathBuf), + + /// Instructions without a file source, including internally defined guidance. + Internal, +} + +impl InstructionProvenance { + fn path(&self) -> Option<&AbsolutePathBuf> { + match self { + Self::User(path) | Self::Project(path) => Some(path), + Self::Internal => None, + } + } +} + fn warn_invalid_utf8( path: &AbsolutePathBuf, data: &[u8], diff --git a/codex-rs/core/src/agents_md_tests.rs b/codex-rs/core/src/agents_md_tests.rs index 2d6a5fa30b..95659511b0 100644 --- a/codex-rs/core/src/agents_md_tests.rs +++ b/codex-rs/core/src/agents_md_tests.rs @@ -16,6 +16,7 @@ async fn get_user_instructions(config: &Config) -> Option { AgentsMdManager::new(config) .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) .await + .map(|loaded| loaded.text()) } async fn agents_md_paths(config: &Config) -> std::io::Result> { @@ -53,7 +54,12 @@ async fn make_config(root: &TempDir, limit: usize, instructions: Option<&str>) - config.cwd = root.abs(); config.project_doc_max_bytes = limit; - config.user_instructions = instructions.map(ToOwned::to_owned); + config.user_instructions = instructions.map(|text| { + LoadedAgentsMd::new_user( + text.to_owned(), + config.codex_home.join(DEFAULT_AGENTS_MD_FILENAME), + ) + }); config } @@ -96,7 +102,12 @@ async fn make_config_with_project_root_markers( config.cwd = root.abs(); config.project_doc_max_bytes = limit; - config.user_instructions = instructions.map(ToOwned::to_owned); + config.user_instructions = instructions.map(|text| { + LoadedAgentsMd::new_user( + text.to_owned(), + config.codex_home.join(DEFAULT_AGENTS_MD_FILENAME), + ) + }); config } @@ -115,17 +126,46 @@ async fn no_doc_file_returns_none() { assert!(res.is_none(), "Expected None when AGENTS.md is absent"); } -#[tokio::test] -async fn no_environment_returns_none() { - let tmp = tempfile::tempdir().expect("tempdir"); - let config = make_config(&tmp, /*limit*/ 4096, Some("user instructions")).await; +#[test] +fn empty_loaded_instructions_are_empty() { + let source = + AbsolutePathBuf::from_absolute_path("/tmp/AGENTS.md").expect("absolute source path"); - let mut warnings = Vec::new(); - let res = AgentsMdManager::new(&config) - .user_instructions(/*environment*/ None, &mut warnings) - .await; + assert_eq!( + LoadedAgentsMd::new_user(String::new(), source.clone()), + LoadedAgentsMd::default() + ); + assert_eq!( + LoadedAgentsMd::new_user(" \n\t".to_string(), source), + LoadedAgentsMd::default() + ); + assert_eq!( + LoadedAgentsMd::from_text_for_testing(String::new()), + LoadedAgentsMd::default() + ); + assert_eq!( + LoadedAgentsMd::from_text_for_testing(" \n\t"), + LoadedAgentsMd::default() + ); +} - assert_eq!(res, None); +#[test] +fn loaded_instructions_with_only_empty_or_whitespace_entries_are_empty() { + let empty = LoadedAgentsMd { + entries: vec![InstructionEntry { + contents: String::new(), + provenance: InstructionProvenance::Internal, + }], + }; + let whitespace = LoadedAgentsMd { + entries: vec![InstructionEntry { + contents: " \n\t".to_string(), + provenance: InstructionProvenance::Internal, + }], + }; + + assert!(empty.is_empty()); + assert!(whitespace.is_empty()); } /// Small file within the byte-limit is returned unmodified. @@ -161,8 +201,10 @@ async fn global_doc_invalid_utf8_warns_and_uses_lossy_text() { .await .expect("global doc expected"); - assert_eq!(loaded.contents, "global\u{FFFD} doc"); - assert_eq!(loaded.path, path); + assert_eq!( + loaded, + LoadedAgentsMd::new_user("global\u{FFFD} doc".to_string(), path.clone()) + ); assert_invalid_utf8_warning(&warnings, "Global", path.as_path()); } @@ -177,7 +219,8 @@ async fn project_doc_invalid_utf8_warns_and_uses_lossy_text() { let res = AgentsMdManager::new(&config) .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) .await - .expect("doc expected"); + .expect("doc expected") + .text(); assert_eq!(res, "project\u{FFFD} doc"); let canonical_path = dunce::canonicalize(&path).expect("canonical doc path"); @@ -272,6 +315,33 @@ async fn merges_existing_instructions_with_agents_md() { assert_eq!(res, expected); } +#[tokio::test] +async fn sourceless_user_instructions_preserve_separator_without_reporting_a_source() { + let tmp = tempfile::tempdir().expect("tempdir"); + fs::write(tmp.path().join("AGENTS.md"), "project doc").unwrap(); + + let mut cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await; + cfg.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "user instructions".to_string(), + )); + + let mut warnings = Vec::new(); + let loaded = AgentsMdManager::new(&cfg) + .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) + .await + .expect("instructions expected"); + let project_agents = AbsolutePathBuf::try_from( + dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical project doc path"), + ) + .expect("absolute project doc path"); + + assert_eq!( + loaded.text(), + format!("user instructions{AGENTS_MD_SEPARATOR}project doc") + ); + assert_eq!(loaded.sources().collect::>(), vec![&project_agents]); +} + /// If there are existing system instructions but AGENTS.md docs are /// missing we expect the original instructions to be returned unchanged. #[tokio::test] @@ -310,8 +380,38 @@ async fn concatenates_root_and_cwd_docs() { let mut cfg = make_config(&repo, /*limit*/ 4096, /*instructions*/ None).await; cfg.cwd = nested.abs(); - let res = get_user_instructions(&cfg).await.expect("doc expected"); - assert_eq!(res, "root doc\n\ncrate doc"); + let mut warnings = Vec::new(); + let loaded = AgentsMdManager::new(&cfg) + .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) + .await + .expect("doc expected"); + let root_agents = AbsolutePathBuf::try_from( + dunce::canonicalize(repo.path().join("AGENTS.md")).expect("canonical root doc path"), + ) + .expect("absolute root doc path"); + let crate_agents = AbsolutePathBuf::try_from( + dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical crate doc path"), + ) + .expect("absolute crate doc path"); + let expected = LoadedAgentsMd { + entries: vec![ + InstructionEntry { + contents: "root doc".to_string(), + provenance: InstructionProvenance::Project(root_agents.clone()), + }, + InstructionEntry { + contents: "crate doc".to_string(), + provenance: InstructionProvenance::Project(crate_agents.clone()), + }, + ], + }; + + assert_eq!(loaded, expected); + assert_eq!(loaded.text(), "root doc\n\ncrate doc"); + assert_eq!( + loaded.sources().collect::>(), + vec![&root_agents, &crate_agents] + ); } #[tokio::test] @@ -350,6 +450,38 @@ async fn project_root_markers_are_honored_for_agents_discovery() { assert_eq!(res, "parent doc\n\nchild doc"); } +#[tokio::test] +async fn child_agents_message_after_global_instructions_uses_plain_separator() { + let tmp = tempfile::tempdir().expect("tempdir"); + let mut cfg = make_config(&tmp, /*limit*/ 4096, Some("global doc")).await; + cfg.features.enable(Feature::ChildAgentsMd).unwrap(); + + let mut warnings = Vec::new(); + let loaded = AgentsMdManager::new(&cfg) + .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) + .await + .expect("instructions expected"); + let global_agents = cfg.codex_home.join(DEFAULT_AGENTS_MD_FILENAME); + let expected = LoadedAgentsMd { + entries: vec![ + InstructionEntry { + contents: "global doc".to_string(), + provenance: InstructionProvenance::User(global_agents), + }, + InstructionEntry { + contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(), + provenance: InstructionProvenance::Internal, + }, + ], + }; + + assert_eq!(loaded, expected); + assert_eq!( + loaded.text(), + format!("global doc\n\n{HIERARCHICAL_AGENTS_MESSAGE}") + ); +} + #[tokio::test] async fn instruction_sources_include_global_before_agents_md_docs() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -360,15 +492,85 @@ async fn instruction_sources_include_global_before_agents_md_docs() { fs::create_dir_all(&cfg.codex_home).unwrap(); fs::write(&global_agents, "global doc").unwrap(); - let sources = AgentsMdManager::new(&cfg) - .instruction_sources(LOCAL_FS.as_ref()) - .await; + let mut warnings = Vec::new(); + let loaded = AgentsMdManager::new(&cfg) + .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) + .await + .expect("instructions expected"); let project_agents = AbsolutePathBuf::try_from( dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical project doc path"), ) .expect("absolute project doc path"); - assert_eq!(sources, vec![global_agents, project_agents]); + let expected = LoadedAgentsMd { + entries: vec![ + InstructionEntry { + contents: "global doc".to_string(), + provenance: InstructionProvenance::User(global_agents.clone()), + }, + InstructionEntry { + contents: "project doc".to_string(), + provenance: InstructionProvenance::Project(project_agents.clone()), + }, + ], + }; + assert_eq!(loaded, expected); + assert_eq!( + loaded.sources().collect::>(), + vec![&global_agents, &project_agents] + ); + assert_eq!( + loaded.text(), + format!("global doc{AGENTS_MD_SEPARATOR}project doc") + ); +} + +#[tokio::test] +async fn child_agents_message_after_project_docs_is_not_an_instruction_source() { + let tmp = tempfile::tempdir().expect("tempdir"); + fs::write(tmp.path().join("AGENTS.md"), "project doc").unwrap(); + + let mut cfg = make_config(&tmp, /*limit*/ 4096, Some("global doc")).await; + cfg.features.enable(Feature::ChildAgentsMd).unwrap(); + let global_agents = cfg.codex_home.join(DEFAULT_AGENTS_MD_FILENAME); + fs::create_dir_all(&cfg.codex_home).unwrap(); + fs::write(&global_agents, "global doc").unwrap(); + + let mut warnings = Vec::new(); + let loaded = AgentsMdManager::new(&cfg) + .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) + .await + .expect("instructions expected"); + let project_agents = AbsolutePathBuf::try_from( + dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical project doc path"), + ) + .expect("absolute project doc path"); + + let expected = LoadedAgentsMd { + entries: vec![ + InstructionEntry { + contents: "global doc".to_string(), + provenance: InstructionProvenance::User(global_agents.clone()), + }, + InstructionEntry { + contents: "project doc".to_string(), + provenance: InstructionProvenance::Project(project_agents.clone()), + }, + InstructionEntry { + contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(), + provenance: InstructionProvenance::Internal, + }, + ], + }; + assert_eq!(loaded, expected); + assert_eq!( + loaded.sources().collect::>(), + vec![&global_agents, &project_agents] + ); + assert_eq!( + loaded.text(), + format!("global doc{AGENTS_MD_SEPARATOR}project doc\n\n{HIERARCHICAL_AGENTS_MESSAGE}") + ); } /// AGENTS.override.md is preferred over AGENTS.md when both are present. diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 2ed8a9ae65..f9f10881b5 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -549,6 +549,11 @@ impl CodexThread { self.codex.thread_config_snapshot().await } + /// Returns the files that supplied the thread's loaded model instructions. + pub async fn instruction_sources(&self) -> Vec { + self.codex.instruction_sources().await + } + pub async fn config(&self) -> Arc { self.codex.session.get_config().await } diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index ba622e8944..588eb7ac1b 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -208,10 +208,8 @@ async fn load_config_normalizes_relative_cwd_override() -> std::io::Result<()> { #[tokio::test] async fn load_config_loads_global_agents_instructions() -> std::io::Result<()> { let codex_home = tempdir()?; - std::fs::write( - codex_home.path().join(DEFAULT_AGENTS_MD_FILENAME), - "\n global instructions \n", - )?; + let global_agents_path = codex_home.abs().join(DEFAULT_AGENTS_MD_FILENAME); + std::fs::write(&global_agents_path, "\n global instructions \n")?; let mut config = Config::load_from_base_config_with_overrides( ConfigToml::default(), @@ -221,9 +219,14 @@ async fn load_config_loads_global_agents_instructions() -> std::io::Result<()> { .await?; let _ = config.features.enable(Feature::MemoryTool); + let user_instructions = config + .user_instructions + .as_ref() + .expect("global instructions expected"); + assert_eq!(user_instructions.text(), "global instructions"); assert_eq!( - config.user_instructions.as_deref(), - Some("global instructions") + user_instructions.sources().collect::>(), + vec![&global_agents_path] ); Ok(()) } @@ -235,7 +238,7 @@ async fn load_config_prefers_global_agents_override_instructions() -> std::io::R codex_home.path().join(DEFAULT_AGENTS_MD_FILENAME), "global instructions", )?; - let global_agents_override_path = codex_home.path().join(LOCAL_AGENTS_MD_FILENAME); + let global_agents_override_path = codex_home.abs().join(LOCAL_AGENTS_MD_FILENAME); std::fs::write(&global_agents_override_path, "local override instructions")?; let config = Config::load_from_base_config_with_overrides( @@ -245,9 +248,14 @@ async fn load_config_prefers_global_agents_override_instructions() -> std::io::R ) .await?; + let user_instructions = config + .user_instructions + .as_ref() + .expect("global override instructions expected"); + assert_eq!(user_instructions.text(), "local override instructions"); assert_eq!( - config.user_instructions.as_deref(), - Some("local override instructions") + user_instructions.sources().collect::>(), + vec![&global_agents_override_path] ); Ok(()) } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index e198443b16..b74f2feefe 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1,4 +1,5 @@ use crate::agents_md::AgentsMdManager; +pub use crate::agents_md::LoadedAgentsMd; use crate::config::edit::ConfigEdit; use crate::config::edit::ConfigEditsBuilder; use crate::path_utils::normalize_for_native_workdir; @@ -645,7 +646,7 @@ pub struct Config { pub show_raw_agent_reasoning: bool, /// User-provided instructions from AGENTS.md. - pub user_instructions: Option, + pub user_instructions: Option, /// Base instructions override. pub base_instructions: Option, @@ -2605,8 +2606,7 @@ impl Config { Some(&codex_home), &mut startup_warnings, ) - .await - .map(|loaded| loaded.contents); + .await; // Destructure ConfigOverrides fully to ensure all overrides are applied. let ConfigOverrides { diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index c139a910a7..0de607de98 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -29,6 +29,7 @@ use tokio::sync::Semaphore; use tokio_util::sync::CancellationToken; use tracing::warn; +use crate::LoadedAgentsMd; use crate::codex_delegate::run_codex_thread_interactive; use crate::config::Config; use crate::config::Constrained; @@ -146,7 +147,7 @@ struct GuardianReviewSessionReuseKey { permissions: Permissions, developer_instructions: Option, base_instructions: Option, - user_instructions: Option, + user_instructions: Option, compact_prompt: Option, cwd: AbsolutePathBuf, mcp_servers: Constrained>, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 10b3511d5d..b77f96cdb1 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -131,6 +131,7 @@ pub(crate) mod agents_md; pub use agents_md::AgentsMdManager; pub use agents_md::DEFAULT_AGENTS_MD_FILENAME; pub use agents_md::LOCAL_AGENTS_MD_FILENAME; +pub use agents_md::LoadedAgentsMd; mod rollout; pub(crate) mod safety; mod session_rollout_init_error; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 294ab2f211..c0bf9629b7 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -507,12 +507,13 @@ impl Codex { let primary_environment = environment_selections.primary_environment(); let mut user_instruction_warnings = Vec::new(); - let user_instructions = AgentsMdManager::new(&config) - .user_instructions( - primary_environment.as_deref(), - &mut user_instruction_warnings, - ) - .await; + let user_instructions = if let Some(primary_environment) = primary_environment { + AgentsMdManager::new(&config) + .user_instructions(primary_environment.as_ref(), &mut user_instruction_warnings) + .await + } else { + None + }; config.startup_warnings.extend(user_instruction_warnings); let exec_policy = if crate::guardian::is_guardian_reviewer_source(&session_source) { @@ -800,6 +801,17 @@ impl Codex { state.session_configuration.thread_config_snapshot() } + pub(crate) async fn instruction_sources(&self) -> Vec { + let state = self.session.state.lock().await; + state + .session_configuration + .user_instructions + .as_ref() + .map_or_else(Vec::new, |instructions| { + instructions.sources().cloned().collect() + }) + } + pub(crate) async fn thread_environment_selections(&self) -> Vec { let state = self.session.state.lock().await; state.session_configuration.environments.clone() diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 756f87831a..9314a743a5 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1,5 +1,6 @@ use super::input_queue::InputQueue; use super::*; +use crate::agents_md::LoadedAgentsMd; use crate::config::ConstraintError; use crate::goals::GoalRuntimeState; use crate::skills::SkillError; @@ -54,8 +55,9 @@ pub(crate) struct SessionConfiguration { /// Developer instructions that supplement the base instructions. pub(super) developer_instructions: Option, - /// Model instructions that are appended to the base instructions. - pub(super) user_instructions: Option, + /// Model instructions that are appended to the base instructions and the + /// files that supplied them. + pub(super) user_instructions: Option, /// Personality preference for the model. pub(super) personality: Option, diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index fea422cb4b..e62e447155 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -1,5 +1,6 @@ use super::*; use crate::SkillLoadOutcome; +use crate::agents_md::LoadedAgentsMd; use crate::config::GhostSnapshotConfig; use crate::environment_selection::ResolvedTurnEnvironments; use codex_core_skills::HostLoadedSkills; @@ -551,7 +552,10 @@ impl Session { app_server_client_name: session_configuration.app_server_client_name.clone(), developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), - user_instructions: session_configuration.user_instructions.clone(), + user_instructions: session_configuration + .user_instructions + .as_ref() + .map(LoadedAgentsMd::text), collaboration_mode: session_configuration.collaboration_mode.clone(), multi_agent_version, personality: session_configuration.personality, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index d32bd5dd62..6eec6fa2ed 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::LoadedAgentsMd; use crate::ThreadManager; use crate::config::AgentRoleConfig; use crate::config::DEFAULT_AGENT_MAX_DEPTH; @@ -4399,7 +4400,10 @@ async fn build_agent_spawn_config_uses_turn_context_values() { async fn build_agent_spawn_config_preserves_base_user_instructions() { let (_session, mut turn) = make_session_and_context().await; let mut base_config = (*turn.config).clone(); - base_config.user_instructions = Some("base-user".to_string()); + base_config.user_instructions = Some(LoadedAgentsMd::new_user( + "base-user".to_string(), + base_config.codex_home.join("AGENTS.md"), + )); turn.user_instructions = Some("resolved-user".to_string()); turn.config = Arc::new(base_config.clone()); let base_instructions = BaseInstructions { diff --git a/codex-rs/core/tests/suite/agents_md.rs b/codex-rs/core/tests/suite/agents_md.rs index 276fa4bb51..318c288dc6 100644 --- a/codex-rs/core/tests/suite/agents_md.rs +++ b/codex-rs/core/tests/suite/agents_md.rs @@ -1,5 +1,6 @@ use anyhow::Result; use codex_exec_server::CreateDirectoryOptions; +use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; @@ -7,6 +8,8 @@ use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::test_codex::TestCodexBuilder; use core_test_support::test_codex::test_codex; +use std::sync::Arc; +use tempfile::TempDir; async fn agents_instructions(mut builder: TestCodexBuilder) -> Result { let server = start_mock_server().await; @@ -139,3 +142,53 @@ async fn agents_docs_are_concatenated_from_project_root_to_cwd() -> Result<()> { Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn selected_environment_sources_match_model_visible_instructions() -> Result<()> { + let server = start_mock_server().await; + let resp_mock = mount_sse_once( + &server, + sse(vec![ev_response_created("resp1"), ev_completed("resp1")]), + ) + .await; + let home = Arc::new(TempDir::new()?); + let global_agents = home.path().join("AGENTS.md"); + std::fs::write(&global_agents, "global doc")?; + + let mut builder = test_codex() + .with_home(home) + .with_workspace_setup(|cwd, fs| async move { + fs.write_file( + &cwd.join("AGENTS.md"), + b"project doc".to_vec(), + /*sandbox*/ None, + ) + .await?; + Ok::<(), anyhow::Error>(()) + }); + let test = builder.build_with_remote_env(&server).await?; + let project_agents = test + .fs() + .canonicalize( + &test.executor_environment().cwd().join("AGENTS.md"), + /*sandbox*/ None, + ) + .await?; + let global_agents = AbsolutePathBuf::try_from(global_agents).expect("absolute path"); + + assert_eq!( + test.codex.instruction_sources().await, + vec![global_agents, project_agents] + ); + + test.submit_turn("hello").await?; + let instructions = resp_mock + .single_request() + .message_input_texts("user") + .into_iter() + .find(|text| text.starts_with("# AGENTS.md instructions for ")) + .expect("instructions message"); + assert!(instructions.contains("global doc\n\n--- project-doc ---\n\nproject doc")); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index c954a954fb..f430a5c1eb 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1,5 +1,6 @@ use codex_config::ConfigLayerStack; use codex_config::types::AuthCredentialsStoreMode; +use codex_core::LoadedAgentsMd; use codex_core::ModelClient; use codex_core::NewThread; use codex_core::Prompt; @@ -364,7 +365,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { .with_home(codex_home.clone()) .with_config(|config| { // Ensure user instructions are NOT delivered on resume. - config.user_instructions = Some("be nice".to_string()); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing("be nice")); }); let test = builder .resume(&server, codex_home, session_path.clone()) @@ -1180,7 +1181,7 @@ async fn includes_user_instructions_message_in_request() { 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.user_instructions = Some(LoadedAgentsMd::from_text_for_testing("be nice")); }); let codex = builder .build(&server) @@ -2212,7 +2213,7 @@ async fn includes_developer_instructions_message_in_request() { 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.user_instructions = Some(LoadedAgentsMd::from_text_for_testing("be nice")); config.developer_instructions = Some("be useful".to_string()); }); let codex = builder diff --git a/codex-rs/core/tests/suite/compact_remote_parity.rs b/codex-rs/core/tests/suite/compact_remote_parity.rs index 22678fc46f..395f3158dd 100644 --- a/codex-rs/core/tests/suite/compact_remote_parity.rs +++ b/codex-rs/core/tests/suite/compact_remote_parity.rs @@ -5,6 +5,7 @@ use std::path::Path; use std::path::PathBuf; use anyhow::Result; +use codex_core::LoadedAgentsMd; use codex_features::Feature; use codex_login::CodexAuth; use codex_protocol::config_types::ServiceTier; @@ -517,7 +518,9 @@ async fn build_harness_inner( FIXED_CWD, )) .expect("fixed cwd should be absolute"); - config.user_instructions = Some("PARITY_USER_INSTRUCTIONS".to_string()); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "PARITY_USER_INSTRUCTIONS", + )); config.developer_instructions = Some("PARITY_DEVELOPER_INSTRUCTIONS".to_string()); if settings.service_tier_fast { config.service_tier = Some(ServiceTier::Fast.request_value().to_string()); diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index b9afa13852..2a42bd0a16 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -1,5 +1,6 @@ #![allow(clippy::unwrap_used)] +use codex_core::LoadedAgentsMd; use codex_core::shell::default_user_shell; use codex_features::Feature; use codex_prompts::APPLY_PATCH_TOOL_INSTRUCTIONS; @@ -122,7 +123,9 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { .. } = test_codex() .with_config(|config| { - config.user_instructions = Some("be consistent and helpful".to_string()); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "be consistent and helpful", + )); config.model = Some("gpt-5.2".to_string()); // Keep tool expectations stable when the default web_search mode changes. config @@ -237,7 +240,9 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an let TestCodex { codex, .. } = test_codex() .with_config(|config| { - config.user_instructions = Some("be consistent and helpful".to_string()); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "be consistent and helpful", + )); config .features .enable(Feature::CollaborationModes) @@ -319,7 +324,9 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests let TestCodex { codex, config, .. } = test_codex() .with_config(|config| { - config.user_instructions = Some("be consistent and helpful".to_string()); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "be consistent and helpful", + )); config .features .enable(Feature::CollaborationModes) @@ -417,7 +424,9 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an let TestCodex { codex, config, .. } = test_codex() .with_config(|config| { - config.user_instructions = Some("be consistent and helpful".to_string()); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "be consistent and helpful", + )); config .features .enable(Feature::CollaborationModes) @@ -709,7 +718,9 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res let TestCodex { codex, .. } = test_codex() .with_config(|config| { - config.user_instructions = Some("be consistent and helpful".to_string()); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "be consistent and helpful", + )); config .features .enable(Feature::CollaborationModes) @@ -844,7 +855,9 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a .. } = test_codex() .with_config(|config| { - config.user_instructions = Some("be consistent and helpful".to_string()); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "be consistent and helpful", + )); config .features .enable(Feature::CollaborationModes) @@ -985,7 +998,9 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu .. } = test_codex() .with_config(|config| { - config.user_instructions = Some("be consistent and helpful".to_string()); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "be consistent and helpful", + )); config .features .enable(Feature::CollaborationModes) diff --git a/codex-rs/core/tests/suite/prompt_debug_tests.rs b/codex-rs/core/tests/suite/prompt_debug_tests.rs index dc506bc474..bad7e1ab5a 100644 --- a/codex-rs/core/tests/suite/prompt_debug_tests.rs +++ b/codex-rs/core/tests/suite/prompt_debug_tests.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use codex_core::LoadedAgentsMd; use codex_core::build_prompt_input; use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; @@ -21,7 +22,9 @@ async fn build_prompt_input_includes_context_and_user_message() -> Result<()> { }) .build() .await?; - config.user_instructions = Some("Project-specific test instructions".to_string()); + config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( + "Project-specific test instructions", + )); let input = build_prompt_input( config, diff --git a/codex-rs/exec/tests/suite/agents_md.rs b/codex-rs/exec/tests/suite/agents_md.rs new file mode 100644 index 0000000000..2891721ae6 --- /dev/null +++ b/codex-rs/exec/tests/suite/agents_md.rs @@ -0,0 +1,74 @@ +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use core_test_support::responses; +use core_test_support::test_codex_exec::test_codex_exec; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_includes_workspace_agents_md_in_request() -> anyhow::Result<()> { + let test = test_codex_exec(); + std::fs::write(test.cwd_path().join("AGENTS.md"), "workspace instructions")?; + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp1"), + responses::ev_assistant_message("m1", "fixture hello"), + responses::ev_completed("resp1"), + ]); + let response_mock = responses::mount_sse_once(&server, body).await; + + test.cmd_with_server(&server) + .arg("--skip-git-repo-check") + .arg("tell me something") + .assert() + .success(); + + let user_messages = response_mock.single_request().message_input_texts("user"); + assert!( + user_messages + .iter() + .any(|text| text.contains("workspace instructions")), + "request should include workspace AGENTS.md instructions: {user_messages:?}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_prefers_workspace_agents_override_md() -> anyhow::Result<()> { + let test = test_codex_exec(); + std::fs::write(test.cwd_path().join("AGENTS.md"), "base instructions")?; + std::fs::write( + test.cwd_path().join("AGENTS.override.md"), + "override instructions", + )?; + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp1"), + responses::ev_assistant_message("m1", "fixture hello"), + responses::ev_completed("resp1"), + ]); + let response_mock = responses::mount_sse_once(&server, body).await; + + test.cmd_with_server(&server) + .arg("--skip-git-repo-check") + .arg("tell me something") + .assert() + .success(); + + let user_messages = response_mock.single_request().message_input_texts("user"); + assert!( + user_messages + .iter() + .any(|text| text.contains("override instructions")), + "request should include AGENTS.override.md instructions: {user_messages:?}" + ); + assert!( + user_messages + .iter() + .all(|text| !text.contains("base instructions")), + "request should exclude shadowed AGENTS.md instructions: {user_messages:?}" + ); + + Ok(()) +} diff --git a/codex-rs/exec/tests/suite/mod.rs b/codex-rs/exec/tests/suite/mod.rs index 6f86856327..e7452cc17a 100644 --- a/codex-rs/exec/tests/suite/mod.rs +++ b/codex-rs/exec/tests/suite/mod.rs @@ -1,5 +1,6 @@ // Aggregates all former standalone integration tests as modules. mod add_dir; +mod agents_md; mod apply_patch; mod approval_policy; mod auth_env; From 555f8caeff42073fcdc52755491d785adcc81e4a Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 4 Jun 2026 13:03:47 -0700 Subject: [PATCH 17/41] [codex] Fix Windows sandbox build script lint (#26445) ## Why The Windows ARM64 Cargo clippy job on `main` is failing because workspace lints deny `clippy::expect_used`, and the `codex-windows-sandbox` build script used `expect()` while reading `CARGO_MANIFEST_DIR`. ## What changed `codex-rs/windows-sandbox-rs/build.rs` now returns `Result<(), String>` from `main()` and converts a missing `CARGO_MANIFEST_DIR` into an explicit build-script error. The non-Windows early return and Windows linker argument behavior are unchanged. ## Verification - `just clippy -p codex-windows-sandbox -- -D warnings` - `just test -p codex-windows-sandbox` --- codex-rs/windows-sandbox-rs/build.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/codex-rs/windows-sandbox-rs/build.rs b/codex-rs/windows-sandbox-rs/build.rs index af5aec78c6..6ba79e6c75 100644 --- a/codex-rs/windows-sandbox-rs/build.rs +++ b/codex-rs/windows-sandbox-rs/build.rs @@ -4,18 +4,16 @@ use std::path::PathBuf; const SETUP_BIN: &str = "codex-windows-sandbox-setup"; const SETUP_MANIFEST: &str = "codex-windows-sandbox-setup.manifest"; -fn main() { +fn main() -> Result<(), String> { println!("cargo:rerun-if-changed={SETUP_MANIFEST}"); if env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { - return; + return Ok(()); } - let manifest_path = PathBuf::from( - env::var_os("CARGO_MANIFEST_DIR") - .expect("CARGO_MANIFEST_DIR should be set for build scripts"), - ) - .join(SETUP_MANIFEST); + let manifest_dir = env::var_os("CARGO_MANIFEST_DIR") + .ok_or_else(|| "CARGO_MANIFEST_DIR should be set for build scripts".to_string())?; + let manifest_path = PathBuf::from(manifest_dir).join(SETUP_MANIFEST); let manifest_path = manifest_path.display(); // Keep this scoped to the setup helper so Codex binaries that link the @@ -36,4 +34,6 @@ fn main() { } _ => {} } + + Ok(()) } From f97d5c327538a8fe8580fa4dd9ee22c213780a59 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Thu, 4 Jun 2026 13:07:53 -0700 Subject: [PATCH 18/41] build: use ThinLTO for release binaries (#23710) ## Why Fat LTO makes release builds substantially slower without providing enough measured runtime benefit to justify the release CI long pole. The build-profile investigation found that keeping Cargo's default release `opt-level=3` and switching from fat LTO to ThinLTO (`3/thin/1`) reduced a clean `codex-cli` release build from 2073.893 seconds to 1243.172 seconds, a 40.06% improvement. The resulting binary increased from 196.7 MiB to 211.8 MiB (+7.63%). Measured runtime changes were small: the worst image workload median was +0.86% and app-server startup was +0.31% relative to fat LTO. ThinLTO retains cross-crate optimization while avoiding most of the fat-LTO build cost. This deliberately avoids global size optimization: final-executable testing showed a substantial regression on the image request path, which is expected to become more important as image usage grows. ## What changed - Set the workspace release profile to `lto = "thin"`, retaining Cargo's default release `opt-level=3`. - Remove release and CI workflow-specific LTO overrides so release-profile builds consistently use the workspace setting. - Remove the now-unused Windows release workflow input and related diagnostic output. ## Validation - Confirmed the release profile parses with `cargo metadata --no-deps --format-version 1`. - CI validates release builds across the supported target matrix. --- .github/workflows/rust-ci-full.yml | 2 -- .github/workflows/rust-release-windows.yml | 11 ++--------- .github/workflows/rust-release.yml | 6 ------ codex-rs/Cargo.toml | 2 +- 4 files changed, 3 insertions(+), 18 deletions(-) diff --git a/.github/workflows/rust-ci-full.yml b/.github/workflows/rust-ci-full.yml index 60d508b21d..50ecf2f8d6 100644 --- a/.github/workflows/rust-ci-full.yml +++ b/.github/workflows/rust-ci-full.yml @@ -167,8 +167,6 @@ jobs: USE_SCCACHE: ${{ (startsWith(matrix.runner, 'windows') || (matrix.runner == 'macos-15-xlarge' && matrix.target == 'x86_64-apple-darwin')) && 'false' || 'true' }} CARGO_INCREMENTAL: "0" SCCACHE_CACHE_SIZE: 10G - # In rust-ci, representative release-profile checks use thin LTO for faster feedback. - CARGO_PROFILE_RELEASE_LTO: ${{ matrix.profile == 'release' && 'thin' || 'fat' }} strategy: fail-fast: false diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index b4c2b84c0a..14d38b9db5 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -2,10 +2,6 @@ name: rust-release-windows on: workflow_call: - inputs: - release-lto: - required: true - type: string # Cargo's libgit2 transport has been flaky when fetching git dependencies with # nested submodules. Prefer the system git CLI across every Cargo invocation. @@ -16,17 +12,14 @@ jobs: build-windows-binaries: name: Build Windows binaries - ${{ matrix.runner }} - ${{ matrix.target }} - ${{ matrix.bundle }} runs-on: ${{ matrix.runs_on }} - # Windows release builds can exceed an hour on fat-LTO mainline releases, - # so keep the timeout aligned with the top-level release build headroom. + # Windows release builds can exceed an hour, so keep the timeout aligned + # with the top-level release build headroom. timeout-minutes: 90 permissions: contents: read defaults: run: working-directory: codex-rs - env: - CARGO_PROFILE_RELEASE_LTO: ${{ inputs.release-lto }} - strategy: fail-fast: false matrix: diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 5e3b263ed0..2888ea6ce8 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -149,9 +149,6 @@ jobs: run: working-directory: codex-rs env: - # 2026-03-04: temporarily change releases to use thin LTO because - # Ubuntu ARM is timing out at 60 minutes. - CARGO_PROFILE_RELEASE_LTO: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'thin' }} # Use the git CLI instead of Cargo's libgit2 path for git dependencies. # macOS release runners have intermittently failed to fetch nested # submodules through SecureTransport/libgit2, especially libwebrtc's @@ -325,7 +322,6 @@ jobs: for binary in ${{ matrix.binaries }}; do build_args+=(--bin "$binary") done - echo "CARGO_PROFILE_RELEASE_LTO: ${CARGO_PROFILE_RELEASE_LTO}" cargo build --target "$target" --release --timings "${build_args[@]}" - name: Upload Cargo timings @@ -1330,8 +1326,6 @@ jobs: if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode == 'build_unsigned' }} needs: tag-check uses: ./.github/workflows/rust-release-windows.yml - with: - release-lto: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'fat' }} secrets: inherit argument-comment-lint-release-assets: diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 15e0e2c010..5d6d116049 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -501,7 +501,7 @@ debug = "none" strip = "symbols" [profile.release] -lto = "fat" +lto = "thin" split-debuginfo = "off" # Because we bundle some of these executables with the TypeScript CLI, we # remove everything to make the binary as small as possible. From d312a53e2a4419c339c890b79f02b56c19abac85 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 4 Jun 2026 13:15:50 -0700 Subject: [PATCH 19/41] Remove response.processed websocket request (#26447) ## Why The Responses websocket client no longer needs to send a follow-up `response.processed` request after a turn response has already been recorded. Keeping that extra acknowledgement path adds feature-gated control flow and a second websocket request shape that no longer carries useful behavior. ## What Changed - Removed the `response.processed` websocket request type and sender. - Removed the `responses_websocket_response_processed` feature flag and schema entry. - Removed turn and remote-compaction plumbing that only tracked response IDs to send the acknowledgement. - Removed tests that existed solely to cover the deleted feature path. ## Validation - `just fix -p codex-core -p codex-api -p codex-features` --- codex-rs/codex-api/src/common.rs | 7 - .../src/endpoint/responses_websocket.rs | 35 ---- codex-rs/codex-api/src/lib.rs | 1 - codex-rs/core/config.schema.json | 8 +- codex-rs/core/src/client.rs | 17 +- codex-rs/core/src/compact_remote_v2.rs | 25 +-- codex-rs/core/src/session/turn.rs | 13 +- .../core/tests/suite/client_websockets.rs | 162 ------------------ codex-rs/features/src/lib.rs | 8 - codex-rs/features/src/tests.rs | 16 -- 10 files changed, 8 insertions(+), 284 deletions(-) diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index 91b251c41f..50ac2685b4 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -239,11 +239,6 @@ pub struct ResponseCreateWsRequest { pub client_metadata: Option>, } -#[derive(Debug, Serialize)] -pub struct ResponseProcessedWsRequest { - pub response_id: String, -} - pub fn response_create_client_metadata( client_metadata: Option>, trace: Option<&W3cTraceContext>, @@ -272,8 +267,6 @@ pub fn response_create_client_metadata( pub enum ResponsesWsRequest { #[serde(rename = "response.create")] ResponseCreate(ResponseCreateWsRequest), - #[serde(rename = "response.processed")] - ResponseProcessed(ResponseProcessedWsRequest), } pub fn create_text_param_for_request( diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index f0a0019817..a7366a9871 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -1,6 +1,5 @@ use crate::auth::SharedAuthProvider; use crate::common::ResponseEvent; -use crate::common::ResponseProcessedWsRequest; use crate::common::ResponseStream; use crate::common::ResponsesWsRequest; use crate::error::ApiError; @@ -206,40 +205,6 @@ impl ResponsesWebsocketConnection { self.stream.lock().await.is_none() } - #[instrument( - name = "responses_websocket.send_response_processed", - level = "info", - skip_all, - fields(transport = "responses_websocket", api.path = "responses") - )] - #[expect( - clippy::await_holding_invalid_type, - reason = "the guard serializes exclusive use of the websocket while sending a request frame" - )] - pub async fn send_response_processed(&self, response_id: String) -> Result<(), ApiError> { - let request = - ResponsesWsRequest::ResponseProcessed(ResponseProcessedWsRequest { response_id }); - let request_body = serde_json::to_value(&request).map_err(|err| { - ApiError::Stream(format!("failed to encode websocket request: {err}")) - })?; - - let mut guard = self.stream.lock().await; - let Some(ws_stream) = guard.as_mut() else { - return Err(ApiError::Stream( - "websocket connection is closed".to_string(), - )); - }; - - send_websocket_request( - ws_stream, - request_body, - self.idle_timeout, - self.telemetry.as_ref(), - /*connection_reused*/ true, - ) - .await - } - #[instrument( name = "responses_websocket.stream_request", level = "info", diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index 08176d8dec..8efebf076c 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -32,7 +32,6 @@ pub use crate::common::RawMemoryMetadata; pub use crate::common::Reasoning; pub use crate::common::ResponseCreateWsRequest; pub use crate::common::ResponseEvent; -pub use crate::common::ResponseProcessedWsRequest; pub use crate::common::ResponseStream; pub use crate::common::ResponsesApiRequest; pub use crate::common::ResponsesWsRequest; diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index a584eae263..b8215e6fa5 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -572,9 +572,6 @@ "request_rule": { "type": "boolean" }, - "responses_websocket_response_processed": { - "type": "boolean" - }, "responses_websockets": { "type": "boolean" }, @@ -4705,9 +4702,6 @@ "request_rule": { "type": "boolean" }, - "responses_websocket_response_processed": { - "type": "boolean" - }, "responses_websockets": { "type": "boolean" }, @@ -5231,4 +5225,4 @@ }, "title": "ConfigToml", "type": "object" -} \ No newline at end of file +} diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 124dfb18c0..2c6be6222a 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -100,7 +100,6 @@ use tokio::sync::oneshot::error::TryRecvError; use tokio_tungstenite::tungstenite::Error; use tokio_tungstenite::tungstenite::Message; use tokio_util::sync::CancellationToken; -use tracing::debug; use tracing::instrument; use tracing::trace; use tracing::warn; @@ -967,18 +966,6 @@ impl ModelClientSession { .set_connection_reused(/*connection_reused*/ false); } - pub(crate) async fn send_response_processed(&self, response_id: &str) { - let Some(connection) = self.websocket_session.connection.as_ref() else { - return; - }; - if let Err(err) = connection - .send_response_processed(response_id.to_string()) - .await - { - debug!("failed to send response.processed websocket request: {err}"); - } - } - #[allow(clippy::too_many_arguments)] /// Builds shared Responses API transport options and request-body options. /// @@ -1672,9 +1659,7 @@ fn parse_turn_metadata_header(turn_metadata_header: Option<&str>) -> Option, } @@ -409,7 +400,7 @@ async fn collect_compaction_output( let mut output_item_count = 0usize; let mut compaction_count = 0usize; let mut compaction_output = None; - let mut completed_response_id = None; + let mut saw_completed = false; let mut completed_token_usage = None; while let Some(event) = stream.next().await { match event? { @@ -422,12 +413,8 @@ async fn collect_compaction_output( } } } - ResponseEvent::Completed { - response_id, - token_usage, - .. - } => { - completed_response_id = Some(response_id); + ResponseEvent::Completed { token_usage, .. } => { + saw_completed = true; completed_token_usage = token_usage; break; } @@ -435,12 +422,12 @@ async fn collect_compaction_output( } } - let Some(response_id) = completed_response_id else { + if !saw_completed { return Err(CodexErr::Stream( "remote compaction v2 stream closed before response.completed".to_string(), None, )); - }; + } if compaction_count != 1 { return Err(CodexErr::Fatal(format!( @@ -453,7 +440,6 @@ async fn collect_compaction_output( }; Ok(RemoteCompactionV2Output { compaction_output, - response_id, token_usage: completed_token_usage, }) } @@ -804,7 +790,6 @@ mod tests { .expect("compaction should be collected"); assert_eq!(output.compaction_output, compaction); - assert_eq!(output.response_id, "resp-compact"); assert_eq!( output.token_usage, Some(TokenUsage { diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 2765de76fb..03b1c4b1db 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1827,7 +1827,6 @@ async fn try_run_sampling_request( !sess.services.extensions.turn_item_contributors().is_empty(); let mut active_item_is_streaming_to_client = false; let receiving_span = trace_span!("receiving_stream"); - let mut completed_response_id: Option = None; let outcome: CodexResult = loop { let handle_responses = trace_span!( parent: &receiving_span, @@ -2071,9 +2070,9 @@ async fn try_run_sampling_request( sess.services.models_manager.refresh_if_new_etag(etag).await; } ResponseEvent::Completed { - response_id, token_usage, end_turn, + .. } => { flush_assistant_text_segments_all( &sess, @@ -2089,7 +2088,6 @@ async fn try_run_sampling_request( if let Some(false) = end_turn { needs_follow_up = true; } - completed_response_id = Some(response_id); break Ok(SamplingRequestResult { needs_follow_up, last_agent_message, @@ -2213,15 +2211,6 @@ async fn try_run_sampling_request( ) .await; - if sess - .features - .enabled(Feature::ResponsesWebsocketResponseProcessed) - && outcome.is_ok() - && let Some(response_id) = completed_response_id.as_deref() - { - client_session.send_response_processed(response_id).await; - } - drain_in_flight(&mut in_flight, sess.clone(), turn_context.clone()).await?; if should_emit_token_count { diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 41db88f1ad..9dee201d64 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -185,168 +185,6 @@ async fn responses_websocket_streams_without_feature_flag_when_provider_supports server.shutdown().await; } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn responses_websocket_sends_response_processed_when_feature_enabled() { - skip_if_no_network!(); - - let server = start_websocket_server(vec![vec![ - vec![ - ev_response_created("resp-prewarm"), - ev_completed("resp-prewarm"), - ], - vec![ - ev_response_created("resp-1"), - ev_assistant_message("msg-1", "hi"), - ev_completed("resp-1"), - ], - vec![], - ]]) - .await; - - let mut builder = test_codex().with_config(|config| { - config - .features - .enable(Feature::ResponsesWebsocketResponseProcessed) - .expect("test config should allow feature update"); - }); - let test = builder - .build_with_websocket_server(&server) - .await - .expect("build websocket codex"); - - test.submit_turn("hello") - .await - .expect("submission should send response.processed after processing"); - - let processed = server - .wait_for_request(/*connection_index*/ 0, /*request_index*/ 2) - .await; - assert_eq!( - processed.body_json(), - json!({ - "type": "response.processed", - "response_id": "resp-1", - }) - ); - - let connection = server.single_connection(); - assert_eq!(connection.len(), 3); - assert_eq!( - connection[1].body_json()["type"].as_str(), - Some("response.create") - ); - - server.shutdown().await; -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn responses_websocket_sends_response_processed_after_remote_compaction_v2() { - skip_if_no_network!(); - - let server = start_websocket_server(vec![vec![ - vec![ - ev_response_created("resp-prewarm"), - ev_completed("resp-prewarm"), - ], - vec![ - ev_response_created("resp-1"), - ev_assistant_message("msg-1", "hi"), - ev_completed("resp-1"), - ], - vec![], - vec![ - json!({ - "type": "response.output_item.done", - "item": { - "type": "compaction", - "encrypted_content": "ENCRYPTED_CONTEXT_COMPACTION_SUMMARY", - } - }), - ev_completed("resp-compact"), - ], - vec![], - ]]) - .await; - - let mut builder = test_codex().with_config(|config| { - config - .features - .enable(Feature::RemoteCompactionV2) - .expect("test config should allow feature update"); - config - .features - .enable(Feature::ResponsesWebsocketResponseProcessed) - .expect("test config should allow feature update"); - }); - let test = builder - .build_with_websocket_server(&server) - .await - .expect("build websocket codex"); - - test.submit_turn("hello") - .await - .expect("submission should send response.processed after processing"); - - test.codex - .submit(Op::Compact) - .await - .expect("compact submission should succeed"); - wait_for_event(&test.codex, |msg| matches!(msg, EventMsg::TurnComplete(_))).await; - - let compact_processed = server - .wait_for_request(/*connection_index*/ 0, /*request_index*/ 4) - .await; - assert_eq!( - compact_processed.body_json(), - json!({ - "type": "response.processed", - "response_id": "resp-compact", - }) - ); - - let connection = server.single_connection(); - assert_eq!(connection.len(), 5); - assert_eq!( - connection[3].body_json()["type"].as_str(), - Some("response.create") - ); - - server.shutdown().await; -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn responses_websocket_omits_response_processed_without_feature() { - skip_if_no_network!(); - - let server = start_websocket_server(vec![vec![ - vec![ - ev_response_created("resp-prewarm"), - ev_completed("resp-prewarm"), - ], - vec![ - ev_response_created("resp-1"), - ev_assistant_message("msg-1", "hi"), - ev_completed("resp-1"), - ], - vec![], - ]]) - .await; - let mut builder = test_codex(); - let test = builder - .build_with_websocket_server(&server) - .await - .expect("build websocket codex"); - - test.submit_turn("hello") - .await - .expect("submission should complete without response.processed"); - - let connection = server.single_connection(); - assert_eq!(connection.len(), 2); - - server.shutdown().await; -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn responses_websocket_reuses_connection_with_per_turn_trace_payloads() { skip_if_no_network!(); diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index a1f5a25c00..3ba973b804 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -207,8 +207,6 @@ pub enum Feature { RealtimeConversation, /// Prevent idle system sleep while a turn is actively running. PreventIdleSleep, - /// Send `response.processed` over Responses API websockets after a turn response is recorded. - ResponsesWebsocketResponseProcessed, /// Enable remote compaction v2 over the normal Responses API. RemoteCompactionV2, /// Enable workspace dependency support. @@ -1228,12 +1226,6 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Removed, default_enabled: false, }, - FeatureSpec { - id: Feature::ResponsesWebsocketResponseProcessed, - key: "responses_websocket_response_processed", - stage: Stage::UnderDevelopment, - default_enabled: false, - }, FeatureSpec { id: Feature::RemoteCompactionV2, key: "remote_compaction_v2", diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 5d7087e90b..aa5e0174ba 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -146,22 +146,6 @@ fn remote_compaction_v2_is_under_development() { ); } -#[test] -fn responses_websocket_response_processed_is_under_development() { - assert_eq!( - Feature::ResponsesWebsocketResponseProcessed.stage(), - Stage::UnderDevelopment - ); - assert_eq!( - Feature::ResponsesWebsocketResponseProcessed.default_enabled(), - false - ); - assert_eq!( - feature_for_key("responses_websocket_response_processed"), - Some(Feature::ResponsesWebsocketResponseProcessed) - ); -} - #[test] fn terminal_resize_reflow_is_experimental_and_enabled_by_default() { assert_eq!( From 4a70e0ac1b39c6da6863069bebc82ce3513bc8b5 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Thu, 4 Jun 2026 13:35:31 -0700 Subject: [PATCH 20/41] Cleanup experimentalFeature/enablement/set (#26312) ## Why `experimentalFeature/enablement/set` still allowed several keys that no longer need to be managed through this API. Keeping those keys also preserved corresponding special-case logic, including refreshing the apps list when the `apps` key was enabled. The endpoint also rejected an entire request when any key was invalid or unsupported. That makes clients brittle when they send a mix of current and stale keys, even when the valid entries can still be applied safely. ## What changed - remove the feature keys that no longer need to be supported by `experimentalFeature/enablement/set` - remove the corresponding apps-list refresh path and its auth/config plumbing - ignore and warn on invalid or unsupported keys while still applying valid keys from the same request - update the app-server documentation and integration coverage for the reduced key set and partial-acceptance behavior ## Test plan - `just test -p codex-app-server experimental_feature_enablement_set` (6 passed) - `just test -p codex-app-server` exercised the changed tests successfully; unrelated sandbox-dependent and watcher/timing tests failed locally --- codex-rs/app-server/README.md | 2 +- codex-rs/app-server/src/message_processor.rs | 3 +- .../request_processors/config_processor.rs | 114 ++---------------- .../app-server/tests/suite/v2/app_list.rs | 104 ---------------- .../suite/v2/experimental_feature_list.rs | 103 ++++++++-------- 5 files changed, 63 insertions(+), 263 deletions(-) diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index dc02ff8c04..1f5d01a429 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -192,7 +192,7 @@ Example with notification opt-out: - `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider. - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`. - `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.]` entries to be included in the current catalog view. -- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for currently supported feature keys. For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. +- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for currently supported feature keys. For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. Invalid keys will be ignored. - `environment/add` — experimental; add or replace a named remote environment by `environmentId` and `execServerUrl` for later selection by `thread/start` or `turn/start`; returns `{}` and does not change the default environment. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). Built-in presets do not select a model; the Plan preset selects medium reasoning effort. This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 84f22809ee..d89f0b0645 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -455,14 +455,13 @@ impl MessageProcessor { .plugins_manager() .maybe_start_plugin_startup_tasks_for_config( &config.plugins_config_input(), - auth_manager.clone(), + auth_manager, Some(on_effective_plugins_changed), ); } let config_processor = ConfigRequestProcessor::new( outgoing.clone(), config_manager.clone(), - auth_manager, thread_manager.clone(), analytics_events_client, ); diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index 3571026193..3753550925 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -7,7 +7,6 @@ use crate::error_code::invalid_request; use crate::outgoing_message::ConnectionRequestId; use crate::outgoing_message::OutgoingMessageSender; use codex_analytics::AnalyticsEventsClient; -use codex_app_server_protocol::AppListUpdatedNotification; use codex_app_server_protocol::ClientResponsePayload; use codex_app_server_protocol::ComputerUseRequirements; use codex_app_server_protocol::ConfigBatchWriteParams; @@ -29,9 +28,7 @@ use codex_app_server_protocol::NetworkDomainPermission; use codex_app_server_protocol::NetworkRequirements; use codex_app_server_protocol::NetworkUnixSocketPermission; use codex_app_server_protocol::SandboxMode; -use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::WindowsSandboxSetupMode; -use codex_chatgpt::connectors; use codex_config::ConfigRequirementsToml; use codex_config::HookEventsToml; use codex_config::HookHandlerConfig as CoreHookHandlerConfig; @@ -42,7 +39,6 @@ use codex_config::SandboxModeRequirement as CoreSandboxModeRequirement; use codex_core::ThreadManager; use codex_features::canonical_feature_for_key; use codex_features::feature_for_key; -use codex_login::AuthManager; use codex_model_provider::create_model_provider; use codex_plugin::PluginId; use codex_protocol::config_types::WebSearchMode; @@ -50,21 +46,18 @@ use serde_json::json; use std::path::PathBuf; const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[ - "apps", + "auth_elicitation", "memories", "mentions_v2", - "plugins", "remote_control", "remote_plugin", "tool_suggest", - "tool_call_mcp_elicitation", ]; #[derive(Clone)] pub(crate) struct ConfigRequestProcessor { outgoing: Arc, config_manager: ConfigManager, - auth_manager: Arc, thread_manager: Arc, analytics_events_client: AnalyticsEventsClient, } @@ -73,14 +66,12 @@ impl ConfigRequestProcessor { pub(crate) fn new( outgoing: Arc, config_manager: ConfigManager, - auth_manager: Arc, thread_manager: Arc, analytics_events_client: AnalyticsEventsClient, ) -> Self { Self { outgoing, config_manager, - auth_manager, thread_manager, analytics_events_client, } @@ -151,7 +142,6 @@ impl ConfigRequestProcessor { request_id: ConnectionRequestId, params: ExperimentalFeatureEnablementSetParams, ) -> Result, JSONRPCErrorError> { - let should_refresh_apps_list = params.enablement.get("apps").copied() == Some(true); let response = self .handle_config_mutation_result(self.set_experimental_feature_enablement(params).await) .await?; @@ -161,10 +151,6 @@ impl ConfigRequestProcessor { ClientResponsePayload::ExperimentalFeatureEnablementSet(response), ) .await; - if should_refresh_apps_list { - self.refresh_apps_list_after_experimental_feature_enablement_set() - .await; - } Ok(None) } @@ -195,71 +181,6 @@ impl ConfigRequestProcessor { Ok(response) } - async fn refresh_apps_list_after_experimental_feature_enablement_set(&self) { - let config = match self.load_latest_config(/*fallback_cwd*/ None).await { - Ok(config) => config, - Err(error) => { - tracing::warn!( - "failed to load config for apps list refresh after experimental feature enablement: {}", - error.message - ); - return; - } - }; - let auth = self.auth_manager.auth().await; - if !config.features.apps_enabled_for_auth( - auth.as_ref() - .is_some_and(codex_login::CodexAuth::uses_codex_backend), - ) { - return; - } - - let outgoing = Arc::clone(&self.outgoing); - let environment_manager = self.thread_manager.environment_manager(); - tokio::spawn(async move { - let (all_connectors_result, accessible_connectors_result) = tokio::join!( - connectors::list_all_connectors_with_options(&config, /*force_refetch*/ true), - connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager( - &config, - /*force_refetch*/ true, - Arc::clone(&environment_manager), - ), - ); - let all_connectors = match all_connectors_result { - Ok(connectors) => connectors, - Err(err) => { - tracing::warn!( - "failed to force-refresh directory apps after experimental feature enablement: {err:#}" - ); - return; - } - }; - let accessible_connectors = match accessible_connectors_result { - Ok(status) => status.connectors, - Err(err) => { - tracing::warn!( - "failed to force-refresh accessible apps after experimental feature enablement: {err:#}" - ); - return; - } - }; - - let data = connectors::with_app_enabled_state( - connectors::merge_connectors_with_accessible( - all_connectors, - accessible_connectors, - /*all_connectors_loaded*/ true, - ), - &config, - ); - outgoing - .send_server_notification(ServerNotification::AppListUpdated( - AppListUpdatedNotification { data }, - )) - .await; - }); - } - async fn load_latest_config( &self, fallback_cwd: Option, @@ -317,28 +238,19 @@ impl ConfigRequestProcessor { &self, params: ExperimentalFeatureEnablementSetParams, ) -> Result { - let ExperimentalFeatureEnablementSetParams { enablement } = params; - for key in enablement.keys() { - if canonical_feature_for_key(key).is_some() { - if SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.contains(&key.as_str()) { - continue; - } - - return Err(invalid_request(format!( - "unsupported feature enablement `{key}`: currently supported features are {}", - SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.join(", ") - ))); + let ExperimentalFeatureEnablementSetParams { mut enablement } = params; + let mut invalid_keys = Vec::new(); + enablement.retain(|key, _| { + let valid = canonical_feature_for_key(key).is_some() + && SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.contains(&key.as_str()); + if !valid { + invalid_keys.push(key.clone()); } - - let message = if let Some(feature) = feature_for_key(key) { - format!( - "invalid feature enablement `{key}`: use canonical feature key `{}`", - feature.key() - ) - } else { - format!("invalid feature enablement `{key}`") - }; - return Err(invalid_request(message)); + valid + }); + if !invalid_keys.is_empty() { + let invalid_keys = invalid_keys.join(", "); + tracing::warn!("ignoring invalid experimental feature enablement keys: {invalid_keys}"); } if enablement.is_empty() { diff --git a/codex-rs/app-server/tests/suite/v2/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs index ea7ff8fa4b..2177106f8f 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -1,5 +1,4 @@ use std::borrow::Cow; -use std::collections::BTreeMap; use std::collections::HashMap; use std::sync::Arc; use std::sync::Mutex as StdMutex; @@ -28,7 +27,6 @@ use codex_app_server_protocol::AppScreenshot; use codex_app_server_protocol::AppsListParams; use codex_app_server_protocol::AppsListResponse; use codex_app_server_protocol::AuthMode; -use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -1335,108 +1333,6 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu Ok(()) } -#[tokio::test] -async fn experimental_feature_enablement_set_refreshes_apps_list_when_apps_turn_on() -> Result<()> { - let initial_connectors = vec![AppInfo { - id: "alpha".to_string(), - name: "Alpha".to_string(), - description: Some("Alpha v1".to_string()), - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: None, - is_accessible: false, - is_enabled: true, - plugin_display_names: Vec::new(), - }]; - let (server_url, server_handle, server_control) = start_apps_server_with_delays_and_control( - initial_connectors, - Vec::new(), - Duration::ZERO, - Duration::ZERO, - ) - .await?; - - let codex_home = TempDir::new()?; - write_connectors_config(codex_home.path(), &server_url)?; - write_chatgpt_auth( - codex_home.path(), - ChatGptAuthFixture::new("chatgpt-token") - .account_id("account-123") - .chatgpt_user_id("user-enable-refresh") - .chatgpt_account_id("account-123"), - AuthCredentialsStoreMode::File, - )?; - - let mut mcp = TestAppServer::new(codex_home.path()).await?; - timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - - let disable_request = mcp - .send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams { - enablement: BTreeMap::from([("apps".to_string(), false)]), - }) - .await?; - let _disable_response: JSONRPCResponse = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(disable_request)), - ) - .await??; - - server_control.set_connectors(vec![AppInfo { - id: "alpha".to_string(), - name: "Alpha".to_string(), - description: Some("Alpha v2".to_string()), - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: None, - is_accessible: false, - is_enabled: true, - plugin_display_names: Vec::new(), - }]); - server_control.set_tools(vec![connector_tool("alpha", "Alpha App")?]); - - let enable_request = mcp - .send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams { - enablement: BTreeMap::from([("apps".to_string(), true)]), - }) - .await?; - let _enable_response: JSONRPCResponse = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(enable_request)), - ) - .await??; - - let update = read_app_list_updated_notification(&mut mcp).await?; - assert_eq!( - update.data, - vec![AppInfo { - id: "alpha".to_string(), - name: "Alpha".to_string(), - description: Some("Alpha v2".to_string()), - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), - is_accessible: true, - is_enabled: true, - plugin_display_names: Vec::new(), - }] - ); - - server_handle.abort(); - Ok(()) -} - async fn read_app_list_updated_notification( mcp: &mut TestAppServer, ) -> Result { diff --git a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs index 60a0f94839..797736b7af 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs @@ -267,13 +267,15 @@ async fn experimental_feature_enablement_set_applies_to_global_and_thread_config let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - let actual = - set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)])) - .await?; + let actual = set_experimental_feature_enablement( + &mut mcp, + BTreeMap::from([("auth_elicitation".to_string(), true)]), + ) + .await?; assert_eq!( actual, ExperimentalFeatureEnablementSetResponse { - enablement: BTreeMap::from([("apps".to_string(), true)]), + enablement: BTreeMap::from([("auth_elicitation".to_string(), true)]), } ); @@ -284,7 +286,7 @@ async fn experimental_feature_enablement_set_applies_to_global_and_thread_config config .additional .get("features") - .and_then(|features| features.get("apps")), + .and_then(|features| features.get("auth_elicitation")), Some(&json!(true)) ); } @@ -333,16 +335,18 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)])) - .await?; + set_experimental_feature_enablement( + &mut mcp, + BTreeMap::from([("mentions_v2".to_string(), true)]), + ) + .await?; let actual = set_experimental_feature_enablement( &mut mcp, BTreeMap::from([ + ("auth_elicitation".to_string(), true), ("memories".to_string(), true), - ("plugins".to_string(), true), ("remote_plugin".to_string(), true), - ("tool_suggest".to_string(), true), - ("tool_call_mcp_elicitation".to_string(), false), + ("tool_suggest".to_string(), false), ]), ) .await?; @@ -351,11 +355,10 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re actual, ExperimentalFeatureEnablementSetResponse { enablement: BTreeMap::from([ + ("auth_elicitation".to_string(), true), ("memories".to_string(), true), - ("plugins".to_string(), true), ("remote_plugin".to_string(), true), - ("tool_suggest".to_string(), true), - ("tool_call_mcp_elicitation".to_string(), false), + ("tool_suggest".to_string(), false), ]), } ); @@ -366,7 +369,14 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re config .additional .get("features") - .and_then(|features| features.get("apps")), + .and_then(|features| features.get("mentions_v2")), + Some(&json!(true)) + ); + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("auth_elicitation")), Some(&json!(true)) ); assert_eq!( @@ -376,13 +386,6 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re .and_then(|features| features.get("memories")), Some(&json!(true)) ); - assert_eq!( - config - .additional - .get("features") - .and_then(|features| features.get("plugins")), - Some(&json!(true)) - ); assert_eq!( config .additional @@ -395,13 +398,6 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re .additional .get("features") .and_then(|features| features.get("tool_suggest")), - Some(&json!(true)) - ); - assert_eq!( - config - .additional - .get("features") - .and_then(|features| features.get("tool_call_mcp_elicitation")), Some(&json!(false)) ); @@ -432,8 +428,11 @@ async fn experimental_feature_enablement_set_empty_map_is_no_op() -> Result<()> let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)])) - .await?; + set_experimental_feature_enablement( + &mut mcp, + BTreeMap::from([("mentions_v2".to_string(), true)]), + ) + .await?; let actual = set_experimental_feature_enablement(&mut mcp, BTreeMap::new()).await?; assert_eq!( @@ -449,7 +448,7 @@ async fn experimental_feature_enablement_set_empty_map_is_no_op() -> Result<()> config .additional .get("features") - .and_then(|features| features.get("apps")), + .and_then(|features| features.get("mentions_v2")), Some(&json!(true)) ); @@ -457,36 +456,30 @@ async fn experimental_feature_enablement_set_empty_map_is_no_op() -> Result<()> } #[tokio::test] -async fn experimental_feature_enablement_set_rejects_non_allowlisted_feature() -> Result<()> { +async fn experimental_feature_enablement_set_ignores_invalid_features() -> Result<()> { let codex_home = TempDir::new()?; let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - let request_id = mcp - .send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams { - enablement: BTreeMap::from([("personality".to_string(), true)]), - }) - .await?; - let JSONRPCError { error, .. } = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + let actual = set_experimental_feature_enablement( + &mut mcp, + BTreeMap::from([ + ("apps".to_string(), false), + ("auth_elicitation".to_string(), true), + ("connectors".to_string(), false), + ("personality".to_string(), false), + ("plugins".to_string(), false), + ("tool_call_mcp_elicitation".to_string(), false), + ("unknown_feature".to_string(), true), + ]), ) - .await??; + .await?; - assert_eq!(error.code, -32600); - assert!( - error - .message - .contains("unsupported feature enablement `personality`"), - "{}", - error.message - ); - assert!( - error.message.contains( - "apps, memories, mentions_v2, plugins, remote_control, remote_plugin, tool_suggest, tool_call_mcp_elicitation" - ), - "{}", - error.message + assert_eq!( + actual, + ExperimentalFeatureEnablementSetResponse { + enablement: BTreeMap::from([("auth_elicitation".to_string(), true)]), + } ); Ok(()) From 8ac304c2997529d3b6fe4be4ba032e0f2dac16af Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Thu, 4 Jun 2026 13:36:24 -0700 Subject: [PATCH 21/41] [codex] Support model-defined reasoning efforts (#26444) ## Summary - accept non-empty model-defined reasoning effort values while preserving built-in effort behavior - propagate the non-Copy effort type through core, app-server, TUI, telemetry, and persistence call sites - preserve string wire encoding and expose an open-string schema for clients - update model selection and shortcut behavior for model-advertised effort values ## Root cause `ReasoningEffort` gained a string-backed custom variant, so it could no longer implement `Copy` or rely on derived closed-enum serialization. Existing consumers still moved effort values from shared references and assumed a fixed built-in value set. ## Validation - `just fmt` - Local tests and compilation were not run per request; relying on CI. --- .../schema/json/ClientRequest.json | 11 +- .../schema/json/ServerNotification.json | 11 +- .../codex_app_server_protocol.schemas.json | 11 +- .../codex_app_server_protocol.v2.schemas.json | 11 +- .../schema/json/v2/ConfigReadResponse.json | 11 +- .../json/v2/ItemCompletedNotification.json | 11 +- .../json/v2/ItemStartedNotification.json | 11 +- .../schema/json/v2/ModelListResponse.json | 11 +- .../schema/json/v2/ReviewStartResponse.json | 11 +- .../schema/json/v2/ThreadForkResponse.json | 11 +- .../schema/json/v2/ThreadListResponse.json | 11 +- .../json/v2/ThreadMetadataUpdateResponse.json | 11 +- .../schema/json/v2/ThreadReadResponse.json | 11 +- .../schema/json/v2/ThreadResumeResponse.json | 11 +- .../json/v2/ThreadRollbackResponse.json | 11 +- .../v2/ThreadSettingsUpdatedNotification.json | 11 +- .../schema/json/v2/ThreadStartResponse.json | 11 +- .../json/v2/ThreadStartedNotification.json | 11 +- .../json/v2/ThreadUnarchiveResponse.json | 11 +- .../json/v2/TurnCompletedNotification.json | 11 +- .../schema/json/v2/TurnStartParams.json | 11 +- .../schema/json/v2/TurnStartResponse.json | 11 +- .../json/v2/TurnStartedNotification.json | 11 +- .../schema/typescript/ReasoningEffort.ts | 2 +- .../src/protocol/thread_history.rs | 4 +- codex-rs/app-server/README.md | 2 +- codex-rs/app-server/src/models.rs | 4 +- .../request_processors/thread_processor.rs | 2 +- .../src/request_processors/thread_summary.rs | 2 +- .../src/request_processors/turn_processor.rs | 2 +- .../app-server/tests/common/models_cache.rs | 2 +- .../app-server/tests/suite/v2/model_list.rs | 8 +- codex-rs/core/config.schema.json | 11 +- codex-rs/core/src/client.rs | 8 +- codex-rs/core/src/compact.rs | 2 +- codex-rs/core/src/compact_remote.rs | 2 +- codex-rs/core/src/compact_remote_v2.rs | 2 +- codex-rs/core/src/config/edit.rs | 2 +- codex-rs/core/src/guardian/review.rs | 7 +- codex-rs/core/src/guardian/review_session.rs | 11 +- codex-rs/core/src/session/config_lock.rs | 2 +- codex-rs/core/src/session/mod.rs | 2 +- codex-rs/core/src/session/review.rs | 2 +- .../session/rollout_reconstruction_tests.rs | 16 +- codex-rs/core/src/session/tests.rs | 20 +- codex-rs/core/src/session/turn.rs | 4 +- codex-rs/core/src/session/turn_context.rs | 22 +- codex-rs/core/src/session_startup_prewarm.rs | 2 +- .../src/tools/handlers/multi_agents/spawn.rs | 12 +- .../src/tools/handlers/multi_agents_common.rs | 11 +- .../src/tools/handlers/multi_agents_spec.rs | 14 +- .../tools/handlers/multi_agents_spec_tests.rs | 21 ++ .../src/tools/handlers/multi_agents_tests.rs | 4 +- .../tools/handlers/multi_agents_v2/spawn.rs | 12 +- codex-rs/core/tests/responses_headers.rs | 6 +- codex-rs/core/tests/suite/client.rs | 4 +- .../core/tests/suite/client_websockets.rs | 24 +- codex-rs/core/tests/suite/model_switching.rs | 2 +- .../core/tests/suite/model_visible_layout.rs | 10 +- codex-rs/core/tests/suite/personality.rs | 2 +- codex-rs/core/tests/suite/prompt_caching.rs | 6 +- codex-rs/core/tests/suite/remote_models.rs | 11 +- codex-rs/core/tests/suite/resume_warning.rs | 2 +- .../tests/suite/safety_check_downgrade.rs | 2 +- codex-rs/docs/codex_mcp_interface.md | 2 +- .../src/event_processor_with_human_output.rs | 3 +- codex-rs/exec/src/lib.rs | 6 +- codex-rs/memories/write/src/runtime.rs | 2 +- codex-rs/otel/src/events/session_telemetry.rs | 2 +- codex-rs/protocol/Cargo.toml | 3 +- codex-rs/protocol/src/config_types.rs | 9 +- codex-rs/protocol/src/openai_models.rs | 226 ++++++++++++++---- codex-rs/state/src/extract.rs | 2 +- codex-rs/state/src/model/thread_metadata.rs | 4 +- codex-rs/thread-store/src/in_memory.rs | 2 +- .../thread-store/src/thread_metadata_sync.rs | 2 +- codex-rs/tui/src/app/config_persistence.rs | 16 +- codex-rs/tui/src/app/event_dispatch.rs | 16 +- codex-rs/tui/src/app/startup_prompts.rs | 8 +- codex-rs/tui/src/app/tests.rs | 4 +- codex-rs/tui/src/app/thread_routing.rs | 2 +- codex-rs/tui/src/app/thread_settings.rs | 6 +- codex-rs/tui/src/app_server_session.rs | 9 +- codex-rs/tui/src/chatwidget/input_flow.rs | 2 +- codex-rs/tui/src/chatwidget/model_popups.rs | 159 ++++++------ codex-rs/tui/src/chatwidget/rate_limits.rs | 6 +- .../tui/src/chatwidget/reasoning_shortcuts.rs | 123 +++++++--- codex-rs/tui/src/chatwidget/session_flow.rs | 4 +- codex-rs/tui/src/chatwidget/settings.rs | 18 +- ...ests__model_reasoning_selection_popup.snap | 1 + .../tui/src/chatwidget/status_controls.rs | 14 +- .../tui/src/chatwidget/status_surfaces.rs | 9 +- .../chatwidget/tests/popups_and_settings.rs | 43 +++- codex-rs/tui/src/history_cell/session.rs | 17 +- codex-rs/tui/src/multi_agents.rs | 2 +- codex-rs/tui/src/status/card.rs | 2 +- .../sandbox-summary/src/config_summary.rs | 3 +- 97 files changed, 685 insertions(+), 581 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index dc5dd6ad60..861ae1dcbd 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1971,15 +1971,8 @@ "type": "string" }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "ReasoningItemContent": { diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index faf5a96879..16656e3a55 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -2777,15 +2777,8 @@ "type": "string" }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "ReasoningSummary": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 6aa4c5f35e..81617aa93c 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -13525,15 +13525,8 @@ "type": "object" }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "ReasoningEffortOption": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 6d859ee5f3..336350bcec 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -10047,15 +10047,8 @@ "type": "object" }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "ReasoningEffortOption": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index 09cf897898..e311090f2c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -667,15 +667,8 @@ "type": "string" }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "ReasoningSummary": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json index 8ba25f9a07..4b1d1dddbf 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -462,15 +462,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "TextElement": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json index 70fc43f7b8..67e493a4da 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -462,15 +462,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "TextElement": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json index 32ec2e7679..ce6c976d34 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json @@ -185,15 +185,8 @@ "type": "object" }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "ReasoningEffortOption": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index a644ce8c4e..0e3e704bf2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -606,15 +606,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "TextElement": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 0d3a3fe8db..0dbb7ef004 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -718,15 +718,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "SandboxPolicy": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index a500c21587..903553afaa 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -632,15 +632,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "SessionSource": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 6e08edb962..93f44759bf 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -632,15 +632,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "SessionSource": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index eed031d1e4..a78d1837c8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -632,15 +632,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "SessionSource": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 3a9e51f2a7..288c3e6c8e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -718,15 +718,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "SandboxPolicy": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index 9bc687fbfb..2ef7959a8b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -632,15 +632,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "SessionSource": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json index 42a76ae364..fbcaee3ee8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json @@ -124,15 +124,8 @@ "type": "string" }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "ReasoningSummary": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 8640c4e9ed..1a182e5d69 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -718,15 +718,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "SandboxPolicy": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index f7830e221e..66ed134c83 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -632,15 +632,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "SessionSource": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 9a634c1110..d331fa9bf7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -632,15 +632,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "SessionSource": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 6c64eb3275..dd5ebbf6ee 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -606,15 +606,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "TextElement": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index f997976aca..070944a28b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -154,15 +154,8 @@ "type": "string" }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "ReasoningSummary": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index 5fe9754576..cc4bce10ff 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -606,15 +606,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "TextElement": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 1db1497218..10b43d4354 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -606,15 +606,8 @@ ] }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "TextElement": { diff --git a/codex-rs/app-server-protocol/schema/typescript/ReasoningEffort.ts b/codex-rs/app-server-protocol/schema/typescript/ReasoningEffort.ts index c0798f43a3..d40f5bd657 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ReasoningEffort.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ReasoningEffort.ts @@ -5,4 +5,4 @@ /** * See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning */ -export type ReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh"; +export type ReasoningEffort = string; diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index aa65f9ab9d..fdc2af15d3 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -621,7 +621,7 @@ impl ThreadHistoryBuilder { receiver_thread_ids: Vec::new(), prompt: Some(payload.prompt.clone()), model: Some(payload.model.clone()), - reasoning_effort: Some(payload.reasoning_effort), + reasoning_effort: Some(payload.reasoning_effort.clone()), agents_states: HashMap::new(), }; self.upsert_item_in_current_turn(item); @@ -656,7 +656,7 @@ impl ThreadHistoryBuilder { receiver_thread_ids, prompt: Some(payload.prompt.clone()), model: Some(payload.model.clone()), - reasoning_effort: Some(payload.reasoning_effort), + reasoning_effort: Some(payload.reasoning_effort.clone()), agents_states, }); } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 1f5d01a429..a789c61af0 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -188,7 +188,7 @@ Example with notification opt-out: - `fs/watch` — subscribe this connection to filesystem change notifications for an absolute file or directory path and caller-provided `watchId`; returns the canonicalized `path`. - `fs/unwatch` — stop sending notifications for a prior `fs/watch`; returns `{}`. - `fs/changed` — notification emitted when watched paths change, including the `watchId` and `changedPaths`. -- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, `additionalSpeedTiers`, `serviceTiers`, optional `defaultServiceTier`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. +- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with model-advertised string reasoning effort options, `additionalSpeedTiers`, `serviceTiers`, optional `defaultServiceTier`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. - `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider. - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`. - `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.]` entries to be included in the current catalog view. diff --git a/codex-rs/app-server/src/models.rs b/codex-rs/app-server/src/models.rs index e53f13df84..7d460d5a97 100644 --- a/codex-rs/app-server/src/models.rs +++ b/codex-rs/app-server/src/models.rs @@ -62,10 +62,10 @@ fn reasoning_efforts_from_preset( efforts: Vec, ) -> Vec { efforts - .iter() + .into_iter() .map(|preset| ReasoningEffortOption { reasoning_effort: preset.effort, - description: preset.description.to_string(), + description: preset.description, }) .collect() } diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 6b4df61aa1..efff8e7cd8 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -158,7 +158,7 @@ fn merge_persisted_resume_metadata( typesafe_overrides.model = persisted_metadata.model.clone(); typesafe_overrides.model_provider = Some(persisted_metadata.model_provider.clone()); - if let Some(reasoning_effort) = persisted_metadata.reasoning_effort { + if let Some(reasoning_effort) = persisted_metadata.reasoning_effort.as_ref() { request_overrides.get_or_insert_with(HashMap::new).insert( "model_reasoning_effort".to_string(), serde_json::Value::String(reasoning_effort.to_string()), diff --git a/codex-rs/app-server/src/request_processors/thread_summary.rs b/codex-rs/app-server/src/request_processors/thread_summary.rs index a57f2fcf79..f467074828 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -203,7 +203,7 @@ pub(crate) fn thread_settings_from_config_snapshot( model: config_snapshot.model.clone(), model_provider: config_snapshot.model_provider_id.clone(), service_tier: config_snapshot.service_tier.clone(), - effort: config_snapshot.reasoning_effort, + effort: config_snapshot.reasoning_effort.clone(), summary: config_snapshot.reasoning_summary, collaboration_mode: config_snapshot.collaboration_mode.clone(), personality: config_snapshot.personality, diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 25beee3cdb..8ab6cf7731 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -629,7 +629,7 @@ impl TurnRequestProcessor { profile_workspace_roots: profile_workspace_roots.clone(), windows_sandbox_level: None, model: model.clone(), - effort, + effort: effort.clone(), summary, service_tier: service_tier.clone(), collaboration_mode: collaboration_mode.clone(), diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index 666f6ed7cd..127c14bbb4 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -18,7 +18,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { slug: preset.id.clone(), display_name: preset.display_name.clone(), description: Some(preset.description.clone()), - default_reasoning_level: Some(preset.default_reasoning_effort), + default_reasoning_level: Some(preset.default_reasoning_effort.clone()), supported_reasoning_levels: preset.supported_reasoning_efforts.clone(), shell_type: ConfigShellToolType::ShellCommand, visibility: if preset.show_in_picker { diff --git a/codex-rs/app-server/tests/suite/v2/model_list.rs b/codex-rs/app-server/tests/suite/v2/model_list.rs index 778577f857..eba04b54bd 100644 --- a/codex-rs/app-server/tests/suite/v2/model_list.rs +++ b/codex-rs/app-server/tests/suite/v2/model_list.rs @@ -48,11 +48,11 @@ fn model_from_preset(preset: &ModelPreset) -> Model { .supported_reasoning_efforts .iter() .map(|preset| ReasoningEffortOption { - reasoning_effort: preset.effort, + reasoning_effort: preset.effort.clone(), description: preset.description.clone(), }) .collect(), - default_reasoning_effort: preset.default_reasoning_effort, + default_reasoning_effort: preset.default_reasoning_effort.clone(), input_modalities: preset.input_modalities.clone(), // `write_models_cache()` round-trips through a simplified ModelInfo fixture that does not // preserve personality placeholders in base instructions, so app-server list results from @@ -164,10 +164,10 @@ async fn list_models_uses_chatgpt_remote_catalog_as_source_of_truth() -> Result< "slug": "chatgpt-remote-only", "display_name": "ChatGPT Remote Only", "description": "Remote-only model for app-server model/list coverage", - "default_reasoning_level": "medium", + "default_reasoning_level": "max", "supported_reasoning_levels": [ {"effort": "low", "description": "low"}, - {"effort": "medium", "description": "medium"} + {"effort": "max", "description": "Maximum"} ], "shell_type": "shell_command", "visibility": "list", diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index b8215e6fa5..f5a97b142a 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2503,15 +2503,8 @@ "type": "string" }, "ReasoningEffort": { - "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ], + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, "type": "string" }, "ReasoningSummary": { diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 2c6be6222a..9c133d85e4 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -721,7 +721,7 @@ impl ModelClient { ) -> Option { if model_info.supports_reasoning_summaries { Some(Reasoning { - effort: effort.or(model_info.default_reasoning_level), + effort: effort.or_else(|| model_info.default_reasoning_level.clone()), summary: if summary == ReasoningSummaryConfig::None { None } else { @@ -1267,7 +1267,7 @@ impl ModelClientSession { &client_setup.api_provider, prompt, model_info, - effort, + effort.clone(), summary, service_tier.clone(), )?; @@ -1376,7 +1376,7 @@ impl ModelClientSession { &client_setup.api_provider, prompt, model_info, - effort, + effort.clone(), summary, service_tier.clone(), )?; @@ -1595,7 +1595,7 @@ impl ModelClientSession { prompt, model_info, session_telemetry, - effort, + effort.clone(), summary, service_tier.clone(), turn_metadata_header, diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 4dc4c34e99..18b5838292 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -573,7 +573,7 @@ async fn drain_to_completed( prompt, &turn_context.model_info, &turn_context.session_telemetry, - turn_context.reasoning_effort, + turn_context.reasoning_effort.clone(), turn_context.reasoning_summary, turn_context.config.service_tier.clone(), turn_metadata_header, diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 444b1c3a69..a480c1eba8 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -242,7 +242,7 @@ async fn run_remote_compact_task_inner_impl( &prompt, &turn_context.model_info, CompactConversationRequestSettings { - effort: turn_context.reasoning_effort, + effort: turn_context.reasoning_effort.clone(), summary: turn_context.reasoning_summary, service_tier: if sess.services.auth_manager.auth_mode() == Some(AuthMode::ApiKey) { None diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 03a31e7230..5f585862dd 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -339,7 +339,7 @@ async fn run_remote_compaction_request_v2( prompt, &turn_context.model_info, &turn_context.session_telemetry, - turn_context.reasoning_effort, + turn_context.reasoning_effort.clone(), turn_context.reasoning_summary, turn_context.config.service_tier.clone(), turn_metadata_header, diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 862660ba2e..2d7a41323b 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -225,7 +225,7 @@ impl ConfigDocument { ); mutated |= self.write_optional_value( &["model_reasoning_effort"], - effort.map(|effort| value(effort.to_string())), + effort.as_ref().map(|effort| value(effort.to_string())), ); mutated }), diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 06d1a78931..934e68042f 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -702,7 +702,7 @@ pub(super) async fn run_guardian_review_session( .supported_reasoning_efforts .iter() .any(|effort| effort.effort == codex_protocol::openai_models::ReasoningEffort::Low), - Some(preset.default_reasoning_effort), + Some(preset.default_reasoning_effort.clone()), ); (review_model_id.to_string(), reasoning_effort) } else { @@ -712,7 +712,8 @@ pub(super) async fn run_guardian_review_session( .iter() .any(|preset| preset.effort == codex_protocol::openai_models::ReasoningEffort::Low), turn.reasoning_effort - .or(turn.model_info.default_reasoning_level), + .clone() + .or_else(|| turn.model_info.default_reasoning_level.clone()), ); ( model_override @@ -725,7 +726,7 @@ pub(super) async fn run_guardian_review_session( turn.config.as_ref(), live_network_config.clone(), guardian_model.as_str(), - guardian_reasoning_effort, + guardian_reasoning_effort.clone(), ); let guardian_config = match guardian_config { Ok(config) => config, diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 0de607de98..d81f84590b 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -167,7 +167,7 @@ impl GuardianReviewSessionReuseKey { model_context_window: spawn_config.model_context_window, model_auto_compact_token_limit: spawn_config.model_auto_compact_token_limit, model_auto_compact_token_limit_scope: spawn_config.model_auto_compact_token_limit_scope, - model_reasoning_effort: spawn_config.model_reasoning_effort, + model_reasoning_effort: spawn_config.model_reasoning_effort.clone(), model_reasoning_summary: spawn_config.model_reasoning_summary, permissions: spawn_config.permissions.clone(), developer_instructions: spawn_config.developer_instructions.clone(), @@ -658,7 +658,8 @@ async fn run_review_on_session( let guardian_reasoning_effort = if model_info.supports_reasoning_summaries { params .reasoning_effort - .or(model_info.default_reasoning_level) + .clone() + .or_else(|| model_info.default_reasoning_level.clone()) } else { None }; @@ -742,7 +743,7 @@ async fn run_review_on_session( mode: codex_protocol::config_types::ModeKind::Default, settings: codex_protocol::config_types::Settings { model: params.model.clone(), - reasoning_effort: params.reasoning_effort, + reasoning_effort: params.reasoning_effort.clone(), developer_instructions: None, }, }), @@ -1107,7 +1108,7 @@ mod tests { async fn test_review_params() -> GuardianReviewSessionParams { let (session, turn) = crate::session::tests::make_session_and_context().await; let model = turn.model_info.slug.clone(); - let reasoning_effort = turn.reasoning_effort; + let reasoning_effort = turn.reasoning_effort.clone(); let reasoning_summary = turn.reasoning_summary; let personality = turn.personality; #[allow(deprecated)] @@ -1116,7 +1117,7 @@ mod tests { turn.config.as_ref(), /*live_network_config*/ None, model.as_str(), - reasoning_effort, + reasoning_effort.clone(), ) .expect("guardian config"); diff --git a/codex-rs/core/src/session/config_lock.rs b/codex-rs/core/src/session/config_lock.rs index 5d608ec900..c081a2aee6 100644 --- a/codex-rs/core/src/session/config_lock.rs +++ b/codex-rs/core/src/session/config_lock.rs @@ -129,7 +129,7 @@ fn save_config_resolved_fields( ) -> anyhow::Result<()> { lock_config.web_search = Some(config.web_search_mode.value()); lock_config.model_provider = Some(config.model_provider_id.clone()); - lock_config.plan_mode_reasoning_effort = config.plan_mode_reasoning_effort; + lock_config.plan_mode_reasoning_effort = config.plan_mode_reasoning_effort.clone(); lock_config.model_verbosity = config.model_verbosity; lock_config.include_permissions_instructions = Some(config.include_permissions_instructions); lock_config.include_apps_instructions = Some(config.include_apps_instructions); diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index c0bf9629b7..9aeca1991b 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -579,7 +579,7 @@ impl Codex { mode: ModeKind::Default, settings: Settings { model: model.clone(), - reasoning_effort: config.model_reasoning_effort, + reasoning_effort: config.model_reasoning_effort.clone(), developer_instructions: None, }, }; diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index de864ebb08..ff4c77aab9 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -74,7 +74,7 @@ pub(super) async fn spawn_review_thread( let auth_manager_for_context = auth_manager.clone(); let provider_for_context = provider.clone(); let session_telemetry_for_context = session_telemetry.clone(); - let reasoning_effort = per_turn_config.model_reasoning_effort; + let reasoning_effort = per_turn_config.model_reasoning_effort.clone(); let reasoning_summary = per_turn_config .model_reasoning_summary .unwrap_or(model_info.default_reasoning_summary); diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index 0ca54e55e0..9ee70dada3 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -74,7 +74,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), - effort: turn_context.reasoning_effort, + effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let rollout_items = vec![RolloutItem::TurnContext(previous_context_item)]; @@ -113,7 +113,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), - effort: turn_context.reasoning_effort, + effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let turn_id = previous_context_item @@ -963,7 +963,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), - effort: turn_context.reasoning_effort, + effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let previous_turn_id = previous_context_item @@ -1042,7 +1042,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), - effort: turn_context.reasoning_effort, + effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, })) .expect("serialize expected reference context item") @@ -1071,7 +1071,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), - effort: turn_context.reasoning_effort, + effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let previous_turn_id = previous_context_item @@ -1190,7 +1190,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), - effort: turn_context.reasoning_effort, + effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, }; @@ -1308,7 +1308,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), - effort: turn_context.reasoning_effort, + effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let previous_turn_id = previous_context_item @@ -1467,7 +1467,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), - effort: turn_context.reasoning_effort, + effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let previous_turn_id = previous_context_item diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 93dc1e109f..c5e37fca0e 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -2559,7 +2559,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { collaboration_mode: Some(turn_context.collaboration_mode.clone()), multi_agent_version: None, realtime_active: Some(turn_context.realtime_active), - effort: turn_context.reasoning_effort, + effort: turn_context.reasoning_effort.clone(), summary: codex_protocol::config_types::ReasoningSummary::Auto, }; let turn_id = previous_context_item @@ -3146,7 +3146,7 @@ async fn set_rate_limits_retains_previous_credits() { let model = get_model_offline_for_tests(config.model.as_deref()); let model_info = construct_model_info_offline_for_tests(model.as_str(), &config.to_models_manager_config()); - let reasoning_effort = config.model_reasoning_effort; + let reasoning_effort = config.model_reasoning_effort.clone(); let collaboration_mode = CollaborationMode { mode: ModeKind::Default, settings: Settings { @@ -3254,7 +3254,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { let model = get_model_offline_for_tests(config.model.as_deref()); let model_info = construct_model_info_offline_for_tests(model.as_str(), &config.to_models_manager_config()); - let reasoning_effort = config.model_reasoning_effort; + let reasoning_effort = config.model_reasoning_effort.clone(); let collaboration_mode = CollaborationMode { mode: ModeKind::Default, settings: Settings { @@ -3785,7 +3785,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati let model = get_model_offline_for_tests(config.model.as_deref()); let model_info = construct_model_info_offline_for_tests(model.as_str(), &config.to_models_manager_config()); - let reasoning_effort = config.model_reasoning_effort; + let reasoning_effort = config.model_reasoning_effort.clone(); let collaboration_mode = CollaborationMode { mode: ModeKind::Default, settings: Settings { @@ -4614,7 +4614,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() { mode: ModeKind::Default, settings: Settings { model, - reasoning_effort: config.model_reasoning_effort, + reasoning_effort: config.model_reasoning_effort.clone(), developer_instructions: None, }, }; @@ -4716,7 +4716,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { let model = get_model_offline_for_tests(config.model.as_deref()); let model_info = construct_model_info_offline_for_tests(model.as_str(), &config.to_models_manager_config()); - let reasoning_effort = config.model_reasoning_effort; + let reasoning_effort = config.model_reasoning_effort.clone(); let collaboration_mode = CollaborationMode { mode: ModeKind::Default, settings: Settings { @@ -4959,7 +4959,7 @@ async fn make_session_with_config_and_rx( mode: ModeKind::Default, settings: Settings { model, - reasoning_effort: config.model_reasoning_effort, + reasoning_effort: config.model_reasoning_effort.clone(), developer_instructions: None, }, }; @@ -5064,7 +5064,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx( mode: ModeKind::Default, settings: Settings { model, - reasoning_effort: config.model_reasoning_effort, + reasoning_effort: config.model_reasoning_effort.clone(), developer_instructions: None, }, }; @@ -5999,7 +5999,7 @@ async fn user_turn_updates_approvals_reviewer() { mode: codex_protocol::config_types::ModeKind::Default, settings: codex_protocol::config_types::Settings { model: turn_context.model_info.slug.clone(), - reasoning_effort: config.model_reasoning_effort, + reasoning_effort: config.model_reasoning_effort.clone(), developer_instructions: None, }, }), @@ -6806,7 +6806,7 @@ where let model = get_model_offline_for_tests(config.model.as_deref()); let model_info = construct_model_info_offline_for_tests(model.as_str(), &config.to_models_manager_config()); - let reasoning_effort = config.model_reasoning_effort; + let reasoning_effort = config.model_reasoning_effort.clone(); let collaboration_mode = CollaborationMode { mode: ModeKind::Default, settings: Settings { diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 03b1c4b1db..4a9316496c 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -698,7 +698,7 @@ async fn track_turn_resolved_config_analytics( permission_profile: turn_context.permission_profile(), #[allow(deprecated)] permission_profile_cwd: turn_context.cwd.to_path_buf(), - reasoning_effort: turn_context.reasoning_effort, + reasoning_effort: turn_context.reasoning_effort.clone(), reasoning_summary: Some(turn_context.reasoning_summary), service_tier: turn_context .config @@ -1799,7 +1799,7 @@ async fn try_run_sampling_request( prompt, &turn_context.model_info, &turn_context.session_telemetry, - turn_context.reasoning_effort, + turn_context.reasoning_effort.clone(), turn_context.reasoning_summary, turn_context.config.service_tier.clone(), turn_metadata_header, diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index e62e447155..66a32f2aa6 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -136,7 +136,8 @@ impl TurnContext { pub(crate) fn effective_reasoning_effort(&self) -> Option { if self.model_info.supports_reasoning_summaries { self.reasoning_effort - .or(self.model_info.default_reasoning_level) + .clone() + .or_else(|| self.model_info.default_reasoning_level.clone()) } else { None } @@ -196,28 +197,29 @@ impl TurnContext { let supported_reasoning_levels = model_info .supported_reasoning_levels .iter() - .map(|preset| preset.effort) + .map(|preset| preset.effort.clone()) .collect::>(); - let reasoning_effort = if let Some(current_reasoning_effort) = self.reasoning_effort { + let reasoning_effort = if let Some(current_reasoning_effort) = self.reasoning_effort.clone() + { if supported_reasoning_levels.contains(¤t_reasoning_effort) { Some(current_reasoning_effort) } else { supported_reasoning_levels .get(supported_reasoning_levels.len().saturating_sub(1) / 2) - .copied() - .or(model_info.default_reasoning_level) + .cloned() + .or_else(|| model_info.default_reasoning_level.clone()) } } else { supported_reasoning_levels .get(supported_reasoning_levels.len().saturating_sub(1) / 2) - .copied() - .or(model_info.default_reasoning_level) + .cloned() + .or_else(|| model_info.default_reasoning_level.clone()) }; - config.model_reasoning_effort = reasoning_effort; + config.model_reasoning_effort = reasoning_effort.clone(); let collaboration_mode = self.collaboration_mode.with_updates( Some(model.clone()), - Some(reasoning_effort), + Some(reasoning_effort.clone()), /*developer_instructions*/ None, ); let features = self.features.clone(); @@ -363,7 +365,7 @@ impl TurnContext { collaboration_mode: Some(self.collaboration_mode.clone()), multi_agent_version: Some(self.multi_agent_version), realtime_active: Some(self.realtime_active), - effort: self.reasoning_effort, + effort: self.reasoning_effort.clone(), summary: ReasoningSummaryConfig::Auto, } } diff --git a/codex-rs/core/src/session_startup_prewarm.rs b/codex-rs/core/src/session_startup_prewarm.rs index d620648179..ba2cc42f29 100644 --- a/codex-rs/core/src/session_startup_prewarm.rs +++ b/codex-rs/core/src/session_startup_prewarm.rs @@ -271,7 +271,7 @@ async fn schedule_startup_prewarm_inner( &startup_prompt, &startup_turn_context.model_info, &startup_turn_context.session_telemetry, - startup_turn_context.reasoning_effort, + startup_turn_context.reasoning_effort.clone(), startup_turn_context.reasoning_summary, startup_turn_context.config.service_tier.clone(), startup_turn_metadata_header.as_deref(), diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs index 004dc973b7..a50d17f34a 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -83,7 +83,7 @@ async fn handle_spawn_agent( sender_thread_id: session.thread_id, prompt: prompt.clone(), model: args.model.clone().unwrap_or_default(), - reasoning_effort: args.reasoning_effort.unwrap_or_default(), + reasoning_effort: args.reasoning_effort.clone().unwrap_or_default(), } .into(), ) @@ -94,14 +94,18 @@ async fn handle_spawn_agent( config.service_tier = Some(service_tier.clone()); } if args.fork_context { - reject_full_fork_spawn_overrides(role_name, args.model.as_deref(), args.reasoning_effort)?; + reject_full_fork_spawn_overrides( + role_name, + args.model.as_deref(), + args.reasoning_effort.clone(), + )?; } else { apply_requested_spawn_agent_model_overrides( &session, turn.as_ref(), &mut config, args.model.as_deref(), - args.reasoning_effort, + args.reasoning_effort.clone(), ) .await?; apply_role_to_config(&mut config, role_name) @@ -174,7 +178,7 @@ async fn handle_spawn_agent( .unwrap_or_else(|| args.model.clone().unwrap_or_default()); let effective_reasoning_effort = agent_snapshot .as_ref() - .and_then(|snapshot| snapshot.reasoning_effort) + .and_then(|snapshot| snapshot.reasoning_effort.clone()) .unwrap_or(args.reasoning_effort.unwrap_or_default()); let nickname = new_agent_nickname.clone(); session diff --git a/codex-rs/core/src/tools/handlers/multi_agents_common.rs b/codex-rs/core/src/tools/handlers/multi_agents_common.rs index 8a4edbff35..8563ed09cb 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -224,7 +224,8 @@ fn build_agent_shared_config(turn: &TurnContext) -> Result Result<(), FunctionCallError> { if supported_reasoning_levels .iter() - .any(|preset| preset.effort == requested_reasoning_effort) + .any(|preset| &preset.effort == requested_reasoning_effort) { return Ok(()); } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs index e80f63e35d..a15464d9df 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs @@ -17,6 +17,7 @@ const SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION: &str = const SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION: &str = "Service tier override for the new agent. Omit unless explicitly requested."; const MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT_DESCRIPTION: usize = 5; +const MAX_REASONING_EFFORT_CHARS_IN_SPAWN_AGENT_DESCRIPTION: usize = 64; #[derive(Debug, Clone, Default)] pub struct SpawnAgentToolOptions { @@ -762,13 +763,20 @@ fn spawn_agent_models_description(models: &[ModelPreset]) -> String { let model_descriptions = visible_models .into_iter() .map(|model| { - let default_reasoning_effort = model.default_reasoning_effort; + let default_reasoning_effort = &model.default_reasoning_effort; let efforts = model .supported_reasoning_efforts .iter() .map(|preset| { - let effort = preset.effort; - if effort == default_reasoning_effort { + let effort = preset.effort.as_str(); + let effort = match effort + .char_indices() + .nth(MAX_REASONING_EFFORT_CHARS_IN_SPAWN_AGENT_DESCRIPTION) + { + Some((index, _)) => &effort[..index], + None => effort, + }; + if &preset.effort == default_reasoning_effort { format!("{effort} (default)") } else { effort.to_string() diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs index 5f4a66a75c..95be504011 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs @@ -186,6 +186,27 @@ fn spawn_agent_tool_caps_visible_model_summaries() { assert!(!description.contains("`sixth-model`")); } +#[test] +fn spawn_agent_tool_caps_reasoning_effort_value_length() { + let mut model = model_preset("visible", /*show_in_picker*/ true); + let custom_effort = ReasoningEffort::Custom( + "é".repeat(MAX_REASONING_EFFORT_CHARS_IN_SPAWN_AGENT_DESCRIPTION + 1), + ); + model.default_reasoning_effort = custom_effort.clone(); + model.supported_reasoning_efforts = vec![ReasoningEffortPreset { + effort: custom_effort, + description: "Model-defined".to_string(), + }]; + + assert_eq!( + spawn_agent_models_description(&[model]), + format!( + "Available model overrides (optional; inherited parent model is preferred):\n- `visible-model`: visible description Reasoning efforts: {} (default). Service tiers: priority.", + "é".repeat(MAX_REASONING_EFFORT_CHARS_IN_SPAWN_AGENT_DESCRIPTION) + ) + ); +} + #[test] fn spawn_agent_tool_hides_service_tier_with_spawn_metadata() { let tool = create_spawn_agent_tool_v2(SpawnAgentToolOptions { diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 6eec6fa2ed..3edbdf19aa 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -4374,7 +4374,7 @@ async fn build_agent_spawn_config_uses_turn_context_values() { expected.base_instructions = Some(base_instructions.text); expected.model = Some(turn.model_info.slug.clone()); expected.model_provider = turn.provider.info().clone(); - expected.model_reasoning_effort = turn.reasoning_effort; + expected.model_reasoning_effort = turn.reasoning_effort.clone(); expected.model_reasoning_summary = Some(turn.reasoning_summary); expected.developer_instructions = turn.developer_instructions.clone(); expected.compact_prompt = turn.compact_prompt.clone(); @@ -4431,7 +4431,7 @@ async fn build_agent_resume_config_clears_base_instructions() { expected.base_instructions = None; expected.model = Some(turn.model_info.slug.clone()); expected.model_provider = turn.provider.info().clone(); - expected.model_reasoning_effort = turn.reasoning_effort; + expected.model_reasoning_effort = turn.reasoning_effort.clone(); expected.model_reasoning_summary = Some(turn.reasoning_summary); expected.developer_instructions = turn.developer_instructions.clone(); expected.compact_prompt = turn.compact_prompt.clone(); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index d0fb6c515e..7d5b469a45 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -75,7 +75,7 @@ async fn handle_spawn_agent( sender_thread_id: session.thread_id, prompt: prompt.clone(), model: args.model.clone().unwrap_or_default(), - reasoning_effort: args.reasoning_effort.unwrap_or_default(), + reasoning_effort: args.reasoning_effort.clone().unwrap_or_default(), } .into(), ) @@ -86,14 +86,18 @@ async fn handle_spawn_agent( config.service_tier = Some(service_tier.clone()); } if matches!(fork_mode, Some(SpawnAgentForkMode::FullHistory)) { - reject_full_fork_spawn_overrides(role_name, args.model.as_deref(), args.reasoning_effort)?; + reject_full_fork_spawn_overrides( + role_name, + args.model.as_deref(), + args.reasoning_effort.clone(), + )?; } else { apply_requested_spawn_agent_model_overrides( &session, turn.as_ref(), &mut config, args.model.as_deref(), - args.reasoning_effort, + args.reasoning_effort.clone(), ) .await?; apply_role_to_config(&mut config, role_name) @@ -188,7 +192,7 @@ async fn handle_spawn_agent( .unwrap_or_else(|| args.model.clone().unwrap_or_default()); let effective_reasoning_effort = agent_snapshot .as_ref() - .and_then(|snapshot| snapshot.reasoning_effort) + .and_then(|snapshot| snapshot.reasoning_effort.clone()) .unwrap_or(args.reasoning_effort.unwrap_or_default()); let nickname = new_agent_nickname.clone(); session diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index eda48a2c0e..75649fa4bc 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -74,7 +74,7 @@ async fn responses_stream_includes_subagent_header_on_review() { let mut config = load_default_config_for_test(&codex_home).await; config.model_provider_id = provider.name.clone(); config.model_provider = provider.clone(); - let effort = config.model_reasoning_effort; + let effort = config.model_reasoning_effort.clone(); let summary = config.model_reasoning_summary; let model = codex_core::test_support::get_model_offline(config.model.as_deref()); config.model = Some(model.clone()); @@ -202,7 +202,7 @@ async fn responses_stream_includes_subagent_header_on_other() { let mut config = load_default_config_for_test(&codex_home).await; config.model_provider_id = provider.name.clone(); config.model_provider = provider.clone(); - let effort = config.model_reasoning_effort; + let effort = config.model_reasoning_effort.clone(); let summary = config.model_reasoning_summary; let model = codex_core::test_support::get_model_offline(config.model.as_deref()); config.model = Some(model.clone()); @@ -318,7 +318,7 @@ async fn responses_respects_model_info_overrides_from_config() { config.model_provider = provider.clone(); config.model_supports_reasoning_summaries = Some(true); config.model_reasoning_summary = Some(ReasoningSummary::Detailed); - let effort = config.model_reasoning_effort; + let effort = config.model_reasoning_effort.clone(); let summary = config.model_reasoning_summary; let model = config.model.clone().expect("model configured"); let config = Arc::new(config); diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index f430a5c1eb..ad8ff17cdc 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -876,7 +876,7 @@ async fn send_provider_auth_request(server: &MockServer, auth: ModelProviderAuth let mut config = load_default_config_for_test(&codex_home).await; config.model_provider_id = provider.name.clone(); config.model_provider = provider.clone(); - let effort = config.model_reasoning_effort; + let effort = config.model_reasoning_effort.clone(); let summary = config.model_reasoning_summary; let model = codex_core::test_support::get_model_offline(config.model.as_deref()); config.model = Some(model.clone()); @@ -2332,7 +2332,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { let mut config = load_default_config_for_test(&codex_home).await; config.model_provider_id = provider.name.clone(); config.model_provider = provider.clone(); - let effort = config.model_reasoning_effort; + let effort = config.model_reasoning_effort.clone(); let summary = config.model_reasoning_summary; let model = codex_core::test_support::get_model_offline(config.model.as_deref()); config.model = Some(model.clone()); diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 9dee201d64..b28f820569 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -343,7 +343,7 @@ async fn responses_websocket_request_prewarm_reuses_connection() { &prompt, &harness.model_info, &harness.session_telemetry, - harness.effort, + harness.effort.clone(), harness.summary, /*service_tier*/ None, /*turn_metadata_header*/ None, @@ -397,7 +397,7 @@ async fn responses_websocket_request_prewarm_traces_logical_request() { &prompt, &harness.model_info, &harness.session_telemetry, - harness.effort, + harness.effort.clone(), harness.summary, /*service_tier*/ None, /*turn_metadata_header*/ None, @@ -442,7 +442,7 @@ async fn responses_websocket_request_prewarm_traces_logical_request() { &prompt, &harness.model_info, &harness.session_telemetry, - harness.effort, + harness.effort.clone(), harness.summary, /*service_tier*/ None, /*turn_metadata_header*/ None, @@ -534,7 +534,7 @@ async fn responses_websocket_preconnect_is_reused_even_with_header_changes() { &prompt, &harness.model_info, &harness.session_telemetry, - harness.effort, + harness.effort.clone(), harness.summary, /*service_tier*/ None, /*turn_metadata_header*/ None, @@ -573,7 +573,7 @@ async fn responses_websocket_request_prewarm_is_reused_even_with_header_changes( &prompt, &harness.model_info, &harness.session_telemetry, - harness.effort, + harness.effort.clone(), harness.summary, /*service_tier*/ None, /*turn_metadata_header*/ None, @@ -585,7 +585,7 @@ async fn responses_websocket_request_prewarm_is_reused_even_with_header_changes( &prompt, &harness.model_info, &harness.session_telemetry, - harness.effort, + harness.effort.clone(), harness.summary, /*service_tier*/ None, /*turn_metadata_header*/ None, @@ -639,7 +639,7 @@ async fn responses_websocket_prewarm_uses_v2_when_provider_supports_websockets() &prompt, &harness.model_info, &harness.session_telemetry, - harness.effort, + harness.effort.clone(), harness.summary, /*service_tier*/ None, /*turn_metadata_header*/ None, @@ -988,7 +988,7 @@ async fn responses_websocket_emits_reasoning_included_event() { &prompt, &harness.model_info, &harness.session_telemetry, - harness.effort, + harness.effort.clone(), harness.summary, /*service_tier*/ None, /*turn_metadata_header*/ None, @@ -1062,7 +1062,7 @@ async fn responses_websocket_emits_rate_limit_events() { &prompt, &harness.model_info, &harness.session_telemetry, - harness.effort, + harness.effort.clone(), harness.summary, /*service_tier*/ None, /*turn_metadata_header*/ None, @@ -1718,7 +1718,7 @@ async fn responses_websocket_v2_after_error_uses_full_create_without_previous_re &prompt_two, &harness.model_info, &harness.session_telemetry, - harness.effort, + harness.effort.clone(), harness.summary, /*service_tier*/ None, /*turn_metadata_header*/ None, @@ -1806,7 +1806,7 @@ async fn responses_websocket_v2_surfaces_terminal_error_without_close_handshake( &prompt_two, &harness.model_info, &harness.session_telemetry, - harness.effort, + harness.effort.clone(), harness.summary, /*service_tier*/ None, /*turn_metadata_header*/ None, @@ -2073,7 +2073,7 @@ async fn stream_until_complete_with_request_metadata( prompt, &harness.model_info, &harness.session_telemetry, - harness.effort, + harness.effort.clone(), harness.summary, service_tier.map(|service_tier| service_tier.request_value().to_string()), turn_metadata_header, diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index b76a3522ec..111ce50436 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -58,7 +58,7 @@ fn read_only_user_turn(test: &TestCodex, items: Vec, model: String) - mode: codex_protocol::config_types::ModeKind::Default, settings: codex_protocol::config_types::Settings { model, - reasoning_effort: test.config.model_reasoning_effort, + reasoning_effort: test.config.model_reasoning_effort.clone(), developer_instructions: None, }, }), diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index e02e1ffaef..27d92d2774 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -135,7 +135,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { mode: codex_protocol::config_types::ModeKind::Default, settings: codex_protocol::config_types::Settings { model: test.session_configured.model.clone(), - reasoning_effort: test.config.model_reasoning_effort, + reasoning_effort: test.config.model_reasoning_effort.clone(), developer_instructions: None, }, }), @@ -172,7 +172,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { mode: codex_protocol::config_types::ModeKind::Default, settings: codex_protocol::config_types::Settings { model: test.session_configured.model.clone(), - reasoning_effort: test.config.model_reasoning_effort, + reasoning_effort: test.config.model_reasoning_effort.clone(), developer_instructions: None, }, }), @@ -261,7 +261,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R mode: codex_protocol::config_types::ModeKind::Default, settings: codex_protocol::config_types::Settings { model: test.session_configured.model.clone(), - reasoning_effort: test.config.model_reasoning_effort, + reasoning_effort: test.config.model_reasoning_effort.clone(), developer_instructions: None, }, }), @@ -295,7 +295,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R mode: codex_protocol::config_types::ModeKind::Default, settings: codex_protocol::config_types::Settings { model: test.session_configured.model.clone(), - reasoning_effort: test.config.model_reasoning_effort, + reasoning_effort: test.config.model_reasoning_effort.clone(), developer_instructions: None, }, }), @@ -422,7 +422,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul mode: codex_protocol::config_types::ModeKind::Default, settings: codex_protocol::config_types::Settings { model: resumed.session_configured.model.clone(), - reasoning_effort: resumed.config.model_reasoning_effort, + reasoning_effort: resumed.config.model_reasoning_effort.clone(), developer_instructions: None, }, }), diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 231a6d826f..cd264c0734 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -79,7 +79,7 @@ fn read_only_text_turn_with_personality( mode: codex_protocol::config_types::ModeKind::Default, settings: codex_protocol::config_types::Settings { model, - reasoning_effort: test.config.model_reasoning_effort, + reasoning_effort: test.config.model_reasoning_effort.clone(), developer_instructions: None, }, }), diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 2a42bd0a16..ea646a4dbc 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -870,7 +870,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a let default_approval_policy = config.permissions.approval_policy.value(); let default_sandbox_policy = &config.legacy_sandbox_policy(); let default_model = session_configured.model; - let default_effort = config.model_reasoning_effort; + let default_effort = config.model_reasoning_effort.clone(); let default_summary = config.model_reasoning_summary; codex @@ -892,7 +892,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a mode: codex_protocol::config_types::ModeKind::Default, settings: codex_protocol::config_types::Settings { model: default_model.clone(), - reasoning_effort: default_effort, + reasoning_effort: default_effort.clone(), developer_instructions: None, }, }), @@ -1013,7 +1013,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu let default_approval_policy = config.permissions.approval_policy.value(); let default_sandbox_policy = &config.legacy_sandbox_policy(); let default_model = session_configured.model; - let default_effort = config.model_reasoning_effort; + let default_effort = config.model_reasoning_effort.clone(); let default_summary = config.model_reasoning_summary; codex diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index fd6e01c075..608558d365 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -318,7 +318,7 @@ async fn remote_models_use_context_window_when_config_override_is_absent() -> Re } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<()> { +async fn remote_models_long_model_slug_is_sent_with_custom_reasoning() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); @@ -331,15 +331,16 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( /*priority*/ 1_000, TruncationPolicyConfig::bytes(/*limit*/ 10_000), ); - remote_model.default_reasoning_level = Some(ReasoningEffort::High); + let custom_reasoning_effort = ReasoningEffort::Custom("max".to_string()); + remote_model.default_reasoning_level = Some(custom_reasoning_effort.clone()); remote_model.supported_reasoning_levels = vec![ ReasoningEffortPreset { effort: ReasoningEffort::Medium, description: ReasoningEffort::Medium.to_string(), }, ReasoningEffortPreset { - effort: ReasoningEffort::High, - description: ReasoningEffort::High.to_string(), + effort: custom_reasoning_effort.clone(), + description: custom_reasoning_effort.to_string(), }, ]; remote_model.supports_reasoning_summaries = true; @@ -393,7 +394,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( .and_then(|reasoning| reasoning.get("summary")) .and_then(|value| value.as_str()); assert_eq!(body["model"].as_str(), Some(requested_model)); - assert_eq!(reasoning_effort, Some("high")); + assert_eq!(reasoning_effort, Some("max")); assert_eq!(reasoning_summary, Some("detailed")); Ok(()) diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 54b046e66c..422489a86a 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -41,7 +41,7 @@ fn resume_history( collaboration_mode: None, multi_agent_version: None, realtime_active: None, - effort: config.model_reasoning_effort, + effort: config.model_reasoning_effort.clone(), summary: config .model_reasoning_summary .unwrap_or(ReasoningSummary::Auto), diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 3d4cb93b9c..532f4821d9 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -55,7 +55,7 @@ fn disabled_text_turn(test: &TestCodex, text: &str) -> Op { mode: codex_protocol::config_types::ModeKind::Default, settings: codex_protocol::config_types::Settings { model: REQUESTED_MODEL.to_string(), - reasoning_effort: test.config.model_reasoning_effort, + reasoning_effort: test.config.model_reasoning_effort.clone(), developer_instructions: None, }, }), diff --git a/codex-rs/docs/codex_mcp_interface.md b/codex-rs/docs/codex_mcp_interface.md index bf2def2407..e3eb814f36 100644 --- a/codex-rs/docs/codex_mcp_interface.md +++ b/codex-rs/docs/codex_mcp_interface.md @@ -68,7 +68,7 @@ Each response yields: - `data` - ordered list of models. A model includes: - `id`, `model`, `displayName`, `description` - `supportedReasoningEfforts` - array of objects with: - - `reasoningEffort` - one of `none|minimal|low|medium|high|xhigh` + - `reasoningEffort` - a string value advertised by the model; common values are `none|minimal|low|medium|high|xhigh` - `description` - human-friendly label for the effort - `defaultReasoningEffort` - suggested effort for the UI - `inputModalities` - accepted input types for the model 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 c3190b8740..00a9f957f1 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -446,7 +446,8 @@ fn config_summary_entries( "reasoning effort", config .model_reasoning_effort - .map(|effort| effort.to_string()) + .as_ref() + .map(std::string::ToString::to_string) .unwrap_or_else(|| "none".to_string()), )); entries.push(( diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 406197d720..797e168dc5 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -694,7 +694,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let default_cwd = config.cwd.to_path_buf(); let default_approval_policy = config.permissions.approval_policy.value(); - let default_effort = config.model_reasoning_effort; + let default_effort = config.model_reasoning_effort.clone(); let (initial_operation, prompt_summary) = match (command.as_ref(), prompt, images) { (Some(ExecCommand::Review(review_cli)), _, _) => { @@ -1169,7 +1169,7 @@ fn session_configured_from_thread_start_response( config.permissions.effective_permission_profile(), response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), - response.reasoning_effort, + response.reasoning_effort.clone(), ) } @@ -1192,7 +1192,7 @@ fn session_configured_from_thread_resume_response( config.permissions.effective_permission_profile(), response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), - response.reasoning_effort, + response.reasoning_effort.clone(), ) } diff --git a/codex-rs/memories/write/src/runtime.rs b/codex-rs/memories/write/src/runtime.rs index 36099fe2e8..58bdc9dfc4 100644 --- a/codex-rs/memories/write/src/runtime.rs +++ b/codex-rs/memories/write/src/runtime.rs @@ -194,7 +194,7 @@ impl MemoryStartupContext { prompt, &context.model_info, &context.session_telemetry, - context.reasoning_effort, + context.reasoning_effort.clone(), context.reasoning_summary, context.service_tier.clone(), context.turn_metadata_header.as_deref(), diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index 1da6497eb0..f4534a0791 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -458,7 +458,7 @@ impl SessionTelemetry { auth.env_provider_key_name = self.metadata.auth_env.provider_env_key_name.as_deref(), auth.env_provider_key_present = self.metadata.auth_env.provider_env_key_present, auth.env_refresh_token_url_override_present = self.metadata.auth_env.refresh_token_url_override_present, - reasoning_effort = reasoning_effort.map(|e| e.to_string()), + reasoning_effort = reasoning_effort.as_ref().map(ToString::to_string), reasoning_summary = %reasoning_summary, context_window = context_window, auto_compact_token_limit = auto_compact_token_limit, diff --git a/codex-rs/protocol/Cargo.toml b/codex-rs/protocol/Cargo.toml index 23a44c48e6..89b10e383e 100644 --- a/codex-rs/protocol/Cargo.toml +++ b/codex-rs/protocol/Cargo.toml @@ -59,4 +59,5 @@ tempfile = { workspace = true } [package.metadata.cargo-shear] # Required because: # `icu_provider`: contains a required `sync` feature for `icu_decimal` -ignored = ["icu_provider"] +# `strum`: is referenced by generated `EnumIter` derive implementations +ignored = ["icu_provider", "strum"] diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 9e5c47a5c1..6654f3166b 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -635,7 +635,7 @@ impl CollaborationMode { } pub fn reasoning_effort(&self) -> Option { - self.settings_ref().reasoning_effort + self.settings_ref().reasoning_effort.clone() } /// Updates the collaboration mode with new model and/or effort values. @@ -654,7 +654,7 @@ impl CollaborationMode { let settings = self.settings_ref(); let updated_settings = Settings { model: model.unwrap_or_else(|| settings.model.clone()), - reasoning_effort: effort.unwrap_or(settings.reasoning_effort), + reasoning_effort: effort.unwrap_or_else(|| settings.reasoning_effort.clone()), developer_instructions: developer_instructions .unwrap_or_else(|| settings.developer_instructions.clone()), }; @@ -676,7 +676,10 @@ impl CollaborationMode { mode: mask.mode.unwrap_or(self.mode), settings: Settings { model: mask.model.clone().unwrap_or_else(|| settings.model.clone()), - reasoning_effort: mask.reasoning_effort.unwrap_or(settings.reasoning_effort), + reasoning_effort: mask + .reasoning_effort + .clone() + .unwrap_or_else(|| settings.reasoning_effort.clone()), developer_instructions: mask .developer_instructions .clone() diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 4b678a9209..fc4065ed99 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -4,14 +4,22 @@ //! are used to preserve compatibility when older payloads omit newly introduced attributes. use std::collections::HashMap; +use std::fmt; use std::str::FromStr; use schemars::JsonSchema; +use schemars::r#gen::SchemaGenerator; +use schemars::schema::InstanceType; +use schemars::schema::Metadata; +use schemars::schema::Schema; +use schemars::schema::SchemaObject; +use schemars::schema::StringValidation; use serde::Deserialize; use serde::Deserializer; use serde::Serialize; +use serde::Serializer; use serde::de::DeserializeOwned; -use strum::IntoEnumIterator; +use serde::de::Error; use strum_macros::Display; use strum_macros::EnumIter; use tracing::warn; @@ -28,23 +36,8 @@ const PERSONALITY_PLACEHOLDER: &str = "{{ personality }}"; pub const SPEED_TIER_FAST: &str = "fast"; /// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning -#[derive( - Debug, - Serialize, - Deserialize, - Default, - Clone, - Copy, - PartialEq, - Eq, - Display, - JsonSchema, - TS, - EnumIter, - Hash, -)] -#[serde(rename_all = "lowercase")] -#[strum(serialize_all = "lowercase")] +#[derive(Debug, Default, Clone, PartialEq, Eq, TS, Hash)] +#[ts(type = "string")] pub enum ReasoningEffort { None, Minimal, @@ -53,14 +46,113 @@ pub enum ReasoningEffort { Medium, High, XHigh, + /// A model-defined effort value that this client does not know yet. + Custom(String), +} + +impl ReasoningEffort { + /// Returns the exact value used on the wire. + pub fn as_str(&self) -> &str { + match self { + Self::None => "none", + Self::Minimal => "minimal", + Self::Low => "low", + Self::Medium => "medium", + Self::High => "high", + Self::XHigh => "xhigh", + Self::Custom(effort) => effort, + } + } + + /// Returns the built-in effort values in ascending order. + pub fn known_values() -> impl DoubleEndedIterator + ExactSizeIterator { + [ + Self::None, + Self::Minimal, + Self::Low, + Self::Medium, + Self::High, + Self::XHigh, + ] + .into_iter() + } + + /// Returns the built-in ordering rank, or `None` for model-defined values. + pub const fn known_rank(&self) -> Option { + match self { + Self::None => Some(0), + Self::Minimal => Some(1), + Self::Low => Some(2), + Self::Medium => Some(3), + Self::High => Some(4), + Self::XHigh => Some(5), + Self::Custom(_) => None, + } + } +} + +impl fmt::Display for ReasoningEffort { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl JsonSchema for ReasoningEffort { + fn schema_name() -> String { + "ReasoningEffort".to_string() + } + + fn json_schema(_generator: &mut SchemaGenerator) -> Schema { + Schema::Object(SchemaObject { + instance_type: Some(InstanceType::String.into()), + metadata: Some(Box::new(Metadata { + description: Some( + "A non-empty reasoning effort value advertised by the model.".to_string(), + ), + ..Default::default() + })), + string: Some(Box::new(StringValidation { + min_length: Some(1), + ..Default::default() + })), + ..Default::default() + }) + } +} + +impl Serialize for ReasoningEffort { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for ReasoningEffort { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let effort = String::deserialize(deserializer)?; + effort.parse().map_err(D::Error::custom) + } } impl FromStr for ReasoningEffort { type Err = String; fn from_str(s: &str) -> Result { - serde_json::from_value(serde_json::Value::String(s.to_string())) - .map_err(|_| format!("invalid reasoning_effort: {s}")) + match s { + "none" => Ok(Self::None), + "minimal" => Ok(Self::Minimal), + "low" => Ok(Self::Low), + "medium" => Ok(Self::Medium), + "high" => Ok(Self::High), + "xhigh" => Ok(Self::XHigh), + "" => Err("reasoning_effort must not be empty".to_string()), + effort => Ok(Self::Custom(effort.to_string())), + } } } @@ -579,39 +671,43 @@ fn reasoning_effort_mapping_from_presets( } // Map every canonical effort to the closest supported effort for the new model. - let supported: Vec = presets.iter().map(|p| p.effort).collect(); + let supported: Vec = presets.iter().map(|p| p.effort.clone()).collect(); let mut map = HashMap::new(); - for effort in ReasoningEffort::iter() { - let nearest = nearest_effort(effort, &supported); + for effort in ReasoningEffort::known_values() { + let nearest = nearest_effort(&effort, &supported); map.insert(effort, nearest); } Some(map) } -fn effort_rank(effort: ReasoningEffort) -> i32 { - match effort { - ReasoningEffort::None => 0, - ReasoningEffort::Minimal => 1, - ReasoningEffort::Low => 2, - ReasoningEffort::Medium => 3, - ReasoningEffort::High => 4, - ReasoningEffort::XHigh => 5, - } -} - -fn nearest_effort(target: ReasoningEffort, supported: &[ReasoningEffort]) -> ReasoningEffort { - let target_rank = effort_rank(target); +fn nearest_effort(target: &ReasoningEffort, supported: &[ReasoningEffort]) -> ReasoningEffort { + let Some(target_rank) = target.known_rank() else { + return supported + .iter() + .find(|candidate| *candidate == target) + .unwrap_or(target) + .clone(); + }; supported .iter() - .copied() - .min_by_key(|candidate| (effort_rank(*candidate) - target_rank).abs()) + .filter_map(|candidate| { + candidate + .known_rank() + .map(|rank| (rank.abs_diff(target_rank), candidate)) + }) + .min_by_key(|(distance, _)| *distance) + .map(|(_, effort)| effort) + .or_else(|| supported.first()) .unwrap_or(target) + .clone() } #[cfg(test)] mod tests { use super::*; use pretty_assertions::assert_eq; + use serde_json::from_str; + use serde_json::to_string; fn test_model(spec: Option) -> ModelInfo { ModelInfo { @@ -663,16 +759,58 @@ mod tests { } #[test] - fn reasoning_effort_from_str_accepts_known_values() { - assert_eq!("high".parse(), Ok(ReasoningEffort::High)); - assert_eq!("minimal".parse(), Ok(ReasoningEffort::Minimal)); + fn reasoning_effort_accepts_known_and_custom_values() { + let custom = ReasoningEffort::Custom("max".to_string()); + let deserialized = from_str::(r#""max""#) + .expect("custom reasoning effort should deserialize"); + let serialized = to_string(&custom).expect("custom reasoning effort should serialize"); + + assert_eq!( + ( + "high".parse(), + "max".parse(), + deserialized, + serialized, + custom.to_string(), + ), + ( + Ok(ReasoningEffort::High), + Ok(custom.clone()), + custom, + r#""max""#.to_string(), + "max".to_string(), + ) + ); } #[test] - fn reasoning_effort_from_str_rejects_unknown_values() { + fn reasoning_effort_rejects_empty_values() { assert_eq!( - "unsupported".parse::(), - Err("invalid reasoning_effort: unsupported".to_string()) + "".parse::(), + Err("reasoning_effort must not be empty".to_string()) + ); + } + + #[test] + fn reasoning_effort_json_schema_is_an_open_string() { + let mut effort_generator = SchemaGenerator::default(); + + assert_eq!( + ReasoningEffort::json_schema(&mut effort_generator), + Schema::Object(SchemaObject { + instance_type: Some(InstanceType::String.into()), + metadata: Some(Box::new(Metadata { + description: Some( + "A non-empty reasoning effort value advertised by the model.".to_string(), + ), + ..Default::default() + })), + string: Some(Box::new(StringValidation { + min_length: Some(1), + ..Default::default() + })), + ..Default::default() + }) ); } diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index 1bba63fbf3..63cb06fea1 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -75,7 +75,7 @@ fn apply_turn_context(metadata: &mut ThreadMetadata, turn_ctx: &TurnContextItem) metadata.cwd = turn_ctx.cwd.clone(); } metadata.model = Some(turn_ctx.model.clone()); - metadata.reasoning_effort = turn_ctx.effort; + metadata.reasoning_effort = turn_ctx.effort.clone(); metadata.sandbox_policy = serde_json::to_string(&turn_ctx.permission_profile()).unwrap_or_default(); metadata.approval_mode = enum_to_string(&turn_ctx.approval_policy); diff --git a/codex-rs/state/src/model/thread_metadata.rs b/codex-rs/state/src/model/thread_metadata.rs index 8f1ac4f556..cfcd49d5f9 100644 --- a/codex-rs/state/src/model/thread_metadata.rs +++ b/codex-rs/state/src/model/thread_metadata.rs @@ -584,13 +584,13 @@ mod tests { } #[test] - fn thread_row_ignores_unknown_reasoning_effort_values() { + fn thread_row_preserves_model_defined_reasoning_effort_values() { let metadata = ThreadMetadata::try_from(thread_row(Some("future"))) .expect("thread metadata should parse"); assert_eq!( metadata, - expected_thread_metadata(/*reasoning_effort*/ None) + expected_thread_metadata(Some(ReasoningEffort::Custom("future".to_string()))) ); } } diff --git a/codex-rs/thread-store/src/in_memory.rs b/codex-rs/thread-store/src/in_memory.rs index b02fc15855..3a44c26b32 100644 --- a/codex-rs/thread-store/src/in_memory.rs +++ b/codex-rs/thread-store/src/in_memory.rs @@ -369,7 +369,7 @@ fn stored_thread_from_state( .and_then(|metadata| metadata.model_provider.clone()) .unwrap_or_else(|| "test".to_string()), model: metadata.and_then(|metadata| metadata.model.clone()), - reasoning_effort: metadata.and_then(|metadata| metadata.reasoning_effort), + reasoning_effort: metadata.and_then(|metadata| metadata.reasoning_effort.clone()), created_at: metadata .and_then(|metadata| metadata.created_at) .unwrap_or_else(Utc::now), diff --git a/codex-rs/thread-store/src/thread_metadata_sync.rs b/codex-rs/thread-store/src/thread_metadata_sync.rs index d5673b3665..4fb9f398c1 100644 --- a/codex-rs/thread-store/src/thread_metadata_sync.rs +++ b/codex-rs/thread-store/src/thread_metadata_sync.rs @@ -232,7 +232,7 @@ impl ThreadMetadataSync { update.cwd = Some(turn_ctx.cwd.clone()); } update.model = Some(turn_ctx.model.clone()); - update.reasoning_effort = turn_ctx.effort; + update.reasoning_effort = turn_ctx.effort.clone(); update.approval_mode = Some(turn_ctx.approval_policy); update.permission_profile = Some(turn_ctx.permission_profile()); } diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index ec72c5974a..946223ec3e 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -712,21 +712,17 @@ impl App { .add_info_message("Reset local memories.".to_string(), /*hint*/ None); } - pub(super) fn reasoning_label(reasoning_effort: Option) -> &'static str { + pub(super) fn reasoning_label(reasoning_effort: Option<&ReasoningEffortConfig>) -> String { match reasoning_effort { - Some(ReasoningEffortConfig::Minimal) => "minimal", - Some(ReasoningEffortConfig::Low) => "low", - Some(ReasoningEffortConfig::Medium) => "medium", - Some(ReasoningEffortConfig::High) => "high", - Some(ReasoningEffortConfig::XHigh) => "xhigh", - None | Some(ReasoningEffortConfig::None) => "default", + None | Some(ReasoningEffortConfig::None) => "default".to_string(), + Some(reasoning_effort) => reasoning_effort.as_str().to_string(), } } pub(super) fn reasoning_label_for( model: &str, - reasoning_effort: Option, - ) -> Option<&'static str> { + reasoning_effort: Option<&ReasoningEffortConfig>, + ) -> Option { (!model.starts_with("codex-auto-")).then(|| Self::reasoning_label(reasoning_effort)) } @@ -737,7 +733,7 @@ impl App { pub(super) fn on_update_reasoning_effort(&mut self, effort: Option) { // TODO(aibrahim): Remove this and don't use config as a state object. // Instead, explicitly pass the stored collaboration mode's effort into new sessions. - self.config.model_reasoning_effort = effort; + self.config.model_reasoning_effort = effort.clone(); self.chat_widget.set_reasoning_effort(effort); } diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index b4dfb93fb7..58f090e4c7 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -750,7 +750,7 @@ impl App { self.chat_widget.on_connectors_loaded(result, is_final); } AppEvent::UpdateReasoningEffort(effort) => { - self.on_update_reasoning_effort(effort); + self.on_update_reasoning_effort(effort.clone()); self.sync_active_thread_reasoning_setting(app_server, effort) .await; } @@ -1300,19 +1300,23 @@ impl App { AppEvent::PersistModelSelection { model, effort } => { match crate::config_update::write_config_batch( app_server.request_handle(), - crate::config_update::build_model_selection_edits(model.as_str(), effort), + crate::config_update::build_model_selection_edits( + model.as_str(), + effort.as_ref(), + ), ) .await { Ok(_) => { let effort_label = effort - .map(|selected_effort| selected_effort.to_string()) + .as_ref() + .map(std::string::ToString::to_string) .unwrap_or_else(|| "default".to_string()); tracing::info!("Selected model: {model}, Selected effort: {effort_label}"); let mut message = format!("Model changed to {model}"); - if let Some(label) = Self::reasoning_label_for(&model, effort) { + if let Some(label) = Self::reasoning_label_for(&model, effort.as_ref()) { message.push(' '); - message.push_str(label); + message.push_str(&label); } self.chat_widget.add_info_message(message, /*hint*/ None); } @@ -1607,7 +1611,7 @@ impl App { self.chat_widget.set_rate_limit_switch_prompt_hidden(hidden); } AppEvent::UpdatePlanModeReasoningEffort(effort) => { - self.config.plan_mode_reasoning_effort = effort; + self.config.plan_mode_reasoning_effort = effort.clone(); self.chat_widget.set_plan_mode_reasoning_effort(effort); self.sync_active_thread_plan_mode_reasoning_setting(app_server) .await; diff --git a/codex-rs/tui/src/app/startup_prompts.rs b/codex-rs/tui/src/app/startup_prompts.rs index 802cda3f80..307c69bbc8 100644 --- a/codex-rs/tui/src/app/startup_prompts.rs +++ b/codex-rs/tui/src/app/startup_prompts.rs @@ -152,9 +152,11 @@ pub(super) fn apply_accepted_model_migration( }); config.model = Some(target_model.clone()); - config.model_reasoning_effort = Some(target_default_effort); + config.model_reasoning_effort = Some(target_default_effort.clone()); app_event_tx.send(AppEvent::UpdateModel(target_model.clone())); - app_event_tx.send(AppEvent::UpdateReasoningEffort(Some(target_default_effort))); + app_event_tx.send(AppEvent::UpdateReasoningEffort(Some( + target_default_effort.clone(), + ))); app_event_tx.send(AppEvent::PersistModelSelection { model: target_model, effort: Some(target_default_effort), @@ -290,7 +292,7 @@ pub(super) async fn handle_model_migration_prompt_if_needed( app_event_tx, model.to_string(), target_model.clone(), - target_preset.default_reasoning_effort, + target_preset.default_reasoning_effort.clone(), ); } ModelMigrationOutcome::Rejected => { diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index eb5e10c50f..b18726004e 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -5241,7 +5241,7 @@ async fn override_turn_context_sends_thread_settings_update() { .expect("thread/start should succeed"); let thread_id = started.session.thread_id; let initial_model = started.session.model.clone(); - let initial_effort = started.session.reasoning_effort; + let initial_effort = started.session.reasoning_effort.clone(); app.enqueue_primary_thread_session(started.session, started.turns) .await .expect("primary thread should be registered"); @@ -5466,7 +5466,7 @@ async fn inactive_thread_settings_notification_updates_cached_collaboration_mode model: "gpt-plan".to_string(), model_provider: "openai".to_string(), service_tier: None, - effort: collaboration_mode.settings.reasoning_effort, + effort: collaboration_mode.settings.reasoning_effort.clone(), summary: None, collaboration_mode: collaboration_mode.clone(), personality: Some(Personality::Pragmatic), diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 1bd28d5f65..c53cfc05e3 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -606,7 +606,7 @@ impl App { permissions_override, config.permissions.user_visible_workspace_roots(), model.to_string(), - *effort, + effort.clone(), *summary, service_tier.clone(), collaboration_mode.clone(), diff --git a/codex-rs/tui/src/app/thread_settings.rs b/codex-rs/tui/src/app/thread_settings.rs index 27c435af3f..84dcebe1a8 100644 --- a/codex-rs/tui/src/app/thread_settings.rs +++ b/codex-rs/tui/src/app/thread_settings.rs @@ -124,7 +124,7 @@ impl App { .as_ref() .map(|profile| profile.id.clone()), model: model.clone(), - effort: effort.unwrap_or_default(), + effort: effort.clone().unwrap_or_default(), summary: *summary, service_tier: service_tier.clone(), collaboration_mode: collaboration_mode.clone(), @@ -172,7 +172,7 @@ impl App { fn apply_thread_settings_to_session(session: &mut ThreadSessionState, settings: &ThreadSettings) { if settings.collaboration_mode.mode == ModeKind::Default { session.model = settings.model.clone(); - session.reasoning_effort = settings.effort; + session.reasoning_effort = settings.effort.clone(); } session.model_provider_id = settings.model_provider.clone(); session.service_tier = settings.service_tier.clone(); @@ -190,7 +190,7 @@ fn apply_thread_settings_to_session(session: &mut ThreadSessionState, settings: .settings .model .clone_from(&settings.model); - collaboration_mode.settings.reasoning_effort = settings.effort; + collaboration_mode.settings.reasoning_effort = settings.effort.clone(); session.collaboration_mode = Some(Box::new(collaboration_mode)); } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 2290640ab4..04f81fa83e 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1273,7 +1273,8 @@ fn config_request_overrides_from_config( "model_reasoning_effort", config .model_reasoning_effort - .map(|effort| effort.to_string()), + .as_ref() + .map(std::string::ToString::to_string), ); insert( "model_reasoning_summary", @@ -1586,7 +1587,7 @@ async fn thread_session_state_from_thread_start_response( response.cwd.clone(), response.runtime_workspace_roots.clone(), response.instruction_sources.clone(), - response.reasoning_effort, + response.reasoning_effort.clone(), config, ) .await @@ -1627,7 +1628,7 @@ async fn thread_session_state_from_thread_resume_response( response.cwd.clone(), response.runtime_workspace_roots.clone(), response.instruction_sources.clone(), - response.reasoning_effort, + response.reasoning_effort.clone(), config, ) .await @@ -1659,7 +1660,7 @@ async fn thread_session_state_from_thread_fork_response( response.cwd.clone(), response.runtime_workspace_roots.clone(), response.instruction_sources.clone(), - response.reasoning_effort, + response.reasoning_effort.clone(), config, ) .await diff --git a/codex-rs/tui/src/chatwidget/input_flow.rs b/codex-rs/tui/src/chatwidget/input_flow.rs index 3930224e04..ddb118baec 100644 --- a/codex-rs/tui/src/chatwidget/input_flow.rs +++ b/codex-rs/tui/src/chatwidget/input_flow.rs @@ -166,7 +166,7 @@ impl ChatWidget { mut collaboration_mode: CollaborationModeMask, ) { if collaboration_mode.mode == Some(ModeKind::Plan) - && let Some(effort) = self.config.plan_mode_reasoning_effort + && let Some(effort) = self.config.plan_mode_reasoning_effort.clone() { collaboration_mode.reasoning_effort = Some(Some(effort)); } diff --git a/codex-rs/tui/src/chatwidget/model_popups.rs b/codex-rs/tui/src/chatwidget/model_popups.rs index 2c0d5aba95..8ddecaa3db 100644 --- a/codex-rs/tui/src/chatwidget/model_popups.rs +++ b/codex-rs/tui/src/chatwidget/model_popups.rs @@ -100,11 +100,11 @@ impl ChatWidget { let model = preset.model.clone(); let should_prompt_plan_mode_scope = self.should_prompt_plan_mode_reasoning_scope( model.as_str(), - Some(preset.default_reasoning_effort), + Some(preset.default_reasoning_effort.clone()), ); let actions = Self::model_selection_actions( model.clone(), - Some(preset.default_reasoning_effort), + Some(preset.default_reasoning_effort.clone()), should_prompt_plan_mode_scope, ); SelectionItem { @@ -222,16 +222,16 @@ impl ChatWidget { if should_prompt_plan_mode_scope { tx.send(AppEvent::OpenPlanReasoningScopePrompt { model: model_for_action.clone(), - effort: effort_for_action, + effort: effort_for_action.clone(), }); return; } tx.send(AppEvent::UpdateModel(model_for_action.clone())); - tx.send(AppEvent::UpdateReasoningEffort(effort_for_action)); + tx.send(AppEvent::UpdateReasoningEffort(effort_for_action.clone())); tx.send(AppEvent::PersistModelSelection { model: model_for_action.clone(), - effort: effort_for_action, + effort: effort_for_action.clone(), }); })] } @@ -261,30 +261,34 @@ impl ChatWidget { model: String, effort: Option, ) { - let reasoning_phrase = match effort { + let reasoning_phrase = match effort.as_ref() { Some(ReasoningEffortConfig::None) => "no reasoning".to_string(), Some(selected_effort) => { format!( "{} reasoning", - Self::reasoning_effort_label(selected_effort).to_lowercase() + Self::reasoning_effort_sentence_label(selected_effort) ) } None => "the selected reasoning".to_string(), }; let plan_only_description = format!("Always use {reasoning_phrase} in Plan mode."); let plan_reasoning_source = if let Some(plan_override) = - self.config.plan_mode_reasoning_effort + self.config.plan_mode_reasoning_effort.as_ref() { format!( "user-chosen Plan override ({})", - Self::reasoning_effort_label(plan_override).to_lowercase() + Self::reasoning_effort_sentence_label(plan_override) ) } else if let Some(plan_mask) = collaboration_modes::plan_mask(self.model_catalog.as_ref()) { - match plan_mask.reasoning_effort.flatten() { + match plan_mask + .reasoning_effort + .as_ref() + .and_then(|effort| effort.as_ref()) + { Some(plan_effort) => format!( "built-in Plan default ({})", - Self::reasoning_effort_label(plan_effort).to_lowercase() + Self::reasoning_effort_sentence_label(plan_effort) ), None => "built-in Plan default (no reasoning)".to_string(), } @@ -298,20 +302,21 @@ impl ChatWidget { let plan_only_actions: Vec = vec![Box::new({ let model = model.clone(); + let effort = effort.clone(); move |tx| { tx.send(AppEvent::UpdateModel(model.clone())); - tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort)); - tx.send(AppEvent::PersistPlanModeReasoningEffort(effort)); + tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort.clone())); + tx.send(AppEvent::PersistPlanModeReasoningEffort(effort.clone())); } })]; let all_modes_actions: Vec = vec![Box::new(move |tx| { tx.send(AppEvent::UpdateModel(model.clone())); - tx.send(AppEvent::UpdateReasoningEffort(effort)); - tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort)); - tx.send(AppEvent::PersistPlanModeReasoningEffort(effort)); + tx.send(AppEvent::UpdateReasoningEffort(effort.clone())); + tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort.clone())); + tx.send(AppEvent::PersistPlanModeReasoningEffort(effort.clone())); tx.send(AppEvent::PersistModelSelection { model: model.clone(), - effort, + effort: effort.clone(), }); })]; @@ -344,7 +349,7 @@ impl ChatWidget { /// Open a popup to choose the reasoning effort (stage 2) for the given model. pub(crate) fn open_reasoning_popup(&mut self, preset: ModelPreset) { - let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort; + let default_effort = preset.default_reasoning_effort; let supported = preset.supported_reasoning_efforts; let in_plan_mode = self.collaboration_modes_enabled() && self.active_mode_kind() == ModeKind::Plan; @@ -362,7 +367,7 @@ impl ChatWidget { } else { None }; - let warning_text = warn_effort.map(|effort| { + let warning_text = warn_effort.as_ref().map(|effort| { let effort_label = Self::reasoning_effort_label(effort); format!("⚠ {effort_label} reasoning effort can quickly consume Plus plan rate limits.") }); @@ -370,30 +375,25 @@ impl ChatWidget { || preset.model.starts_with("gpt-5.1-codex-max") || preset.model.starts_with("gpt-5.2"); - struct EffortChoice { - stored: Option, - display: ReasoningEffortConfig, - } - let mut choices: Vec = Vec::new(); - for effort in ReasoningEffortConfig::iter() { - if supported.iter().any(|option| option.effort == effort) { - choices.push(EffortChoice { - stored: Some(effort), - display: effort, - }); - } - } + let mut choices: Vec = ReasoningEffortConfig::known_values() + .filter(|effort| supported.iter().any(|option| option.effort == *effort)) + .collect(); + choices.extend( + supported + .iter() + .filter(|option| option.effort.known_rank().is_none()) + .map(|option| option.effort.clone()), + ); if choices.is_empty() { - choices.push(EffortChoice { - stored: Some(default_effort), - display: default_effort, - }); + choices.push(default_effort.clone()); } if choices.len() == 1 { - let selected_effort = choices.first().and_then(|c| c.stored); + let selected_effort = choices.first().cloned(); let selected_model = preset.model; - if self.should_prompt_plan_mode_reasoning_scope(&selected_model, selected_effort) { + if self + .should_prompt_plan_mode_reasoning_scope(&selected_model, selected_effort.clone()) + { self.app_event_tx .send(AppEvent::OpenPlanReasoningScopePrompt { model: selected_model, @@ -405,12 +405,10 @@ impl ChatWidget { return; } - let default_choice: Option = choices - .iter() - .any(|choice| choice.stored == Some(default_effort)) - .then_some(Some(default_effort)) - .flatten() - .or_else(|| choices.iter().find_map(|choice| choice.stored)) + let default_choice = choices + .contains(&default_effort) + .then(|| default_effort.clone()) + .or_else(|| choices.first().cloned()) .or(Some(default_effort)); let model_slug = preset.model.to_string(); @@ -419,40 +417,33 @@ impl ChatWidget { if in_plan_mode { self.config .plan_mode_reasoning_effort - .or(self.effective_reasoning_effort()) + .clone() + .or_else(|| self.effective_reasoning_effort()) } else { self.effective_reasoning_effort() } } else { - default_choice + default_choice.clone() }; - let selection_choice = highlight_choice.or(default_choice); + let selection_choice = highlight_choice.clone().or_else(|| default_choice.clone()); let initial_selected_idx = choices .iter() - .position(|choice| choice.stored == selection_choice) - .or_else(|| { - selection_choice - .and_then(|effort| choices.iter().position(|choice| choice.display == effort)) - }); + .position(|choice| Some(choice) == selection_choice.as_ref()); let mut items: Vec = Vec::new(); for choice in choices.iter() { - let effort = choice.display; - let mut effort_label = Self::reasoning_effort_label(effort).to_string(); - if choice.stored == default_choice { + let effort = choice.clone(); + let mut effort_label = Self::reasoning_effort_label(&effort); + if Some(choice) == default_choice.as_ref() { effort_label.push_str(" (default)"); } - let description = choice - .stored - .and_then(|effort| { - supported - .iter() - .find(|option| option.effort == effort) - .map(|option| option.description.to_string()) - }) + let description = supported + .iter() + .find(|option| option.effort == effort) + .map(|option| option.description.to_string()) .filter(|text| !text.is_empty()); - let show_warning = warn_for_model && warn_effort == Some(effort); + let show_warning = warn_for_model && warn_effort.as_ref() == Some(&effort); let selected_description = if show_warning { warning_text.as_ref().map(|warning_message| { description.as_ref().map_or_else( @@ -465,21 +456,23 @@ impl ChatWidget { }; let model_for_action = model_slug.clone(); - let choice_effort = choice.stored; - let should_prompt_plan_mode_scope = - self.should_prompt_plan_mode_reasoning_scope(model_slug.as_str(), choice_effort); + let choice_effort = Some(effort); + let should_prompt_plan_mode_scope = self.should_prompt_plan_mode_reasoning_scope( + model_slug.as_str(), + choice_effort.clone(), + ); let actions: Vec = vec![Box::new(move |tx| { if should_prompt_plan_mode_scope { tx.send(AppEvent::OpenPlanReasoningScopePrompt { model: model_for_action.clone(), - effort: choice_effort, + effort: choice_effort.clone(), }); } else { tx.send(AppEvent::UpdateModel(model_for_action.clone())); - tx.send(AppEvent::UpdateReasoningEffort(choice_effort)); + tx.send(AppEvent::UpdateReasoningEffort(choice_effort.clone())); tx.send(AppEvent::PersistModelSelection { model: model_for_action.clone(), - effort: choice_effort, + effort: choice_effort.clone(), }); } })]; @@ -488,7 +481,7 @@ impl ChatWidget { name: effort_label, description, selected_description, - is_current: is_current_model && choice.stored == highlight_choice, + is_current: is_current_model && Some(choice) == highlight_choice.as_ref(), actions, dismiss_on_select: true, ..Default::default() @@ -509,14 +502,22 @@ impl ChatWidget { }); } - pub(super) fn reasoning_effort_label(effort: ReasoningEffortConfig) -> &'static str { + pub(super) fn reasoning_effort_label(effort: &ReasoningEffortConfig) -> String { match effort { - ReasoningEffortConfig::None => "None", - ReasoningEffortConfig::Minimal => "Minimal", - ReasoningEffortConfig::Low => "Low", - ReasoningEffortConfig::Medium => "Medium", - ReasoningEffortConfig::High => "High", - ReasoningEffortConfig::XHigh => "Extra high", + ReasoningEffortConfig::None => "None".to_string(), + ReasoningEffortConfig::Minimal => "Minimal".to_string(), + ReasoningEffortConfig::Low => "Low".to_string(), + ReasoningEffortConfig::Medium => "Medium".to_string(), + ReasoningEffortConfig::High => "High".to_string(), + ReasoningEffortConfig::XHigh => "Extra high".to_string(), + ReasoningEffortConfig::Custom(value) => value.clone(), + } + } + + pub(super) fn reasoning_effort_sentence_label(effort: &ReasoningEffortConfig) -> String { + match effort { + ReasoningEffortConfig::Custom(value) => value.clone(), + effort => Self::reasoning_effort_label(effort).to_lowercase(), } } @@ -531,7 +532,7 @@ impl ChatWidget { } fn apply_model_and_effort(&self, model: String, effort: Option) { - self.apply_model_and_effort_without_persist(model.clone(), effort); + self.apply_model_and_effort_without_persist(model.clone(), effort.clone()); self.app_event_tx .send(AppEvent::PersistModelSelection { model, effort }); } diff --git a/codex-rs/tui/src/chatwidget/rate_limits.rs b/codex-rs/tui/src/chatwidget/rate_limits.rs index f831b5a007..efb0b63628 100644 --- a/codex-rs/tui/src/chatwidget/rate_limits.rs +++ b/codex-rs/tui/src/chatwidget/rate_limits.rs @@ -343,14 +343,16 @@ impl ChatWidget { /*active_permission_profile*/ None, /*windows_sandbox_level*/ None, Some(switch_model_for_events.clone()), - Some(Some(default_effort)), + Some(Some(default_effort.clone())), /*summary*/ None, /*service_tier*/ None, /*collaboration_mode*/ None, /*personality*/ None, ))); tx.send(AppEvent::UpdateModel(switch_model_for_events.clone())); - tx.send(AppEvent::UpdateReasoningEffort(Some(default_effort))); + tx.send(AppEvent::UpdateReasoningEffort(Some( + default_effort.clone(), + ))); })]; let keep_actions: Vec = Vec::new(); diff --git a/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs b/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs index 612dbe7ddd..a5102888b1 100644 --- a/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs +++ b/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs @@ -8,15 +8,14 @@ //! The shortcut state machine is deliberately narrow: it only handles key //! presses when no modal or popup owns input, it anchors unset reasoning to the //! current model preset's default, and it walks only efforts advertised by the -//! active model. Unsupported current efforts are not normalized eagerly; the -//! next shortcut moves to the nearest supported effort in the requested -//! direction. +//! active model. Unsupported known efforts move to the nearest advertised known +//! effort in the requested direction. Unknown efforts anchor to the model +//! default before stepping through the advertised order. use codex_protocol::config_types::ModeKind; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use crossterm::event::KeyEvent; -use strum::IntoEnumIterator; use super::ChatWidget; use crate::app_event::AppEvent; @@ -30,8 +29,8 @@ pub(super) enum ReasoningShortcutDirection { } impl ReasoningShortcutDirection { - fn bound_message(self, effort: ReasoningEffortConfig) -> String { - let label = ChatWidget::reasoning_effort_label(effort).to_lowercase(); + fn bound_message(self, effort: &ReasoningEffortConfig) -> String { + let label = ChatWidget::reasoning_effort_sentence_label(effort); match self { Self::Lower => format!("Reasoning is already at the lowest level ({label})."), Self::Raise => format!("Reasoning is already at the highest level ({label})."), @@ -90,12 +89,23 @@ impl ChatWidget { }; let choices = reasoning_choices(&preset); - let current_effort = self + let configured_effort = self .effective_reasoning_effort() - .unwrap_or(preset.default_reasoning_effort); - let Some(next_effort) = next_reasoning_effort(&choices, Some(current_effort), direction) + .unwrap_or_else(|| preset.default_reasoning_effort.clone()); + let current_effort = if choices.contains(&configured_effort) { + configured_effort + } else if choices.contains(&preset.default_reasoning_effort) { + preset.default_reasoning_effort + } else { + choices + .first() + .cloned() + .unwrap_or(preset.default_reasoning_effort) + }; + let Some(next_effort) = + next_reasoning_effort(&choices, Some(current_effort.clone()), direction) else { - self.add_info_message(direction.bound_message(current_effort), /*hint*/ None); + self.add_info_message(direction.bound_message(¤t_effort), /*hint*/ None); return true; }; @@ -120,18 +130,23 @@ impl ChatWidget { } fn reasoning_choices(preset: &ModelPreset) -> Vec { - let mut choices = Vec::new(); - for effort in ReasoningEffortConfig::iter() { - if preset + let mut choices: Vec = ReasoningEffortConfig::known_values() + .filter(|effort| { + preset + .supported_reasoning_efforts + .iter() + .any(|option| option.effort == *effort) + }) + .collect(); + choices.extend( + preset .supported_reasoning_efforts .iter() - .any(|option| option.effort == effort) - { - choices.push(effort); - } - } + .filter(|option| option.effort.known_rank().is_none()) + .map(|option| option.effort.clone()), + ); if choices.is_empty() { - choices.push(preset.default_reasoning_effort); + choices.push(preset.default_reasoning_effort.clone()); } choices } @@ -142,32 +157,51 @@ fn next_reasoning_effort( direction: ReasoningShortcutDirection, ) -> Option { let current_effort = current_effort?; - if choices.is_empty() { - return None; + if let Some(current_index) = choices.iter().position(|choice| choice == ¤t_effort) { + return match direction { + ReasoningShortcutDirection::Lower => current_index + .checked_sub(1) + .and_then(|index| choices.get(index)) + .cloned(), + ReasoningShortcutDirection::Raise => choices.get(current_index + 1).cloned(), + }; } - let current_rank = effort_rank(current_effort); - match direction { + let current_rank = current_effort.known_rank()?; + let ranked_choice = match direction { ReasoningShortcutDirection::Lower => choices .iter() - .rev() - .copied() - .find(|choice| effort_rank(*choice) < current_rank), + .filter_map(|choice| choice.known_rank().map(|rank| (rank, choice))) + .filter(|(rank, _)| *rank < current_rank) + .max_by_key(|(rank, _)| *rank) + .map(|(_, choice)| choice.clone()), ReasoningShortcutDirection::Raise => choices .iter() - .copied() - .find(|choice| effort_rank(*choice) > current_rank), + .filter_map(|choice| choice.known_rank().map(|rank| (rank, choice))) + .filter(|(rank, _)| *rank > current_rank) + .min_by_key(|(rank, _)| *rank) + .map(|(_, choice)| choice.clone()), + }; + if let Some(ranked_choice) = ranked_choice { + return Some(ranked_choice); } -} -fn effort_rank(effort: ReasoningEffortConfig) -> i32 { - match effort { - ReasoningEffortConfig::None => 0, - ReasoningEffortConfig::Minimal => 1, - ReasoningEffortConfig::Low => 2, - ReasoningEffortConfig::Medium => 3, - ReasoningEffortConfig::High => 4, - ReasoningEffortConfig::XHigh => 5, + let nearest_known_index = choices + .iter() + .enumerate() + .filter_map(|(index, choice)| { + choice + .known_rank() + .map(|rank| (rank.abs_diff(current_rank), index)) + }) + .min_by_key(|(distance, _)| *distance) + .map(|(_, index)| index)?; + match direction { + ReasoningShortcutDirection::Lower => nearest_known_index + .checked_sub(1) + .and_then(|index| choices.get(index)) + .cloned(), + ReasoningShortcutDirection::Raise => choices.get(nearest_known_index + 1).cloned(), } } @@ -235,6 +269,21 @@ mod tests { ); } + #[test] + fn next_reasoning_effort_reaches_custom_level_from_nearest_known_anchor() { + let custom_effort = ReasoningEffortConfig::Custom("max".to_string()); + let choices = vec![ReasoningEffortConfig::Medium, custom_effort.clone()]; + + assert_eq!( + next_reasoning_effort( + &choices, + Some(ReasoningEffortConfig::High), + ReasoningShortcutDirection::Raise, + ), + Some(custom_effort) + ); + } + #[test] fn next_reasoning_effort_clamps_at_bounds() { let choices = vec![ diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chatwidget/session_flow.rs index 6eb5910afe..004f64c726 100644 --- a/codex-rs/tui/src/chatwidget/session_flow.rs +++ b/codex-rs/tui/src/chatwidget/session_flow.rs @@ -79,7 +79,7 @@ impl ChatWidget { let default_model = session.model.clone(); self.current_collaboration_mode = self.current_collaboration_mode.with_updates( Some(default_model.clone()), - Some(session.reasoning_effort), + Some(session.reasoning_effort.clone()), /*developer_instructions*/ None, ); match session.collaboration_mode.as_deref() { @@ -93,7 +93,7 @@ impl ChatWidget { Some(&default_model), ); if let Some(mask) = self.active_collaboration_mask.as_mut() { - mask.reasoning_effort = Some(session.reasoning_effort); + mask.reasoning_effort = Some(session.reasoning_effort.clone()); } self.update_collaboration_mode_indicator(); self.refresh_plan_mode_nudge(); diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 45dbc362e9..c05788acaf 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -159,7 +159,7 @@ impl ChatWidget { /// so the footer reflects it without waiting for the next mode switch. /// Passing `None` resets to the Plan-mode preset default. pub(crate) fn set_plan_mode_reasoning_effort(&mut self, effort: Option) { - self.config.plan_mode_reasoning_effort = effort; + self.config.plan_mode_reasoning_effort = effort.clone(); if self.collaboration_modes_enabled() && let Some(mask) = self.active_collaboration_mask.as_mut() && mask.mode == Some(ModeKind::Plan) @@ -182,7 +182,7 @@ impl ChatWidget { pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { self.current_collaboration_mode = self.current_collaboration_mode.with_updates( /*model*/ None, - Some(effort), + Some(effort.clone()), /*developer_instructions*/ None, ); if self.collaboration_modes_enabled() @@ -471,7 +471,7 @@ impl ChatWidget { let current_effort = self.current_collaboration_mode.reasoning_effort(); self.active_collaboration_mask .as_ref() - .and_then(|mask| mask.reasoning_effort) + .and_then(|mask| mask.reasoning_effort.clone()) .unwrap_or(current_effort) } @@ -590,7 +590,7 @@ impl ChatWidget { name: mode_kind.display_name().to_string(), mode: Some(mode_kind), model: Some(settings.model.clone()), - reasoning_effort: Some(settings.reasoning_effort), + reasoning_effort: Some(settings.reasoning_effort.clone()), developer_instructions: Some(settings.developer_instructions), }); self.update_collaboration_mode_indicator(); @@ -712,7 +712,7 @@ impl ChatWidget { let previous_model = self.current_model().to_string(); let previous_effort = self.effective_reasoning_effort(); if mask.mode == Some(ModeKind::Plan) - && let Some(effort) = self.config.plan_mode_reasoning_effort + && let Some(effort) = self.config.plan_mode_reasoning_effort.clone() { mask.reasoning_effort = Some(Some(effort)); } @@ -732,13 +732,9 @@ impl ChatWidget { { let mut message = format!("Model changed to {next_model}"); if !next_model.starts_with("codex-auto-") { - let reasoning_label = match next_effort { - Some(ReasoningEffortConfig::Minimal) => "minimal", - Some(ReasoningEffortConfig::Low) => "low", - Some(ReasoningEffortConfig::Medium) => "medium", - Some(ReasoningEffortConfig::High) => "high", - Some(ReasoningEffortConfig::XHigh) => "xhigh", + let reasoning_label = match next_effort.as_ref() { None | Some(ReasoningEffortConfig::None) => "default", + Some(effort) => effort.as_str(), }; message.push(' '); message.push_str(reasoning_label); diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap index baef861f4c..8a37596df7 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap @@ -8,5 +8,6 @@ expression: popup 2. Medium (default) Balances speed and reasoning depth for everyday tasks › 3. High (current) Greater reasoning depth for complex problems 4. Extra high Extra high reasoning depth for complex problems + 5. max Maximum available reasoning Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/status_controls.rs b/codex-rs/tui/src/chatwidget/status_controls.rs index d0394464fa..d4de738314 100644 --- a/codex-rs/tui/src/chatwidget/status_controls.rs +++ b/codex-rs/tui/src/chatwidget/status_controls.rs @@ -212,7 +212,7 @@ impl ChatWidget { }); let reasoning_effort_override = Some( self.effective_reasoning_effort() - .or(self.config.model_reasoning_effort) + .or_else(|| self.config.model_reasoning_effort.clone()) .or(model_default_reasoning_effort), ); let rate_limit_snapshots: Vec = self @@ -382,15 +382,11 @@ impl ChatWidget { } pub(super) fn status_line_reasoning_effort_label( - effort: Option, - ) -> &'static str { + effort: Option<&ReasoningEffortConfig>, + ) -> String { match effort { - Some(ReasoningEffortConfig::Minimal) => "minimal", - Some(ReasoningEffortConfig::Low) => "low", - Some(ReasoningEffortConfig::Medium) => "medium", - Some(ReasoningEffortConfig::High) => "high", - Some(ReasoningEffortConfig::XHigh) => "xhigh", - None | Some(ReasoningEffortConfig::None) => "default", + None | Some(ReasoningEffortConfig::None) => "default".to_string(), + Some(effort) => effort.as_str().to_string(), } } } diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index d8facdb0cf..592dd04381 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -562,7 +562,7 @@ impl ChatWidget { match item { StatusLineItem::ModelName => Some(self.model_display_name().to_string()), StatusLineItem::ModelWithReasoning => Some(self.model_with_reasoning_display_name()), - StatusLineItem::Reasoning => Some(self.reasoning_display_name().to_string()), + StatusLineItem::Reasoning => Some(self.reasoning_display_name()), StatusLineItem::CurrentDir => { Some(format_directory_display( self.status_line_cwd(), @@ -762,15 +762,16 @@ impl ChatWidget { /*max_chars*/ 32, )), TerminalTitleItem::Reasoning => Some(Self::truncate_terminal_title_part( - self.reasoning_display_name().to_string(), + self.reasoning_display_name(), /*max_chars*/ 32, )), TerminalTitleItem::TaskProgress => self.terminal_title_task_progress(), } } - fn reasoning_display_name(&self) -> &'static str { - Self::status_line_reasoning_effort_label(self.effective_reasoning_effort()) + fn reasoning_display_name(&self) -> String { + let effort = self.effective_reasoning_effort(); + Self::status_line_reasoning_effort_label(effort.as_ref()) } fn model_with_reasoning_display_name(&self) -> String { diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index bcc960e647..ae19fd6f3f 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -2405,13 +2405,54 @@ async fn model_reasoning_selection_popup_snapshot() { set_chatgpt_auth(&mut chat); chat.set_reasoning_effort(Some(ReasoningEffortConfig::High)); - let preset = get_available_model(&chat, "gpt-5.4"); + let mut preset = get_available_model(&chat, "gpt-5.4"); + preset + .supported_reasoning_efforts + .push(ReasoningEffortPreset { + effort: ReasoningEffortConfig::Custom("max".to_string()), + description: "Maximum available reasoning".to_string(), + }); chat.open_reasoning_popup(preset); let popup = render_bottom_popup(&chat, /*width*/ 80); assert_chatwidget_snapshot!("model_reasoning_selection_popup", popup); } +#[tokio::test] +async fn model_reasoning_selection_popup_applies_custom_effort() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; + let custom_effort = ReasoningEffortConfig::Custom("max".to_string()); + chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); + + let mut preset = get_available_model(&chat, "gpt-5.4"); + preset + .supported_reasoning_efforts + .push(ReasoningEffortPreset { + effort: custom_effort.clone(), + description: "Maximum available reasoning".to_string(), + }); + chat.open_reasoning_popup(preset); + while rx.try_recv().is_ok() {} + + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let selected_effort_events = std::iter::from_fn(|| rx.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::UpdateReasoningEffort(effort) => Some((None, effort)), + AppEvent::PersistModelSelection { model, effort } => Some((Some(model), effort)), + _ => None, + }) + .collect::>(); + assert_eq!( + selected_effort_events, + vec![ + (None, Some(custom_effort.clone())), + (Some("gpt-5.4".to_string()), Some(custom_effort)), + ] + ); +} + #[tokio::test] async fn model_reasoning_selection_popup_extra_high_warning_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; diff --git a/codex-rs/tui/src/history_cell/session.rs b/codex-rs/tui/src/history_cell/session.rs index 23e8836454..c63f6f746c 100644 --- a/codex-rs/tui/src/history_cell/session.rs +++ b/codex-rs/tui/src/history_cell/session.rs @@ -149,7 +149,7 @@ pub(crate) fn new_session_info( // Header box rendered as history (so it appears at the very top) let header = SessionHeaderHistoryCell::new( session.model.clone(), - session.reasoning_effort, + session.reasoning_effort.clone(), show_fast_status, config.cwd.to_path_buf(), CODEX_CLI_VERSION, @@ -317,15 +317,10 @@ impl SessionHeaderHistoryCell { formatted } - fn reasoning_label(&self) -> Option<&'static str> { - self.reasoning_effort.map(|effort| match effort { - ReasoningEffortConfig::Minimal => "minimal", - ReasoningEffortConfig::Low => "low", - ReasoningEffortConfig::Medium => "medium", - ReasoningEffortConfig::High => "high", - ReasoningEffortConfig::XHigh => "xhigh", - ReasoningEffortConfig::None => "none", - }) + fn reasoning_label(&self) -> Option<&str> { + self.reasoning_effort + .as_ref() + .map(ReasoningEffortConfig::as_str) } } @@ -368,7 +363,7 @@ impl HistoryCell for SessionHeaderHistoryCell { ]; if let Some(reasoning) = reasoning_label { spans.push(Span::from(" ")); - spans.push(Span::from(reasoning)); + spans.push(Span::from(reasoning.to_owned())); } if self.show_fast_status { spans.push(" ".into()); diff --git a/codex-rs/tui/src/multi_agents.rs b/codex-rs/tui/src/multi_agents.rs index 3273c610ae..0532ea9fb5 100644 --- a/codex-rs/tui/src/multi_agents.rs +++ b/codex-rs/tui/src/multi_agents.rs @@ -182,7 +182,7 @@ pub(crate) fn spawn_request_summary(item: &ThreadItem) -> Option Some(SpawnRequestSummary { model: model.clone(), - reasoning_effort: *reasoning_effort, + reasoning_effort: reasoning_effort.clone(), }), _ => None, } diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index b8c9c16530..61ffd3da7d 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -287,7 +287,7 @@ impl StatusHistoryCell { ]; if config.model_provider.wire_api == WireApi::Responses { let effort_value = reasoning_effort_override - .unwrap_or(config.model_reasoning_effort) + .unwrap_or_else(|| config.model_reasoning_effort.clone()) .map(|effort| effort.to_string()) .unwrap_or_else(|| "none".to_string()); config_entries.push(("reasoning effort", effort_value)); diff --git a/codex-rs/utils/sandbox-summary/src/config_summary.rs b/codex-rs/utils/sandbox-summary/src/config_summary.rs index b3de5b6382..7e0f7dc4e9 100644 --- a/codex-rs/utils/sandbox-summary/src/config_summary.rs +++ b/codex-rs/utils/sandbox-summary/src/config_summary.rs @@ -25,7 +25,8 @@ pub fn create_config_summary_entries(config: &Config, model: &str) -> Vec<(&'sta if config.model_provider.wire_api == WireApi::Responses { let reasoning_effort = config .model_reasoning_effort - .map(|effort| effort.to_string()); + .as_ref() + .map(std::string::ToString::to_string); entries.push(( "reasoning effort", reasoning_effort.unwrap_or_else(|| "none".to_string()), From f6e529656f7ce4af6d146f4b0c8500a6d89221c2 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Thu, 4 Jun 2026 14:01:14 -0700 Subject: [PATCH 22/41] [codex] Use model-advertised reasoning effort order (#26446) ## Summary - preserve the model catalog order for app-server `supportedReasoningEfforts` and document that client contract - render TUI reasoning choices in the advertised order - step reasoning shortcuts by adjacent list position instead of deriving order from known effort names - anchor unsupported configured values to the advertised default, or the first option when needed - remove canonical effort ordering helpers and the unused upgrade effort mapping ## Validation - `just fmt` - Local tests and compilation were not run per request; relying on CI. Stacked on #26444. --- codex-rs/app-server/README.md | 2 +- .../app-server/tests/suite/v2/model_list.rs | 22 +++- codex-rs/protocol/src/openai_models.rs | 70 ----------- codex-rs/tui/src/app/startup_prompts.rs | 1 - codex-rs/tui/src/app/tests/model_catalog.rs | 1 - codex-rs/tui/src/app_server_session.rs | 1 - codex-rs/tui/src/chatwidget/model_popups.rs | 11 +- .../tui/src/chatwidget/reasoning_shortcuts.rs | 116 +++++++----------- ...ests__model_reasoning_selection_popup.snap | 6 +- .../chatwidget/tests/popups_and_settings.rs | 9 +- 10 files changed, 73 insertions(+), 166 deletions(-) diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index a789c61af0..6d705afa98 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -188,7 +188,7 @@ Example with notification opt-out: - `fs/watch` — subscribe this connection to filesystem change notifications for an absolute file or directory path and caller-provided `watchId`; returns the canonicalized `path`. - `fs/unwatch` — stop sending notifications for a prior `fs/watch`; returns `{}`. - `fs/changed` — notification emitted when watched paths change, including the `watchId` and `changedPaths`. -- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with model-advertised string reasoning effort options, `additionalSpeedTiers`, `serviceTiers`, optional `defaultServiceTier`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. +- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with model-advertised string reasoning effort options in the catalog's intended progression order, `additionalSpeedTiers`, `serviceTiers`, optional `defaultServiceTier`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. Clients should preserve the `supportedReasoningEfforts` array order rather than deriving order from the effort names. - `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider. - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`. - `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.]` entries to be included in the current catalog view. diff --git a/codex-rs/app-server/tests/suite/v2/model_list.rs b/codex-rs/app-server/tests/suite/v2/model_list.rs index eba04b54bd..d2538629c8 100644 --- a/codex-rs/app-server/tests/suite/v2/model_list.rs +++ b/codex-rs/app-server/tests/suite/v2/model_list.rs @@ -1,5 +1,6 @@ use std::time::Duration; +use anyhow::Error; use anyhow::Result; use app_test_support::ChatGptAuthFixture; use app_test_support::TestAppServer; @@ -166,8 +167,9 @@ async fn list_models_uses_chatgpt_remote_catalog_as_source_of_truth() -> Result< "description": "Remote-only model for app-server model/list coverage", "default_reasoning_level": "max", "supported_reasoning_levels": [ - {"effort": "low", "description": "low"}, - {"effort": "max", "description": "Maximum"} + {"effort": "max", "description": "Maximum"}, + {"effort": "low", "description": "Low"}, + {"effort": "focused", "description": "Focused"} ], "shell_type": "shell_command", "visibility": "list", @@ -238,10 +240,24 @@ openai_base_url = "{server_uri}/v1" } = to_response::(response)?; let mut expected_presets: Vec = vec![remote_model.into()]; ModelPreset::mark_default_by_picker_visibility(&mut expected_presets); - let expected_items = expected_presets + let mut expected_items = expected_presets .iter() .map(model_from_preset) .collect::>(); + expected_items[0].supported_reasoning_efforts = vec![ + ReasoningEffortOption { + reasoning_effort: "max".parse().map_err(Error::msg)?, + description: "Maximum".to_string(), + }, + ReasoningEffortOption { + reasoning_effort: "low".parse().map_err(Error::msg)?, + description: "Low".to_string(), + }, + ReasoningEffortOption { + reasoning_effort: "focused".parse().map_err(Error::msg)?, + description: "Focused".to_string(), + }, + ]; assert_eq!(items, expected_items); assert!(next_cursor.is_none()); diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index fc4065ed99..4c04edbb56 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -3,7 +3,6 @@ //! These types are serialized across core, TUI, app-server, and SDK boundaries, so field defaults //! are used to preserve compatibility when older payloads omit newly introduced attributes. -use std::collections::HashMap; use std::fmt; use std::str::FromStr; @@ -63,32 +62,6 @@ impl ReasoningEffort { Self::Custom(effort) => effort, } } - - /// Returns the built-in effort values in ascending order. - pub fn known_values() -> impl DoubleEndedIterator + ExactSizeIterator { - [ - Self::None, - Self::Minimal, - Self::Low, - Self::Medium, - Self::High, - Self::XHigh, - ] - .into_iter() - } - - /// Returns the built-in ordering rank, or `None` for model-defined values. - pub const fn known_rank(&self) -> Option { - match self { - Self::None => Some(0), - Self::Minimal => Some(1), - Self::Low => Some(2), - Self::Medium => Some(3), - Self::High => Some(4), - Self::XHigh => Some(5), - Self::Custom(_) => None, - } - } } impl fmt::Display for ReasoningEffort { @@ -200,7 +173,6 @@ pub struct ReasoningEffortPreset { #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] pub struct ModelUpgrade { pub id: String, - pub reasoning_effort_mapping: Option>, pub migration_config_key: String, pub model_link: Option, pub upgrade_copy: Option, @@ -593,9 +565,6 @@ impl From for ModelPreset { is_default: false, // default is the highest priority available model upgrade: info.upgrade.as_ref().map(|upgrade| ModelUpgrade { id: upgrade.model.clone(), - reasoning_effort_mapping: reasoning_effort_mapping_from_presets( - &info.supported_reasoning_levels, - ), migration_config_key: info.slug.clone(), // todo(aibrahim): add the model link here. model_link: None, @@ -663,45 +632,6 @@ impl ModelPreset { } } -fn reasoning_effort_mapping_from_presets( - presets: &[ReasoningEffortPreset], -) -> Option> { - if presets.is_empty() { - return None; - } - - // Map every canonical effort to the closest supported effort for the new model. - let supported: Vec = presets.iter().map(|p| p.effort.clone()).collect(); - let mut map = HashMap::new(); - for effort in ReasoningEffort::known_values() { - let nearest = nearest_effort(&effort, &supported); - map.insert(effort, nearest); - } - Some(map) -} - -fn nearest_effort(target: &ReasoningEffort, supported: &[ReasoningEffort]) -> ReasoningEffort { - let Some(target_rank) = target.known_rank() else { - return supported - .iter() - .find(|candidate| *candidate == target) - .unwrap_or(target) - .clone(); - }; - supported - .iter() - .filter_map(|candidate| { - candidate - .known_rank() - .map(|rank| (rank.abs_diff(target_rank), candidate)) - }) - .min_by_key(|(distance, _)| *distance) - .map(|(_, effort)| effort) - .or_else(|| supported.first()) - .unwrap_or(target) - .clone() -} - #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/tui/src/app/startup_prompts.rs b/codex-rs/tui/src/app/startup_prompts.rs index 307c69bbc8..a027dcd505 100644 --- a/codex-rs/tui/src/app/startup_prompts.rs +++ b/codex-rs/tui/src/app/startup_prompts.rs @@ -242,7 +242,6 @@ pub(super) async fn handle_model_migration_prompt_if_needed( if let Some(ModelUpgrade { id: target_model, - reasoning_effort_mapping: _, migration_config_key, model_link, upgrade_copy, diff --git a/codex-rs/tui/src/app/tests/model_catalog.rs b/codex-rs/tui/src/app/tests/model_catalog.rs index 7d2eb9c125..961c65159a 100644 --- a/codex-rs/tui/src/app/tests/model_catalog.rs +++ b/codex-rs/tui/src/app/tests/model_catalog.rs @@ -297,7 +297,6 @@ async fn model_migration_prompt_skips_when_target_missing_or_hidden() { .expect("preset present"); current.upgrade = Some(ModelUpgrade { id: "missing-target".to_string(), - reasoning_effort_mapping: None, migration_config_key: HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG.to_string(), model_link: None, upgrade_copy: None, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 04f81fa83e..ccd6304d76 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1204,7 +1204,6 @@ fn model_preset_from_api_model(model: ApiModel) -> ModelPreset { let upgrade_info = model.upgrade_info.clone(); ModelUpgrade { id: upgrade_id, - reasoning_effort_mapping: None, migration_config_key: model.model.clone(), model_link: upgrade_info .as_ref() diff --git a/codex-rs/tui/src/chatwidget/model_popups.rs b/codex-rs/tui/src/chatwidget/model_popups.rs index 8ddecaa3db..90fd6c2cc3 100644 --- a/codex-rs/tui/src/chatwidget/model_popups.rs +++ b/codex-rs/tui/src/chatwidget/model_popups.rs @@ -375,15 +375,10 @@ impl ChatWidget { || preset.model.starts_with("gpt-5.1-codex-max") || preset.model.starts_with("gpt-5.2"); - let mut choices: Vec = ReasoningEffortConfig::known_values() - .filter(|effort| supported.iter().any(|option| option.effort == *effort)) + let mut choices: Vec = supported + .iter() + .map(|option| option.effort.clone()) .collect(); - choices.extend( - supported - .iter() - .filter(|option| option.effort.known_rank().is_none()) - .map(|option| option.effort.clone()), - ); if choices.is_empty() { choices.push(default_effort.clone()); } diff --git a/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs b/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs index a5102888b1..404bf87029 100644 --- a/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs +++ b/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs @@ -8,9 +8,9 @@ //! The shortcut state machine is deliberately narrow: it only handles key //! presses when no modal or popup owns input, it anchors unset reasoning to the //! current model preset's default, and it walks only efforts advertised by the -//! active model. Unsupported known efforts move to the nearest advertised known -//! effort in the requested direction. Unknown efforts anchor to the model -//! default before stepping through the advertised order. +//! active model. Unsupported efforts anchor to the model default, or the first +//! advertised effort when the default is absent, before stepping through the +//! advertised order. use codex_protocol::config_types::ModeKind; use codex_protocol::openai_models::ModelPreset; @@ -130,21 +130,11 @@ impl ChatWidget { } fn reasoning_choices(preset: &ModelPreset) -> Vec { - let mut choices: Vec = ReasoningEffortConfig::known_values() - .filter(|effort| { - preset - .supported_reasoning_efforts - .iter() - .any(|option| option.effort == *effort) - }) + let mut choices: Vec = preset + .supported_reasoning_efforts + .iter() + .map(|option| option.effort.clone()) .collect(); - choices.extend( - preset - .supported_reasoning_efforts - .iter() - .filter(|option| option.effort.known_rank().is_none()) - .map(|option| option.effort.clone()), - ); if choices.is_empty() { choices.push(preset.default_reasoning_effort.clone()); } @@ -167,42 +157,7 @@ fn next_reasoning_effort( }; } - let current_rank = current_effort.known_rank()?; - let ranked_choice = match direction { - ReasoningShortcutDirection::Lower => choices - .iter() - .filter_map(|choice| choice.known_rank().map(|rank| (rank, choice))) - .filter(|(rank, _)| *rank < current_rank) - .max_by_key(|(rank, _)| *rank) - .map(|(_, choice)| choice.clone()), - ReasoningShortcutDirection::Raise => choices - .iter() - .filter_map(|choice| choice.known_rank().map(|rank| (rank, choice))) - .filter(|(rank, _)| *rank > current_rank) - .min_by_key(|(rank, _)| *rank) - .map(|(_, choice)| choice.clone()), - }; - if let Some(ranked_choice) = ranked_choice { - return Some(ranked_choice); - } - - let nearest_known_index = choices - .iter() - .enumerate() - .filter_map(|(index, choice)| { - choice - .known_rank() - .map(|rank| (rank.abs_diff(current_rank), index)) - }) - .min_by_key(|(distance, _)| *distance) - .map(|(_, index)| index)?; - match direction { - ReasoningShortcutDirection::Lower => nearest_known_index - .checked_sub(1) - .and_then(|index| choices.get(index)) - .cloned(), - ReasoningShortcutDirection::Raise => choices.get(nearest_known_index + 1).cloned(), - } + None } #[cfg(test)] @@ -248,39 +203,52 @@ mod tests { } #[test] - fn next_reasoning_effort_skips_to_supported_level_from_unsupported_current() { + fn next_reasoning_effort_does_not_infer_position_for_unsupported_current() { let choices = vec![ReasoningEffortConfig::Low, ReasoningEffortConfig::High]; assert_eq!( - next_reasoning_effort( - &choices, - Some(ReasoningEffortConfig::Medium), - ReasoningShortcutDirection::Raise, + ( + next_reasoning_effort( + &choices, + Some(ReasoningEffortConfig::Medium), + ReasoningShortcutDirection::Raise, + ), + next_reasoning_effort( + &choices, + Some(ReasoningEffortConfig::Medium), + ReasoningShortcutDirection::Lower, + ), ), - Some(ReasoningEffortConfig::High) - ); - assert_eq!( - next_reasoning_effort( - &choices, - Some(ReasoningEffortConfig::Medium), - ReasoningShortcutDirection::Lower, - ), - Some(ReasoningEffortConfig::Low) + (None, None) ); } #[test] - fn next_reasoning_effort_reaches_custom_level_from_nearest_known_anchor() { + fn next_reasoning_effort_uses_advertised_order_for_custom_levels() { let custom_effort = ReasoningEffortConfig::Custom("max".to_string()); - let choices = vec![ReasoningEffortConfig::Medium, custom_effort.clone()]; + let choices = vec![ + ReasoningEffortConfig::High, + ReasoningEffortConfig::Low, + custom_effort.clone(), + ]; assert_eq!( - next_reasoning_effort( - &choices, - Some(ReasoningEffortConfig::High), - ReasoningShortcutDirection::Raise, + ( + next_reasoning_effort( + &choices, + Some(ReasoningEffortConfig::High), + ReasoningShortcutDirection::Raise, + ), + next_reasoning_effort( + &choices, + Some(custom_effort), + ReasoningShortcutDirection::Lower, + ), ), - Some(custom_effort) + ( + Some(ReasoningEffortConfig::Low), + Some(ReasoningEffortConfig::Low), + ) ); } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap index 8a37596df7..a436136570 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap @@ -6,8 +6,8 @@ expression: popup 1. Low Fast responses with lighter reasoning 2. Medium (default) Balances speed and reasoning depth for everyday tasks -› 3. High (current) Greater reasoning depth for complex problems - 4. Extra high Extra high reasoning depth for complex problems - 5. max Maximum available reasoning + 3. max Maximum available reasoning +› 4. High (current) Greater reasoning depth for complex problems + 5. Extra high Extra high reasoning depth for complex problems Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index ae19fd6f3f..89c332a4ef 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -2406,12 +2406,13 @@ async fn model_reasoning_selection_popup_snapshot() { chat.set_reasoning_effort(Some(ReasoningEffortConfig::High)); let mut preset = get_available_model(&chat, "gpt-5.4"); - preset - .supported_reasoning_efforts - .push(ReasoningEffortPreset { + preset.supported_reasoning_efforts.insert( + 2, + ReasoningEffortPreset { effort: ReasoningEffortConfig::Custom("max".to_string()), description: "Maximum available reasoning".to_string(), - }); + }, + ); chat.open_reasoning_popup(preset); let popup = render_bottom_popup(&chat, /*width*/ 80); From 37c8aefa14e1a8bb817ee63ddc4561da04741082 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Thu, 4 Jun 2026 14:38:11 -0700 Subject: [PATCH 23/41] Use Winget release environment secret (#26466) ## Why `WINGET_PUBLISH_PAT` now lives as a GitHub environment secret under `mainline-release-winget`. The WinGet release job needs to enter that environment so `secrets.WINGET_PUBLISH_PAT` resolves during stable/mainline Rust releases. ## What Changed - Attach the `winget` job in `.github/workflows/rust-release.yml` to the `mainline-release-winget` environment. - Set `deployment: false` so the job can read environment secrets without creating GitHub deployment records. ## Operational Note The `mainline-release-winget` environment must allow `rust-v*.*.*` tag refs before this can run on release tags. The live environment currently has a custom policy named `rust-v*.*.*` with type `branch`; add the corresponding `tag` policy before relying on this path for a release. ## Validation - `git diff --check origin/main...HEAD -- .github/workflows/rust-release.yml` - `ruby -e 'require "yaml"; ARGV.each { |f| YAML.load_file(f); puts "yaml ok: #{f}" }' .github/workflows/rust-release.yml` --- .github/workflows/rust-release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 2888ea6ce8..03a0cc57ff 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -1946,6 +1946,9 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + environment: + name: mainline-release-winget + deployment: false steps: - name: Publish to WinGet From 59ca34206b4606a8800a4e565d6006f02fa37206 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Thu, 4 Jun 2026 15:08:52 -0700 Subject: [PATCH 24/41] [codex] Preserve logical paths during AGENTS.md discovery (#26465) ## Intent Follow up on #26205 by avoiding unnecessary filesystem canonicalization during `AGENTS.md` discovery. The configured working directory is already absolute, and canonicalization incorrectly switches symlinked workspaces from their logical parent hierarchy to the target's hierarchy. ## User-facing behavior For a symlinked working directory such as: ```text test-root/ |-- logical-repo/ | |-- AGENTS.md ("logical parent doc") | `-- workspace ------------> physical-repo/workspace/ `-- physical-repo/ |-- AGENTS.md ("physical parent doc") `-- workspace/ `-- AGENTS.md ("workspace doc") ``` Before this change, Codex canonicalized `logical-repo/workspace` to `physical-repo/workspace` before discovery. It therefore loaded `physical-repo/AGENTS.md` and `physical-repo/workspace/AGENTS.md`, ignoring the instructions from the repository through which the user entered the workspace. After this change, ancestor discovery walks the configured logical path, so Codex loads `logical-repo/AGENTS.md`. Opening `logical-repo/workspace/AGENTS.md` still follows the symlink through the host filesystem, so the workspace document is also loaded. `physical-repo/AGENTS.md` is not loaded. ## Implementation Use the logical absolute working directory when discovering project instructions and reporting instruction sources. Filesystem reads still follow the working-directory symlink, so an `AGENTS.md` in the target workspace continues to load while ancestor discovery uses the symlink's parents. ## Validation Added integration coverage proving that discovery loads the logical parent's instructions and the target workspace's instructions, but not the target parent's instructions. --- .../tests/suite/v2/thread_resume.rs | 2 +- .../app-server/tests/suite/v2/thread_start.rs | 2 +- codex-rs/core/src/agents_md.rs | 5 +- codex-rs/core/src/agents_md_tests.rs | 59 ++++++------ codex-rs/core/tests/common/lib.rs | 15 ++++ codex-rs/core/tests/suite/agents_md.rs | 90 +++++++++++++++++-- 6 files changed, 130 insertions(+), 43 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 4e052041ad..4ae68b0e71 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -284,7 +284,7 @@ async fn thread_resume_running_thread_uses_cached_instruction_sources() -> Resul instruction_sources, .. } = to_response::(start_resp)?; - let project_agents = AbsolutePathBuf::try_from(std::fs::canonicalize(project_agents)?)?; + let project_agents = AbsolutePathBuf::try_from(project_agents)?; assert_eq!(instruction_sources, vec![project_agents.clone()]); let turn_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 208af14299..a2da95545a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -348,7 +348,7 @@ async fn thread_start_response_includes_loaded_instruction_sources() -> Result<( .collect::>(); let expected_instruction_sources = vec![ std::fs::canonicalize(global_agents_path)?, - std::fs::canonicalize(project_agents_path)?, + project_agents_path, ] .into_iter() .map(normalize_path_for_comparison) diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index bc3740a46f..5739e42f30 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -206,10 +206,7 @@ impl<'a> AgentsMdManager<'a> { return Ok(Vec::new()); } - let mut dir = self.config.cwd.clone(); - if let Ok(canonical_dir) = fs.canonicalize(&dir, /*sandbox*/ None).await { - dir = canonical_dir; - } + let dir = self.config.cwd.clone(); let mut merged = TomlValue::Table(toml::map::Map::new()); for layer in self.config.config_layer_stack.get_layers( diff --git a/codex-rs/core/src/agents_md_tests.rs b/codex-rs/core/src/agents_md_tests.rs index 95659511b0..d8298dfd7d 100644 --- a/codex-rs/core/src/agents_md_tests.rs +++ b/codex-rs/core/src/agents_md_tests.rs @@ -5,6 +5,7 @@ use codex_features::Feature; use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::PathBufExt; use core_test_support::TempDirExt; +use core_test_support::create_directory_symlink; use pretty_assertions::assert_eq; use std::fs; use std::path::Path; @@ -223,8 +224,7 @@ async fn project_doc_invalid_utf8_warns_and_uses_lossy_text() { .text(); assert_eq!(res, "project\u{FFFD} doc"); - let canonical_path = dunce::canonicalize(&path).expect("canonical doc path"); - assert_invalid_utf8_warning(&warnings, "Project", &canonical_path); + assert_invalid_utf8_warning(&warnings, "Project", config.cwd.join("AGENTS.md").as_path()); } /// Oversize file is truncated to `project_doc_max_bytes`. @@ -330,10 +330,7 @@ async fn sourceless_user_instructions_preserve_separator_without_reporting_a_sou .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) .await .expect("instructions expected"); - let project_agents = AbsolutePathBuf::try_from( - dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical project doc path"), - ) - .expect("absolute project doc path"); + let project_agents = cfg.cwd.join("AGENTS.md"); assert_eq!( loaded.text(), @@ -385,14 +382,8 @@ async fn concatenates_root_and_cwd_docs() { .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) .await .expect("doc expected"); - let root_agents = AbsolutePathBuf::try_from( - dunce::canonicalize(repo.path().join("AGENTS.md")).expect("canonical root doc path"), - ) - .expect("absolute root doc path"); - let crate_agents = AbsolutePathBuf::try_from( - dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical crate doc path"), - ) - .expect("absolute crate doc path"); + let root_agents = repo.path().join("AGENTS.md").abs(); + let crate_agents = cfg.cwd.join("AGENTS.md"); let expected = LoadedAgentsMd { entries: vec![ InstructionEntry { @@ -434,14 +425,8 @@ async fn project_root_markers_are_honored_for_agents_discovery() { cfg.cwd = nested.abs(); let discovery = agents_md_paths(&cfg).await.expect("discover paths"); - let expected_parent = AbsolutePathBuf::try_from( - dunce::canonicalize(root.path().join("AGENTS.md")).expect("canonical parent doc path"), - ) - .expect("absolute parent doc path"); - let expected_child = AbsolutePathBuf::try_from( - dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical child doc path"), - ) - .expect("absolute child doc path"); + let expected_parent = root.path().join("AGENTS.md").abs(); + let expected_child = cfg.cwd.join("AGENTS.md"); assert_eq!(discovery.len(), 2); assert_eq!(discovery[0], expected_parent); assert_eq!(discovery[1], expected_child); @@ -450,6 +435,26 @@ async fn project_root_markers_are_honored_for_agents_discovery() { assert_eq!(res, "parent doc\n\nchild doc"); } +#[tokio::test] +async fn agents_md_paths_preserve_symlinked_cwd() { + let tmp = tempfile::tempdir().expect("tempdir"); + let target = tmp.path().join("target"); + fs::create_dir(&target).unwrap(); + fs::write(target.join("AGENTS.md"), "project doc").unwrap(); + + let linked_cwd = tmp.path().join("linked"); + create_directory_symlink(&target, &linked_cwd); + + let mut cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await; + cfg.cwd = linked_cwd.abs(); + + let discovery = agents_md_paths(&cfg).await.expect("discover paths"); + assert_eq!(discovery, vec![cfg.cwd.join("AGENTS.md")]); + + let res = get_user_instructions(&cfg).await.expect("doc expected"); + assert_eq!(res, "project doc"); +} + #[tokio::test] async fn child_agents_message_after_global_instructions_uses_plain_separator() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -497,10 +502,7 @@ async fn instruction_sources_include_global_before_agents_md_docs() { .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) .await .expect("instructions expected"); - let project_agents = AbsolutePathBuf::try_from( - dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical project doc path"), - ) - .expect("absolute project doc path"); + let project_agents = cfg.cwd.join("AGENTS.md"); let expected = LoadedAgentsMd { entries: vec![ @@ -541,10 +543,7 @@ async fn child_agents_message_after_project_docs_is_not_an_instruction_source() .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) .await .expect("instructions expected"); - let project_agents = AbsolutePathBuf::try_from( - dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical project doc path"), - ) - .expect("absolute project doc path"); + let project_agents = cfg.cwd.join("AGENTS.md"); let expected = LoadedAgentsMd { entries: vec![ diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index be07b803f8..94d72d619b 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -19,6 +19,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; pub use codex_utils_absolute_path::test_support::PathBufExt; pub use codex_utils_absolute_path::test_support::PathExt; use regex_lite::Regex; +use std::path::Path; use std::path::PathBuf; pub mod apps_test_server; @@ -110,6 +111,20 @@ pub fn test_absolute_path(unix_path: &str) -> AbsolutePathBuf { test_absolute_path_with_windows(unix_path, /*windows_path*/ None) } +#[cfg(unix)] +#[allow(clippy::expect_used)] +pub fn create_directory_symlink(source: &Path, link: &Path) { + std::os::unix::fs::symlink(source, link).expect("create directory symlink"); +} + +#[cfg(windows)] +#[allow(clippy::expect_used)] +pub fn create_directory_symlink(source: &Path, link: &Path) { + // Running this test locally may require Windows Developer Mode or an elevated process. + std::os::windows::fs::symlink_dir(source, link) + .expect("create directory symlink; enable Developer Mode or run the test elevated"); +} + pub trait TempDirExt { fn abs(&self) -> AbsolutePathBuf; } diff --git a/codex-rs/core/tests/suite/agents_md.rs b/codex-rs/core/tests/suite/agents_md.rs index 318c288dc6..3fa2136ecb 100644 --- a/codex-rs/core/tests/suite/agents_md.rs +++ b/codex-rs/core/tests/suite/agents_md.rs @@ -1,6 +1,7 @@ use anyhow::Result; use codex_exec_server::CreateDirectoryOptions; use codex_utils_absolute_path::AbsolutePathBuf; +use core_test_support::create_directory_symlink; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; @@ -8,6 +9,7 @@ use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::test_codex::TestCodexBuilder; use core_test_support::test_codex::test_codex; +use pretty_assertions::assert_eq; use std::sync::Arc; use tempfile::TempDir; @@ -143,6 +145,86 @@ async fn agents_docs_are_concatenated_from_project_root_to_cwd() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn symlinked_cwd_uses_logical_parent_for_agents_discovery() -> Result<()> { + let server = start_mock_server().await; + let resp_mock = mount_sse_once( + &server, + sse(vec![ev_response_created("resp1"), ev_completed("resp1")]), + ) + .await; + + let mut builder = test_codex() + .with_config(|config| { + config.cwd = config.cwd.join("logical-repo/workspace"); + }) + .with_workspace_setup(|cwd, _fs| async move { + // Construct two sibling repositories with the configured cwd as a + // directory symlink from the logical repository into the physical + // repository: + // + // test-root/ + // |-- logical-repo/ + // | |-- .git + // | |-- AGENTS.md ("logical parent doc") + // | `-- workspace ------------> physical-repo/workspace/ + // `-- physical-repo/ + // |-- .git + // |-- AGENTS.md ("physical parent doc") + // `-- workspace/ + // `-- AGENTS.md ("workspace doc") + // + // Discovery should walk the lexical path through logical-repo, + // while opening logical-repo/workspace/AGENTS.md still follows the + // symlink into physical-repo/workspace. + let logical_root = cwd.parent().expect("symlink should have a parent"); + let test_root = logical_root + .parent() + .expect("logical repository should have a parent"); + let physical_root = test_root.join("physical-repo"); + let physical_workspace = physical_root.join("workspace"); + + std::fs::create_dir_all(logical_root.as_path())?; + std::fs::write(logical_root.join(".git"), "")?; + std::fs::write(logical_root.join("AGENTS.md"), "logical parent doc")?; + + std::fs::create_dir_all(physical_workspace.as_path())?; + std::fs::write(physical_root.join(".git"), "")?; + std::fs::write(physical_root.join("AGENTS.md"), "physical parent doc")?; + std::fs::write(physical_workspace.join("AGENTS.md"), "workspace doc")?; + + create_directory_symlink(physical_workspace.as_path(), cwd.as_path()); + Ok(()) + }); + let test = builder.build(&server).await?; + let logical_root = test + .config + .cwd + .parent() + .expect("symlink should have a parent"); + + assert_eq!( + test.codex.instruction_sources().await, + vec![ + logical_root.join("AGENTS.md"), + test.config.cwd.join("AGENTS.md") + ] + ); + + test.submit_turn("hello").await?; + let instructions = resp_mock + .single_request() + .message_input_texts("user") + .into_iter() + .find(|text| text.starts_with("# AGENTS.md instructions for ")) + .expect("instructions message"); + assert!(instructions.contains("logical parent doc")); + assert!(instructions.contains("workspace doc")); + assert!(!instructions.contains("physical parent doc")); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn selected_environment_sources_match_model_visible_instructions() -> Result<()> { let server = start_mock_server().await; @@ -167,13 +249,7 @@ async fn selected_environment_sources_match_model_visible_instructions() -> Resu Ok::<(), anyhow::Error>(()) }); let test = builder.build_with_remote_env(&server).await?; - let project_agents = test - .fs() - .canonicalize( - &test.executor_environment().cwd().join("AGENTS.md"), - /*sandbox*/ None, - ) - .await?; + let project_agents = test.config.cwd.join("AGENTS.md"); let global_agents = AbsolutePathBuf::try_from(global_agents).expect("absolute path"); assert_eq!( From 769c231aa1d69e2482fda2a51444d7f7a2158e3a Mon Sep 17 00:00:00 2001 From: Eric Ning Date: Thu, 4 Jun 2026 15:10:24 -0700 Subject: [PATCH 25/41] fix(app-server): expose remote MCP servers in plugin read (#26453) ## Why Remote plugin detail responses include MCP server metadata under `release.mcp_servers`, but Codex did not deserialize or propagate that field. As a result, `plugin/read` always returned an empty `mcpServers` list for remote plugins, so the plugin details pane omitted the MCP Servers section even when the remote plugin declares one. This affects uninstalled plugins as well: the remote detail API is the source of truth and returns MCP server keys without requiring a local plugin bundle. ## What changed - Deserialize MCP server entries from remote plugin detail responses. - Normalize their keys into a sorted, deduplicated list on `RemotePluginDetail`. - Return those keys from app-server `plugin/read` instead of hardcoding an empty list. - Add regression coverage proving an uninstalled remote plugin returns its MCP server names. ## Test plan - `just test -p codex-core-plugins` - `just test -p codex-app-server plugin_read` --- .../src/request_processors/plugins.rs | 2 +- .../app-server/tests/suite/v2/plugin_read.rs | 36 +++++++++++++------ codex-rs/core-plugins/src/remote.rs | 17 +++++++++ 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 2335694856..3163532449 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -2026,7 +2026,7 @@ fn remote_plugin_detail_to_info( .collect(), hooks: Vec::new(), apps, - mcp_servers: Vec::new(), + mcp_servers: detail.mcp_servers, } } diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index f78cd2a088..9e9e844228 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -122,7 +122,7 @@ async fn plugin_read_rejects_multiple_read_sources() -> Result<()> { } #[tokio::test] -async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_is_disabled() -> Result<()> { +async fn plugin_read_returns_remote_mcp_servers_when_uninstalled() -> Result<()> { let codex_home = TempDir::new()?; let server = MockServer::start().await; std::fs::write( @@ -148,22 +148,31 @@ plugins = true let detail_body = r#"{ "id": "plugins~Plugin_00000000000000000000000000000000", - "name": "linear", + "name": "example-plugin", "scope": "GLOBAL", "installation_policy": "AVAILABLE", "authentication_policy": "ON_USE", "release": { - "display_name": "Linear", - "description": "Track work in Linear", + "version": "1.2.1", + "display_name": "Example Plugin", + "description": "Example plugin", "app_ids": [], "keywords": [], "interface": { - "short_description": "Plan and track work", + "short_description": "Example plugin", "capabilities": [], - "default_prompt": "Use the legacy Linear prompt", + "default_prompt": "Use the legacy example prompt", "default_prompts": [] }, - "skills": [] + "skills": [], + "mcp_servers": [ + { + "key": "example-server", + "metadata": { + "command": "example-mcp" + } + } + ] } }"#; let installed_body = r#"{ @@ -211,12 +220,15 @@ plugins = true let response: PluginReadResponse = to_response(response)?; assert_eq!(response.plugin.marketplace_name, "openai-curated-remote"); - assert_eq!(response.plugin.summary.id, "linear@openai-curated-remote"); + assert_eq!( + response.plugin.summary.id, + "example-plugin@openai-curated-remote" + ); assert_eq!( response.plugin.summary.remote_plugin_id.as_deref(), Some("plugins~Plugin_00000000000000000000000000000000") ); - assert_eq!(response.plugin.summary.name, "linear"); + assert_eq!(response.plugin.summary.name, "example-plugin"); assert_eq!(response.plugin.summary.source, PluginSource::Remote); assert_eq!(response.plugin.summary.share_context, None); assert_eq!( @@ -226,7 +238,11 @@ plugins = true .interface .as_ref() .and_then(|interface| interface.default_prompt.clone()), - Some(vec!["Use the legacy Linear prompt".to_string()]) + Some(vec!["Use the legacy example prompt".to_string()]) + ); + assert_eq!( + response.plugin.mcp_servers, + vec!["example-server".to_string()] ); Ok(()) } diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 045ad8e184..9b34fd293c 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -167,6 +167,7 @@ pub struct RemotePluginDetail { pub app_manifest: Option, pub skills: Vec, pub app_ids: Vec, + pub mcp_servers: Vec, } #[derive(Debug, Clone, PartialEq)] @@ -417,6 +418,13 @@ struct RemotePluginReleaseResponse { interface: RemotePluginReleaseInterfaceResponse, #[serde(default)] skills: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + mcp_servers: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +struct RemotePluginMcpServerResponse { + key: String, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] @@ -948,6 +956,14 @@ async fn build_remote_plugin_detail( enabled: !disabled_skill_names.contains(&skill.name), }) .collect(); + let mut mcp_servers = plugin + .release + .mcp_servers + .iter() + .map(|server| server.key.clone()) + .collect::>(); + mcp_servers.sort_unstable(); + mcp_servers.dedup(); Ok(RemotePluginDetail { marketplace_name, @@ -959,6 +975,7 @@ async fn build_remote_plugin_detail( app_manifest: plugin.release.app_manifest, skills, app_ids: plugin.release.app_ids, + mcp_servers, }) } From e695ec8ec612f61f6150299cc5c0ec39138dc902 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Thu, 4 Jun 2026 15:40:16 -0700 Subject: [PATCH 26/41] Add skill for pushing CI configuration changes (#26473) ## Why Codex agents that modify GitHub Actions configuration need clear guidance when repository push protections require temporary approval. Without it, an agent may pursue an unavailable exemption or stop before checking whether the user already has access. ## What Add a `pushing-ci-changes` skill that explains the restriction, directs agents to attempt the push first, and tells them how to involve the user when approval is required. ## Validation Not run; this change only adds skill documentation. --- .codex/skills/pushing-ci-changes/SKILL.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .codex/skills/pushing-ci-changes/SKILL.md diff --git a/.codex/skills/pushing-ci-changes/SKILL.md b/.codex/skills/pushing-ci-changes/SKILL.md new file mode 100644 index 0000000000..cb91bdd981 --- /dev/null +++ b/.codex/skills/pushing-ci-changes/SKILL.md @@ -0,0 +1,17 @@ +--- +name: pushing-ci-changes +description: Pushing GitHub Actions changes, resolving push rejection, requesting upload exceptions. +--- + +The Codex repository prevents anyone from uploading changes to its CI configuration unless they've +been granted a temporary role. + +In order to push changes to `.github/**/*.yml` and related files you'll need the user to read +go/workflow-approvals and request an approval through that flow. It is not possible for you to +make progress by requesting an exemption yourself. + +If you know you're about to push changes that would fail due to these restrictions you should still +try to push anyways to confirm the user's account doesn't already have the necessary approval. + +If you encounter a failed push due to these restrictions, share the go/workflow-approvals link with +the user and ask them to return control to you once the approval has propagated to GitHub. From b9ff4509029b40a0c10964aa751a1a006ab46359 Mon Sep 17 00:00:00 2001 From: charlesgong-openai Date: Thu, 4 Jun 2026 16:42:27 -0700 Subject: [PATCH 27/41] [codex] Expose unavailable app templates in plugin detail (#26317) ## Summary - Adds `unavailable_app_templates` to the app-server protocol and generated schemas/types. - Parses plugin-service `release.unavailable_app_templates` in the remote plugin client. - Maps remote unavailable templates into app-server `PluginDetail`. - Defaults local plugins to an empty unavailable app template list. ## Validation - `just write-app-server-schema` - `cargo +1.95.0 fmt --manifest-path codex-rs/Cargo.toml --all --check` - `cargo +1.95.0 test --manifest-path codex-rs/Cargo.toml -p codex-app-server-protocol schema_fixtures` - `cargo +1.95.0 check --manifest-path codex-rs/Cargo.toml -p codex-app-server-protocol -p codex-core-plugins -p codex-app-server` - `git diff --check` Note: default `cargo check` uses rustc 1.89 locally and failed because dependencies require newer Rust, so validation was rerun with installed Rust 1.95. --- .../codex_app_server_protocol.schemas.json | 76 ++++++++++++++++++- .../codex_app_server_protocol.v2.schemas.json | 70 +++++++++++++++++ .../schema/json/v2/PluginReadResponse.json | 70 +++++++++++++++++ .../typescript/v2/AppTemplateSummary.ts | 6 ++ .../v2/AppTemplateUnavailableReason.ts | 5 ++ .../schema/typescript/v2/PluginDetail.ts | 3 +- .../schema/typescript/v2/index.ts | 2 + .../src/protocol/v2/plugin.rs | 23 ++++++ codex-rs/app-server/src/request_processors.rs | 2 + .../src/request_processors/plugins.rs | 25 ++++++ .../app-server/tests/suite/v2/plugin_read.rs | 49 ++++++++++++ codex-rs/core-plugins/src/remote.rs | 55 ++++++++++++++ codex-rs/tui/src/chatwidget/tests/helpers.rs | 1 + 13 files changed, 383 insertions(+), 4 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/AppTemplateSummary.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/AppTemplateUnavailableReason.ts diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 81617aa93c..4802a52300 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -3786,8 +3786,8 @@ "environmentId": { "default": null, "type": [ - "null", - "string" + "string", + "null" ] }, "itemId": { @@ -6278,6 +6278,69 @@ ], "type": "object" }, + "AppTemplateSummary": { + "properties": { + "canonicalConnectorId": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "logoUrl": { + "type": [ + "string", + "null" + ] + }, + "logoUrlDark": { + "type": [ + "string", + "null" + ] + }, + "materializedAppIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "name": { + "type": "string" + }, + "reason": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AppTemplateUnavailableReason" + }, + { + "type": "null" + } + ] + }, + "templateId": { + "type": "string" + } + }, + "required": [ + "materializedAppIds", + "name", + "templateId" + ], + "type": "object" + }, + "AppTemplateUnavailableReason": { + "enum": [ + "NOT_CONFIGURED_FOR_WORKSPACE", + "NO_ACTIVE_WORKSPACE" + ], + "type": "string" + }, "AppToolApproval": { "enum": [ "auto", @@ -12168,6 +12231,12 @@ }, "PluginDetail": { "properties": { + "appTemplates": { + "items": { + "$ref": "#/definitions/v2/AppTemplateSummary" + }, + "type": "array" + }, "apps": { "items": { "$ref": "#/definitions/v2/AppSummary" @@ -12216,6 +12285,7 @@ } }, "required": [ + "appTemplates", "apps", "hooks", "marketplaceName", @@ -19232,4 +19302,4 @@ }, "title": "CodexAppServerProtocol", "type": "object" -} +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 336350bcec..2b9e30bae7 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -643,6 +643,69 @@ ], "type": "object" }, + "AppTemplateSummary": { + "properties": { + "canonicalConnectorId": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "logoUrl": { + "type": [ + "string", + "null" + ] + }, + "logoUrlDark": { + "type": [ + "string", + "null" + ] + }, + "materializedAppIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "name": { + "type": "string" + }, + "reason": { + "anyOf": [ + { + "$ref": "#/definitions/AppTemplateUnavailableReason" + }, + { + "type": "null" + } + ] + }, + "templateId": { + "type": "string" + } + }, + "required": [ + "materializedAppIds", + "name", + "templateId" + ], + "type": "object" + }, + "AppTemplateUnavailableReason": { + "enum": [ + "NOT_CONFIGURED_FOR_WORKSPACE", + "NO_ACTIVE_WORKSPACE" + ], + "type": "string" + }, "AppToolApproval": { "enum": [ "auto", @@ -8690,6 +8753,12 @@ }, "PluginDetail": { "properties": { + "appTemplates": { + "items": { + "$ref": "#/definitions/AppTemplateSummary" + }, + "type": "array" + }, "apps": { "items": { "$ref": "#/definitions/AppSummary" @@ -8738,6 +8807,7 @@ } }, "required": [ + "appTemplates", "apps", "hooks", "marketplaceName", diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json index c9fe43c34f..5a4c2e5b09 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -37,6 +37,69 @@ ], "type": "object" }, + "AppTemplateSummary": { + "properties": { + "canonicalConnectorId": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "logoUrl": { + "type": [ + "string", + "null" + ] + }, + "logoUrlDark": { + "type": [ + "string", + "null" + ] + }, + "materializedAppIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "name": { + "type": "string" + }, + "reason": { + "anyOf": [ + { + "$ref": "#/definitions/AppTemplateUnavailableReason" + }, + { + "type": "null" + } + ] + }, + "templateId": { + "type": "string" + } + }, + "required": [ + "materializedAppIds", + "name", + "templateId" + ], + "type": "object" + }, + "AppTemplateUnavailableReason": { + "enum": [ + "NOT_CONFIGURED_FOR_WORKSPACE", + "NO_ACTIVE_WORKSPACE" + ], + "type": "string" + }, "HookEventName": { "enum": [ "preToolUse", @@ -78,6 +141,12 @@ }, "PluginDetail": { "properties": { + "appTemplates": { + "items": { + "$ref": "#/definitions/AppTemplateSummary" + }, + "type": "array" + }, "apps": { "items": { "$ref": "#/definitions/AppSummary" @@ -126,6 +195,7 @@ } }, "required": [ + "appTemplates", "apps", "hooks", "marketplaceName", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppTemplateSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppTemplateSummary.ts new file mode 100644 index 0000000000..dd5f76229f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppTemplateSummary.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AppTemplateUnavailableReason } from "./AppTemplateUnavailableReason"; + +export type AppTemplateSummary = { templateId: string, name: string, description: string | null, canonicalConnectorId: string | null, logoUrl: string | null, logoUrlDark: string | null, materializedAppIds: Array, reason: AppTemplateUnavailableReason | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppTemplateUnavailableReason.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppTemplateUnavailableReason.ts new file mode 100644 index 0000000000..563056798e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppTemplateUnavailableReason.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AppTemplateUnavailableReason = "NOT_CONFIGURED_FOR_WORKSPACE" | "NO_ACTIVE_WORKSPACE"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts index 64836c87f7..cc2042dd5d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts @@ -3,8 +3,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { AppSummary } from "./AppSummary"; +import type { AppTemplateSummary } from "./AppTemplateSummary"; import type { PluginHookSummary } from "./PluginHookSummary"; import type { PluginSummary } from "./PluginSummary"; import type { SkillSummary } from "./SkillSummary"; -export type PluginDetail = { marketplaceName: string, marketplacePath: AbsolutePathBuf | null, summary: PluginSummary, description: string | null, skills: Array, hooks: Array, apps: Array, mcpServers: Array, }; +export type PluginDetail = { marketplaceName: string, marketplacePath: AbsolutePathBuf | null, summary: PluginSummary, description: string | null, skills: Array, hooks: Array, apps: Array, appTemplates: Array, mcpServers: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 48e1f9580b..b4c65946d5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -21,6 +21,8 @@ export type { AppMetadata } from "./AppMetadata"; export type { AppReview } from "./AppReview"; export type { AppScreenshot } from "./AppScreenshot"; export type { AppSummary } from "./AppSummary"; +export type { AppTemplateSummary } from "./AppTemplateSummary"; +export type { AppTemplateUnavailableReason } from "./AppTemplateUnavailableReason"; export type { AppToolApproval } from "./AppToolApproval"; export type { AppToolsConfig } from "./AppToolsConfig"; export type { ApprovalsReviewer } from "./ApprovalsReviewer"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs index 1b7e8ba8ff..6222eb6e73 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -642,9 +642,32 @@ pub struct PluginDetail { pub skills: Vec, pub hooks: Vec, pub apps: Vec, + pub app_templates: Vec, pub mcp_servers: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +#[ts(export_to = "v2/")] +pub enum AppTemplateUnavailableReason { + NotConfiguredForWorkspace, + NoActiveWorkspace, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AppTemplateSummary { + pub template_id: String, + pub name: String, + pub description: Option, + pub canonical_connector_id: Option, + pub logo_url: Option, + pub logo_url_dark: Option, + pub materialized_app_ids: Vec, + pub reason: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 2f3c87d868..0e94528a7a 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -30,6 +30,8 @@ use codex_app_server_protocol::AdditionalContextKind; use codex_app_server_protocol::AppInfo; use codex_app_server_protocol::AppListUpdatedNotification; use codex_app_server_protocol::AppSummary; +use codex_app_server_protocol::AppTemplateSummary; +use codex_app_server_protocol::AppTemplateUnavailableReason; use codex_app_server_protocol::AppsListParams; use codex_app_server_protocol::AppsListResponse; use codex_app_server_protocol::AskForApproval; diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 3163532449..79791c142a 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -8,6 +8,7 @@ use codex_app_server_protocol::PluginShareTargetRole; use codex_config::types::McpServerConfig; use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME; use codex_core_plugins::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; +use codex_core_plugins::remote::RemoteAppTemplateUnavailableReason; use codex_core_plugins::remote::RemotePluginScope; use codex_core_plugins::remote::is_valid_remote_plugin_id; use codex_core_plugins::remote::validate_remote_plugin_id; @@ -1062,6 +1063,7 @@ impl PluginRequestProcessor { }) .collect(), apps: app_summaries, + app_templates: Vec::new(), mcp_servers: outcome.plugin.mcp_server_names, } } @@ -2007,6 +2009,28 @@ fn remote_plugin_detail_to_info( detail: RemoteCatalogPluginDetail, apps: Vec, ) -> PluginDetail { + let app_templates = detail + .app_templates + .into_iter() + .map(|template| AppTemplateSummary { + template_id: template.template_id, + name: template.name, + description: template.description, + canonical_connector_id: template.canonical_connector_id, + logo_url: template.logo_url, + logo_url_dark: template.logo_url_dark, + materialized_app_ids: template.materialized_app_ids, + reason: template.reason.map(|reason| match reason { + RemoteAppTemplateUnavailableReason::NotConfiguredForWorkspace => { + AppTemplateUnavailableReason::NotConfiguredForWorkspace + } + RemoteAppTemplateUnavailableReason::NoActiveWorkspace => { + AppTemplateUnavailableReason::NoActiveWorkspace + } + }), + }) + .collect(); + PluginDetail { marketplace_name: detail.marketplace_name, marketplace_path: None, @@ -2026,6 +2050,7 @@ fn remote_plugin_detail_to_info( .collect(), hooks: Vec::new(), apps, + app_templates, mcp_servers: detail.mcp_servers, } } diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index 9e9e844228..e675a6172a 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -17,6 +17,8 @@ use axum::http::Uri; use axum::http::header::AUTHORIZATION; use axum::routing::get; use codex_app_server_protocol::AppInfo; +use codex_app_server_protocol::AppTemplateSummary; +use codex_app_server_protocol::AppTemplateUnavailableReason; use codex_app_server_protocol::HookEventName; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; @@ -427,6 +429,28 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> "display_name": "Linear", "description": "Track work in Linear", "app_ids": [], + "app_templates": [ + { + "template_id": "templated_apps_GitHubEnterprise", + "name": "GitHub Enterprise", + "description": "Connect GitHub Enterprise", + "canonical_connector_id": "github_enterprise", + "logo_url": "https://example.com/ghe-light.png", + "logo_url_dark": "https://example.com/ghe-dark.png", + "materialized_app_ids": ["asdk_app_ghe"], + "reason": null + }, + { + "template_id": "templated_apps_Databricks", + "name": "Databricks", + "description": null, + "canonical_connector_id": null, + "logo_url": null, + "logo_url_dark": null, + "materialized_app_ids": [], + "reason": "NOT_CONFIGURED_FOR_WORKSPACE" + } + ], "keywords": ["issue-tracking", "project management"], "interface": { "short_description": "Plan and track work", @@ -564,6 +588,31 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> assert_eq!(response.plugin.skills[0].path, None); assert_eq!(response.plugin.skills[0].enabled, false); assert_eq!(response.plugin.apps.len(), 0); + assert_eq!( + response.plugin.app_templates, + vec![ + AppTemplateSummary { + template_id: "templated_apps_GitHubEnterprise".to_string(), + name: "GitHub Enterprise".to_string(), + description: Some("Connect GitHub Enterprise".to_string()), + canonical_connector_id: Some("github_enterprise".to_string()), + logo_url: Some("https://example.com/ghe-light.png".to_string()), + logo_url_dark: Some("https://example.com/ghe-dark.png".to_string()), + materialized_app_ids: vec!["asdk_app_ghe".to_string()], + reason: None, + }, + AppTemplateSummary { + template_id: "templated_apps_Databricks".to_string(), + name: "Databricks".to_string(), + description: None, + canonical_connector_id: None, + logo_url: None, + logo_url_dark: None, + materialized_app_ids: Vec::new(), + reason: Some(AppTemplateUnavailableReason::NotConfiguredForWorkspace), + }, + ] + ); Ok(()) } diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 9b34fd293c..7daf1da31f 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -167,9 +167,29 @@ pub struct RemotePluginDetail { pub app_manifest: Option, pub skills: Vec, pub app_ids: Vec, + pub app_templates: Vec, pub mcp_servers: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteAppTemplate { + pub template_id: String, + pub name: String, + pub description: Option, + pub canonical_connector_id: Option, + pub logo_url: Option, + pub logo_url_dark: Option, + pub materialized_app_ids: Vec, + pub reason: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum RemoteAppTemplateUnavailableReason { + NotConfiguredForWorkspace, + NoActiveWorkspace, +} + #[derive(Debug, Clone, PartialEq)] pub struct RemotePluginSkill { pub name: String, @@ -413,6 +433,8 @@ struct RemotePluginReleaseResponse { app_ids: Vec, #[serde(default)] app_manifest: Option, + #[serde(default, alias = "unavailable_app_templates")] + app_templates: Vec, #[serde(default)] keywords: Vec, interface: RemotePluginReleaseInterfaceResponse, @@ -427,6 +449,24 @@ struct RemotePluginMcpServerResponse { key: String, } +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +struct RemoteAppTemplateResponse { + template_id: String, + name: String, + #[serde(default)] + description: Option, + #[serde(default)] + canonical_connector_id: Option, + #[serde(default)] + logo_url: Option, + #[serde(default)] + logo_url_dark: Option, + #[serde(default)] + materialized_app_ids: Vec, + #[serde(default)] + reason: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] struct RemotePluginDirectoryItem { id: String, @@ -975,6 +1015,21 @@ async fn build_remote_plugin_detail( app_manifest: plugin.release.app_manifest, skills, app_ids: plugin.release.app_ids, + app_templates: plugin + .release + .app_templates + .into_iter() + .map(|template| RemoteAppTemplate { + template_id: template.template_id, + name: template.name, + description: template.description, + canonical_connector_id: template.canonical_connector_id, + logo_url: template.logo_url, + logo_url_dark: template.logo_url_dark, + materialized_app_ids: template.materialized_app_ids, + reason: template.reason, + }) + .collect(), mcp_servers, }) } diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index cfc9278ded..b058b47cca 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -1429,6 +1429,7 @@ pub(super) fn plugins_test_detail( needs_auth: *needs_auth, }) .collect(), + app_templates: Vec::new(), mcp_servers: mcp_servers.iter().map(|name| (*name).to_string()).collect(), } } From 0b2e7b5eb1cfa74e5807a84b291e6c900eeb197d Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Thu, 4 Jun 2026 16:52:10 -0700 Subject: [PATCH 28/41] Improve Windows sandbox setup refresh diagnostics (#26471) ## Why Users have been seeing opaque Windows sandbox setup refresh failures such as `windows sandbox: spawn setup refresh`, including reports in #24391 and #21208. The setup refresh path already runs the Windows sandbox setup helper, but it was not using the same structured `setup_error.json` reporting path that elevated setup uses. As a result, when the helper exited non-zero, Codex only surfaced a generic refresh status instead of the helper's `SetupFailure` code and message. ## What changed - Clear stale `setup_error.json` before non-elevated setup refresh launches the helper. - When the refresh helper exits non-zero, read the helper-written report through the existing `report_helper_failure` path. - Keep a parent-side launch diagnostic for cases where the helper never starts, including the helper path, cwd, sandbox log path, and spawn error. - Clear the setup error report after a successful refresh. - Add regression coverage for report consumption and stale-report avoidance. ## Verification - `cargo test -p codex-windows-sandbox setup::tests::` --- codex-rs/windows-sandbox-rs/src/setup.rs | 110 +++++++++++++++--- .../windows-sandbox-rs/src/setup_error.rs | 2 +- 2 files changed, 97 insertions(+), 15 deletions(-) diff --git a/codex-rs/windows-sandbox-rs/src/setup.rs b/codex-rs/windows-sandbox-rs/src/setup.rs index c1881b55c8..428b5ff6a1 100644 --- a/codex-rs/windows-sandbox-rs/src/setup.rs +++ b/codex-rs/windows-sandbox-rs/src/setup.rs @@ -15,6 +15,7 @@ use crate::allow::compute_allow_paths_for_permissions; use crate::helper_materialization::bundled_executable_path_for_exe; use crate::helper_materialization::helper_bin_dir; use crate::identity::sandbox_setup_is_complete; +use crate::logging::current_log_file_path; use crate::logging::log_note; use crate::path_normalization::canonical_path_key; use crate::path_normalization::canonicalize_path; @@ -25,7 +26,6 @@ use crate::setup_error::clear_setup_error_report; use crate::setup_error::failure; use crate::setup_error::read_setup_error_report; use crate::ssh_config_dependencies::ssh_config_dependency_paths; -use anyhow::Context; use anyhow::Result; use anyhow::anyhow; use base64::Engine; @@ -210,6 +210,18 @@ fn run_setup_refresh_inner( let json = serde_json::to_vec(&payload)?; let b64 = BASE64_STANDARD.encode(json); let exe = find_setup_exe(); + let sbx_dir = sandbox_dir(request.codex_home); + let log_path = current_log_file_path(&sbx_dir); + let cleared_report = match clear_setup_error_report(request.codex_home) { + Ok(()) => true, + Err(err) => { + log_note( + &format!("setup refresh: failed to clear setup_error.json before launch: {err}"), + Some(&sbx_dir), + ); + false + } + }; // Refresh should never request elevation; ensure verb isn't set and we don't trigger UAC. let mut cmd = Command::new(&exe); cmd.arg(&b64).stdout(Stdio::null()).stderr(Stdio::null()); @@ -221,24 +233,34 @@ fn run_setup_refresh_inner( cwd.display(), b64.len() ), - Some(&sandbox_dir(request.codex_home)), + Some(&sbx_dir), ); - let status = cmd - .status() - .map_err(|e| { - log_note( - &format!("setup refresh: failed to spawn {}: {e}", exe.display()), - Some(&sandbox_dir(request.codex_home)), - ); - e - }) - .context("spawn setup refresh")?; + let status = cmd.status().map_err(|err| { + let message = format!( + "setup refresh failed to launch helper: helper={}, cwd={}, log={}, error={err}", + exe.display(), + cwd.display(), + log_path.display() + ); + log_note(&format!("setup refresh: {message}"), Some(&sbx_dir)); + failure(SetupErrorCode::OrchestratorHelperLaunchFailed, message) + })?; if !status.success() { log_note( &format!("setup refresh: exited with status {status:?}"), - Some(&sandbox_dir(request.codex_home)), + Some(&sbx_dir), + ); + return Err(report_helper_failure( + request.codex_home, + cleared_report, + status.code(), + )); + } + if let Err(err) = clear_setup_error_report(request.codex_home) { + log_note( + &format!("setup refresh: failed to clear setup_error.json after success: {err}"), + Some(&sbx_dir), ); - return Err(anyhow!("setup refresh failed with status {status}")); } Ok(()) } @@ -1087,7 +1109,9 @@ mod tests { use crate::helper_materialization::helper_bin_dir; use crate::resolved_permissions::ResolvedWindowsSandboxPermissions; use crate::setup_error::SetupErrorCode; + use crate::setup_error::SetupErrorReport; use crate::setup_error::extract_failure; + use crate::setup_error::write_setup_error_report; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; @@ -1146,6 +1170,64 @@ mod tests { ) } + #[test] + fn report_helper_failure_uses_setup_error_report_when_clear_succeeded() { + let tmp = TempDir::new().expect("tempdir"); + let codex_home = tmp.path().join("codex-home"); + write_setup_error_report( + codex_home.as_path(), + &SetupErrorReport { + code: super::SetupErrorCode::HelperFirewallPolicyAccessFailed, + message: "firewall policy unavailable".to_string(), + }, + ) + .expect("write setup error report"); + + let err = super::report_helper_failure( + codex_home.as_path(), + /*cleared_report*/ true, + /*exit_code*/ Some(1), + ); + + let failure = extract_failure(&err).expect("structured setup failure"); + assert_eq!( + &super::SetupFailure::new( + super::SetupErrorCode::HelperFirewallPolicyAccessFailed, + "firewall policy unavailable", + ), + failure + ); + } + + #[test] + fn report_helper_failure_ignores_setup_error_report_when_clear_failed() { + let tmp = TempDir::new().expect("tempdir"); + let codex_home = tmp.path().join("codex-home"); + write_setup_error_report( + codex_home.as_path(), + &SetupErrorReport { + code: super::SetupErrorCode::HelperFirewallPolicyAccessFailed, + message: "stale report".to_string(), + }, + ) + .expect("write setup error report"); + + let err = super::report_helper_failure( + codex_home.as_path(), + /*cleared_report*/ false, + /*exit_code*/ Some(1), + ); + + let failure = extract_failure(&err).expect("structured setup failure"); + assert_eq!( + &super::SetupFailure::new( + super::SetupErrorCode::OrchestratorHelperExitNonzero, + "setup helper exited with status Some(1)", + ), + failure + ); + } + #[test] fn setup_refresh_skips_profiles_without_managed_filesystem_permissions() { let tmp = TempDir::new().expect("tempdir"); diff --git a/codex-rs/windows-sandbox-rs/src/setup_error.rs b/codex-rs/windows-sandbox-rs/src/setup_error.rs index 0f759ef879..d9104602bc 100644 --- a/codex-rs/windows-sandbox-rs/src/setup_error.rs +++ b/codex-rs/windows-sandbox-rs/src/setup_error.rs @@ -117,7 +117,7 @@ pub struct SetupErrorReport { pub message: String, } -#[derive(Debug)] +#[derive(Debug, PartialEq, Eq)] pub struct SetupFailure { pub code: SetupErrorCode, pub message: String, From 72d0bfb6ba4b59ad78fbe012052a06a2c0874fc3 Mon Sep 17 00:00:00 2001 From: beggers-openai Date: Thu, 4 Jun 2026 17:47:58 -0700 Subject: [PATCH 29/41] Pull plugin service less frequently (#26431) # Summary Reduce download traffic to `github.com/openai/plugins` while continuing to check for updates on every Codex startup. # Root cause The startup sync replaced the local repository with a fresh shallow clone whenever the remote revision changed. At Codex's global scale, repeatedly downloading the repository created excessive GitHub traffic. # Changes - Run `git ls-remote` on each startup to read the remote HEAD SHA. - Skip all repository downloads when the local and remote SHAs match. - Update existing checkouts with an exact-SHA shallow `git fetch`, followed by reset and clean. - Bootstrap new installations with `git init` plus the same shallow fetch, rather than cloning. - Keep the existing file lock so concurrent Codex processes serialize updates and do not duplicate fetches. - Preserve the existing GitHub HTTP and export archive fallback behavior. # Impact Each startup makes one lightweight remote HEAD check. Repository objects are downloaded only when the revision changes, and existing Git objects are reused during updates. # Validation - `just test -p codex-core-plugins startup_sync` (15 tests passed) - `just test -p codex-core-plugins` (201 tests passed) - `just clippy -p codex-core-plugins` (passes with one pre-existing `large_enum_variant` warning) - Production app-server smoke test against GitHub: - Fresh home: `ls-remote`, `git init`, one exact-SHA shallow fetch - Unchanged restart: `ls-remote` and local `rev-parse` only; no fetch or clone - Bench smoke passed --- codex-rs/core-plugins/src/startup_sync.rs | 141 ++++++- .../core-plugins/src/startup_sync_tests.rs | 349 +++++++++++++++--- 2 files changed, 418 insertions(+), 72 deletions(-) diff --git a/codex-rs/core-plugins/src/startup_sync.rs b/codex-rs/core-plugins/src/startup_sync.rs index f03aff93f6..c5965f2212 100644 --- a/codex-rs/core-plugins/src/startup_sync.rs +++ b/codex-rs/core-plugins/src/startup_sync.rs @@ -1,3 +1,4 @@ +use std::fs::File; use std::path::Path; use std::path::PathBuf; use std::process::Command; @@ -22,8 +23,11 @@ const CURATED_PLUGINS_BACKUP_ARCHIVE_API_URL: &str = "https://chatgpt.com/backend-api/plugins/export/curated"; const OPENAI_PLUGINS_OWNER: &str = "openai"; const OPENAI_PLUGINS_REPO: &str = "plugins"; +const OPENAI_PLUGINS_GIT_URL: &str = "https://github.com/openai/plugins.git"; +const CURATED_PLUGINS_FETCH_REF: &str = "refs/codex/curated-sync"; const CURATED_PLUGINS_RELATIVE_DIR: &str = ".tmp/plugins"; const CURATED_PLUGINS_SHA_FILE: &str = ".tmp/plugins.sha"; +const CURATED_PLUGINS_SYNC_LOCK_FILE: &str = ".tmp/plugins.sync.lock"; const CURATED_PLUGINS_BACKUP_ARCHIVE_FALLBACK_VERSION: &str = "export-backup"; const CURATED_PLUGINS_GIT_TIMEOUT: Duration = Duration::from_secs(30); const CURATED_PLUGINS_HTTP_TIMEOUT: Duration = Duration::from_secs(30); @@ -78,6 +82,8 @@ fn sync_openai_plugins_repo_with_transport_overrides( api_base_url: &str, backup_archive_api_url: &str, ) -> Result { + let _file_guard = lock_curated_plugins_startup_sync(codex_home)?; + match sync_openai_plugins_repo_via_git(codex_home, git_binary) { Ok(remote_sha) => { emit_curated_plugins_startup_sync_metric("git", "success"); @@ -135,6 +141,22 @@ fn sync_openai_plugins_repo_with_transport_overrides( } } +fn lock_curated_plugins_startup_sync(codex_home: &Path) -> Result { + let lock_path = codex_home.join(CURATED_PLUGINS_SYNC_LOCK_FILE); + std::fs::create_dir_all(codex_home.join(".tmp")) + .map_err(|err| format!("failed to create curated plugins sync directory: {err}"))?; + let lock_file = File::options() + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + .map_err(|err| format!("failed to open curated plugins sync lock: {err}"))?; + lock_file + .lock() + .map_err(|err| format!("failed to lock curated plugins sync: {err}"))?; + Ok(lock_file) +} + fn sync_openai_plugins_repo_via_git(codex_home: &Path, git_binary: &str) -> Result { let repo_path = curated_plugins_repo_path(codex_home); let sha_path = codex_home.join(CURATED_PLUGINS_SHA_FILE); @@ -146,23 +168,30 @@ fn sync_openai_plugins_repo_via_git(codex_home: &Path, git_binary: &str) -> Resu } let staged_repo_dir = prepare_curated_repo_parent_and_temp_dir(&repo_path)?; - let clone_output = run_git_command_with_timeout( - Command::new(git_binary) - .env("GIT_OPTIONAL_LOCKS", "0") - .arg("clone") - .arg("--depth") - .arg("1") - .arg("https://github.com/openai/plugins.git") - .arg(staged_repo_dir.path()), - "git clone curated plugins repo", - CURATED_PLUGINS_GIT_TIMEOUT, + run_git_in_repo( + staged_repo_dir.path(), + git_binary, + &["init"], + "git init curated plugins repo", )?; - ensure_git_success(&clone_output, "git clone curated plugins repo")?; - let cloned_sha = git_head_sha(staged_repo_dir.path(), git_binary)?; - if cloned_sha != remote_sha { + if repo_path.join(".git").is_dir() { + fetch_curated_plugins_commit(&repo_path, &remote_sha, git_binary)?; + fetch_curated_plugins_commit_from_source( + staged_repo_dir.path(), + &repo_path, + CURATED_PLUGINS_FETCH_REF, + git_binary, + )?; + } else { + fetch_curated_plugins_commit(staged_repo_dir.path(), &remote_sha, git_binary)?; + } + + reset_curated_plugins_checkout(staged_repo_dir.path(), git_binary)?; + let fetched_sha = git_head_sha(staged_repo_dir.path(), git_binary)?; + if fetched_sha != remote_sha { return Err(format!( - "curated plugins clone HEAD mismatch: expected {remote_sha}, got {cloned_sha}" + "curated plugins fetch HEAD mismatch: expected {remote_sha}, got {fetched_sha}" )); } @@ -172,6 +201,90 @@ fn sync_openai_plugins_repo_via_git(codex_home: &Path, git_binary: &str) -> Resu Ok(remote_sha) } +fn fetch_curated_plugins_commit( + repo_path: &Path, + remote_sha: &str, + git_binary: &str, +) -> Result<(), String> { + fetch_curated_plugins_commit_from( + repo_path, + OPENAI_PLUGINS_GIT_URL.as_ref(), + remote_sha, + git_binary, + "git fetch curated plugins repo", + ) +} + +fn fetch_curated_plugins_commit_from_source( + repo_path: &Path, + source_repo_path: &Path, + remote_sha: &str, + git_binary: &str, +) -> Result<(), String> { + fetch_curated_plugins_commit_from( + repo_path, + source_repo_path, + remote_sha, + git_binary, + "git copy fetched curated plugins commit", + ) +} + +fn fetch_curated_plugins_commit_from( + repo_path: &Path, + source: &Path, + source_revision: &str, + git_binary: &str, + context: &str, +) -> Result<(), String> { + let fetch_refspec = format!("+{source_revision}:{CURATED_PLUGINS_FETCH_REF}"); + let output = run_git_command_with_timeout( + Command::new(git_binary) + .env("GIT_OPTIONAL_LOCKS", "0") + .arg("-C") + .arg(repo_path) + .args(["fetch", "--depth", "1", "--no-tags"]) + .arg(source) + .arg(fetch_refspec), + context, + CURATED_PLUGINS_GIT_TIMEOUT, + )?; + ensure_git_success(&output, context) +} + +fn reset_curated_plugins_checkout(repo_path: &Path, git_binary: &str) -> Result<(), String> { + run_git_in_repo( + repo_path, + git_binary, + &["reset", "--hard", CURATED_PLUGINS_FETCH_REF], + "git reset curated plugins repo", + )?; + run_git_in_repo( + repo_path, + git_binary, + &["clean", "-fdx"], + "git clean curated plugins repo", + ) +} + +fn run_git_in_repo( + repo_path: &Path, + git_binary: &str, + args: &[&str], + context: &str, +) -> Result<(), String> { + let output = run_git_command_with_timeout( + Command::new(git_binary) + .env("GIT_OPTIONAL_LOCKS", "0") + .arg("-C") + .arg(repo_path) + .args(args), + context, + CURATED_PLUGINS_GIT_TIMEOUT, + )?; + ensure_git_success(&output, context) +} + fn sync_openai_plugins_repo_via_http( codex_home: &Path, api_base_url: &str, diff --git a/codex-rs/core-plugins/src/startup_sync_tests.rs b/codex-rs/core-plugins/src/startup_sync_tests.rs index a9388e3fce..f73e4ad9d1 100644 --- a/codex-rs/core-plugins/src/startup_sync_tests.rs +++ b/codex-rs/core-plugins/src/startup_sync_tests.rs @@ -3,6 +3,8 @@ use pretty_assertions::assert_eq; use std::io::Write; use std::path::Path; use std::path::PathBuf; +#[cfg(unix)] +use std::sync::Barrier; use tempfile::tempdir; use wiremock::Mock; use wiremock::MockServer; @@ -95,6 +97,22 @@ fn write_executable_script(path: &Path, contents: &str) { } } +#[cfg(unix)] +fn run_git(repo: &Path, args: &[&str]) -> std::process::Output { + let output = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .output() + .expect("run git"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + output +} + async fn mount_github_repo_and_ref(server: &MockServer, sha: &str) { Mock::given(method("GET")) .and(path("/repos/openai/plugins")) @@ -253,30 +271,42 @@ fn remove_stale_curated_repo_temp_dirs_removes_only_matching_directories() { #[cfg(unix)] #[test] -fn sync_openai_plugins_repo_prefers_git_when_available() { +fn concurrent_syncs_serialize_fetches_without_skipping_remote_checks() { let tmp = tempdir().expect("tempdir"); let bin_dir = tempfile::Builder::new() .prefix("fake-git-") .tempdir() .expect("tempdir"); let git_path = bin_dir.path().join("git"); + let invocation_log = bin_dir.path().join("invocations.log"); let sha = "0123456789abcdef0123456789abcdef01234567"; write_executable_script( &git_path, &format!( r#"#!/bin/sh +printf '%s\n' "$*" >> '{}' if [ "$1" = "ls-remote" ]; then + sleep 1 printf '%s\tHEAD\n' "{sha}" exit 0 fi -if [ "$1" = "clone" ]; then - dest="$5" - mkdir -p "$dest/.git" "$dest/.agents/plugins" "$dest/plugins/gmail/.codex-plugin" - cat > "$dest/.agents/plugins/marketplace.json" <<'EOF' +if [ "$1" = "-C" ] && [ "$3" = "init" ]; then + mkdir -p "$2/.git" + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "fetch" ]; then + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "reset" ]; then + mkdir -p "$2/.agents/plugins" "$2/plugins/gmail/.codex-plugin" + cat > "$2/.agents/plugins/marketplace.json" <<'EOF' {{"name":"openai-curated","plugins":[{{"name":"gmail","source":{{"source":"local","path":"./plugins/gmail"}}}}]}} EOF - printf '%s\n' '{{"name":"gmail"}}' > "$dest/plugins/gmail/.codex-plugin/plugin.json" + printf '%s\n' '{{"name":"gmail"}}' > "$2/plugins/gmail/.codex-plugin/plugin.json" + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "clean" ]; then exit 0 fi if [ "$1" = "-C" ] && [ "$3" = "rev-parse" ] && [ "$4" = "HEAD" ]; then @@ -285,23 +315,55 @@ if [ "$1" = "-C" ] && [ "$3" = "rev-parse" ] && [ "$4" = "HEAD" ]; then fi echo "unexpected git invocation: $@" >&2 exit 1 -"# +"#, + invocation_log.display() ), ); - let synced_sha = sync_openai_plugins_repo_with_transport_overrides( - tmp.path(), - git_path.to_str().expect("utf8 path"), - "http://127.0.0.1:9", - "http://127.0.0.1:9/backend-api/plugins/export/curated", - ) - .expect("git sync should succeed"); + let barrier = Barrier::new(2); + let results = std::thread::scope(|scope| { + let run_sync = || { + barrier.wait(); + sync_openai_plugins_repo_with_transport_overrides( + tmp.path(), + git_path.to_str().expect("utf8 path"), + "http://127.0.0.1:9", + "http://127.0.0.1:9/backend-api/plugins/export/curated", + ) + }; + let first = scope.spawn(run_sync); + let second = scope.spawn(run_sync); + [ + first.join().expect("first sync thread"), + second.join().expect("second sync thread"), + ] + }); - assert_eq!(synced_sha, sha); + assert_eq!(results, [Ok(sha.to_string()), Ok(sha.to_string())]); let repo_path = curated_plugins_repo_path(tmp.path()); assert!(repo_path.join(".git").is_dir()); assert_curated_gmail_repo(&repo_path); assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha)); + let invocations = std::fs::read_to_string(invocation_log).expect("read invocation log"); + assert_eq!( + invocations + .lines() + .filter(|invocation| invocation.starts_with("ls-remote ")) + .count(), + 2 + ); + assert_eq!( + invocations + .lines() + .filter(|invocation| invocation.contains(" fetch --depth 1 --no-tags ")) + .count(), + 1 + ); + assert!( + !invocations + .lines() + .any(|invocation| invocation.split_whitespace().any(|arg| arg == "clone")) + ); } #[cfg(unix)] @@ -328,36 +390,20 @@ fn sync_openai_plugins_repo_via_git_succeeds_with_local_rewritten_remote() { ) .expect("write plugin manifest"); - let init_status = Command::new("git") - .arg("-C") - .arg(&work_repo) - .arg("init") - .status() - .expect("run git init"); - assert!(init_status.success()); - - let add_status = Command::new("git") - .arg("-C") - .arg(&work_repo) - .arg("add") - .arg(".") - .status() - .expect("run git add"); - assert!(add_status.success()); - - let commit_status = Command::new("git") - .arg("-C") - .arg(&work_repo) - .arg("-c") - .arg("user.name=Codex Test") - .arg("-c") - .arg("user.email=codex@example.com") - .arg("commit") - .arg("-m") - .arg("init") - .status() - .expect("run git commit"); - assert!(commit_status.success()); + run_git(&work_repo, &["init"]); + run_git(&work_repo, &["add", "."]); + run_git( + &work_repo, + &[ + "-c", + "user.name=Codex Test", + "-c", + "user.email=codex@example.com", + "commit", + "-m", + "init", + ], + ); std::fs::create_dir_all(remote_repo.parent().expect("remote parent")) .expect("create remote parent"); @@ -370,14 +416,7 @@ fn sync_openai_plugins_repo_via_git_succeeds_with_local_rewritten_remote() { .expect("run git clone --bare"); assert!(clone_status.success()); - let sha_output = Command::new("git") - .arg("-C") - .arg(&work_repo) - .arg("rev-parse") - .arg("HEAD") - .output() - .expect("run git rev-parse"); - assert!(sha_output.status.success()); + let sha_output = run_git(&work_repo, &["rev-parse", "HEAD"]); let sha = String::from_utf8_lossy(&sha_output.stdout) .trim() .to_string(); @@ -397,10 +436,12 @@ fn sync_openai_plugins_repo_via_git_succeeds_with_local_rewritten_remote() { .tempdir() .expect("tempdir"); let git_wrapper = bin_dir.path().join("git"); + let invocation_log = bin_dir.path().join("invocations.log"); write_executable_script( &git_wrapper, &format!( - "#!/bin/sh\nGIT_CONFIG_GLOBAL='{}' exec git \"$@\"\n", + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\nGIT_CONFIG_GLOBAL='{}' exec git \"$@\"\n", + invocation_log.display(), git_config_path.display() ), ); @@ -416,6 +457,125 @@ fn sync_openai_plugins_repo_via_git_succeeds_with_local_rewritten_remote() { Some(sha.as_str()) ); assert!(!has_plugins_clone_dirs(tmp.path())); + + let first_sync_invocation_count = std::fs::read_to_string(&invocation_log) + .expect("read first sync invocations") + .lines() + .count(); + let first_sync_invocations = + std::fs::read_to_string(&invocation_log).expect("read first sync invocations"); + assert!( + first_sync_invocations + .lines() + .any(|invocation| invocation.contains(" fetch --depth 1 --no-tags ")) + ); + assert!( + !first_sync_invocations + .lines() + .any(|invocation| invocation.split_whitespace().any(|arg| arg == "clone")) + ); + write_openai_curated_marketplace(&work_repo, &["gmail", "linear"]); + run_git(&work_repo, &["add", "."]); + run_git( + &work_repo, + &[ + "-c", + "user.name=Codex Test", + "-c", + "user.email=codex@example.com", + "commit", + "-m", + "update", + ], + ); + let branch_output = run_git(&work_repo, &["symbolic-ref", "--short", "HEAD"]); + let branch = String::from_utf8_lossy(&branch_output.stdout) + .trim() + .to_string(); + let remote_repo = remote_repo.to_str().expect("utf8 remote repo"); + let push_ref = format!("HEAD:refs/heads/{branch}"); + run_git(&work_repo, &["push", remote_repo, &push_ref]); + let updated_sha_output = run_git(&work_repo, &["rev-parse", "HEAD"]); + let updated_sha = String::from_utf8_lossy(&updated_sha_output.stdout) + .trim() + .to_string(); + + let synced_sha = + sync_openai_plugins_repo_via_git(tmp.path(), git_wrapper.to_str().expect("utf8 path")) + .expect("incremental git sync should succeed"); + + assert_eq!(synced_sha, updated_sha); + assert!( + curated_plugins_repo_path(tmp.path()) + .join("plugins/linear/.codex-plugin/plugin.json") + .is_file() + ); + assert_eq!( + read_curated_plugins_sha(tmp.path()).as_deref(), + Some(updated_sha.as_str()) + ); + assert!( + !curated_plugins_repo_path(tmp.path()) + .join(".git/objects/info/alternates") + .exists() + ); + let invocation_log_contents = + std::fs::read_to_string(&invocation_log).expect("read sync invocations"); + let incremental_sync_invocations = invocation_log_contents + .lines() + .skip(first_sync_invocation_count) + .collect::>(); + let curated_repo_path = curated_plugins_repo_path(tmp.path()); + assert!(incremental_sync_invocations.iter().any(|invocation| { + invocation.starts_with(&format!("-C {} fetch ", curated_repo_path.display())) + && invocation.contains(" https://github.com/openai/plugins.git ") + && invocation.contains(updated_sha.as_str()) + && invocation.ends_with(CURATED_PLUGINS_FETCH_REF) + })); + assert!(incremental_sync_invocations.iter().any(|invocation| { + invocation.contains(" fetch --depth 1 --no-tags ") + && invocation.contains(&format!(" {} ", curated_repo_path.display())) + && invocation.ends_with(&format!( + "{CURATED_PLUGINS_FETCH_REF}:{CURATED_PLUGINS_FETCH_REF}" + )) + })); + assert!( + incremental_sync_invocations + .iter() + .any(|invocation| invocation.ends_with(" init")) + ); + assert!( + !incremental_sync_invocations + .iter() + .any(|invocation| invocation.split_whitespace().any(|arg| arg == "clone")) + ); + assert!(!incremental_sync_invocations.iter().any(|invocation| { + invocation.starts_with(&format!("-C {} reset ", curated_repo_path.display())) + || invocation.starts_with(&format!("-C {} clean ", curated_repo_path.display())) + })); + assert!(!has_plugins_clone_dirs(tmp.path())); + + let unchanged_sync_invocation_count = invocation_log_contents.lines().count(); + let synced_sha = + sync_openai_plugins_repo_via_git(tmp.path(), git_wrapper.to_str().expect("utf8 path")) + .expect("unchanged git sync should succeed"); + + assert_eq!(synced_sha, updated_sha); + let invocation_log = std::fs::read_to_string(&invocation_log).expect("read sync invocations"); + let unchanged_sync_invocations = invocation_log + .lines() + .skip(unchanged_sync_invocation_count) + .collect::>(); + assert!( + unchanged_sync_invocations + .iter() + .any(|invocation| invocation.starts_with("ls-remote ")) + ); + assert!( + !unchanged_sync_invocations + .iter() + .any(|invocation| invocation.contains(" fetch ")) + ); } #[tokio::test] @@ -482,7 +642,7 @@ exit 1 #[cfg(unix)] #[test] -fn sync_openai_plugins_repo_via_git_cleans_up_staged_dir_on_clone_failure() { +fn sync_openai_plugins_repo_via_git_cleans_up_staged_dir_on_fetch_failure() { let tmp = tempdir().expect("tempdir"); let bin_dir = tempfile::Builder::new() .prefix("fake-git-partial-fail-") @@ -499,9 +659,11 @@ if [ "$1" = "ls-remote" ]; then printf '%s\tHEAD\n' "{sha}" exit 0 fi -if [ "$1" = "clone" ]; then - dest="$5" - mkdir -p "$dest/.git" +if [ "$1" = "-C" ] && [ "$3" = "init" ]; then + mkdir -p "$2/.git" + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "fetch" ]; then echo "fatal: early EOF" >&2 exit 128 fi @@ -518,6 +680,77 @@ exit 1 assert!(!has_plugins_clone_dirs(tmp.path())); } +#[cfg(unix)] +#[test] +fn sync_openai_plugins_repo_via_git_preserves_existing_snapshot_on_validation_failure() { + let tmp = tempdir().expect("tempdir"); + let repo_path = curated_plugins_repo_path(tmp.path()); + write_openai_curated_marketplace(&repo_path, &["gmail"]); + std::fs::create_dir_all(repo_path.join(".git")).expect("create git dir"); + write_curated_plugin_sha(tmp.path()); + + let bin_dir = tempfile::Builder::new() + .prefix("fake-git-invalid-update-") + .tempdir() + .expect("tempdir"); + let git_path = bin_dir.path().join("git"); + let remote_sha = "fedcba9876543210fedcba9876543210fedcba98"; + + write_executable_script( + &git_path, + &format!( + r#"#!/bin/sh +if [ "$1" = "ls-remote" ]; then + printf '%s\tHEAD\n' "{remote_sha}" + exit 0 +fi +if [ "$1" = "-C" ] && [ "$2" = "{}" ] && [ "$3" = "rev-parse" ]; then + printf '%s\n' "{TEST_CURATED_PLUGIN_SHA}" + exit 0 +fi +if [ "$1" = "-C" ] && [ "$2" = "{}" ] && [ "$3" = "fetch" ]; then + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "init" ]; then + mkdir -p "$2/.git" + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "fetch" ]; then + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "reset" ]; then + mkdir -p "$2/plugins/linear/.codex-plugin" + printf '%s\n' '{{"name":"linear"}}' > "$2/plugins/linear/.codex-plugin/plugin.json" + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "clean" ]; then + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "rev-parse" ]; then + printf '%s\n' "{remote_sha}" + exit 0 +fi +echo "unexpected git invocation: $@" >&2 +exit 1 +"#, + repo_path.display(), + repo_path.display(), + ), + ); + + let err = sync_openai_plugins_repo_via_git(tmp.path(), git_path.to_str().expect("utf8 path")) + .expect_err("invalid staged checkout should fail"); + + assert!(err.contains("curated plugins archive missing marketplace manifest")); + assert_curated_gmail_repo(&repo_path); + assert!(!repo_path.join("plugins/linear").exists()); + assert_eq!( + read_curated_plugins_sha(tmp.path()).as_deref(), + Some(TEST_CURATED_PLUGIN_SHA) + ); + assert!(!has_plugins_clone_dirs(tmp.path())); +} + #[tokio::test] async fn sync_openai_plugins_repo_via_http_cleans_up_staged_dir_on_extract_failure() { let tmp = tempdir().expect("tempdir"); From 4be1a168fc7ed3313ddc62031da036f0c50209b7 Mon Sep 17 00:00:00 2001 From: Channing Conger Date: Thu, 4 Jun 2026 17:51:13 -0700 Subject: [PATCH 30/41] ci: test windows cross build (#25000) We cross build when using bazel for windows. This causes a couple hiccups in that v8 does a mksnapshot step that is expecting to snapshot on the host arch which wasn't matching when we were doing the crossbuild. This was causing segfault failiures when starting up codemode from a cross built artifact. This changes things such that we cross build the library and then run and link a snapshot on the host machine/arch which is windows. This gives us a functional snapshot and library that can start code-mode on windows. This fixes the build and then fixes two test regressions we had. --- .bazelrc | 14 ++++--- .github/workflows/bazel.yml | 24 +++++------ codex-rs/core/tests/suite/code_mode.rs | 16 +++++-- patches/v8_bazel_rules.patch | 58 +++++++++++++++++++++++++- 4 files changed, 90 insertions(+), 22 deletions(-) diff --git a/.bazelrc b/.bazelrc index e39a3aff22..c8b8682c4b 100644 --- a/.bazelrc +++ b/.bazelrc @@ -105,9 +105,9 @@ common:ci --disk_cache= # Shared config for the main Bazel CI workflow. common:ci-bazel --config=ci common:ci-bazel --build_metadata=TAG_workflow=bazel -# Bazel CI cross-compiles in several legs, and the V8-backed code-mode tests -# are not stable in that setup yet. Keep running the rest of the Rust -# integration suites through the workspace-root launcher. +# Keep code-mode integration cases out of ordinary Bazel legs. The +# Windows-cross config below re-enables them after generating its Windows V8 +# snapshot on the Windows runner. common:ci-bazel --test_env=CODEX_BAZEL_TEST_SKIP_FILTERS=suite::code_mode:: # Shared config for Bazel-backed Rust linting. @@ -186,12 +186,16 @@ common:ci-windows-cross --config=ci-windows common:ci-windows-cross --build_metadata=TAG_windows_cross_compile=true common:ci-windows-cross --host_platform=//:rbe common:ci-windows-cross --strategy=TestRunner=local +# V8 embeds IsolateData offsets in snapshot builtins; Windows snapshots must be +# generated by a Windows mksnapshot binary rather than the Linux RBE host tool. +common:ci-windows-cross --strategy=V8Mksnapshot=local common:ci-windows-cross --local_test_jobs=4 common:ci-windows-cross --test_env=RUST_TEST_THREADS=1 # Native Windows CI still covers the PowerShell tests. The cross-built gnullvm # binaries currently hang in PowerShell AST parser tests when those binaries are -# run on the Windows runner. -common:ci-windows-cross --test_env=CODEX_BAZEL_TEST_SKIP_FILTERS=suite::code_mode::,powershell +# run on the Windows runner. Keep V8-backed code-mode tests enabled except for +# the hidden dynamic-tool callback test, which currently times out on Windows. +common:ci-windows-cross --test_env=CODEX_BAZEL_TEST_SKIP_FILTERS=powershell,suite::code_mode::code_mode_can_call_hidden_dynamic_tools common:ci-windows-cross --platforms=//:windows_x86_64_gnullvm common:ci-windows-cross --extra_execution_platforms=//:rbe,//:windows_x86_64_msvc common:ci-windows-cross --extra_toolchains=//:windows_gnullvm_tests_on_msvc_host_toolchain diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index c6dfd60231..bddbb5adc9 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -20,8 +20,9 @@ jobs: test: # PRs use the sharded Windows cross-compiled test jobs below. Post-merge # pushes to main also run the native Windows test job for broader Windows - # signal without putting PR latency back on the critical path. Cargo CI - # owns V8/code-mode test coverage for now. + # signal without putting PR latency back on the critical path. When + # authenticated RBE is available, the Windows-cross shards exercise the + # source-built V8/code-mode targets. timeout-minutes: 30 strategy: fail-fast: false @@ -91,9 +92,9 @@ jobs: # path. V8 consumers under `//codex-rs/...` still participate # transitively through `//...`. -//third_party/v8:all - # V8-backed code-mode tests are covered by Cargo CI. Bazel CI - # cross-compiles in several legs, and those tests are not stable in - # that setup yet. + # Keep V8-backed code-mode tests out of the ordinary macOS/Linux + # legs; authenticated Windows-cross shards below exercise the + # source-built gnullvm V8 path. -//codex-rs/code-mode:code-mode-unit-tests -//codex-rs/v8-poc:v8-poc-unit-tests ) @@ -135,9 +136,9 @@ jobs: key: ${{ steps.prepare_bazel.outputs.repository-cache-key }} test-windows-shard: - # Split the Windows Bazel test leg across separate Windows - # hosts. Each shard still uses Linux RBE for build actions, but the test - # execution itself happens on its own Windows runner. + # Split the Windows Bazel test leg across separate Windows hosts. Jobs with + # BuildBuddy credentials use Linux RBE for build actions; test execution + # remains on a Windows runner. timeout-minutes: 30 strategy: fail-fast: false @@ -183,7 +184,7 @@ jobs: run: | set -euo pipefail - bazel_test_query='tests(//...) except tests(//third_party/v8:all) except //codex-rs/code-mode:code-mode-unit-tests except //codex-rs/v8-poc:v8-poc-unit-tests except attr(tags, "manual", tests(//...))' + bazel_test_query='tests(//...) except tests(//third_party/v8:all) except attr(tags, "manual", tests(//...))' mapfile -t bazel_targets < <( MSYS2_ARG_CONV_EXCL='*' bazel query --output=label "${bazel_test_query}" \ | LC_ALL=C sort @@ -289,9 +290,8 @@ jobs: # path. V8 consumers under `//codex-rs/...` still participate # transitively through `//...`. -//third_party/v8:all - # Keep this aligned with the main Bazel job. The native Windows - # job preserves broad post-merge coverage, but code-mode/V8 tests - # are covered by Cargo CI rather than Bazel for now. + # Keep this job broad and cheap; authenticated Windows-cross jobs + # add source-built V8-backed code-mode coverage. -//codex-rs/code-mode:code-mode-unit-tests -//codex-rs/v8-poc:v8-poc-unit-tests ) diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index ffe23bff56..bc311d4245 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -375,7 +375,11 @@ async fn code_mode_only_restricts_prompt_tools() -> Result<()> { let first_body = resp_mock.single_request().body_json(); assert_eq!( tool_names(&first_body), - vec!["exec".to_string(), "wait".to_string()] + vec![ + "exec".to_string(), + "wait".to_string(), + "web_search".to_string() + ] ); Ok(()) @@ -457,7 +461,12 @@ if (!tool) { let first_body = resp_mock.single_request().body_json(); assert_eq!( tool_names(&first_body), - vec!["exec".to_string(), "wait".to_string()] + vec![ + "exec".to_string(), + "wait".to_string(), + "web_search".to_string(), + "image_generation".to_string() + ] ); let exec_description = first_body @@ -2898,6 +2907,7 @@ text(JSON.stringify(Object.getOwnPropertyNames(globalThis).sort())); "escape", "exit", "eval", + "generatedImage", "globalThis", "image", "isFinite", @@ -2953,7 +2963,7 @@ text(JSON.stringify(tool)); parsed, serde_json::json!({ "name": "view_image", - "description": "View a local image file from the filesystem when visual inspection is needed. Use this for images already available on disk.\n\nexec tool declaration:\n```ts\ndeclare const tools: { view_image(args: {\n // Local filesystem path to an image file\n path: string;\n}): Promise<{\n // Image detail hint returned by view_image. Returns `high` for default resized behavior or `original` when original resolution is preserved.\n detail: \"high\" | \"original\";\n // Data URL for the loaded image.\n image_url: string;\n}>; };\n```", + "description": "View a local image file from the filesystem when visual inspection is needed. Use this for images already available on disk.\n\nexec tool declaration:\n```ts\ndeclare const tools: { view_image(args: {\n // Local filesystem path to an image file.\n path: string;\n}): Promise<{\n // Image detail hint returned by view_image. Returns `high` for default resized behavior or `original` when original resolution is preserved.\n detail: \"high\" | \"original\";\n // Data URL for the loaded image.\n image_url: string;\n}>; };\n```", }) ); diff --git a/patches/v8_bazel_rules.patch b/patches/v8_bazel_rules.patch index 1c49d06b09..97e07d9d47 100644 --- a/patches/v8_bazel_rules.patch +++ b/patches/v8_bazel_rules.patch @@ -106,6 +106,55 @@ index 9648e4a..88efd41 100644 }) + select({ ":should_add_rdynamic": ["-rdynamic"], "//conditions:default": [], +@@ -459,6 +488,11 @@ + def _mksnapshot(ctx): + prefix = ctx.attr.prefix + suffix = ctx.attr.suffix ++ # Windows cross-builds use Linux for the exec configuration, but the ++ # snapshot generator must match the target ABI and run on the Windows ++ # runner. Action strategies only choose where an action runs; they cannot ++ # change this executable from the exec to the target configuration. ++ tool = ctx.executable.target_tool if ctx.attr.target_os == "win" else ctx.executable.tool + outs = [ + ctx.actions.declare_file(prefix + "/snapshot" + suffix + ".cc"), + ctx.actions.declare_file(prefix + "/embedded" + suffix + ".S"), +@@ -476,7 +510,7 @@ + outs[1].path, + ] + ctx.attr.args, +- executable = ctx.executable.tool, ++ executable = tool, + progress_message = "Running mksnapshot", + ) + return [DefaultInfo(files = depset(outs))] +@@ -491,6 +525,12 @@ + executable = True, + cfg = "exec", + ), ++ "target_tool": attr.label( ++ mandatory = True, ++ allow_files = True, ++ executable = True, ++ cfg = "target", ++ ), + "target_os": attr.string(mandatory = True), + "prefix": attr.string(mandatory = True), + "suffix": attr.string(mandatory = True), +@@ -504,6 +544,7 @@ + args = args, + prefix = "noicu", + tool = ":noicu/mksnapshot" + suffix, ++ target_tool = ":noicu/mksnapshot" + suffix, + suffix = suffix, + target_os = select({ + "@v8//bazel/config:is_macos": "mac", +@@ -516,6 +557,7 @@ + args = args, + prefix = "icu", + tool = ":icu/mksnapshot" + suffix, ++ target_tool = ":icu/mksnapshot" + suffix, + suffix = suffix, + target_os = select({ + "@v8//bazel/config:is_macos": "mac", diff --git a/orig/v8-14.6.202.11/BUILD.bazel b/mod/v8-14.6.202.11/BUILD.bazel index 421ebcd..52283ea 100644 --- a/orig/v8-14.6.202.11/BUILD.bazel @@ -309,7 +358,7 @@ index 421ebcd..52283ea 100644 ], ) -@@ -4772,9 +4784,15 @@ v8_binary( +@@ -4772,9 +4784,20 @@ v8_binary( ":icu/generated_torque_initializers", ":icu/v8_initializers_files", ], @@ -317,11 +366,16 @@ index 421ebcd..52283ea 100644 + # external-reference helpers together while producing the snapshot, the + # final embedder binary may not fold the same pair and startup + # deserialization will reject the snapshot. ++ # GN also increases the x64 Windows stack reserve because constructing the ++ # snapshot overflows the linker's 1 MiB default. linkopts = select({ "@v8//bazel/config:is_android": ["-llog"], - "//conditions:default": [], + "@v8//bazel/config:is_macos": ["-Wl,-no_deduplicate"], -+ "@v8//bazel/config:is_windows": ["/OPT:NOICF"], ++ "@v8//bazel/config:is_windows": [ ++ "-Wl,/OPT:NOICF", ++ "-Wl,/STACK:2097152", ++ ], + "//conditions:default": ["-Wl,--icf=none"], }), noicu_deps = [":v8_libshared_noicu"], From ecae41274091252f454eb5ed0507036ed4e6fc19 Mon Sep 17 00:00:00 2001 From: rreichel3-oai Date: Thu, 4 Jun 2026 20:58:14 -0400 Subject: [PATCH 31/41] [codex] Emit sandbox outcome telemetry event (#25955) ## Summary Adds a dedicated `codex.sandbox_outcome` telemetry event so we can query sandbox edge outcomes without threading sandbox metadata through tool-result output types. This is meant to make sandbox failures and approved escalation retries visible in OTEL while keeping the existing `codex.tool_result` event shape focused on tool completion data. ## What changed - Adds `SessionTelemetry::sandbox_outcome(...)`, which emits `codex.sandbox_outcome` as both a log and trace event. - Records the tool name, call id, sandbox outcome, initial attempt duration, and escalated attempt duration when a retry runs. - Emits `denied` when the sandbox blocks execution and no retry is run. - Emits `timed_out` and `signal` when those sandbox errors surface from tool execution. - Emits `escalated` when the initial sandboxed attempt fails and the approved unsandboxed retry succeeds. - Adds OTEL coverage for the new event payload, including timing fields. ## Validation - `RUST_MIN_STACK=8388608 just test -p codex-core sandbox_outcome_event_records_outcome handle_sandbox_error_user_approves_retry_records_tool_decision` - `just test -p codex-otel otel_export_routing_policy_routes_tool_result_log_and_trace_events runtime_metrics_summary_collects_tool_api_and_streaming_metrics` - `just fix -p codex-core` - `just fix -p codex-otel` --- codex-rs/core/src/tools/orchestrator.rs | 86 +++++++++++++++++-- codex-rs/core/tests/suite/otel.rs | 69 +++++++++++++++ codex-rs/otel/src/events/session_telemetry.rs | 31 +++++++ 3 files changed, 181 insertions(+), 5 deletions(-) diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 235f8c4e96..77cbffde32 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -39,6 +39,7 @@ use codex_protocol::protocol::NetworkPolicyRuleAction; use codex_protocol::protocol::ReviewDecision; use codex_sandboxing::SandboxManager; use codex_sandboxing::SandboxType; +use std::time::Instant; pub(crate) struct ToolOrchestrator { sandbox: SandboxManager, @@ -256,6 +257,7 @@ impl ToolOrchestrator { network_denial_cancellation_token: None, }; + let initial_attempt_start = Instant::now(); let (first_result, first_deferred_network_approval) = Self::run_attempt( tool, req, @@ -264,6 +266,7 @@ impl ToolOrchestrator { managed_network_active, ) .await; + let initial_duration = initial_attempt_start.elapsed(); match first_result { Ok(out) => { // We have a successful initial result @@ -284,12 +287,26 @@ impl ToolOrchestrator { None }; if network_policy_decision.is_some() && network_approval_context.is_none() { + otel.sandbox_outcome( + &otel_tn, + otel_ci, + "denied", + initial_duration, + /*escalated_duration*/ None, + ); return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output, network_policy_decision, }))); } if !tool.escalate_on_failure() { + otel.sandbox_outcome( + &otel_tn, + otel_ci, + "denied", + initial_duration, + /*escalated_duration*/ None, + ); return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output, network_policy_decision, @@ -312,6 +329,13 @@ impl ToolOrchestrator { ExecApprovalRequirement::NeedsApproval { .. } ); if !allow_on_request_network_prompt { + otel.sandbox_outcome( + &otel_tn, + otel_ci, + "denied", + initial_duration, + /*escalated_duration*/ None, + ); return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output, network_policy_decision, @@ -319,6 +343,13 @@ impl ToolOrchestrator { } } if !unsandboxed_allowed && network_approval_context.is_none() { + otel.sandbox_outcome( + &otel_tn, + otel_ci, + "denied", + initial_duration, + /*escalated_duration*/ None, + ); return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output, network_policy_decision, @@ -400,15 +431,51 @@ impl ToolOrchestrator { }; // Second attempt. + let escalated_attempt_start = Instant::now(); let (retry_result, retry_deferred_network_approval) = Self::run_attempt(tool, req, tool_ctx, &retry_attempt, managed_network_active) .await; - retry_result.map(|output| OrchestratorRunResult { - output, - deferred_network_approval: retry_deferred_network_approval, - }) + let escalated_duration = escalated_attempt_start.elapsed(); + match retry_result { + Ok(output) => { + otel.sandbox_outcome( + &otel_tn, + otel_ci, + "escalated", + initial_duration, + Some(escalated_duration), + ); + Ok(OrchestratorRunResult { + output, + deferred_network_approval: retry_deferred_network_approval, + }) + } + Err(err) => { + if let Some(outcome) = sandbox_outcome_from_tool_error(&err) { + otel.sandbox_outcome( + &otel_tn, + otel_ci, + outcome, + initial_duration, + Some(escalated_duration), + ); + } + Err(err) + } + } + } + Err(err) => { + if let Some(outcome) = sandbox_outcome_from_tool_error(&err) { + otel.sandbox_outcome( + &otel_tn, + otel_ci, + outcome, + initial_duration, + /*escalated_duration*/ None, + ); + } + Err(err) } - Err(err) => Err(err), } } @@ -509,6 +576,15 @@ impl ToolOrchestrator { } } +fn sandbox_outcome_from_tool_error(err: &ToolError) -> Option<&'static str> { + match err { + ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { .. })) => Some("denied"), + ToolError::Codex(CodexErr::Sandbox(SandboxErr::Timeout { .. })) => Some("timed_out"), + ToolError::Codex(CodexErr::Sandbox(SandboxErr::Signal(_))) => Some("signal"), + ToolError::Rejected(_) | ToolError::Codex(_) => None, + } +} + fn build_denial_reason_from_output(_output: &ExecToolCallOutput) -> String { // Keep approval reason terse and stable for UX/tests, but accept the // output so we can evolve heuristics later without touching call sites. diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index 7f5fa260cd..d6c7577a7f 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -1,11 +1,15 @@ use codex_core::config::Constrained; use codex_features::Feature; +use codex_otel::SessionTelemetry; +use codex_otel::TelemetryAuthMode; +use codex_protocol::ThreadId; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; +use codex_protocol::protocol::SessionSource; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -27,6 +31,7 @@ use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use std::sync::Mutex; +use std::time::Duration; use tracing::Level; use tracing_test::traced_test; @@ -1145,6 +1150,70 @@ fn tool_decision_assertion<'a>( } } +fn sandbox_outcome_assertion<'a>( + call_id: &'a str, + expected_outcome: &'a str, +) -> impl Fn(&[&str]) -> Result<(), String> + 'a { + let call_id = call_id.to_string(); + let expected_outcome = expected_outcome.to_string(); + + move |lines: &[&str]| { + let line = lines + .iter() + .find(|line| { + line.contains("codex.sandbox_outcome") + && line.contains(&format!("call_id={call_id}")) + }) + .ok_or_else(|| format!("missing codex.sandbox_outcome event for {call_id}"))?; + + let lower = line.to_lowercase(); + if !lower.contains("tool_name=shell_command") { + return Err("missing tool_name for shell_command".to_string()); + } + if !lower.contains(&format!("outcome={expected_outcome}")) { + return Err(format!("unexpected sandbox outcome for {call_id}")); + } + if !lower.contains("initial_duration_ms=12") { + return Err("missing initial_duration_ms field".to_string()); + } + if !lower.contains("escalated_duration_ms=34") { + return Err("missing escalated_duration_ms field".to_string()); + } + + Ok(()) + } +} + +#[test] +#[traced_test] +fn sandbox_outcome_event_records_outcome() { + let telemetry = SessionTelemetry::new( + ThreadId::new(), + "gpt-5.5", + "gpt-5.5", + /*account_id*/ None, + /*account_email*/ None, + Some(TelemetryAuthMode::ApiKey), + "Codex_Desktop".to_string(), + /*log_user_prompts*/ false, + "tty".to_string(), + SessionSource::Cli, + ); + + telemetry.sandbox_outcome( + "shell_command", + "sandbox-outcome-call", + "escalated", + Duration::from_millis(/*millis*/ 12), + Some(Duration::from_millis(/*millis*/ 34)), + ); + + logs_assert(sandbox_outcome_assertion( + "sandbox-outcome-call", + "escalated", + )); +} + #[tokio::test] #[traced_test] async fn handle_shell_command_autoapprove_from_config_records_tool_decision() { diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index f4534a0791..0003cb04a5 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -998,6 +998,37 @@ impl SessionTelemetry { ); } + pub fn sandbox_outcome( + &self, + tool_name: &str, + call_id: &str, + outcome: &str, + initial_duration: Duration, + escalated_duration: Option, + ) { + let initial_duration_ms = initial_duration.as_millis().min(i64::MAX as u128) as i64; + let escalated_duration_ms = + escalated_duration.map(|duration| duration.as_millis().min(i64::MAX as u128) as i64); + log_event!( + self, + event.name = "codex.sandbox_outcome", + tool_name = %tool_name, + call_id = %call_id, + outcome = %outcome, + initial_duration_ms = initial_duration_ms, + escalated_duration_ms = escalated_duration_ms, + ); + trace_event!( + self, + event.name = "codex.sandbox_outcome", + tool_name = %tool_name, + call_id = %call_id, + outcome = %outcome, + initial_duration_ms = initial_duration_ms, + escalated_duration_ms = escalated_duration_ms, + ); + } + #[allow(clippy::too_many_arguments)] pub async fn log_tool_result_with_tags( &self, From e0096db6dc8a2f89394986123802a676a1be20c5 Mon Sep 17 00:00:00 2001 From: rka-oai Date: Thu, 4 Jun 2026 18:49:51 -0700 Subject: [PATCH 32/41] [codex] Add use_responses_lite 'override' logic (#26487) ## Summary - add a defaulted `ModelInfo.use_responses_lite` catalog field - support serializing `reasoning.context` while preserving the existing effort and summary path - has not been turned on for any models yet I've added an override to parallel tools if responses_lite is on. I've also forced persistent reasoning when using responses_lite. It would be ideal if we could centralize all the responses_lite plumbing, but I think this is best for now to keep the plumbing & diffs small. ## Testing - `cargo test -p codex-protocol model_info_defaults_availability_nux_to_none_when_omitted` - `RUST_MIN_STACK=8388608 cargo test -p codex-core responses_lite_sets_all_turns_context_and_disables_parallel_tool_calls` - `RUST_MIN_STACK=8388608 cargo test -p codex-core configured_reasoning_summary_is_sent` - `cargo check -p codex-core --tests` - `RUST_MIN_STACK=8388608 cargo clippy -p codex-core --tests` (passes with pre-existing warnings in `codex-code-mode` and `codex-core-plugins`) --- .../app-server/tests/common/models_cache.rs | 1 + codex-rs/codex-api/src/common.rs | 10 +++ codex-rs/codex-api/src/lib.rs | 1 + .../codex-api/tests/models_integration.rs | 1 + codex-rs/core/src/client.rs | 9 ++- codex-rs/core/tests/suite/auto_review.rs | 1 + codex-rs/core/tests/suite/client.rs | 65 +++++++++++++++++++ codex-rs/core/tests/suite/model_switching.rs | 2 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + codex-rs/core/tests/suite/personality.rs | 2 + codex-rs/core/tests/suite/remote_models.rs | 3 + codex-rs/core/tests/suite/rmcp_client.rs | 1 + .../tests/suite/spawn_agent_description.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 1 + codex-rs/models-manager/src/model_info.rs | 1 + codex-rs/protocol/src/openai_models.rs | 4 ++ codex-rs/tools/src/tool_config_tests.rs | 1 + 17 files changed, 104 insertions(+), 1 deletion(-) diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index 127c14bbb4..8233b1b296 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -52,6 +52,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index 50ac2685b4..12f8cc55da 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -110,12 +110,22 @@ pub enum ResponseEvent { ModelsEtag(String), } +#[derive(Debug, Serialize, Clone, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum ReasoningContext { + Auto, + CurrentTurn, + AllTurns, +} + #[derive(Debug, Serialize, Clone, PartialEq)] pub struct Reasoning { #[serde(skip_serializing_if = "Option::is_none")] pub effort: Option, #[serde(skip_serializing_if = "Option::is_none")] pub summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub context: Option, } #[derive(Debug, Serialize, Default, Clone, PartialEq)] diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index 8efebf076c..d2d7679406 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -30,6 +30,7 @@ pub use crate::common::OpenAiVerbosity; pub use crate::common::RawMemory; pub use crate::common::RawMemoryMetadata; pub use crate::common::Reasoning; +pub use crate::common::ReasoningContext; pub use crate::common::ResponseCreateWsRequest; pub use crate::common::ResponseEvent; pub use crate::common::ResponseStream; diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index 9ff273211f..4ee6069898 100644 --- a/codex-rs/codex-api/tests/models_integration.rs +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -98,6 +98,7 @@ async fn models_client_hits_models_endpoint() { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 9c133d85e4..ee8ea3619f 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -44,6 +44,7 @@ use codex_api::RawMemory as ApiRawMemory; use codex_api::RealtimeCallClient as ApiRealtimeCallClient; use codex_api::RealtimeSessionConfig as ApiRealtimeSessionConfig; use codex_api::Reasoning; +use codex_api::ReasoningContext; use codex_api::RequestTelemetry; use codex_api::ReqwestTransport; use codex_api::ResponseCreateWsRequest; @@ -609,6 +610,7 @@ impl ModelClient { reasoning: effort.map(|effort| Reasoning { effort: Some(effort), summary: None, + context: None, }), }; @@ -727,6 +729,11 @@ impl ModelClient { } else { Some(summary) }, + // When Responses Lite is disabled, omit context so Responses uses the default, + // which is currently `current_turn`. + context: model_info + .use_responses_lite + .then_some(ReasoningContext::AllTurns), }) } else { None @@ -775,7 +782,7 @@ impl ModelClient { input, tools, tool_choice: "auto".to_string(), - parallel_tool_calls: prompt.parallel_tool_calls, + parallel_tool_calls: prompt.parallel_tool_calls && !model_info.use_responses_lite, reasoning, store: provider.is_azure_responses_endpoint(), stream: true, diff --git a/codex-rs/core/tests/suite/auto_review.rs b/codex-rs/core/tests/suite/auto_review.rs index 570f97008f..64ae10d688 100644 --- a/codex-rs/core/tests/suite/auto_review.rs +++ b/codex-rs/core/tests/suite/auto_review.rs @@ -231,6 +231,7 @@ fn remote_model_with_auto_review_override(slug: &str, review_model: &str) -> Mod input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: Some(review_model.to_string()), tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index ad8ff17cdc..72e415e2be 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1872,6 +1872,71 @@ async fn configured_reasoning_summary_is_sent() -> anyhow::Result<()> { .and_then(|value| value.as_str()), Some("concise") ); + pretty_assertions::assert_eq!( + request_body + .get("reasoning") + .and_then(|reasoning| reasoning.get("context")), + None + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_lite_sets_all_turns_context_and_disables_parallel_tool_calls() +-> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + let server = MockServer::start().await; + + let resp_mock = mount_sse_once( + &server, + sse(vec![ev_response_created("resp1"), ev_completed("resp1")]), + ) + .await; + + let mut model_catalog = bundled_models_response() + .unwrap_or_else(|err| panic!("bundled models.json should parse: {err}")); + let model = model_catalog + .models + .iter_mut() + .find(|model| model.slug == "gpt-5.4") + .expect("gpt-5.4 exists in bundled models.json"); + model.use_responses_lite = true; + model.supports_parallel_tool_calls = true; + + let TestCodex { codex, .. } = test_codex() + .with_model("gpt-5.4") + .with_config(move |config| { + config.model_catalog = Some(model_catalog); + }) + .build(&server) + .await?; + + codex + .submit(Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let request_body = resp_mock.single_request().body_json(); + pretty_assertions::assert_eq!( + request_body + .get("reasoning") + .and_then(|reasoning| reasoning.get("context")) + .and_then(|value| value.as_str()), + Some("all_turns") + ); + pretty_assertions::assert_eq!(request_body.get("parallel_tool_calls"), Some(&json!(false))); Ok(()) } diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 111ce50436..a80c43660c 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -112,6 +112,7 @@ fn test_model_info( input_modalities, used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, @@ -932,6 +933,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 34fc98b59f..d27c43cfc6 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -370,6 +370,7 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index cd264c0734..bf411d9cd1 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -592,6 +592,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, @@ -705,6 +706,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 608558d365..0ed39216d0 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -478,6 +478,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, @@ -730,6 +731,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, @@ -1216,6 +1218,7 @@ fn test_remote_model_with_policy( input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 36ea7d4b2c..552c4ed529 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -1349,6 +1349,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re input_modalities: vec![InputModality::Text], used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/tests/suite/spawn_agent_description.rs b/codex-rs/core/tests/suite/spawn_agent_description.rs index 3b581c9051..ba29b4f00c 100644 --- a/codex-rs/core/tests/suite/spawn_agent_description.rs +++ b/codex-rs/core/tests/suite/spawn_agent_description.rs @@ -60,6 +60,7 @@ fn test_model_info( input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 2e34475387..19758592c3 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -1355,6 +1355,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an input_modalities: vec![InputModality::Text], used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/models-manager/src/model_info.rs b/codex-rs/models-manager/src/model_info.rs index 58137a3f50..81a5c6e5ed 100644 --- a/codex-rs/models-manager/src/model_info.rs +++ b/codex-rs/models-manager/src/model_info.rs @@ -99,6 +99,7 @@ pub fn model_info_from_slug(slug: &str) -> ModelInfo { input_modalities: default_input_modalities(), used_fallback_model_metadata: true, // this is the fallback model metadata supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 4c04edbb56..1553ef3435 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -404,6 +404,8 @@ pub struct ModelInfo { pub used_fallback_model_metadata: bool, #[serde(default)] pub supports_search_tool: bool, + #[serde(default)] + pub use_responses_lite: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub auto_review_model_override: Option, #[serde( @@ -674,6 +676,7 @@ mod tests { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, @@ -936,6 +939,7 @@ mod tests { assert!(!model.supports_image_detail_original); assert_eq!(model.web_search_tool_type, WebSearchToolType::Text); assert!(!model.supports_search_tool); + assert!(!model.use_responses_lite); assert_eq!(model.auto_review_model_override, None); assert_eq!(model.tool_mode, None); } diff --git a/codex-rs/tools/src/tool_config_tests.rs b/codex-rs/tools/src/tool_config_tests.rs index 1b4d789394..0e3917d697 100644 --- a/codex-rs/tools/src/tool_config_tests.rs +++ b/codex-rs/tools/src/tool_config_tests.rs @@ -44,6 +44,7 @@ fn model_with_shell_type(shell_type: ConfigShellToolType) -> ModelInfo { input_modalities: codex_protocol::openai_models::default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, From 4de7a2b9d8eae19e00ca7f744647fa1aabdc204f Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Thu, 4 Jun 2026 19:31:06 -0700 Subject: [PATCH 33/41] fix(rmcp): refresh expired OAuth tokens before startup (#26482) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Codex persists OAuth expiry as an absolute `expires_at`, then reconstructs RMCP’s relative `expires_in` when credentials are loaded. For an already-expired token, Codex reconstructed `expires_in` as missing. [RMCP 0.15 treated a missing `expires_in` as zero when a refresh token was present](https://github.com/modelcontextprotocol/rust-sdk/blob/9cfc905a9ef17c8bba6748dc0a9bdd2452681733/crates/rmcp/src/transport/auth.rs#L704-L723), so this still triggered a refresh. [RMCP 1.7 treats missing expiry information as unknown and uses the access token as-is](https://github.com/modelcontextprotocol/rust-sdk/blob/3529c3675ff64db805bd947ca6ece6090809e43d/crates/rmcp/src/transport/auth.rs#L1233-L1265), causing the stale token to be sent during `initialize`. ## What changed - Represent a known-expired persisted token as `expires_in = 0`, preserving `None` for genuinely unknown expiry. - Add Streamable HTTP coverage requiring the token to refresh before the startup handshake. ## Validation - The new regression test fails on RMCP 1.7 before the fix and passes afterward. - The same scenario passes on the commit immediately before the RMCP 1.7 update, using RMCP 0.15. - `just test -p codex-rmcp-client` (63 passed). --- codex-rs/Cargo.lock | 1 + codex-rs/rmcp-client/Cargo.toml | 1 + codex-rs/rmcp-client/src/oauth.rs | 12 +- .../tests/streamable_http_oauth_startup.rs | 155 ++++++++++++++++++ .../tests/streamable_http_test_support.rs | 9 +- 5 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 codex-rs/rmcp-client/tests/streamable_http_oauth_startup.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 1e79865d43..6da7688435 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3597,6 +3597,7 @@ dependencies = [ "urlencoding", "webbrowser", "which 8.0.0", + "wiremock", ] [[package]] diff --git a/codex-rs/rmcp-client/Cargo.toml b/codex-rs/rmcp-client/Cargo.toml index e3417de70e..1006ad74e8 100644 --- a/codex-rs/rmcp-client/Cargo.toml +++ b/codex-rs/rmcp-client/Cargo.toml @@ -70,6 +70,7 @@ codex-utils-cargo-bin = { workspace = true } pretty_assertions = { workspace = true } serial_test = { workspace = true } tempfile = { workspace = true } +wiremock = { workspace = true } [target.'cfg(target_os = "linux")'.dependencies] keyring = { workspace = true, features = ["linux-native-async-persistent"] } diff --git a/codex-rs/rmcp-client/src/oauth.rs b/codex-rs/rmcp-client/src/oauth.rs index e23eee84be..c348460795 100644 --- a/codex-rs/rmcp-client/src/oauth.rs +++ b/codex-rs/rmcp-client/src/oauth.rs @@ -113,7 +113,13 @@ fn refresh_expires_in_from_timestamp(tokens: &mut StoredOAuthTokens) { tokens.token_response.0.set_expires_in(Some(&duration)); } None => { - tokens.token_response.0.set_expires_in(None); + // RMCP treats a missing expiry as unknown and uses the access token + // as-is. Treat a known-expired timestamp as an explicit zero so + // startup refreshes the token before the first request. + tokens + .token_response + .0 + .set_expires_in(Some(&Duration::ZERO)); } } } @@ -830,7 +836,7 @@ mod tests { } #[test] - fn refresh_expires_in_from_timestamp_clears_expired_tokens() { + fn refresh_expires_in_from_timestamp_marks_expired_tokens() { let mut tokens = sample_tokens(); let now = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -843,7 +849,7 @@ mod tests { super::refresh_expires_in_from_timestamp(&mut tokens); - assert!(tokens.token_response.0.expires_in().is_none()); + assert_eq!(tokens.token_response.0.expires_in(), Some(Duration::ZERO)); } fn assert_tokens_match_without_expiry( diff --git a/codex-rs/rmcp-client/tests/streamable_http_oauth_startup.rs b/codex-rs/rmcp-client/tests/streamable_http_oauth_startup.rs new file mode 100644 index 0000000000..1c18f2c98b --- /dev/null +++ b/codex-rs/rmcp-client/tests/streamable_http_oauth_startup.rs @@ -0,0 +1,155 @@ +mod streamable_http_test_support; + +use std::time::Duration; + +use codex_config::types::OAuthCredentialsStoreMode; +use codex_exec_server::Environment; +use codex_rmcp_client::RmcpClient; +use codex_rmcp_client::StoredOAuthTokens; +use codex_rmcp_client::WrappedOAuthTokenResponse; +use codex_rmcp_client::save_oauth_tokens; +use oauth2::AccessToken; +use oauth2::RefreshToken; +use oauth2::basic::BasicTokenType; +use rmcp::transport::auth::OAuthTokenResponse; +use rmcp::transport::auth::VendorExtraTokenFields; +use serde_json::Value; +use serde_json::json; +use tempfile::TempDir; +use tokio::process::Command; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::Request; +use wiremock::ResponseTemplate; +use wiremock::matchers::body_string_contains; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; + +use streamable_http_test_support::initialize_client; + +const SERVER_NAME: &str = "test-streamable-http-oauth-startup"; +const EXPIRED_ACCESS_TOKEN: &str = "expired-access-token"; +const REFRESH_TOKEN: &str = "valid-refresh-token"; +const REFRESHED_ACCESS_TOKEN: &str = "refreshed-access-token"; +const CHILD_SERVER_URL_ENV: &str = "MCP_TEST_OAUTH_STARTUP_SERVER_URL"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn refreshes_expired_persisted_token_before_initialize() -> anyhow::Result<()> { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/.well-known/oauth-authorization-server/mcp")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "authorization_endpoint": format!("{}/oauth/authorize", server.uri()), + "token_endpoint": format!("{}/oauth/token", server.uri()), + "scopes_supported": [""], + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .and(body_string_contains("grant_type=refresh_token")) + .and(body_string_contains(format!( + "refresh_token={REFRESH_TOKEN}" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": REFRESHED_ACCESS_TOKEN, + "token_type": "Bearer", + "expires_in": 7200, + "refresh_token": REFRESH_TOKEN, + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/mcp")) + .and(header( + "authorization", + format!("Bearer {REFRESHED_ACCESS_TOKEN}"), + )) + .respond_with(|request: &Request| { + let body: Value = request.body_json().expect("valid JSON-RPC request"); + match body.get("method").and_then(Value::as_str) { + Some("initialize") => ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", + "id": body.get("id").cloned().unwrap_or(Value::Null), + "result": { + "protocolVersion": body + .pointer("/params/protocolVersion") + .cloned() + .unwrap_or_else(|| json!("2025-06-18")), + "capabilities": {}, + "serverInfo": { + "name": "oauth-startup-test", + "version": "0.0.0-test", + }, + }, + })), + Some("notifications/initialized") => ResponseTemplate::new(202), + method => ResponseTemplate::new(400) + .set_body_string(format!("unexpected JSON-RPC method: {method:?}")), + } + }) + .expect(2) + .mount(&server) + .await; + + let codex_home = TempDir::new()?; + let server_url = format!("{}/mcp", server.uri()); + + // Credential storage resolves CODEX_HOME from the process environment. + // Run the client half of the test in an ignored helper test so it can use + // an isolated home without mutating the parent test runner's environment. + let status = Command::new(std::env::current_exe()?) + .args(["oauth_startup_child", "--exact", "--ignored", "--nocapture"]) + .env("CODEX_HOME", codex_home.path()) + .env(CHILD_SERVER_URL_ENV, server_url) + .status() + .await?; + assert!(status.success(), "OAuth startup child failed: {status}"); + server.verify().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[ignore = "spawned by refreshes_expired_persisted_token_before_initialize"] +async fn oauth_startup_child() -> anyhow::Result<()> { + let server_url = std::env::var(CHILD_SERVER_URL_ENV)?; + + // Save an expired access token with a valid refresh token so startup must + // refresh before sending the initialize request. + let mut response = OAuthTokenResponse::new( + AccessToken::new(EXPIRED_ACCESS_TOKEN.to_string()), + BasicTokenType::Bearer, + VendorExtraTokenFields::default(), + ); + response.set_refresh_token(Some(RefreshToken::new(REFRESH_TOKEN.to_string()))); + response.set_expires_in(Some(&Duration::from_secs(7200))); + let tokens = StoredOAuthTokens { + server_name: SERVER_NAME.to_string(), + url: server_url.clone(), + client_id: "test-client-id".to_string(), + token_response: WrappedOAuthTokenResponse(response), + expires_at: Some(0), + }; + save_oauth_tokens(SERVER_NAME, &tokens, OAuthCredentialsStoreMode::File)?; + + // This mirrors create_client's transport and initialization setup, except + // it omits the direct bearer token. Supplying that token would bypass the + // persisted OAuth credentials and the startup refresh under test. + let client = RmcpClient::new_streamable_http_client( + SERVER_NAME, + &server_url, + /*bearer_token*/ None, + /*http_headers*/ None, + /*env_http_headers*/ None, + OAuthCredentialsStoreMode::File, + Environment::default_for_tests().get_http_client(), + /*auth_provider*/ None, + ) + .await?; + + initialize_client(&client).await?; + Ok(()) +} diff --git a/codex-rs/rmcp-client/tests/streamable_http_test_support.rs b/codex-rs/rmcp-client/tests/streamable_http_test_support.rs index 822acef1a2..03ee79392c 100644 --- a/codex-rs/rmcp-client/tests/streamable_http_test_support.rs +++ b/codex-rs/rmcp-client/tests/streamable_http_test_support.rs @@ -86,6 +86,12 @@ pub(crate) async fn create_client(base_url: &str) -> anyhow::Result ) .await?; + initialize_client(&client).await?; + + Ok(client) +} + +pub(crate) async fn initialize_client(client: &RmcpClient) -> anyhow::Result<()> { client .initialize( init_params(), @@ -102,8 +108,7 @@ pub(crate) async fn create_client(base_url: &str) -> anyhow::Result }), ) .await?; - - Ok(client) + Ok(()) } /// Creates a Streamable HTTP RMCP client that sends traffic through the remote From 1d9c9c9f33735223cc564ec942001c9141a11eb1 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Thu, 4 Jun 2026 20:23:37 -0700 Subject: [PATCH 34/41] [codex] Keep Bazel startup options stable across commands (#26256) ## Why `just bazel-clippy` ran target discovery with `--noexperimental_remote_repo_contents_cache`, then ran the build with the workspace default `--experimental_remote_repo_contents_cache`. Bazel therefore killed and restarted its server on each transition, slowing repeated commands and discarding the in-memory analysis cache. An audit found the same class of startup-option variation in several CI command sequences. ## What changed - Keep local lint target-discovery queries on the workspace-default Bazel server, while making CI target discovery explicitly use the CI startup options. - Normalize GitHub Actions launches through the BuildBuddy wrapper to share `BAZEL_OUTPUT_USER_ROOT` and `--noexperimental_remote_repo_contents_cache`. - Route the CI lockfile check and Windows test-shard query through the same startup configuration. - Document the startup-option invariant and add wrapper regression coverage. ## Validation - Confirmed consecutive local clippy target-discovery runs retained the same Bazel server PID. --- .github/scripts/run-bazel-query-ci.sh | 20 +++------ .github/scripts/run_bazel_with_buildbuddy.py | 43 ++++++++++++++++++- .../scripts/test_run_bazel_with_buildbuddy.py | 34 +++++++++++++++ .github/workflows/bazel.yml | 2 +- codex-rs/docs/bazel.md | 5 ++- scripts/check-module-bazel-lock.sh | 3 +- 6 files changed, 89 insertions(+), 18 deletions(-) diff --git a/.github/scripts/run-bazel-query-ci.sh b/.github/scripts/run-bazel-query-ci.sh index f5d4f56f49..39fa04c47d 100755 --- a/.github/scripts/run-bazel-query-ci.sh +++ b/.github/scripts/run-bazel-query-ci.sh @@ -4,7 +4,8 @@ set -euo pipefail # Run target-discovery queries with the same startup settings as the main # build/test invocation so they can reuse the same Bazel server. Queries only -# enumerate labels, so they intentionally do not select CI or remote configs. +# enumerate labels, so they intentionally do not select a CI build/test config +# or remote execution. if [[ $# -lt 2 || "${@: -2:1}" != "--" ]]; then echo "Usage: $0 [...] -- " >&2 @@ -14,21 +15,16 @@ fi query_args=("${@:1:$#-2}") query_expression="${@: -1}" -bazel_startup_args=() -if [[ -n "${BAZEL_OUTPUT_USER_ROOT:-}" ]]; then - bazel_startup_args+=("--output_user_root=${BAZEL_OUTPUT_USER_ROOT}") -fi - run_bazel() { if [[ "${RUNNER_OS:-}" == "Windows" ]]; then - MSYS2_ARG_CONV_EXCL='*' bazel "$@" + MSYS2_ARG_CONV_EXCL='*' "$(dirname "${BASH_SOURCE[0]}")/run_bazel_with_buildbuddy.py" "$@" return fi - bazel "$@" + "$(dirname "${BASH_SOURCE[0]}")/run_bazel_with_buildbuddy.py" "$@" } -bazel_query_args=(--noexperimental_remote_repo_contents_cache query) +bazel_query_args=(query) if [[ -n "${BAZEL_REPO_CONTENTS_CACHE:-}" ]]; then bazel_query_args+=("--repo_contents_cache=${BAZEL_REPO_CONTENTS_CACHE}") @@ -43,8 +39,4 @@ if (( ${#query_args[@]} > 0 )); then fi bazel_query_args+=("$query_expression") -if (( ${#bazel_startup_args[@]} > 0 )); then - run_bazel "${bazel_startup_args[@]}" "${bazel_query_args[@]}" -else - run_bazel "${bazel_query_args[@]}" -fi +run_bazel "${bazel_query_args[@]}" diff --git a/.github/scripts/run_bazel_with_buildbuddy.py b/.github/scripts/run_bazel_with_buildbuddy.py index f9f329d686..4503b4fda3 100755 --- a/.github/scripts/run_bazel_with_buildbuddy.py +++ b/.github/scripts/run_bazel_with_buildbuddy.py @@ -22,6 +22,47 @@ REMOTE_EXECUTION_CONFIGS = { "--config=ci-v8", "--config=ci-windows-cross", } +# Honor either explicit setting so the wrapper never overrides the caller's +# choice when it supplies the CI default below. +REMOTE_REPO_CONTENTS_CACHE_STARTUP_OPTIONS = { + "--experimental_remote_repo_contents_cache", + "--noexperimental_remote_repo_contents_cache", +} + + +def startup_args(args: Sequence[str], env: Mapping[str, str]) -> list[str]: + """Return shared startup options that are missing from a Bazel invocation. + + Bazel startup options must precede the command, and changing them restarts + the server and discards its analysis cache. GitHub Actions invokes Bazel + through several helpers, so normalize their startup options here while + preserving any explicit choice made by the caller. + """ + command_idx = next( + (idx for idx, arg in enumerate(args) if not arg.startswith("-")), + len(args), + ) + configured_startup_args = args[:command_idx] + injected_args = [] + + output_user_root = env.get("BAZEL_OUTPUT_USER_ROOT") + if output_user_root and not any( + arg.startswith("--output_user_root=") for arg in configured_startup_args + ): + injected_args.append(f"--output_user_root={output_user_root}") + + if env.get("GITHUB_ACTIONS") == "true" and not any( + arg in REMOTE_REPO_CONTENTS_CACHE_STARTUP_OPTIONS + for arg in configured_startup_args + ): + # Work around Bazel 9 overlay materialization failures seen in CI. This + # disables only the startup-level repo contents cache; keyed runs still + # use BuildBuddy. + injected_args.append("--noexperimental_remote_repo_contents_cache") + + return injected_args + + # Only authenticated workflow runs executing trusted upstream code may use the # OpenAI BuildBuddy host. A pull request event without proof that its head is # in the upstream repository fails closed to the generic host. @@ -114,7 +155,7 @@ def bazel_args_with_remote_config( def bazel_command(*args: str, env: Mapping[str, str] | None = None) -> list[str]: env = os.environ if env is None else env bazel = env.get("CODEX_BAZEL_BIN", "bazel") - return [bazel, *bazel_args_with_remote_config(args, env)] + return [bazel, *startup_args(args, env), *bazel_args_with_remote_config(args, env)] def main() -> None: diff --git a/.github/scripts/test_run_bazel_with_buildbuddy.py b/.github/scripts/test_run_bazel_with_buildbuddy.py index 0f594b7947..f06e34b895 100644 --- a/.github/scripts/test_run_bazel_with_buildbuddy.py +++ b/.github/scripts/test_run_bazel_with_buildbuddy.py @@ -182,6 +182,38 @@ class RunBazelWithBuildBuddyTest(unittest.TestCase): ["fake-bazel", "info", "execution_root"], ) + def test_bazel_command_normalizes_github_actions_startup_options(self) -> None: + env = { + "BAZEL_OUTPUT_USER_ROOT": "/tmp/bazel-output", + "GITHUB_ACTIONS": "true", + } + + self.assertEqual( + run_bazel_with_buildbuddy.bazel_command("build", "//codex-rs/...", env=env), + [ + "bazel", + "--output_user_root=/tmp/bazel-output", + "--noexperimental_remote_repo_contents_cache", + "build", + "//codex-rs/...", + ], + ) + self.assertEqual( + run_bazel_with_buildbuddy.bazel_command( + "--experimental_remote_repo_contents_cache", + "build", + "//codex-rs/...", + env=env, + ), + [ + "bazel", + "--output_user_root=/tmp/bazel-output", + "--experimental_remote_repo_contents_cache", + "build", + "//codex-rs/...", + ], + ) + def test_main_preserves_spaced_argument_and_child_exit_status(self) -> None: spaced_arg = ( r"--test_env=PATH=C:\Program Files\PowerShell\7;C:\Program Files\Git\bin" @@ -191,7 +223,9 @@ class RunBazelWithBuildBuddyTest(unittest.TestCase): ) env = os.environ.copy() env["CODEX_BAZEL_BIN"] = sys.executable + env.pop("BAZEL_OUTPUT_USER_ROOT", None) env.pop("BUILDBUDDY_API_KEY", None) + env.pop("GITHUB_ACTIONS", None) result = subprocess.run( [ diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index bddbb5adc9..cc1ef09245 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -186,7 +186,7 @@ jobs: bazel_test_query='tests(//...) except tests(//third_party/v8:all) except attr(tags, "manual", tests(//...))' mapfile -t bazel_targets < <( - MSYS2_ARG_CONV_EXCL='*' bazel query --output=label "${bazel_test_query}" \ + ./.github/scripts/run-bazel-query-ci.sh --output=label -- "${bazel_test_query}" \ | LC_ALL=C sort ) diff --git a/codex-rs/docs/bazel.md b/codex-rs/docs/bazel.md index 085c15992f..f12eeefd70 100644 --- a/codex-rs/docs/bazel.md +++ b/codex-rs/docs/bazel.md @@ -81,7 +81,10 @@ GitHub Actions routes Bazel build and output-resolution commands through `.github/scripts/run-bazel-ci.sh` and `.github/scripts/rusty_v8_bazel.py` delegate remote configuration selection to that wrapper. The wrapper reads the GitHub Actions repository and event payload rather than relying on workflow -files to duplicate tenant-selection logic. +files to duplicate tenant-selection logic. It also normalizes GitHub Actions +startup options so all Bazel launches in a job reuse the same server and +in-memory analysis cache. Target-discovery and lockfile helpers delegate to the +same wrapper so their callers do not need to select CI-specific startup options. Loading-phase target-discovery `bazel query` commands run locally because they only enumerate labels and do not need remote caches or execution. diff --git a/scripts/check-module-bazel-lock.sh b/scripts/check-module-bazel-lock.sh index 1a14889643..49ddaaef2b 100755 --- a/scripts/check-module-bazel-lock.sh +++ b/scripts/check-module-bazel-lock.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash set -euo pipefail -if ! bazel mod deps --lockfile_mode=error; then +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +if ! "${repo_root}/.github/scripts/run_bazel_with_buildbuddy.py" mod deps --lockfile_mode=error; then echo "MODULE.bazel.lock is out of date." echo "Run 'just bazel-lock-update' and commit the updated lockfile." exit 1 From a2f5874b7a2db79b46c60a73858ba4c4b4807c05 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 4 Jun 2026 21:48:45 -0700 Subject: [PATCH 35/41] core: derive exec policy filesystem policy from profile (#26499) ## Why `PermissionProfile` already owns the runtime filesystem sandbox policy through `file_system_sandbox_policy()`. Keeping a separate `FileSystemSandboxPolicy` on exec-policy fallback contexts made it possible for callers and tests to construct split states that the production permission model should not rely on. ## What changed - Removed `file_system_sandbox_policy` from `UnmatchedCommandContext`, `ExecApprovalRequest`, and the intercepted Unix exec-policy context. - Derived filesystem sandbox policy inside unmatched-command decision logic from `PermissionProfile::file_system_sandbox_policy()`. - Simplified shell/unified-exec callers and tests that were only plumbing the duplicate policy through. ## Testing Local tests not run per request; relying on remote CI. --- codex-rs/core/src/exec_policy.rs | 17 +--- codex-rs/core/src/exec_policy_tests.rs | 79 +------------------ .../core/src/exec_policy_windows_tests.rs | 4 - codex-rs/core/src/session/tests.rs | 2 - codex-rs/core/src/tools/handlers/shell.rs | 2 - .../tools/runtimes/shell/unix_escalation.rs | 4 - .../runtimes/shell/unix_escalation_tests.rs | 7 -- .../core/src/unified_exec/process_manager.rs | 2 - 8 files changed, 7 insertions(+), 110 deletions(-) diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index a44ffbe3ab..cc2f18c4fb 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -22,7 +22,6 @@ use codex_execpolicy::blocking_append_network_rule; use codex_protocol::approvals::ExecPolicyAmendment; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemSandboxKind; -use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_shell_command::is_dangerous_command::command_might_be_dangerous; use codex_shell_command::is_safe_command::is_known_safe_command; @@ -121,7 +120,6 @@ pub(crate) enum ExecPolicyCommandOrigin { pub(crate) struct UnmatchedCommandContext<'a> { pub(crate) approval_policy: AskForApproval, pub(crate) permission_profile: &'a PermissionProfile, - pub(crate) file_system_sandbox_policy: &'a FileSystemSandboxPolicy, pub(crate) sandbox_cwd: &'a Path, pub(crate) sandbox_permissions: SandboxPermissions, pub(crate) used_complex_parsing: bool, @@ -242,7 +240,6 @@ pub(crate) struct ExecApprovalRequest<'a> { pub(crate) command: &'a [String], pub(crate) approval_policy: AskForApproval, pub(crate) permission_profile: PermissionProfile, - pub(crate) file_system_sandbox_policy: &'a FileSystemSandboxPolicy, pub(crate) sandbox_cwd: &'a Path, pub(crate) sandbox_permissions: SandboxPermissions, pub(crate) prefix_rule: Option>, @@ -277,7 +274,6 @@ impl ExecPolicyManager { command, approval_policy, permission_profile, - file_system_sandbox_policy, sandbox_cwd, sandbox_permissions, prefix_rule, @@ -298,7 +294,6 @@ impl ExecPolicyManager { UnmatchedCommandContext { approval_policy, permission_profile: &permission_profile, - file_system_sandbox_policy, sandbox_cwd, sandbox_permissions, used_complex_parsing, @@ -636,12 +631,12 @@ pub(crate) fn render_decision_for_unmatched_command( let UnmatchedCommandContext { approval_policy, permission_profile, - file_system_sandbox_policy, sandbox_cwd, sandbox_permissions, used_complex_parsing, command_origin, } = context; + let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); let is_known_safe = match command_origin { ExecPolicyCommandOrigin::Generic => is_known_safe_command(command), #[cfg(windows)] @@ -652,12 +647,8 @@ pub(crate) fn render_decision_for_unmatched_command( // On Windows, ReadOnly sandbox is not a real sandbox, so special-case it // here. - let environment_lacks_sandbox_protections = cfg!(windows) - && profile_is_managed_read_only( - permission_profile, - file_system_sandbox_policy, - sandbox_cwd, - ); + let environment_lacks_sandbox_protections = + cfg!(windows) && profile_is_managed_read_only(permission_profile, sandbox_cwd); if is_known_safe && !used_complex_parsing @@ -751,9 +742,9 @@ pub(crate) fn render_decision_for_unmatched_command( fn profile_is_managed_read_only( permission_profile: &PermissionProfile, - file_system_sandbox_policy: &FileSystemSandboxPolicy, sandbox_cwd: &Path, ) -> bool { + let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); matches!(permission_profile, PermissionProfile::Managed { .. }) && matches!( file_system_sandbox_policy.kind, diff --git a/codex-rs/core/src/exec_policy_tests.rs b/codex-rs/core/src/exec_policy_tests.rs index 7b0883db2a..1adf6d471b 100644 --- a/codex-rs/core/src/exec_policy_tests.rs +++ b/codex-rs/core/src/exec_policy_tests.rs @@ -19,6 +19,7 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; @@ -104,31 +105,6 @@ async fn write_project_trust_config( .await } -fn read_only_file_system_sandbox_policy() -> FileSystemSandboxPolicy { - FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Read, - }]) -} - -fn workspace_write_file_system_sandbox_policy() -> FileSystemSandboxPolicy { - FileSystemSandboxPolicy::workspace_write( - &[], - /*exclude_tmpdir_env_var*/ false, - /*exclude_slash_tmp*/ false, - ) -} - -fn unrestricted_file_system_sandbox_policy() -> FileSystemSandboxPolicy { - FileSystemSandboxPolicy::unrestricted() -} - -fn external_file_system_sandbox_policy() -> FileSystemSandboxPolicy { - FileSystemSandboxPolicy::external_sandbox() -} - async fn test_config() -> (TempDir, Config) { let home = TempDir::new().expect("create temp dir"); let config = ConfigBuilder::without_managed_config_for_tests() @@ -665,7 +641,6 @@ async fn evaluates_bash_lc_inner_commands() { ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -761,7 +736,6 @@ async fn evaluates_heredoc_script_against_prefix_rules() { command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -785,7 +759,6 @@ async fn omits_auto_amendment_for_heredoc_fallback_prompts() { ], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -809,7 +782,6 @@ async fn drops_requested_amendment_for_heredoc_fallback_prompts_when_it_wont_mat ], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: Some(vec![ "python3".to_string(), @@ -837,7 +809,6 @@ async fn drops_requested_amendment_for_heredoc_fallback_prompts_when_it_matches( ], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: Some(vec!["python3".to_string()]), }, @@ -862,7 +833,6 @@ async fn heredoc_with_variable_assignment_is_not_reduced_to_allowed_prefix() { ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -893,7 +863,6 @@ EOF"# ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::workspace_write(), - file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -927,7 +896,6 @@ EOF"# ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::workspace_write(), - file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, }, @@ -967,7 +935,6 @@ prefix_rule( ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -986,7 +953,6 @@ async fn exec_approval_requirement_prefers_execpolicy_match() { command: vec!["rm".to_string()], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1014,7 +980,6 @@ prefix_rule(pattern=["git"], decision="allow") command: vec![git_path, "status".to_string()], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1048,7 +1013,6 @@ prefix_rule(pattern=["git"], decision="prompt") command: vec![disallowed_git_path.clone(), "status".to_string()], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1075,7 +1039,6 @@ async fn requested_prefix_rule_can_approve_absolute_path_commands() { ], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]), }, @@ -1098,7 +1061,6 @@ async fn exec_approval_requirement_respects_approval_policy() { command: vec!["rm".to_string()], approval_policy: AskForApproval::Never, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1126,7 +1088,6 @@ fn unmatched_granular_policy_still_prompts_for_restricted_sandbox_escalation() { mcp_elicitations: true, }), permission_profile: &PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, used_complex_parsing: false, @@ -1137,9 +1098,8 @@ fn unmatched_granular_policy_still_prompts_for_restricted_sandbox_escalation() { } #[test] -fn unmatched_on_request_uses_split_filesystem_policy_for_escalation_prompts() { +fn unmatched_on_request_uses_permission_profile_file_system_policy_for_escalation_prompts() { let command = vec!["madeup-cmd".to_string()]; - let restricted_file_system_policy = FileSystemSandboxPolicy::restricted(vec![]); assert_eq!( Decision::Prompt, @@ -1147,8 +1107,7 @@ fn unmatched_on_request_uses_split_filesystem_policy_for_escalation_prompts() { &command, UnmatchedCommandContext { approval_policy: AskForApproval::OnRequest, - permission_profile: &PermissionProfile::Disabled, - file_system_sandbox_policy: &restricted_file_system_policy, + permission_profile: &PermissionProfile::read_only(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, used_complex_parsing: false, @@ -1169,7 +1128,6 @@ fn known_safe_on_request_still_prompts_for_restricted_sandbox_escalation() { UnmatchedCommandContext { approval_policy: AskForApproval::OnRequest, permission_profile: &PermissionProfile::workspace_write(), - file_system_sandbox_policy: &workspace_write_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, used_complex_parsing: false, @@ -1202,7 +1160,6 @@ fn managed_cwd_write_profile_is_not_read_only() { assert!(!profile_is_managed_read_only( &permission_profile, - &file_system_sandbox_policy, Path::new("/tmp/project") )); } @@ -1233,7 +1190,6 @@ fn managed_unresolvable_write_profile_is_still_read_only() { assert!(profile_is_managed_read_only( &permission_profile, - &file_system_sandbox_policy, Path::new("/tmp/project") )); } @@ -1250,7 +1206,6 @@ async fn exec_approval_requirement_prompts_for_inline_additional_permissions_und ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::WithAdditionalPermissions, prefix_rule: None, }, @@ -1273,7 +1228,6 @@ async fn exec_approval_requirement_prompts_for_known_safe_escalation_under_on_re command: vec!["echo".to_string(), "hello".to_string()], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::workspace_write(), - file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, }, @@ -1303,7 +1257,6 @@ async fn exec_approval_requirement_rejects_known_safe_escalation_when_granular_s mcp_elicitations: true, }), permission_profile: PermissionProfile::workspace_write(), - file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, }, @@ -1329,7 +1282,6 @@ async fn exec_approval_requirement_rejects_unmatched_sandbox_escalation_when_gra mcp_elicitations: true, }), permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, }, @@ -1365,7 +1317,6 @@ async fn mixed_rule_and_sandbox_prompt_prioritizes_rule_for_rejection_decision() mcp_elicitations: true, }), permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, @@ -1403,7 +1354,6 @@ async fn mixed_rule_and_sandbox_prompt_rejects_when_granular_rules_are_disabled( mcp_elicitations: true, }), permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, @@ -1428,7 +1378,6 @@ async fn exec_approval_requirement_falls_back_to_heuristics() { command: &command, approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1454,7 +1403,6 @@ async fn empty_bash_lc_script_falls_back_to_original_command() { command: &command, approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1484,7 +1432,6 @@ async fn whitespace_bash_lc_script_falls_back_to_original_command() { command: &command, approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1514,7 +1461,6 @@ async fn request_rule_uses_prefix_rule() { command: &command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]), @@ -1547,7 +1493,6 @@ async fn request_rule_falls_back_when_prefix_rule_does_not_approve_all_commands( command: &command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]), @@ -1587,7 +1532,6 @@ async fn heuristics_apply_when_other_commands_match_policy() { command: &command, approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1663,7 +1607,6 @@ async fn proposed_execpolicy_amendment_is_present_for_single_command_without_pol command: command.clone(), approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1683,7 +1626,6 @@ async fn proposed_execpolicy_amendment_is_omitted_when_policy_prompts() { command: vec!["rm".to_string()], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1707,7 +1649,6 @@ async fn proposed_execpolicy_amendment_is_present_for_multi_command_scripts() { ], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1737,7 +1678,6 @@ async fn proposed_execpolicy_amendment_uses_first_no_match_in_multi_command_scri command, approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1761,7 +1701,6 @@ async fn proposed_execpolicy_amendment_is_present_when_heuristics_allow() { command: command.clone(), approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1785,7 +1724,6 @@ async fn proposed_execpolicy_amendment_is_suppressed_when_policy_matches_allow() ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1816,7 +1754,6 @@ prefix_rule(pattern=["cat"], decision="allow") command: command.clone(), approval_policy, permission_profile: PermissionProfile::workspace_write(), - file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1848,7 +1785,6 @@ prefix_rule(pattern=["bash"], decision="allow") ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -2014,7 +1950,6 @@ async fn dangerous_rm_rf_requires_approval_in_danger_full_access() { command: command.clone(), approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -2078,7 +2013,6 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() { command: &sneaky_command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: permissions, prefix_rule: None, @@ -2103,7 +2037,6 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() { command: &dangerous_command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: permissions, prefix_rule: None, @@ -2124,7 +2057,6 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() { command: &dangerous_command, approval_policy: AskForApproval::Never, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: permissions, prefix_rule: None, @@ -2146,7 +2078,6 @@ async fn dangerous_command_allowed_when_sandbox_is_explicitly_disabled() { permission_profile: PermissionProfile::External { network: NetworkSandboxPolicy::Restricted, }, - file_system_sandbox_policy: external_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -2171,7 +2102,6 @@ async fn dangerous_command_forbidden_in_external_sandbox_when_policy_matches() { permission_profile: PermissionProfile::External { network: NetworkSandboxPolicy::Restricted, }, - file_system_sandbox_policy: external_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -2188,7 +2118,6 @@ struct ExecApprovalRequirementScenario { command: Vec, approval_policy: AskForApproval, permission_profile: PermissionProfile, - file_system_sandbox_policy: FileSystemSandboxPolicy, sandbox_permissions: SandboxPermissions, prefix_rule: Option>, } @@ -2212,7 +2141,6 @@ async fn exec_approval_requirement_for_command( command, approval_policy, permission_profile, - file_system_sandbox_policy, sandbox_permissions, prefix_rule, } = test; @@ -2224,7 +2152,6 @@ async fn exec_approval_requirement_for_command( command: &command, approval_policy, permission_profile, - file_system_sandbox_policy: &file_system_sandbox_policy, sandbox_cwd: Path::new("/tmp"), sandbox_permissions, prefix_rule, diff --git a/codex-rs/core/src/exec_policy_windows_tests.rs b/codex-rs/core/src/exec_policy_windows_tests.rs index 1d14d93813..3fba240b41 100644 --- a/codex-rs/core/src/exec_policy_windows_tests.rs +++ b/codex-rs/core/src/exec_policy_windows_tests.rs @@ -15,7 +15,6 @@ async fn evaluates_powershell_inner_commands_against_prompt_rules() { ], approval_policy: AskForApproval::Never, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -39,7 +38,6 @@ async fn evaluates_powershell_inner_commands_against_allow_rules() { ], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -81,7 +79,6 @@ fn unmatched_safe_powershell_words_are_allowed() { UnmatchedCommandContext { approval_policy: AskForApproval::UnlessTrusted, permission_profile: &PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::UseDefault, used_complex_parsing: false, @@ -110,7 +107,6 @@ async fn unmatched_dangerous_powershell_inner_commands_require_approval() { ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index c5e37fca0e..a2023ea378 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -10868,7 +10868,6 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { let turn_context_mut = Arc::get_mut(&mut turn_context).expect("unique thread settings Arc"); turn_context_mut.permission_profile = PermissionProfile::Disabled; - let file_system_sandbox_policy = turn_context.file_system_sandbox_policy(); let command = session.user_shell().derive_exec_args( command_script, turn_context.config.permissions.allow_login_shell, @@ -10880,7 +10879,6 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { command: &command, approval_policy: turn_context.approval_policy.value(), permission_profile: turn_context.permission_profile(), - file_system_sandbox_policy: &file_system_sandbox_policy, #[allow(deprecated)] sandbox_cwd: turn_context.cwd.as_path(), sandbox_permissions: SandboxPermissions::UseDefault, diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 504653cf76..b3c955595a 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -160,7 +160,6 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result Result { approval_policy: AskForApproval, permission_profile: PermissionProfile, - file_system_sandbox_policy: &'a FileSystemSandboxPolicy, sandbox_cwd: &'a Path, sandbox_permissions: SandboxPermissions, enable_shell_wrapper_parsing: bool, diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index 6e7a1ced54..ecdf015fd5 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -522,7 +522,6 @@ fn evaluate_intercepted_exec_policy_uses_wrapper_command_when_shell_wrapper_pars InterceptedExecPolicyContext { approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: sandbox_cwd.as_path(), sandbox_permissions: SandboxPermissions::UseDefault, enable_shell_wrapper_parsing: enable_intercepted_exec_policy_shell_wrapper_parsing, @@ -575,7 +574,6 @@ fn evaluate_intercepted_exec_policy_matches_inner_shell_commands_when_enabled() InterceptedExecPolicyContext { approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: sandbox_cwd.as_path(), sandbox_permissions: SandboxPermissions::UseDefault, enable_shell_wrapper_parsing: enable_intercepted_exec_policy_shell_wrapper_parsing, @@ -619,7 +617,6 @@ host_executable(name = "git", paths = ["{git_path_literal}"]) InterceptedExecPolicyContext { approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: sandbox_cwd.as_path(), sandbox_permissions: SandboxPermissions::UseDefault, enable_shell_wrapper_parsing: false, @@ -747,7 +744,6 @@ fn intercepted_exec_policy_treats_preapproved_additional_permissions_as_default( let argv = ["printf".to_string(), "hello".to_string()]; let approval_policy = AskForApproval::OnRequest; let permission_profile = PermissionProfile::workspace_write(); - let file_system_sandbox_policy = read_only_file_system_sandbox_policy(); let sandbox_cwd = test_sandbox_cwd(); let preapproved = evaluate_intercepted_exec_policy( @@ -757,7 +753,6 @@ fn intercepted_exec_policy_treats_preapproved_additional_permissions_as_default( InterceptedExecPolicyContext { approval_policy, permission_profile: permission_profile.clone(), - file_system_sandbox_policy: &file_system_sandbox_policy, sandbox_cwd: sandbox_cwd.as_path(), sandbox_permissions: super::approval_sandbox_permissions( SandboxPermissions::WithAdditionalPermissions, @@ -773,7 +768,6 @@ fn intercepted_exec_policy_treats_preapproved_additional_permissions_as_default( InterceptedExecPolicyContext { approval_policy, permission_profile, - file_system_sandbox_policy: &file_system_sandbox_policy, sandbox_cwd: sandbox_cwd.as_path(), sandbox_permissions: SandboxPermissions::WithAdditionalPermissions, enable_shell_wrapper_parsing: false, @@ -808,7 +802,6 @@ host_executable(name = "git", paths = ["{allowed_git_literal}"]) InterceptedExecPolicyContext { approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: sandbox_cwd.as_path(), sandbox_permissions: SandboxPermissions::UseDefault, enable_shell_wrapper_parsing: false, diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 66b8b49a80..2c86deafaf 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -1011,7 +1011,6 @@ impl UnifiedExecProcessManager { }; let mut orchestrator = ToolOrchestrator::new(); let mut runtime = UnifiedExecRuntime::new(self, request.shell_mode.clone()); - let file_system_sandbox_policy = context.turn.file_system_sandbox_policy(); let exec_approval_requirement = context .session .services @@ -1020,7 +1019,6 @@ impl UnifiedExecProcessManager { command: &request.command, approval_policy: context.turn.approval_policy.value(), permission_profile: context.turn.permission_profile(), - file_system_sandbox_policy: &file_system_sandbox_policy, // The process cwd may be model-controlled. Policy resolution // stays anchored to the selected turn environment cwd instead. sandbox_cwd: request.sandbox_cwd.as_path(), From 64e0829cab9dccb14b8273a2ebea2eee9e06c12c Mon Sep 17 00:00:00 2001 From: Anton Panasenko Date: Thu, 4 Jun 2026 22:12:23 -0700 Subject: [PATCH 36/41] feat(remote-control): allow pairing while disabled (#26215) ## Why `remoteControl/pairing/start` creates authorization for future remote-control connections, so it should not require the live websocket to already be enabled. Requiring enable first made pairing depend on presence instead of the persisted server enrollment that pairing actually uses. Pairing also needs to recover when that persisted server row is stale. If `/server/pair` returns `404`, making the first pairing attempt fail forces a manual retry even though the client can clear the stale row and create a replacement enrollment immediately. ## What Changed - Allow `remoteControl/pairing/start` to reuse or create the persisted remote-control server enrollment while remote control is disabled. - Keep the selected in-memory enrollment across disable and share it with websocket connect so a later enable uses the same selected server. - Thread the app-server client name through pairing so stdio persistence keeps using the websocket-owned enrollment key. - Recover pairing server-token auth failures through the existing refresh/auth-recovery path. - Recover stale pairing enrollment on `/server/pair` `404` by clearing the stale selected enrollment, re-enrolling once, and retrying pairing once. - Add focused disabled-pairing and stale-pairing recovery coverage. ## Verification - `remote_control_pairing_start_returns_pairing_artifacts_while_disabled` exercises pairing before enable. - `remote_control_handle_reenrolls_after_stale_pairing_enrollment` exercises stale `/server/pair` `404` recovery without a manual retry. Related: N/A --- .../src/transport/remote_control/mod.rs | 353 +++++++++++++++--- .../src/transport/remote_control/tests.rs | 30 +- .../remote_control/tests/clients_tests.rs | 5 +- .../remote_control/tests/pairing_tests.rs | 156 +++++++- .../src/transport/remote_control/websocket.rs | 348 +++++++++-------- codex-rs/app-server/src/message_processor.rs | 2 +- .../remote_control_processor.rs | 3 +- .../remote_control_processor_tests.rs | 5 +- .../tests/suite/v2/remote_control.rs | 44 ++- 9 files changed, 702 insertions(+), 244 deletions(-) diff --git a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs index 38e34e6d43..443dcc897e 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs @@ -9,7 +9,10 @@ mod websocket; use self::auth::load_remote_control_auth; use self::auth::recover_remote_control_auth; use self::enroll::RemoteControlEnrollment; +use self::enroll::enroll_remote_control_server; +use self::enroll::load_persisted_remote_control_enrollment; use self::enroll::refresh_remote_control_server; +use self::enroll::update_persisted_remote_control_enrollment; use crate::transport::remote_control::websocket::RemoteControlChannels; use crate::transport::remote_control::websocket::RemoteControlStatusPublisher; use crate::transport::remote_control::websocket::RemoteControlWebsocket; @@ -36,9 +39,13 @@ use gethostname::gethostname; use std::error::Error; use std::fmt; use std::io; +use std::ops::Deref; +use std::ops::DerefMut; use std::panic::AssertUnwindSafe; use std::sync::Arc; use std::sync::Mutex as StdMutex; +use tokio::sync::Semaphore; +use tokio::sync::SemaphorePermit; use tokio::sync::mpsc; use tokio::sync::oneshot; use tokio::sync::watch; @@ -65,12 +72,81 @@ pub struct RemoteControlHandle { enabled_tx: Arc>, status_tx: Arc>, state_db_available: bool, + state_db: Option>, remote_control_url: String, current_enrollment: CurrentRemoteControlEnrollment, + pairing_persistence_key: RemoteControlPairingPersistenceKey, + pairing_persistence_key_required: bool, auth_manager: Arc, } -type CurrentRemoteControlEnrollment = Arc>>; +// Pairing and websocket connect share one selected server so they cannot enroll or clear +// different persisted rows while either path is awaiting backend I/O. +type CurrentRemoteControlEnrollment = Arc; +type RemoteControlPairingPersistenceKey = watch::Sender>; + +struct RemoteControlEnrollmentState { + enrollment: StdMutex>, + lock: Semaphore, +} + +impl RemoteControlEnrollmentState { + fn new(enrollment: Option) -> Self { + Self { + enrollment: StdMutex::new(enrollment), + lock: Semaphore::new(1), + } + } + + async fn lock(&self) -> RemoteControlEnrollmentLease<'_> { + let permit = match self.lock.acquire().await { + Ok(permit) => permit, + Err(_) => unreachable!("remote control enrollment lock should stay open"), + }; + RemoteControlEnrollmentLease { + state: self, + enrollment: self.snapshot(), + _permit: permit, + } + } + + fn snapshot(&self) -> Option { + self.enrollment + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } +} + +struct RemoteControlEnrollmentLease<'a> { + state: &'a RemoteControlEnrollmentState, + enrollment: Option, + _permit: SemaphorePermit<'a>, +} + +impl Deref for RemoteControlEnrollmentLease<'_> { + type Target = Option; + + fn deref(&self) -> &Self::Target { + &self.enrollment + } +} + +impl DerefMut for RemoteControlEnrollmentLease<'_> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.enrollment + } +} + +impl Drop for RemoteControlEnrollmentLease<'_> { + fn drop(&mut self) { + *self + .state + .enrollment + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = self.enrollment.take(); + } +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RemoteControlUnavailable; @@ -126,8 +202,6 @@ impl RemoteControlHandle { *state = false; changed }); - clear_current_enrollment(&self.current_enrollment); - let status = self.status(); info!( enabled_changed, @@ -151,28 +225,30 @@ impl RemoteControlHandle { pub async fn start_pairing( &self, params: RemoteControlPairingStartParams, + app_server_client_name: Option<&str>, ) -> io::Result { - if !*self.enabled_tx.borrow() { - return Err(Self::pairing_disabled_error()); - } let mut auth = load_remote_control_auth(&self.auth_manager) .await .map_err(|_| pairing_unavailable_error())?; - let mut enrollment = { - let current_enrollment = self - .current_enrollment - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - current_enrollment - .as_ref() - .filter(|enrollment| enrollment.account_id == auth.account_id) - .cloned() - } - .ok_or_else(pairing_unavailable_error)?; - let installation_id = self.status().installation_id; + let status = self.status(); + let installation_id = status.installation_id; + let app_server_client_name = self.pairing_persistence_key(app_server_client_name)?; + let app_server_client_name = app_server_client_name.as_deref(); + let mut current_enrollment = self.current_enrollment.lock().await; + let mut enrollment = self + .load_or_enroll_pairing_server( + &mut current_enrollment, + &mut auth, + &installation_id, + &status.server_name, + app_server_client_name, + ) + .await?; if enrollment.should_refresh_server_token() { refresh_pairing_enrollment( - &self.current_enrollment, + &mut current_enrollment, + self.state_db.as_deref(), + app_server_client_name, &self.auth_manager, &mut auth, &installation_id, @@ -185,9 +261,11 @@ impl RemoteControlHandle { }; let pairing_response = match enrollment.start_pairing(pairing_request()).await { Err(err) if err.kind() == io::ErrorKind::PermissionDenied => { - clear_pairing_server_token(&self.current_enrollment, &mut enrollment)?; + clear_pairing_server_token(&mut current_enrollment, &mut enrollment)?; refresh_pairing_enrollment( - &self.current_enrollment, + &mut current_enrollment, + self.state_db.as_deref(), + app_server_client_name, &self.auth_manager, &mut auth, &installation_id, @@ -196,24 +274,46 @@ impl RemoteControlHandle { .await?; enrollment.start_pairing(pairing_request()).await } + Err(err) if err.kind() == io::ErrorKind::NotFound => { + clear_pairing_enrollment( + &mut current_enrollment, + self.state_db.as_deref(), + app_server_client_name, + &enrollment, + ) + .await; + enrollment = self + .load_or_enroll_pairing_server( + &mut current_enrollment, + &mut auth, + &installation_id, + &status.server_name, + app_server_client_name, + ) + .await?; + enrollment.start_pairing(pairing_request()).await + } pairing_response => pairing_response, }; if let Err(err) = &pairing_response { match err.kind() { io::ErrorKind::NotFound => { - clear_current_enrollment_if_matches(&self.current_enrollment, &enrollment); + clear_pairing_enrollment( + &mut current_enrollment, + self.state_db.as_deref(), + app_server_client_name, + &enrollment, + ) + .await; return Err(pairing_unavailable_error()); } io::ErrorKind::PermissionDenied => { - clear_pairing_server_token(&self.current_enrollment, &mut enrollment)?; + clear_pairing_server_token(&mut current_enrollment, &mut enrollment)?; return Err(pairing_unavailable_error()); } _ => {} } } - if !*self.enabled_tx.borrow() { - return Err(Self::pairing_disabled_error()); - } let current_auth = load_remote_control_auth(&self.auth_manager) .await .map_err(|_| pairing_unavailable_error())?; @@ -223,6 +323,74 @@ impl RemoteControlHandle { pairing_response } + async fn load_or_enroll_pairing_server( + &self, + current_enrollment: &mut Option, + auth: &mut auth::RemoteControlConnectionAuth, + installation_id: &str, + server_name: &str, + app_server_client_name: Option<&str>, + ) -> io::Result { + if let Some(enrollment) = current_enrollment + .as_ref() + .filter(|enrollment| enrollment.account_id == auth.account_id) + .cloned() + { + return Ok(enrollment); + } + + let remote_control_target = normalize_remote_control_url(&self.remote_control_url)?; + let state_db = self + .state_db + .as_deref() + .ok_or_else(pairing_unavailable_error)?; + if let Some(mut enrollment) = load_persisted_remote_control_enrollment( + Some(state_db), + &remote_control_target, + &auth.account_id, + app_server_client_name, + ) + .await? + { + enrollment.server_name = server_name.to_string(); + publish_current_enrollment(current_enrollment, &enrollment); + return Ok(enrollment); + } + + let enrollment = enroll_pairing_server( + &self.auth_manager, + auth, + &remote_control_target, + installation_id, + server_name, + ) + .await?; + update_persisted_remote_control_enrollment( + Some(state_db), + &remote_control_target, + &auth.account_id, + app_server_client_name, + Some(&enrollment), + ) + .await?; + publish_current_enrollment(current_enrollment, &enrollment); + Ok(enrollment) + } + + fn pairing_persistence_key( + &self, + app_server_client_name: Option<&str>, + ) -> io::Result> { + if self.pairing_persistence_key_required && self.pairing_persistence_key.borrow().is_none() + { + let app_server_client_name = + app_server_client_name.ok_or_else(pairing_unavailable_error)?; + self.pairing_persistence_key + .send_replace(Some(app_server_client_name.to_string())); + } + Ok(self.pairing_persistence_key.borrow().clone()) + } + pub async fn list_clients( &self, params: RemoteControlClientsListParams, @@ -239,13 +407,6 @@ impl RemoteControlHandle { .await } - fn pairing_disabled_error() -> io::Error { - io::Error::new( - io::ErrorKind::InvalidInput, - "remote control pairing requires remote control to be enabled", - ) - } - fn publish_status( &self, connection_status: RemoteControlConnectionStatus, @@ -277,8 +438,36 @@ impl RemoteControlHandle { } } +async fn enroll_pairing_server( + auth_manager: &Arc, + auth: &mut auth::RemoteControlConnectionAuth, + remote_control_target: &protocol::RemoteControlTarget, + installation_id: &str, + server_name: &str, +) -> io::Result { + match enroll_remote_control_server(remote_control_target, auth, installation_id, server_name) + .await + { + Ok(enrollment) => return Ok(enrollment), + Err(err) if err.kind() == io::ErrorKind::PermissionDenied => { + let mut auth_recovery = auth_manager.unauthorized_recovery(); + let mut auth_change_rx = auth_manager.auth_change_receiver(); + if !recover_remote_control_auth(&mut auth_recovery, &mut auth_change_rx).await { + return Err(err); + } + *auth = load_remote_control_auth(auth_manager) + .await + .map_err(|_| pairing_unavailable_error())?; + } + Err(err) => return Err(err), + } + enroll_remote_control_server(remote_control_target, auth, installation_id, server_name).await +} + async fn refresh_pairing_enrollment( - current_enrollment: &CurrentRemoteControlEnrollment, + current_enrollment: &mut Option, + state_db: Option<&StateRuntime>, + app_server_client_name: Option<&str>, auth_manager: &Arc, auth: &mut auth::RemoteControlConnectionAuth, installation_id: &str, @@ -286,7 +475,14 @@ async fn refresh_pairing_enrollment( ) -> io::Result<()> { if let Err(err) = refresh_remote_control_server(auth, installation_id, enrollment).await { if err.kind() != io::ErrorKind::PermissionDenied { - return handle_pairing_refresh_error(current_enrollment, enrollment, err); + return handle_pairing_refresh_error( + current_enrollment, + state_db, + app_server_client_name, + enrollment, + err, + ) + .await; } let mut auth_recovery = auth_manager.unauthorized_recovery(); let mut auth_change_rx = auth_manager.auth_change_receiver(); @@ -300,7 +496,14 @@ async fn refresh_pairing_enrollment( return Err(pairing_unavailable_error()); } if let Err(err) = refresh_remote_control_server(auth, installation_id, enrollment).await { - return handle_pairing_refresh_error(current_enrollment, enrollment, err); + return handle_pairing_refresh_error( + current_enrollment, + state_db, + app_server_client_name, + enrollment, + err, + ) + .await; } } if replace_current_enrollment(current_enrollment, enrollment) { @@ -310,21 +513,54 @@ async fn refresh_pairing_enrollment( } } -fn handle_pairing_refresh_error( - current_enrollment: &CurrentRemoteControlEnrollment, +async fn handle_pairing_refresh_error( + current_enrollment: &mut Option, + state_db: Option<&StateRuntime>, + app_server_client_name: Option<&str>, enrollment: &RemoteControlEnrollment, err: io::Error, ) -> io::Result<()> { if err.kind() == io::ErrorKind::NotFound { - clear_current_enrollment_if_matches(current_enrollment, enrollment); + clear_pairing_enrollment( + current_enrollment, + state_db, + app_server_client_name, + enrollment, + ) + .await; Err(pairing_unavailable_error()) } else { Err(err) } } +async fn clear_pairing_enrollment( + current_enrollment: &mut Option, + state_db: Option<&StateRuntime>, + app_server_client_name: Option<&str>, + enrollment: &RemoteControlEnrollment, +) { + if !clear_current_enrollment_if_matches(current_enrollment, enrollment) { + return; + } + let Some(state_db) = state_db else { + return; + }; + if let Err(err) = update_persisted_remote_control_enrollment( + Some(state_db), + &enrollment.remote_control_target, + &enrollment.account_id, + app_server_client_name, + /*enrollment*/ None, + ) + .await + { + warn!("failed to clear stale pairing enrollment: {err}"); + } +} + fn clear_pairing_server_token( - current_enrollment: &CurrentRemoteControlEnrollment, + current_enrollment: &mut Option, enrollment: &mut RemoteControlEnrollment, ) -> io::Result<()> { enrollment.clear_server_token(); @@ -359,27 +595,16 @@ fn remote_control_status_with_connection_status( } fn publish_current_enrollment( - current_enrollment: &CurrentRemoteControlEnrollment, + current_enrollment: &mut Option, enrollment: &RemoteControlEnrollment, ) { - *current_enrollment - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(enrollment.clone()); -} - -fn clear_current_enrollment(current_enrollment: &CurrentRemoteControlEnrollment) { - *current_enrollment - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + *current_enrollment = Some(enrollment.clone()); } fn replace_current_enrollment( - current_enrollment: &CurrentRemoteControlEnrollment, + current_enrollment: &mut Option, enrollment: &RemoteControlEnrollment, ) -> bool { - let mut current_enrollment = current_enrollment - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); if !current_enrollment .as_ref() .is_some_and(|current| same_remote_control_enrollment(current, enrollment)) @@ -391,17 +616,17 @@ fn replace_current_enrollment( } fn clear_current_enrollment_if_matches( - current_enrollment: &CurrentRemoteControlEnrollment, + current_enrollment: &mut Option, enrollment: &RemoteControlEnrollment, -) { - let mut current_enrollment = current_enrollment - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); +) -> bool { if current_enrollment .as_ref() .is_some_and(|current| same_remote_control_enrollment(current, enrollment)) { *current_enrollment = None; + true + } else { + false } } @@ -438,9 +663,13 @@ pub async fn start_remote_control( }; let (enabled_tx, enabled_rx) = watch::channel(initial_enabled); - let current_enrollment = Arc::new(StdMutex::new(None)); + let current_enrollment = Arc::new(RemoteControlEnrollmentState::new(/*enrollment*/ None)); let websocket_current_enrollment = current_enrollment.clone(); + let pairing_persistence_key_required = app_server_client_name_rx.is_some(); + let (pairing_persistence_key, _pairing_persistence_key_rx) = watch::channel(None); + let websocket_pairing_persistence_key = pairing_persistence_key.clone(); let handle_auth_manager = auth_manager.clone(); + let handle_state_db = state_db.clone(); let server_name = gethostname().to_string_lossy().trim().to_string(); let remote_control_url = config.remote_control_url; let installation_id = config.installation_id; @@ -490,6 +719,7 @@ pub async fn start_remote_control( transport_event_tx, status_publisher, current_enrollment: websocket_current_enrollment, + pairing_persistence_key: websocket_pairing_persistence_key, }, shutdown_token, enabled_rx, @@ -534,8 +764,11 @@ pub async fn start_remote_control( enabled_tx: Arc::new(enabled_tx), status_tx: Arc::new(status_tx), state_db_available, + state_db: handle_state_db, remote_control_url: handle_remote_control_url, current_enrollment, + pairing_persistence_key, + pairing_persistence_key_required, auth_manager: handle_auth_manager, }, )) diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs index a1a284da9b..3e9a911b2d 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs @@ -40,7 +40,6 @@ use pretty_assertions::assert_eq; use serde_json::json; use std::collections::BTreeMap; use std::sync::Arc; -use std::sync::Mutex as StdMutex; use tempfile::TempDir; use time::OffsetDateTime; use tokio::io::AsyncBufReadExt; @@ -148,24 +147,29 @@ fn remote_control_handle_with_current_enrollment( }); let remote_control_target = normalize_remote_control_url(remote_control_url) .expect("remote control target should normalize"); - let current_enrollment = Arc::new(StdMutex::new(Some(RemoteControlEnrollment { - remote_control_target, - account_id: "account_id".to_string(), - environment_id: "env_test".to_string(), - server_id: "srv_e_test".to_string(), - server_name: test_server_name(), - remote_control_token: Some(TEST_REMOTE_CONTROL_SERVER_TOKEN.to_string()), - expires_at: Some( - OffsetDateTime::from_unix_timestamp(33_336_362_096) - .expect("future timestamp should parse"), - ), - }))); + let current_enrollment = Arc::new(RemoteControlEnrollmentState::new(Some( + RemoteControlEnrollment { + remote_control_target, + account_id: "account_id".to_string(), + environment_id: "env_test".to_string(), + server_id: "srv_e_test".to_string(), + server_name: test_server_name(), + remote_control_token: Some(TEST_REMOTE_CONTROL_SERVER_TOKEN.to_string()), + expires_at: Some( + OffsetDateTime::from_unix_timestamp(33_336_362_096) + .expect("future timestamp should parse"), + ), + }, + ))); RemoteControlHandle { enabled_tx: Arc::new(enabled_tx), status_tx: Arc::new(status_tx), state_db_available: true, + state_db: None, remote_control_url: remote_control_url.to_string(), current_enrollment, + pairing_persistence_key: watch::channel(None).0, + pairing_persistence_key_required: false, auth_manager, } } diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs index 22aa290762..060063909c 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs @@ -24,8 +24,11 @@ fn client_management_handle( enabled_tx: Arc::new(enabled_tx), status_tx: Arc::new(status_tx), state_db_available: false, + state_db: None, remote_control_url, - current_enrollment: Arc::new(StdMutex::new(None)), + current_enrollment: Arc::new(RemoteControlEnrollmentState::new(/*enrollment*/ None)), + pairing_persistence_key: watch::channel(None).0, + pairing_persistence_key_required: false, auth_manager, } } diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs index 176aeb4ed5..7586f0b4d1 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs @@ -131,13 +131,16 @@ async fn remote_control_handle_starts_pairing_before_websocket_connects() { remote_handle .current_enrollment .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) + .await .as_mut() .expect("current enrollment should exist") .expires_at = Some(OffsetDateTime::now_utc() + time::Duration::seconds(29)); let response = remote_handle - .start_pairing(RemoteControlPairingStartParams { manual_code: true }) + .start_pairing( + RemoteControlPairingStartParams { manual_code: true }, + /*app_server_client_name*/ None, + ) .await .expect("pairing should use the current server before websocket connect"); server_task.await.expect("server task should finish"); @@ -219,7 +222,10 @@ async fn remote_control_handle_refreshes_after_pairing_auth_failure() { ); let response = remote_handle - .start_pairing(RemoteControlPairingStartParams::default()) + .start_pairing( + RemoteControlPairingStartParams::default(), + /*app_server_client_name*/ None, + ) .await .expect("pairing should refresh after server token auth failure"); server_task.await.expect("server task should finish"); @@ -332,13 +338,16 @@ async fn remote_control_handle_recovers_auth_before_refreshing_pairing() { remote_handle .current_enrollment .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) + .await .as_mut() .expect("current enrollment should exist") .expires_at = Some(OffsetDateTime::now_utc() + time::Duration::seconds(29)); let response = remote_handle - .start_pairing(RemoteControlPairingStartParams::default()) + .start_pairing( + RemoteControlPairingStartParams::default(), + /*app_server_client_name*/ None, + ) .await .expect("pairing should refresh after auth recovery"); server_task.await.expect("server task should finish"); @@ -414,21 +423,137 @@ async fn start_remote_control_pairing_preserves_expiry_parse_error_context() { } #[tokio::test] -async fn remote_control_handle_disable_clears_current_enrollment() { +async fn remote_control_handle_disable_keeps_current_enrollment() { let remote_handle = remote_control_handle_with_current_enrollment( TEST_REMOTE_CONTROL_URL, remote_control_auth_manager(), ); remote_handle.disable(); - remote_handle.enable().expect("enable should succeed"); + assert!( + remote_handle.current_enrollment.lock().await.is_some(), + "disabled remote control should keep the selected pairing server" + ); +} + +#[tokio::test] +async fn remote_control_handle_reenrolls_after_stale_pairing_enrollment() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let codex_home = TempDir::new().expect("temp dir should create"); + let state_db = remote_control_state_runtime(&codex_home).await; + let mut remote_handle = remote_control_handle_with_current_enrollment( + &remote_control_url, + remote_control_auth_manager_with_home(&codex_home), + ); + remote_handle.state_db = Some(state_db.clone()); + remote_handle.disable(); + let stale_enrollment = remote_handle + .current_enrollment + .lock() + .await + .clone() + .expect("current enrollment should exist"); + let remote_control_target = stale_enrollment.remote_control_target.clone(); + let refreshed_enrollment = RemoteControlEnrollment { + remote_control_target: remote_control_target.clone(), + account_id: "account_id".to_string(), + environment_id: "env_refreshed".to_string(), + server_id: "srv_e_refreshed".to_string(), + server_name: test_server_name(), + remote_control_token: None, + expires_at: None, + }; + update_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &remote_control_target, + "account_id", + /*app_server_client_name*/ None, + Some(&stale_enrollment), + ) + .await + .expect("stale enrollment should save"); + let server_refreshed_enrollment = refreshed_enrollment.clone(); + let server_task = tokio::spawn(async move { + let stale_pairing_request = accept_http_request(&listener).await; + assert_eq!( + stale_pairing_request.request_line, + "POST /backend-api/wham/remote/control/server/pair HTTP/1.1" + ); + assert_eq!( + stale_pairing_request.headers.get("authorization"), + Some(&format!("Bearer {TEST_REMOTE_CONTROL_SERVER_TOKEN}")) + ); + respond_with_status(stale_pairing_request.stream, "404 Not Found", "").await; + + let enroll_request = accept_http_request(&listener).await; + assert_eq!( + enroll_request.request_line, + "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1" + ); + respond_with_json( + enroll_request.stream, + remote_control_server_token_response( + &server_refreshed_enrollment.server_id, + &server_refreshed_enrollment.environment_id, + TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN, + ), + ) + .await; + + let refreshed_pairing_request = accept_http_request(&listener).await; + assert_eq!( + refreshed_pairing_request.request_line, + "POST /backend-api/wham/remote/control/server/pair HTTP/1.1" + ); + assert_eq!( + refreshed_pairing_request.headers.get("authorization"), + Some(&format!( + "Bearer {TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN}" + )) + ); + respond_with_json( + refreshed_pairing_request.stream, + json!({ + "pairing_code": "pairing-code", + "manual_pairing_code": "ABCD-EFGH", + "server_id": server_refreshed_enrollment.server_id, + "environment_id": server_refreshed_enrollment.environment_id, + "expires_at": "3026-05-22T12:34:56Z", + }), + ) + .await; + }); + let response = remote_handle + .start_pairing( + RemoteControlPairingStartParams::default(), + /*app_server_client_name*/ None, + ) + .await + .expect("pairing should re-enroll after stale enrollment"); + server_task.await.expect("server task should finish"); + assert_eq!( - remote_handle - .start_pairing(RemoteControlPairingStartParams::default()) - .await - .expect_err("re-enabled remote control should wait for a current server") - .to_string(), - "remote control pairing is unavailable until enrollment completes" + response, + RemoteControlPairingStartResponse { + pairing_code: "pairing-code".to_string(), + manual_pairing_code: Some("ABCD-EFGH".to_string()), + environment_id: "env_refreshed".to_string(), + expires_at: 33_336_362_096, + } + ); + assert_eq!( + load_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &remote_control_target, + "account_id", + /*app_server_client_name*/ None, + ) + .await + .expect("refreshed enrollment should load"), + Some(refreshed_enrollment) ); } @@ -458,7 +583,10 @@ async fn remote_control_handle_discards_pairing_response_after_auth_change() { let remote_handle = remote_handle.clone(); async move { remote_handle - .start_pairing(RemoteControlPairingStartParams::default()) + .start_pairing( + RemoteControlPairingStartParams::default(), + /*app_server_client_name*/ None, + ) .await } }); diff --git a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs index 60792df54c..43fce8ed36 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs @@ -1,13 +1,13 @@ use super::CurrentRemoteControlEnrollment; -use super::clear_current_enrollment; +use super::RemoteControlPairingPersistenceKey; use super::protocol::ClientEnvelope; use super::protocol::ClientEvent; use super::protocol::ClientId; use super::protocol::RemoteControlTarget; use super::protocol::ServerEnvelope; use super::protocol::StreamId; -use super::publish_current_enrollment; use super::remote_control_status_with_connection_status; +use super::same_remote_control_enrollment; use super::segment::ClientSegmentObservation; use super::segment::ClientSegmentReassembler; use super::segment::REMOTE_CONTROL_SEGMENT_MAX_BYTES; @@ -55,6 +55,9 @@ use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite; use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_util::sync::CancellationToken; + +#[cfg(test)] +use super::RemoteControlEnrollmentState; use tracing::error; use tracing::info; use tracing::warn; @@ -252,10 +255,10 @@ pub(crate) struct RemoteControlWebsocket { status_publisher: RemoteControlStatusPublisher, shutdown_token: CancellationToken, reconnect_attempt: u64, - enrollment: Option, auth_recovery: UnauthorizedRecovery, auth_change_rx: watch::Receiver, current_enrollment: CurrentRemoteControlEnrollment, + pairing_persistence_key: RemoteControlPairingPersistenceKey, client_tracker: Arc>, state: Arc>, server_event_rx: Arc>>, @@ -294,6 +297,7 @@ pub(super) struct RemoteControlChannels { pub(super) transport_event_tx: mpsc::Sender, pub(super) status_publisher: RemoteControlStatusPublisher, pub(super) current_enrollment: CurrentRemoteControlEnrollment, + pub(super) pairing_persistence_key: RemoteControlPairingPersistenceKey, } #[derive(Clone)] @@ -407,10 +411,10 @@ impl RemoteControlWebsocket { status_publisher: channels.status_publisher, shutdown_token, reconnect_attempt: 0, - enrollment: None, auth_recovery, auth_change_rx, current_enrollment: channels.current_enrollment, + pairing_persistence_key: channels.pairing_persistence_key, client_tracker: Arc::new(Mutex::new(client_tracker)), state: Arc::new(Mutex::new(WebsocketState { outbound_buffer, @@ -457,6 +461,8 @@ impl RemoteControlWebsocket { return; } }; + self.pairing_persistence_key + .send_replace(app_server_client_name.clone()); loop { if !self.wait_until_enabled().await { @@ -579,15 +585,15 @@ impl RemoteControlWebsocket { loop { let subscribe_cursor = self.state.lock().await.subscribe_cursor.clone(); - let enrollment = self.enrollment.as_ref(); + let enrollment = self.current_enrollment.snapshot(); info!( websocket_url = %remote_control_target.websocket_url, installation_id = %self.installation_id, server_name = %self.server_name, reconnect_attempt = self.reconnect_attempt.saturating_add(1), has_enrollment = enrollment.is_some(), - server_id = ?enrollment.map(|enrollment| enrollment.server_id.as_str()), - environment_id = ?enrollment.map(|enrollment| enrollment.environment_id.as_str()), + server_id = ?enrollment.as_ref().map(|enrollment| enrollment.server_id.as_str()), + environment_id = ?enrollment.as_ref().map(|enrollment| enrollment.environment_id.as_str()), subscribe_cursor_present = subscribe_cursor.is_some(), app_server_client_name = ?app_server_client_name, "connecting to app-server remote control websocket" @@ -611,15 +617,17 @@ impl RemoteControlWebsocket { } return ConnectOutcome::Disabled; } - connect_result = connect_remote_control_websocket( - &remote_control_target, - self.state_db.as_deref(), - auth_context, - &mut self.enrollment, - connect_options, - &self.status_publisher, - &self.current_enrollment, - ) => connect_result, + connect_result = async { + connect_remote_control_websocket( + &remote_control_target, + self.state_db.as_deref(), + auth_context, + &self.current_enrollment, + connect_options, + &self.status_publisher, + ) + .await + } => connect_result, }; match connect_result { @@ -631,13 +639,13 @@ impl RemoteControlWebsocket { self.auth_recovery = self.auth_manager.unauthorized_recovery(); self.status_publisher .publish_status(RemoteControlConnectionStatus::Connected); - let enrollment = self.enrollment.as_ref(); + let enrollment = self.current_enrollment.snapshot(); info!( websocket_url = %remote_control_target.websocket_url, installation_id = %self.installation_id, server_name = %self.server_name, - server_id = ?enrollment.map(|enrollment| enrollment.server_id.as_str()), - environment_id = ?enrollment.map(|enrollment| enrollment.environment_id.as_str()), + server_id = ?enrollment.as_ref().map(|enrollment| enrollment.server_id.as_str()), + environment_id = ?enrollment.as_ref().map(|enrollment| enrollment.environment_id.as_str()), subscribe_cursor_present = subscribe_cursor.is_some(), response_headers = %format_headers(response.headers()), "connected to app-server remote control websocket" @@ -656,7 +664,7 @@ impl RemoteControlWebsocket { let reconnect_attempt = self.reconnect_attempt.saturating_add(1); let (reconnect_delay, reconnect_backoff_reset) = next_reconnect_delay(&mut self.reconnect_attempt); - let enrollment = self.enrollment.as_ref(); + let enrollment = self.current_enrollment.snapshot(); warn!( websocket_url = %remote_control_target.websocket_url, installation_id = %self.installation_id, @@ -667,8 +675,8 @@ impl RemoteControlWebsocket { reconnect_delay = ?reconnect_delay, reconnect_backoff_reset, has_enrollment = enrollment.is_some(), - server_id = ?enrollment.map(|enrollment| enrollment.server_id.as_str()), - environment_id = ?enrollment.map(|enrollment| enrollment.environment_id.as_str()), + server_id = ?enrollment.as_ref().map(|enrollment| enrollment.server_id.as_str()), + environment_id = ?enrollment.as_ref().map(|enrollment| enrollment.environment_id.as_str()), subscribe_cursor_present = subscribe_cursor.is_some(), "failed to connect to app-server remote control websocket" ); @@ -1189,19 +1197,108 @@ pub(super) async fn connect_remote_control_websocket( remote_control_target: &RemoteControlTarget, state_db: Option<&StateRuntime>, mut auth_context: RemoteControlAuthContext<'_>, - enrollment: &mut Option, + current_enrollment: &CurrentRemoteControlEnrollment, connect_options: RemoteControlConnectOptions<'_>, status_publisher: &RemoteControlStatusPublisher, - current_enrollment: &CurrentRemoteControlEnrollment, ) -> io::Result<( WebSocketStream>, tungstenite::http::Response<()>, )> { ensure_rustls_crypto_provider(); + let (auth, enrollment) = { + let mut current_enrollment = current_enrollment.lock().await; + let auth = prepare_remote_control_enrollment( + remote_control_target, + state_db, + &mut auth_context, + &mut current_enrollment, + connect_options, + status_publisher, + ) + .await?; + let enrollment = current_enrollment.as_ref().cloned().ok_or_else(|| { + io::Error::other("missing remote control enrollment after enrollment step") + })?; + (auth, enrollment) + }; + let request = build_remote_control_websocket_request( + &remote_control_target.websocket_url, + &enrollment, + connect_options.installation_id, + connect_options.subscribe_cursor, + )?; + + let websocket_connect_result = tokio::time::timeout( + REMOTE_CONTROL_WEBSOCKET_CONNECT_TIMEOUT, + connect_async(request), + ) + .await + .map_err(|_| { + io::Error::new( + ErrorKind::TimedOut, + format!( + "timed out connecting to remote control websocket at `{}` after {:?}", + remote_control_target.websocket_url, REMOTE_CONTROL_WEBSOCKET_CONNECT_TIMEOUT + ), + ) + })?; + + match websocket_connect_result { + Ok((websocket_stream, response)) => Ok((websocket_stream, response.map(|_| ()))), + Err(err) => { + match &err { + tungstenite::Error::Http(response) if response.status().as_u16() == 404 => { + info!( + "remote control websocket returned HTTP 404; clearing stale enrollment before re-enrolling: websocket_url={}, account_id={}, server_id={}, environment_id={}", + remote_control_target.websocket_url, + auth.account_id, + enrollment.server_id, + enrollment.environment_id + ); + clear_remote_control_enrollment_if_matches( + state_db, + remote_control_target, + &auth.account_id, + connect_options.app_server_client_name, + current_enrollment, + &enrollment, + status_publisher, + ) + .await; + } + tungstenite::Error::Http(response) + if matches!(response.status().as_u16(), 401 | 403) => + { + clear_remote_control_server_token_if_matches(current_enrollment, &enrollment) + .await?; + return Err(io::Error::other(format!( + "remote control websocket auth failed with HTTP {}; refreshing server token before reconnect", + response.status() + ))); + } + _ => {} + } + Err(io::Error::other( + format_remote_control_websocket_connect_error( + &remote_control_target.websocket_url, + &err, + ), + )) + } + } +} + +async fn prepare_remote_control_enrollment( + remote_control_target: &RemoteControlTarget, + state_db: Option<&StateRuntime>, + auth_context: &mut RemoteControlAuthContext<'_>, + enrollment: &mut Option, + connect_options: RemoteControlConnectOptions<'_>, + status_publisher: &RemoteControlStatusPublisher, +) -> io::Result { let Some(state_db) = state_db else { *enrollment = None; - clear_current_enrollment(current_enrollment); return Err(io::Error::new( ErrorKind::NotFound, "remote control requires sqlite state db", @@ -1214,7 +1311,6 @@ pub(super) async fn connect_remote_control_websocket( if err.kind() == ErrorKind::PermissionDenied { *enrollment = None; status_publisher.publish_environment_id(/*environment_id*/ None); - clear_current_enrollment(current_enrollment); } return Err(err); } @@ -1231,7 +1327,6 @@ pub(super) async fn connect_remote_control_websocket( ); *enrollment = None; status_publisher.publish_environment_id(/*environment_id*/ None); - clear_current_enrollment(current_enrollment); } if let Some(enrollment) = enrollment.as_mut() { enrollment.remote_control_target = remote_control_target.clone(); @@ -1262,7 +1357,7 @@ pub(super) async fn connect_remote_control_websocket( remote_control_target, state_db, &auth, - &mut auth_context, + auth_context, enrollment, connect_options, status_publisher, @@ -1307,14 +1402,13 @@ pub(super) async fn connect_remote_control_websocket( connect_options.app_server_client_name, enrollment, status_publisher, - current_enrollment, ) .await; enroll_remote_control_server_if_missing( remote_control_target, state_db, &auth, - &mut auth_context, + auth_context, enrollment, connect_options, status_publisher, @@ -1337,82 +1431,7 @@ pub(super) async fn connect_remote_control_websocket( } } - let enrollment_ref = enrollment.as_ref().ok_or_else(|| { - io::Error::other("missing remote control enrollment after enrollment step") - })?; - publish_current_enrollment(current_enrollment, enrollment_ref); - let request = build_remote_control_websocket_request( - &remote_control_target.websocket_url, - enrollment_ref, - connect_options.installation_id, - connect_options.subscribe_cursor, - )?; - - let websocket_connect_result = tokio::time::timeout( - REMOTE_CONTROL_WEBSOCKET_CONNECT_TIMEOUT, - connect_async(request), - ) - .await - .map_err(|_| { - io::Error::new( - ErrorKind::TimedOut, - format!( - "timed out connecting to remote control websocket at `{}` after {:?}", - remote_control_target.websocket_url, REMOTE_CONTROL_WEBSOCKET_CONNECT_TIMEOUT - ), - ) - })?; - - match websocket_connect_result { - Ok((websocket_stream, response)) => Ok((websocket_stream, response.map(|_| ()))), - Err(err) => { - match &err { - tungstenite::Error::Http(response) if response.status().as_u16() == 404 => { - info!( - "remote control websocket returned HTTP 404; clearing stale enrollment before re-enrolling: websocket_url={}, account_id={}, server_id={}, environment_id={}", - remote_control_target.websocket_url, - auth.account_id, - enrollment_ref.server_id, - enrollment_ref.environment_id - ); - clear_remote_control_enrollment( - state_db, - remote_control_target, - &auth.account_id, - connect_options.app_server_client_name, - enrollment, - status_publisher, - current_enrollment, - ) - .await; - } - tungstenite::Error::Http(response) - if matches!(response.status().as_u16(), 401 | 403) => - { - enrollment - .as_mut() - .ok_or_else(|| { - io::Error::other( - "missing remote control enrollment after websocket auth failure", - ) - })? - .clear_server_token(); - clear_current_enrollment(current_enrollment); - return Err(io::Error::other(format!( - "remote control websocket auth failed with HTTP {}; refreshing server token before reconnect", - response.status() - ))); - } - _ => {} - } - Err(io::Error::other( - format_remote_control_websocket_connect_error( - &remote_control_target.websocket_url, - &err, - ), - )) - } - } + Ok(auth) } async fn clear_remote_control_enrollment( @@ -1422,7 +1441,6 @@ async fn clear_remote_control_enrollment( app_server_client_name: Option<&str>, enrollment: &mut Option, status_publisher: &RemoteControlStatusPublisher, - current_enrollment: &CurrentRemoteControlEnrollment, ) { if let Err(clear_err) = update_persisted_remote_control_enrollment( Some(state_db), @@ -1437,7 +1455,51 @@ async fn clear_remote_control_enrollment( } *enrollment = None; status_publisher.publish_environment_id(/*environment_id*/ None); - clear_current_enrollment(current_enrollment); +} + +async fn clear_remote_control_enrollment_if_matches( + state_db: Option<&StateRuntime>, + remote_control_target: &RemoteControlTarget, + account_id: &str, + app_server_client_name: Option<&str>, + current_enrollment: &CurrentRemoteControlEnrollment, + enrollment: &RemoteControlEnrollment, + status_publisher: &RemoteControlStatusPublisher, +) { + let Some(state_db) = state_db else { + return; + }; + let mut current_enrollment = current_enrollment.lock().await; + if !current_enrollment + .as_ref() + .is_some_and(|current| same_remote_control_enrollment(current, enrollment)) + { + return; + } + clear_remote_control_enrollment( + state_db, + remote_control_target, + account_id, + app_server_client_name, + &mut current_enrollment, + status_publisher, + ) + .await; +} + +async fn clear_remote_control_server_token_if_matches( + current_enrollment: &CurrentRemoteControlEnrollment, + enrollment: &RemoteControlEnrollment, +) -> io::Result<()> { + let mut current_enrollment = current_enrollment.lock().await; + current_enrollment + .as_mut() + .filter(|current| same_remote_control_enrollment(current, enrollment)) + .ok_or_else(|| { + io::Error::other("missing remote control enrollment after websocket auth failure") + })? + .clear_server_token(); + Ok(()) } async fn enroll_remote_control_server_if_missing( @@ -1585,8 +1647,10 @@ mod tests { } } - fn test_current_enrollment() -> CurrentRemoteControlEnrollment { - Arc::new(std::sync::Mutex::new(None)) + fn test_current_enrollment( + enrollment: Option, + ) -> CurrentRemoteControlEnrollment { + Arc::new(RemoteControlEnrollmentState::new(enrollment)) } #[test] @@ -1739,10 +1803,9 @@ mod tests { let auth_manager = remote_control_auth_manager(); let mut auth_recovery = auth_manager.unauthorized_recovery(); let mut auth_change_rx = auth_manager.auth_change_receiver(); - let mut enrollment = Some(remote_control_enrollment(Some( + let current_enrollment = test_current_enrollment(Some(remote_control_enrollment(Some( TEST_REMOTE_CONTROL_SERVER_TOKEN, - ))); - let current_enrollment = test_current_enrollment(); + )))); let (status_publisher, status_rx) = remote_control_status_channel(); let err = match connect_remote_control_websocket( @@ -1753,7 +1816,7 @@ mod tests { auth_recovery: &mut auth_recovery, auth_change_rx: &mut auth_change_rx, }, - &mut enrollment, + ¤t_enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, server_name: "test-server", @@ -1761,7 +1824,6 @@ mod tests { app_server_client_name: None, }, &status_publisher, - ¤t_enrollment, ) .await { @@ -1771,12 +1833,7 @@ mod tests { server_task.await.expect("server task should succeed"); assert_eq!(err.to_string(), expected_error); - assert!( - current_enrollment - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .is_some() - ); + assert!(current_enrollment.lock().await.is_some()); assert_eq!( status_rx.borrow().clone(), RemoteControlStatusChangedNotification { @@ -1801,10 +1858,9 @@ mod tests { let auth_manager = remote_control_auth_manager(); let mut auth_recovery = auth_manager.unauthorized_recovery(); let mut auth_change_rx = auth_manager.auth_change_receiver(); - let mut enrollment = Some(remote_control_enrollment(Some( + let current_enrollment = test_current_enrollment(Some(remote_control_enrollment(Some( TEST_REMOTE_CONTROL_SERVER_TOKEN, - ))); - let current_enrollment = test_current_enrollment(); + )))); let (status_publisher, status_rx) = remote_control_status_channel(); let server_task = tokio::spawn(async move { @@ -1824,7 +1880,7 @@ mod tests { auth_recovery: &mut auth_recovery, auth_change_rx: &mut auth_change_rx, }, - &mut enrollment, + ¤t_enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, server_name: "test-server", @@ -1832,7 +1888,6 @@ mod tests { app_server_client_name: None, }, &status_publisher, - ¤t_enrollment, ) .await .expect_err("unauthorized response should fail the websocket connect"); @@ -1853,13 +1908,7 @@ mod tests { ); let mut expected_enrollment = remote_control_enrollment(/*remote_control_token*/ None); expected_enrollment.remote_control_target = remote_control_target; - assert_eq!(enrollment, Some(expected_enrollment)); - assert!( - current_enrollment - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .is_none() - ); + assert_eq!(*current_enrollment.lock().await, Some(expected_enrollment)); } #[tokio::test] @@ -1896,7 +1945,7 @@ mod tests { .await; let mut auth_recovery = auth_manager.unauthorized_recovery(); let mut auth_change_rx = auth_manager.auth_change_receiver(); - let mut enrollment = None; + let current_enrollment = test_current_enrollment(/*enrollment*/ None); let (status_publisher, status_rx) = remote_control_status_channel(); save_auth( codex_home.path(), @@ -1913,7 +1962,7 @@ mod tests { auth_recovery: &mut auth_recovery, auth_change_rx: &mut auth_change_rx, }, - &mut enrollment, + ¤t_enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, server_name: "test-server", @@ -1921,7 +1970,6 @@ mod tests { app_server_client_name: None, }, &status_publisher, - &test_current_enrollment(), ) .await .expect_err("unauthorized enrollment should fail the websocket connect"); @@ -1989,9 +2037,9 @@ mod tests { .await; let mut auth_recovery = auth_manager.unauthorized_recovery(); let mut auth_change_rx = auth_manager.auth_change_receiver(); - let mut enrollment = Some(remote_control_enrollment( + let current_enrollment = test_current_enrollment(Some(remote_control_enrollment( /*remote_control_token*/ None, - )); + ))); let (status_publisher, status_rx) = remote_control_status_channel(); save_auth( codex_home.path(), @@ -2008,7 +2056,7 @@ mod tests { auth_recovery: &mut auth_recovery, auth_change_rx: &mut auth_change_rx, }, - &mut enrollment, + ¤t_enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, server_name: "test-server", @@ -2016,7 +2064,6 @@ mod tests { app_server_client_name: None, }, &status_publisher, - &test_current_enrollment(), ) .await .expect_err("unauthorized refresh should fail the websocket connect"); @@ -2061,9 +2108,9 @@ mod tests { let auth_manager = remote_control_auth_manager(); let mut auth_recovery = auth_manager.unauthorized_recovery(); let mut auth_change_rx = auth_manager.auth_change_receiver(); - let mut enrollment = Some(remote_control_enrollment(Some( + let current_enrollment = test_current_enrollment(Some(remote_control_enrollment(Some( TEST_REMOTE_CONTROL_SERVER_TOKEN, - ))); + )))); let (status_publisher, _status_rx) = remote_control_status_channel(); let err = connect_remote_control_websocket( @@ -2074,7 +2121,7 @@ mod tests { auth_recovery: &mut auth_recovery, auth_change_rx: &mut auth_change_rx, }, - &mut enrollment, + ¤t_enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, server_name: "test-server", @@ -2082,14 +2129,13 @@ mod tests { app_server_client_name: None, }, &status_publisher, - &test_current_enrollment(), ) .await .expect_err("missing sqlite state db should fail remote control"); assert_eq!(err.kind(), ErrorKind::NotFound); assert_eq!(err.to_string(), "remote control requires sqlite state db"); - assert_eq!(enrollment, None); + assert_eq!(*current_enrollment.lock().await, None); } #[tokio::test] @@ -2107,9 +2153,9 @@ mod tests { .await; let mut auth_recovery = auth_manager.unauthorized_recovery(); let mut auth_change_rx = auth_manager.auth_change_receiver(); - let mut enrollment = Some(remote_control_enrollment(Some( + let current_enrollment = test_current_enrollment(Some(remote_control_enrollment(Some( TEST_REMOTE_CONTROL_SERVER_TOKEN, - ))); + )))); let (status_publisher, mut status_rx) = remote_control_status_channel(); status_publisher.publish_environment_id(Some("env_test".to_string())); status_rx @@ -2125,7 +2171,7 @@ mod tests { auth_recovery: &mut auth_recovery, auth_change_rx: &mut auth_change_rx, }, - &mut enrollment, + ¤t_enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, server_name: "test-server", @@ -2133,7 +2179,6 @@ mod tests { app_server_client_name: None, }, &status_publisher, - &test_current_enrollment(), ) .await .expect_err("missing auth should fail remote control"); @@ -2143,7 +2188,7 @@ mod tests { err.to_string(), "remote control requires ChatGPT authentication" ); - assert_eq!(enrollment, None); + assert_eq!(*current_enrollment.lock().await, None); assert_eq!( status_rx.borrow().clone(), RemoteControlStatusChangedNotification { @@ -2185,7 +2230,8 @@ mod tests { RemoteControlChannels { transport_event_tx, status_publisher, - current_enrollment: test_current_enrollment(), + current_enrollment: test_current_enrollment(/*enrollment*/ None), + pairing_persistence_key: watch::channel(None).0, }, shutdown_token, enabled_rx, diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index d89f0b0645..6c578ce8e5 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -918,7 +918,7 @@ impl MessageProcessor { .map(|response| Some(response.into())), ClientRequest::RemoteControlPairingStart { params, .. } => self .remote_control_processor - .pairing_start(params) + .pairing_start(params, app_server_client_name.as_deref()) .await .map(|response| Some(response.into())), ClientRequest::RemoteControlClientsList { params, .. } => self diff --git a/codex-rs/app-server/src/request_processors/remote_control_processor.rs b/codex-rs/app-server/src/request_processors/remote_control_processor.rs index 804339c17a..22223687bc 100644 --- a/codex-rs/app-server/src/request_processors/remote_control_processor.rs +++ b/codex-rs/app-server/src/request_processors/remote_control_processor.rs @@ -52,9 +52,10 @@ impl RemoteControlRequestProcessor { pub(crate) async fn pairing_start( &self, params: RemoteControlPairingStartParams, + app_server_client_name: Option<&str>, ) -> Result { self.handle()? - .start_pairing(params) + .start_pairing(params, app_server_client_name) .await .map_err(map_pairing_start_error) } diff --git a/codex-rs/app-server/src/request_processors/remote_control_processor/remote_control_processor_tests.rs b/codex-rs/app-server/src/request_processors/remote_control_processor/remote_control_processor_tests.rs index 4381e48959..e336491cd3 100644 --- a/codex-rs/app-server/src/request_processors/remote_control_processor/remote_control_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/remote_control_processor/remote_control_processor_tests.rs @@ -6,7 +6,10 @@ use pretty_assertions::assert_eq; #[tokio::test] async fn pairing_start_returns_internal_error_when_remote_control_is_unavailable() { let err = RemoteControlRequestProcessor::new(/*remote_control_handle*/ None) - .pairing_start(RemoteControlPairingStartParams::default()) + .pairing_start( + RemoteControlPairingStartParams::default(), + /*app_server_client_name*/ None, + ) .await .expect_err("missing remote control should fail pairing"); diff --git a/codex-rs/app-server/tests/suite/v2/remote_control.rs b/codex-rs/app-server/tests/suite/v2/remote_control.rs index b5384d3bad..c48ffda0f6 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_control.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_control.rs @@ -193,6 +193,42 @@ async fn remote_control_pairing_start_returns_pairing_artifacts() -> Result<()> Ok(()) } +#[tokio::test] +async fn remote_control_pairing_start_returns_pairing_artifacts_while_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let mut backend = PairingRemoteControlBackend::start(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_remote_control_pairing_start_request(RemoteControlPairingStartParams { + manual_code: true, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!( + timeout(DEFAULT_TIMEOUT, backend.wait_for_enroll_request()).await??, + "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1" + ); + assert_eq!(response.result.get("serverId"), None); + let received: RemoteControlPairingStartResponse = to_response(response)?; + + assert_eq!( + received, + RemoteControlPairingStartResponse { + pairing_code: "pairing-code".to_string(), + manual_pairing_code: Some("ABCD-EFGH".to_string()), + environment_id: "environment-id".to_string(), + expires_at: 33_336_362_096, + } + ); + Ok(()) +} + #[tokio::test] async fn remote_control_client_management_works_while_disabled() -> Result<()> { let codex_home = TempDir::new()?; @@ -376,8 +412,12 @@ impl PairingRemoteControlBackend { ) .await?; - let _websocket_request = read_http_request(&listener).await?; - let pair_http_request = read_http_request(&listener).await?; + let request_after_enroll = read_http_request(&listener).await?; + let pair_http_request = if request_after_enroll.request_line.starts_with("GET ") { + read_http_request(&listener).await? + } else { + request_after_enroll + }; respond_with_json( pair_http_request.reader.into_inner(), serde_json::json!({ From 6a6a5f925e97bee23d5d31938b5f9f9f38becb8a Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 4 Jun 2026 22:36:25 -0700 Subject: [PATCH 37/41] [codex] Add environment shell info (#26480) ## Why Shell detection needs to be available through the `Environment` abstraction so callers can ask the selected local or remote environment for shell metadata without adding a separate HTTP endpoint or parallel info-source path. This keeps shell metadata shaped like the existing environment-owned filesystem capability and lets remote environments answer through exec-server JSON-RPC. ## What changed - Added `environment/info` to the exec-server protocol/client/server and exposed `Environment::info()`. - Added local and remote environment info providers on `Environment`, following the existing capability-provider pattern used for filesystem access. - Moved the shared shell detection logic into `codex-shell-command` and kept core shell APIs as wrappers around that implementation. - Returned shell metadata as `EnvironmentInfo { shell: ShellInfo }` using the existing shell detection path. - Added a remote environment test that calls `Environment::info()` through an exec-server-backed environment. ## Validation - `git diff --check` - `just test -p codex-shell-command` - `just test -p codex-core -E 'test(/shell::tests::/)'`\n- `just test -p codex-exec-server environment` --- codex-rs/Cargo.lock | 2 + codex-rs/core/src/lib.rs | 1 - codex-rs/core/src/shell.rs | 328 +---------------- codex-rs/core/src/shell_detect.rs | 24 -- codex-rs/core/src/shell_snapshot.rs | 8 +- .../src/tools/handlers/shell/shell_command.rs | 2 +- .../core/src/tools/handlers/unified_exec.rs | 2 +- .../core/src/unified_exec/process_manager.rs | 2 +- codex-rs/exec-server/Cargo.toml | 1 + codex-rs/exec-server/src/client.rs | 12 + codex-rs/exec-server/src/environment.rs | 62 ++++ codex-rs/exec-server/src/lib.rs | 2 + codex-rs/exec-server/src/protocol.rs | 18 + codex-rs/exec-server/src/server/handler.rs | 6 + codex-rs/exec-server/src/server/registry.rs | 5 + codex-rs/exec-server/tests/health.rs | 15 + codex-rs/shell-command/Cargo.toml | 1 + codex-rs/shell-command/src/bash.rs | 2 +- codex-rs/shell-command/src/lib.rs | 2 +- codex-rs/shell-command/src/powershell.rs | 2 +- codex-rs/shell-command/src/shell_detect.rs | 346 +++++++++++++++++- 21 files changed, 488 insertions(+), 355 deletions(-) delete mode 100644 codex-rs/core/src/shell_detect.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 6da7688435..b899222384 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2789,6 +2789,7 @@ dependencies = [ "codex-file-system", "codex-protocol", "codex-sandboxing", + "codex-shell-command", "codex-test-binary-support", "codex-utils-absolute-path", "codex-utils-pty", @@ -3692,6 +3693,7 @@ dependencies = [ "base64 0.22.1", "codex-protocol", "codex-utils-absolute-path", + "libc", "once_cell", "pretty_assertions", "regex", diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index b77f96cdb1..4dc19de7e6 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -83,7 +83,6 @@ mod sandbox_tags; pub mod sandboxing; mod session_prefix; mod session_startup_prewarm; -mod shell_detect; pub mod skills; pub(crate) use skills::SkillInjections; pub(crate) use skills::SkillLoadOutcome; diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs index 48c760d667..9843d4cea5 100644 --- a/codex-rs/core/src/shell.rs +++ b/codex-rs/core/src/shell.rs @@ -1,19 +1,12 @@ -use crate::shell_detect::detect_shell_type; use crate::shell_snapshot::ShellSnapshot; +use codex_shell_command::shell_detect::DetectedShell; use serde::Deserialize; use serde::Serialize; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::watch; -#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] -pub enum ShellType { - Zsh, - Bash, - PowerShell, - Sh, - Cmd, -} +pub use codex_shell_command::shell_detect::ShellType; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Shell { @@ -29,13 +22,7 @@ pub struct Shell { impl Shell { pub fn name(&self) -> &'static str { - match self.shell_type { - ShellType::Zsh => "zsh", - ShellType::Bash => "bash", - ShellType::PowerShell => "powershell", - ShellType::Sh => "sh", - ShellType::Cmd => "cmd", - } + self.shell_type.name() } /// Takes a string of shell and returns the full list of command args to @@ -88,319 +75,36 @@ impl PartialEq for Shell { impl Eq for Shell {} -#[cfg(unix)] -fn get_user_shell_path() -> Option { - let uid = unsafe { libc::getuid() }; - use std::ffi::CStr; - use std::mem::MaybeUninit; - use std::ptr; - - let mut passwd = MaybeUninit::::uninit(); - - // We cannot use getpwuid here: it returns pointers into libc-managed - // storage, which is not safe to read concurrently on all targets (the musl - // static build used by the CLI can segfault when parallel callers race on - // that buffer). getpwuid_r keeps the passwd data in caller-owned memory. - let suggested_buffer_len = unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) }; - let buffer_len = usize::try_from(suggested_buffer_len) - .ok() - .filter(|len| *len > 0) - .unwrap_or(1024); - let mut buffer = vec![0; buffer_len]; - - loop { - let mut result = ptr::null_mut(); - let status = unsafe { - libc::getpwuid_r( - uid, - passwd.as_mut_ptr(), - buffer.as_mut_ptr().cast(), - buffer.len(), - &mut result, - ) - }; - - if status == 0 { - if result.is_null() { - return None; - } - - let passwd = unsafe { passwd.assume_init_ref() }; - if passwd.pw_shell.is_null() { - return None; - } - - let shell_path = unsafe { CStr::from_ptr(passwd.pw_shell) } - .to_string_lossy() - .into_owned(); - return Some(PathBuf::from(shell_path)); - } - - if status != libc::ERANGE { - return None; - } - - // Retry with a larger buffer until libc can materialize the passwd entry. - let new_len = buffer.len().checked_mul(2)?; - if new_len > 1024 * 1024 { - return None; - } - buffer.resize(new_len, 0); - } -} - -#[cfg(not(unix))] -fn get_user_shell_path() -> Option { - None -} - -fn file_exists(path: &PathBuf) -> Option { - if std::fs::metadata(path).is_ok_and(|metadata| metadata.is_file()) { - Some(PathBuf::from(path)) - } else { - None - } -} - -fn get_shell_path( - shell_type: ShellType, - provided_path: Option<&PathBuf>, - binary_name: &str, - fallback_paths: &[&str], -) -> Option { - // If exact provided path exists, use it - if provided_path.and_then(file_exists).is_some() { - return provided_path.cloned(); - } - - // Check if the shell we are trying to load is user's default shell - // if just use it - let default_shell_path = get_user_shell_path(); - if let Some(default_shell_path) = default_shell_path - && detect_shell_type(&default_shell_path) == Some(shell_type) - && file_exists(&default_shell_path).is_some() - { - return Some(default_shell_path); - } - - if let Ok(path) = which::which(binary_name) { - return Some(path); - } - - for path in fallback_paths { - //check exists - if let Some(path) = file_exists(&PathBuf::from(path)) { - return Some(path); +impl From for Shell { + fn from(detected: DetectedShell) -> Self { + Self { + shell_type: detected.shell_type, + shell_path: detected.shell_path, + shell_snapshot: empty_shell_snapshot_receiver(), } } - - None -} - -const ZSH_FALLBACK_PATHS: &[&str] = &["/bin/zsh"]; - -fn get_zsh_shell(path: Option<&PathBuf>) -> Option { - let shell_path = get_shell_path(ShellType::Zsh, path, "zsh", ZSH_FALLBACK_PATHS); - - shell_path.map(|shell_path| Shell { - shell_type: ShellType::Zsh, - shell_path, - shell_snapshot: empty_shell_snapshot_receiver(), - }) -} - -const BASH_FALLBACK_PATHS: &[&str] = &["/bin/bash"]; - -fn get_bash_shell(path: Option<&PathBuf>) -> Option { - let shell_path = get_shell_path(ShellType::Bash, path, "bash", BASH_FALLBACK_PATHS); - - shell_path.map(|shell_path| Shell { - shell_type: ShellType::Bash, - shell_path, - shell_snapshot: empty_shell_snapshot_receiver(), - }) -} - -const SH_FALLBACK_PATHS: &[&str] = &["/bin/sh"]; - -fn get_sh_shell(path: Option<&PathBuf>) -> Option { - let shell_path = get_shell_path(ShellType::Sh, path, "sh", SH_FALLBACK_PATHS); - - shell_path.map(|shell_path| Shell { - shell_type: ShellType::Sh, - shell_path, - shell_snapshot: empty_shell_snapshot_receiver(), - }) -} - -// Note the `pwsh` and `powershell` fallback paths are where the respective -// shells are commonly installed on GitHub Actions Windows runners, but may not -// be present on all Windows machines: -// https://docs.github.com/en/actions/tutorials/build-and-test-code/powershell - -#[cfg(windows)] -const PWSH_FALLBACK_PATHS: &[&str] = &[r#"C:\Program Files\PowerShell\7\pwsh.exe"#]; -#[cfg(not(windows))] -const PWSH_FALLBACK_PATHS: &[&str] = &["/usr/local/bin/pwsh"]; - -#[cfg(windows)] -const POWERSHELL_FALLBACK_PATHS: &[&str] = - &[r#"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"#]; -#[cfg(not(windows))] -const POWERSHELL_FALLBACK_PATHS: &[&str] = &[]; - -fn get_powershell_shell(path: Option<&PathBuf>) -> Option { - let shell_path = get_shell_path(ShellType::PowerShell, path, "pwsh", PWSH_FALLBACK_PATHS) - .or_else(|| { - get_shell_path( - ShellType::PowerShell, - path, - "powershell", - POWERSHELL_FALLBACK_PATHS, - ) - }); - - shell_path.map(|shell_path| Shell { - shell_type: ShellType::PowerShell, - shell_path, - shell_snapshot: empty_shell_snapshot_receiver(), - }) -} - -fn get_cmd_shell(path: Option<&PathBuf>) -> Option { - let shell_path = get_shell_path(ShellType::Cmd, path, "cmd", &[]); - - shell_path.map(|shell_path| Shell { - shell_type: ShellType::Cmd, - shell_path, - shell_snapshot: empty_shell_snapshot_receiver(), - }) } +#[cfg(all(test, unix))] fn ultimate_fallback_shell() -> Shell { - if cfg!(windows) { - Shell { - shell_type: ShellType::Cmd, - shell_path: PathBuf::from("cmd.exe"), - shell_snapshot: empty_shell_snapshot_receiver(), - } - } else { - Shell { - shell_type: ShellType::Sh, - shell_path: PathBuf::from("/bin/sh"), - shell_snapshot: empty_shell_snapshot_receiver(), - } - } + codex_shell_command::shell_detect::ultimate_fallback_shell().into() } pub fn get_shell_by_model_provided_path(shell_path: &PathBuf) -> Shell { - detect_shell_type(shell_path) - .and_then(|shell_type| get_shell(shell_type, Some(shell_path))) - .unwrap_or(ultimate_fallback_shell()) + codex_shell_command::shell_detect::get_shell_by_model_provided_path(shell_path).into() } pub fn get_shell(shell_type: ShellType, path: Option<&PathBuf>) -> Option { - match shell_type { - ShellType::Zsh => get_zsh_shell(path), - ShellType::Bash => get_bash_shell(path), - ShellType::PowerShell => get_powershell_shell(path), - ShellType::Sh => get_sh_shell(path), - ShellType::Cmd => get_cmd_shell(path), - } + codex_shell_command::shell_detect::get_shell(shell_type, path).map(Into::into) } pub fn default_user_shell() -> Shell { - default_user_shell_from_path(get_user_shell_path()) + codex_shell_command::shell_detect::default_user_shell().into() } +#[cfg(all(test, target_os = "macos"))] fn default_user_shell_from_path(user_shell_path: Option) -> Shell { - if cfg!(windows) { - get_shell(ShellType::PowerShell, /*path*/ None).unwrap_or(ultimate_fallback_shell()) - } else { - let user_default_shell = user_shell_path - .and_then(|shell| detect_shell_type(&shell)) - .and_then(|shell_type| get_shell(shell_type, /*path*/ None)); - - let shell_with_fallback = if cfg!(target_os = "macos") { - user_default_shell - .or_else(|| get_shell(ShellType::Zsh, /*path*/ None)) - .or_else(|| get_shell(ShellType::Bash, /*path*/ None)) - } else { - user_default_shell - .or_else(|| get_shell(ShellType::Bash, /*path*/ None)) - .or_else(|| get_shell(ShellType::Zsh, /*path*/ None)) - }; - - shell_with_fallback.unwrap_or(ultimate_fallback_shell()) - } -} - -#[cfg(test)] -mod detect_shell_type_tests { - use super::*; - - #[test] - fn test_detect_shell_type() { - assert_eq!( - detect_shell_type(&PathBuf::from("zsh")), - Some(ShellType::Zsh) - ); - assert_eq!( - detect_shell_type(&PathBuf::from("bash")), - Some(ShellType::Bash) - ); - assert_eq!( - detect_shell_type(&PathBuf::from("pwsh")), - Some(ShellType::PowerShell) - ); - assert_eq!( - detect_shell_type(&PathBuf::from("powershell")), - Some(ShellType::PowerShell) - ); - assert_eq!(detect_shell_type(&PathBuf::from("fish")), None); - assert_eq!(detect_shell_type(&PathBuf::from("other")), None); - assert_eq!( - detect_shell_type(&PathBuf::from("/bin/zsh")), - Some(ShellType::Zsh) - ); - assert_eq!( - detect_shell_type(&PathBuf::from("/bin/bash")), - Some(ShellType::Bash) - ); - assert_eq!( - detect_shell_type(&PathBuf::from("powershell.exe")), - Some(ShellType::PowerShell) - ); - assert_eq!( - detect_shell_type(&PathBuf::from(if cfg!(windows) { - "C:\\windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" - } else { - "/usr/local/bin/pwsh" - })), - Some(ShellType::PowerShell) - ); - assert_eq!( - detect_shell_type(&PathBuf::from("pwsh.exe")), - Some(ShellType::PowerShell) - ); - assert_eq!( - detect_shell_type(&PathBuf::from("/usr/local/bin/pwsh")), - Some(ShellType::PowerShell) - ); - assert_eq!( - detect_shell_type(&PathBuf::from("/bin/sh")), - Some(ShellType::Sh) - ); - assert_eq!(detect_shell_type(&PathBuf::from("sh")), Some(ShellType::Sh)); - assert_eq!( - detect_shell_type(&PathBuf::from("cmd")), - Some(ShellType::Cmd) - ); - assert_eq!( - detect_shell_type(&PathBuf::from("cmd.exe")), - Some(ShellType::Cmd) - ); - } + codex_shell_command::shell_detect::default_user_shell_from_path(user_shell_path).into() } #[cfg(test)] diff --git a/codex-rs/core/src/shell_detect.rs b/codex-rs/core/src/shell_detect.rs deleted file mode 100644 index 3595ab3469..0000000000 --- a/codex-rs/core/src/shell_detect.rs +++ /dev/null @@ -1,24 +0,0 @@ -use crate::shell::ShellType; -use std::path::Path; -use std::path::PathBuf; - -pub(crate) fn detect_shell_type(shell_path: &PathBuf) -> Option { - match shell_path.as_os_str().to_str() { - Some("zsh") => Some(ShellType::Zsh), - Some("sh") => Some(ShellType::Sh), - Some("cmd") => Some(ShellType::Cmd), - Some("bash") => Some(ShellType::Bash), - Some("pwsh") => Some(ShellType::PowerShell), - Some("powershell") => Some(ShellType::PowerShell), - _ => { - let shell_name = shell_path.file_stem(); - if let Some(shell_name) = shell_name { - let shell_name_path = Path::new(shell_name); - if shell_name_path != Path::new(shell_path) { - return detect_shell_type(&shell_name_path.to_path_buf()); - } - } - None - } - } -} diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index b328a977d7..1cbd538667 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -151,9 +151,7 @@ impl ShellSnapshot { }); // Make the new snapshot. - if let Err(err) = - write_shell_snapshot(shell.shell_type.clone(), &temp_path, session_cwd).await - { + if let Err(err) = write_shell_snapshot(shell.shell_type, &temp_path, session_cwd).await { tracing::warn!( "Failed to create shell snapshot for {}: {err:?}", shell.name() @@ -203,7 +201,7 @@ async fn write_shell_snapshot( if shell_type == ShellType::PowerShell || shell_type == ShellType::Cmd { bail!("Shell snapshot not supported yet for {shell_type:?}"); } - let shell = get_shell(shell_type.clone(), /*path*/ None) + let shell = get_shell(shell_type, /*path*/ None) .with_context(|| format!("No available shell for {shell_type:?}"))?; let raw_snapshot = capture_snapshot(&shell, cwd).await?; @@ -225,7 +223,7 @@ async fn write_shell_snapshot( } async fn capture_snapshot(shell: &Shell, cwd: &AbsolutePathBuf) -> Result { - let shell_type = shell.shell_type.clone(); + let shell_type = shell.shell_type; match shell_type { ShellType::Zsh => run_shell_script(shell, &zsh_snapshot_script(), cwd).await, ShellType::Bash => run_shell_script(shell, &bash_snapshot_script(), cwd).await, diff --git a/codex-rs/core/src/tools/handlers/shell/shell_command.rs b/codex-rs/core/src/tools/handlers/shell/shell_command.rs index b4b60997d6..7c492b3646 100644 --- a/codex-rs/core/src/tools/handlers/shell/shell_command.rs +++ b/codex-rs/core/src/tools/handlers/shell/shell_command.rs @@ -182,7 +182,7 @@ impl ToolExecutor for ShellCommandHandler { session.thread_id, turn.config.permissions.allow_login_shell, )?; - let shell_type = Some(session.user_shell().shell_type.clone()); + let shell_type = Some(session.user_shell().shell_type); run_exec_like(RunExecLikeArgs { tool_name, exec_params, diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 6fa2e40685..ca6b2a4b3e 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -122,7 +122,7 @@ pub(crate) fn get_command( let shell = model_shell.as_ref().unwrap_or(session_shell.as_ref()); Ok(ResolvedCommand { command: shell.derive_exec_args(&args.cmd, use_login_shell), - shell_type: shell.shell_type.clone(), + shell_type: shell.shell_type, }) } UnifiedExecShellMode::ZshFork(zsh_fork_config) => { diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 2c86deafaf..a4493cf114 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -1032,7 +1032,7 @@ impl UnifiedExecProcessManager { .await; let req = UnifiedExecToolRequest { command: request.command.clone(), - shell_type: request.shell_type.clone(), + shell_type: request.shell_type, hook_command: request.hook_command.clone(), process_id: request.process_id, cwd, diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index d842094a16..eadab83fe7 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -22,6 +22,7 @@ codex-client = { workspace = true } codex-file-system = { workspace = true } codex-protocol = { workspace = true } codex-sandboxing = { workspace = true } +codex-shell-command = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-pty = { workspace = true } codex-utils-rustls-provider = { workspace = true } diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index b6c56e9f5e..8cf18e7d1f 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -29,6 +29,7 @@ use crate::connection::JsonRpcConnection; use crate::process::ExecProcessEvent; use crate::process::ExecProcessEventLog; use crate::process::ExecProcessEventReceiver; +use crate::protocol::ENVIRONMENT_INFO_METHOD; use crate::protocol::EXEC_CLOSED_METHOD; use crate::protocol::EXEC_EXITED_METHOD; use crate::protocol::EXEC_METHOD; @@ -36,6 +37,7 @@ use crate::protocol::EXEC_OUTPUT_DELTA_METHOD; use crate::protocol::EXEC_READ_METHOD; use crate::protocol::EXEC_TERMINATE_METHOD; use crate::protocol::EXEC_WRITE_METHOD; +use crate::protocol::EnvironmentInfo; use crate::protocol::ExecClosedNotification; use crate::protocol::ExecExitedNotification; use crate::protocol::ExecOutputDeltaNotification; @@ -279,6 +281,12 @@ impl HttpClient for LazyRemoteExecServerClient { } } +impl LazyRemoteExecServerClient { + pub(crate) async fn environment_info(&self) -> Result { + self.get().await?.environment_info().await + } +} + #[derive(Debug, thiserror::Error)] pub enum ExecServerError { #[error("failed to spawn exec-server: {0}")] @@ -363,6 +371,10 @@ impl ExecServerClient { self.call(EXEC_METHOD, ¶ms).await } + pub async fn environment_info(&self) -> Result { + self.call(ENVIRONMENT_INFO_METHOD, &()).await + } + pub async fn read(&self, params: ReadParams) -> Result { self.call(EXEC_READ_METHOD, ¶ms).await } diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 55dc031273..1dfd49d3fd 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -2,6 +2,9 @@ use std::collections::HashMap; use std::sync::Arc; use std::sync::RwLock; +use futures::FutureExt; +use futures::future::BoxFuture; + use crate::ExecServerError; use crate::ExecServerRuntimePaths; use crate::ExecutorFileSystem; @@ -18,8 +21,11 @@ use crate::environment_toml::environment_provider_from_codex_home; use crate::local_file_system::LocalFileSystem; use crate::local_process::LocalProcess; use crate::process::ExecBackend; +use crate::protocol::EnvironmentInfo; +use crate::protocol::ShellInfo; use crate::remote_file_system::RemoteFileSystem; use crate::remote_process::RemoteProcess; +use codex_shell_command::shell_detect::DetectedShell; pub const CODEX_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_EXEC_SERVER_URL"; @@ -286,18 +292,49 @@ impl EnvironmentManager { pub struct Environment { exec_server_url: Option, remote_transport: Option, + info_provider: Arc, exec_backend: Arc, filesystem: Arc, http_client: Arc, local_runtime_paths: Option, } +/// Provides environment metadata from either a local environment or a remote exec-server. +trait EnvironmentInfoProvider: Send + Sync { + fn info(&self) -> BoxFuture<'_, Result>; +} + +struct LocalEnvironmentInfoProvider; + +impl EnvironmentInfoProvider for LocalEnvironmentInfoProvider { + fn info(&self) -> BoxFuture<'_, Result> { + std::future::ready(Ok(EnvironmentInfo::local())).boxed() + } +} + +struct RemoteEnvironmentInfoProvider { + client: LazyRemoteExecServerClient, +} + +impl RemoteEnvironmentInfoProvider { + fn new(client: LazyRemoteExecServerClient) -> Self { + Self { client } + } +} + +impl EnvironmentInfoProvider for RemoteEnvironmentInfoProvider { + fn info(&self) -> BoxFuture<'_, Result> { + async move { self.client.environment_info().await }.boxed() + } +} + impl Environment { /// Builds a test-only local environment without configured sandbox helper paths. pub fn default_for_tests() -> Self { Self { exec_server_url: None, remote_transport: None, + info_provider: Arc::new(LocalEnvironmentInfoProvider), exec_backend: Arc::new(LocalProcess::default()), filesystem: Arc::new(LocalFileSystem::unsandboxed()), http_client: Arc::new(ReqwestHttpClient), @@ -354,6 +391,7 @@ impl Environment { Self { exec_server_url: None, remote_transport: None, + info_provider: Arc::new(LocalEnvironmentInfoProvider), exec_backend: Arc::new(LocalProcess::default()), filesystem: Arc::new(LocalFileSystem::with_runtime_paths( local_runtime_paths.clone(), @@ -392,6 +430,7 @@ impl Environment { Self { exec_server_url, remote_transport: Some(remote_transport), + info_provider: Arc::new(RemoteEnvironmentInfoProvider::new(client.clone())), exec_backend, filesystem, http_client: Arc::new(client), @@ -412,6 +451,11 @@ impl Environment { self.local_runtime_paths.as_ref() } + /// Returns environment information from the selected execution/filesystem environment. + pub async fn info(&self) -> Result { + self.info_provider.info().await + } + pub fn get_exec_backend(&self) -> Arc { Arc::clone(&self.exec_backend) } @@ -425,6 +469,23 @@ impl Environment { } } +impl EnvironmentInfo { + pub(crate) fn local() -> Self { + Self { + shell: codex_shell_command::shell_detect::default_user_shell().into(), + } + } +} + +impl From for ShellInfo { + fn from(shell: DetectedShell) -> Self { + Self { + name: shell.name().to_string(), + path: shell.shell_path.to_string_lossy().into_owned(), + } + } +} + #[cfg(test)] mod tests { use std::sync::Arc; @@ -458,6 +519,7 @@ mod tests { assert_eq!(environment.exec_server_url(), None); assert!(!environment.is_remote()); + assert!(environment.info().await.is_ok()); } #[tokio::test] diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index dea039c288..e306ec395e 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -57,6 +57,7 @@ pub use process::ExecProcessEvent; pub use process::ExecProcessEventReceiver; pub use process::StartedExecProcess; pub use process_id::ProcessId; +pub use protocol::EnvironmentInfo; pub use protocol::ExecClosedNotification; pub use protocol::ExecEnvPolicy; pub use protocol::ExecExitedNotification; @@ -94,6 +95,7 @@ pub use protocol::InitializeResponse; pub use protocol::ProcessOutputChunk; pub use protocol::ReadParams; pub use protocol::ReadResponse; +pub use protocol::ShellInfo; pub use protocol::TerminateParams; pub use protocol::TerminateResponse; pub use protocol::WriteParams; diff --git a/codex-rs/exec-server/src/protocol.rs b/codex-rs/exec-server/src/protocol.rs index 9ac80c188e..6eeeeefdd7 100644 --- a/codex-rs/exec-server/src/protocol.rs +++ b/codex-rs/exec-server/src/protocol.rs @@ -19,6 +19,7 @@ pub const EXEC_TERMINATE_METHOD: &str = "process/terminate"; pub const EXEC_OUTPUT_DELTA_METHOD: &str = "process/output"; pub const EXEC_EXITED_METHOD: &str = "process/exited"; pub const EXEC_CLOSED_METHOD: &str = "process/closed"; +pub const ENVIRONMENT_INFO_METHOD: &str = "environment/info"; pub const FS_READ_FILE_METHOD: &str = "fs/readFile"; pub const FS_WRITE_FILE_METHOD: &str = "fs/writeFile"; pub const FS_CREATE_DIRECTORY_METHOD: &str = "fs/createDirectory"; @@ -64,6 +65,23 @@ pub struct InitializeResponse { pub session_id: String, } +/// Information about an execution/filesystem environment. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EnvironmentInfo { + pub shell: ShellInfo, +} + +/// Shell detected for an execution/filesystem environment. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ShellInfo { + /// Stable shell name, for example `zsh`, `bash`, `powershell`, `sh`, or `cmd`. + pub name: String, + /// Path the exec server would use for that shell. + pub path: String, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExecParams { diff --git a/codex-rs/exec-server/src/server/handler.rs b/codex-rs/exec-server/src/server/handler.rs index 5456ce41c0..b8934705d2 100644 --- a/codex-rs/exec-server/src/server/handler.rs +++ b/codex-rs/exec-server/src/server/handler.rs @@ -14,6 +14,7 @@ use tokio_util::task::TaskTracker; use crate::ExecServerRuntimePaths; use crate::client::http_client::PendingReqwestHttpBodyStream; use crate::client::http_client::ReqwestHttpRequestRunner; +use crate::protocol::EnvironmentInfo; use crate::protocol::ExecParams; use crate::protocol::ExecResponse; use crate::protocol::FsCanonicalizeParams; @@ -147,6 +148,11 @@ impl ExecServerHandler { session.process().exec(params).await } + pub(crate) fn environment_info(&self) -> Result { + self.require_initialized_for("environment info")?; + Ok(EnvironmentInfo::local()) + } + pub(crate) async fn exec_read( &self, params: ReadParams, diff --git a/codex-rs/exec-server/src/server/registry.rs b/codex-rs/exec-server/src/server/registry.rs index 74adf90580..26d2876c63 100644 --- a/codex-rs/exec-server/src/server/registry.rs +++ b/codex-rs/exec-server/src/server/registry.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use crate::protocol::ENVIRONMENT_INFO_METHOD; use crate::protocol::EXEC_METHOD; use crate::protocol::EXEC_READ_METHOD; use crate::protocol::EXEC_TERMINATE_METHOD; @@ -60,6 +61,10 @@ pub(crate) fn build_router() -> RpcRouter { EXEC_METHOD, |handler: Arc, params: ExecParams| async move { handler.exec(params).await }, ); + router.request( + ENVIRONMENT_INFO_METHOD, + |handler: Arc, _params: ()| async move { handler.environment_info() }, + ); router.request( EXEC_READ_METHOD, |handler: Arc, params: ReadParams| async move { diff --git a/codex-rs/exec-server/tests/health.rs b/codex-rs/exec-server/tests/health.rs index 91b3806a22..1fcfb049b5 100644 --- a/codex-rs/exec-server/tests/health.rs +++ b/codex-rs/exec-server/tests/health.rs @@ -2,6 +2,7 @@ mod common; +use codex_exec_server::Environment; use common::exec_server::exec_server; use pretty_assertions::assert_eq; @@ -19,3 +20,17 @@ async fn exec_server_serves_readyz_alongside_websocket_endpoint() -> anyhow::Res server.shutdown().await?; Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_environment_fetches_info_from_exec_server() -> anyhow::Result<()> { + let mut server = exec_server().await?; + let environment = Environment::create_for_tests(Some(server.websocket_url().to_string()))?; + assert!(environment.is_remote()); + + let remote_info = environment.info().await?; + let local_info = Environment::default_for_tests().info().await?; + assert_eq!(remote_info, local_info); + + server.shutdown().await?; + Ok(()) +} diff --git a/codex-rs/shell-command/Cargo.toml b/codex-rs/shell-command/Cargo.toml index cc33d3621c..e918488f82 100644 --- a/codex-rs/shell-command/Cargo.toml +++ b/codex-rs/shell-command/Cargo.toml @@ -11,6 +11,7 @@ workspace = true base64 = { workspace = true } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } +libc = { workspace = true } once_cell = { workspace = true } regex = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/codex-rs/shell-command/src/bash.rs b/codex-rs/shell-command/src/bash.rs index 60ee5c420c..b25d3fd37c 100644 --- a/codex-rs/shell-command/src/bash.rs +++ b/codex-rs/shell-command/src/bash.rs @@ -100,7 +100,7 @@ pub fn extract_bash_command(command: &[String]) -> Option<(&str, &str)> { }; if !matches!(flag.as_str(), "-lc" | "-c") || !matches!( - detect_shell_type(&PathBuf::from(shell)), + detect_shell_type(PathBuf::from(shell)), Some(ShellType::Zsh) | Some(ShellType::Bash) | Some(ShellType::Sh) ) { diff --git a/codex-rs/shell-command/src/lib.rs b/codex-rs/shell-command/src/lib.rs index 1d9e302a4e..947de5e645 100644 --- a/codex-rs/shell-command/src/lib.rs +++ b/codex-rs/shell-command/src/lib.rs @@ -1,6 +1,6 @@ //! Command parsing and safety utilities shared across Codex crates. -mod shell_detect; +pub mod shell_detect; pub mod bash; pub(crate) mod command_safety; diff --git a/codex-rs/shell-command/src/powershell.rs b/codex-rs/shell-command/src/powershell.rs index e8c9a500c4..9730439bea 100644 --- a/codex-rs/shell-command/src/powershell.rs +++ b/codex-rs/shell-command/src/powershell.rs @@ -47,7 +47,7 @@ pub fn extract_powershell_command(command: &[String]) -> Option<(&str, &str)> { let shell = &command[0]; if !matches!( - detect_shell_type(&PathBuf::from(shell)), + detect_shell_type(PathBuf::from(shell)), Some(ShellType::PowerShell) ) { return None; diff --git a/codex-rs/shell-command/src/shell_detect.rs b/codex-rs/shell-command/src/shell_detect.rs index 34322a06d0..7068463830 100644 --- a/codex-rs/shell-command/src/shell_detect.rs +++ b/codex-rs/shell-command/src/shell_detect.rs @@ -1,8 +1,10 @@ -use std::path::Path; use std::path::PathBuf; -#[derive(Debug, PartialEq, Eq, Clone, Copy)] -pub(crate) enum ShellType { +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)] +pub enum ShellType { Zsh, Bash, PowerShell, @@ -10,7 +12,32 @@ pub(crate) enum ShellType { Cmd, } -pub(crate) fn detect_shell_type(shell_path: &PathBuf) -> Option { +impl ShellType { + pub fn name(self) -> &'static str { + match self { + Self::Zsh => "zsh", + Self::Bash => "bash", + Self::PowerShell => "powershell", + Self::Sh => "sh", + Self::Cmd => "cmd", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DetectedShell { + pub shell_type: ShellType, + pub shell_path: PathBuf, +} + +impl DetectedShell { + pub fn name(&self) -> &'static str { + self.shell_type.name() + } +} + +pub fn detect_shell_type(shell_path: impl AsRef) -> Option { + let shell_path = shell_path.as_ref(); match shell_path.as_os_str().to_str() { Some("zsh") => Some(ShellType::Zsh), Some("sh") => Some(ShellType::Sh), @@ -21,12 +48,317 @@ pub(crate) fn detect_shell_type(shell_path: &PathBuf) -> Option { _ => { let shell_name = shell_path.file_stem(); if let Some(shell_name) = shell_name { - let shell_name_path = Path::new(shell_name); - if shell_name_path != Path::new(shell_path) { - return detect_shell_type(&shell_name_path.to_path_buf()); + let shell_name_path = std::path::Path::new(shell_name); + if shell_name_path != shell_path { + return detect_shell_type(shell_name_path); } } None } } } + +#[cfg(unix)] +fn get_user_shell_path() -> Option { + let uid = unsafe { libc::getuid() }; + use std::ffi::CStr; + use std::mem::MaybeUninit; + use std::ptr; + + let mut passwd = MaybeUninit::::uninit(); + + // We cannot use getpwuid here: it returns pointers into libc-managed + // storage, which is not safe to read concurrently on all targets (the musl + // static build used by the CLI can segfault when parallel callers race on + // that buffer). getpwuid_r keeps the passwd data in caller-owned memory. + let suggested_buffer_len = unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) }; + let buffer_len = usize::try_from(suggested_buffer_len) + .ok() + .filter(|len| *len > 0) + .unwrap_or(1024); + let mut buffer = vec![0; buffer_len]; + + loop { + let mut result = ptr::null_mut(); + let status = unsafe { + libc::getpwuid_r( + uid, + passwd.as_mut_ptr(), + buffer.as_mut_ptr().cast(), + buffer.len(), + &mut result, + ) + }; + + if status == 0 { + if result.is_null() { + return None; + } + + let passwd = unsafe { passwd.assume_init_ref() }; + if passwd.pw_shell.is_null() { + return None; + } + + let shell_path = unsafe { CStr::from_ptr(passwd.pw_shell) } + .to_string_lossy() + .into_owned(); + return Some(PathBuf::from(shell_path)); + } + + if status != libc::ERANGE { + return None; + } + + // Retry with a larger buffer until libc can materialize the passwd entry. + let new_len = buffer.len().checked_mul(2)?; + if new_len > 1024 * 1024 { + return None; + } + buffer.resize(new_len, 0); + } +} + +#[cfg(not(unix))] +fn get_user_shell_path() -> Option { + None +} + +fn file_exists(path: &std::path::Path) -> Option { + if std::fs::metadata(path).is_ok_and(|metadata| metadata.is_file()) { + Some(PathBuf::from(path)) + } else { + None + } +} + +fn get_shell_path( + shell_type: ShellType, + provided_path: Option<&PathBuf>, + binary_name: &str, + fallback_paths: &[&str], +) -> Option { + if let Some(path) = provided_path.and_then(|path| file_exists(path)) { + return Some(path); + } + + let default_shell_path = get_user_shell_path(); + if let Some(default_shell_path) = default_shell_path + && detect_shell_type(&default_shell_path) == Some(shell_type) + && file_exists(&default_shell_path).is_some() + { + return Some(default_shell_path); + } + + if let Ok(path) = which::which(binary_name) { + return Some(path); + } + + for path in fallback_paths { + if let Some(path) = file_exists(std::path::Path::new(path)) { + return Some(path); + } + } + + None +} + +const ZSH_FALLBACK_PATHS: &[&str] = &["/bin/zsh"]; + +fn get_zsh_shell(path: Option<&PathBuf>) -> Option { + let shell_path = get_shell_path(ShellType::Zsh, path, "zsh", ZSH_FALLBACK_PATHS); + + shell_path.map(|shell_path| DetectedShell { + shell_type: ShellType::Zsh, + shell_path, + }) +} + +const BASH_FALLBACK_PATHS: &[&str] = &["/bin/bash"]; + +fn get_bash_shell(path: Option<&PathBuf>) -> Option { + let shell_path = get_shell_path(ShellType::Bash, path, "bash", BASH_FALLBACK_PATHS); + + shell_path.map(|shell_path| DetectedShell { + shell_type: ShellType::Bash, + shell_path, + }) +} + +const SH_FALLBACK_PATHS: &[&str] = &["/bin/sh"]; + +fn get_sh_shell(path: Option<&PathBuf>) -> Option { + let shell_path = get_shell_path(ShellType::Sh, path, "sh", SH_FALLBACK_PATHS); + + shell_path.map(|shell_path| DetectedShell { + shell_type: ShellType::Sh, + shell_path, + }) +} + +// Note the `pwsh` and `powershell` fallback paths are where the respective +// shells are commonly installed on GitHub Actions Windows runners, but may not +// be present on all Windows machines: +// https://docs.github.com/en/actions/tutorials/build-and-test-code/powershell + +#[cfg(windows)] +const PWSH_FALLBACK_PATHS: &[&str] = &[r#"C:\Program Files\PowerShell\7\pwsh.exe"#]; +#[cfg(not(windows))] +const PWSH_FALLBACK_PATHS: &[&str] = &["/usr/local/bin/pwsh"]; + +#[cfg(windows)] +const POWERSHELL_FALLBACK_PATHS: &[&str] = + &[r#"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"#]; +#[cfg(not(windows))] +const POWERSHELL_FALLBACK_PATHS: &[&str] = &[]; + +fn get_powershell_shell(path: Option<&PathBuf>) -> Option { + let shell_path = get_shell_path(ShellType::PowerShell, path, "pwsh", PWSH_FALLBACK_PATHS) + .or_else(|| { + get_shell_path( + ShellType::PowerShell, + path, + "powershell", + POWERSHELL_FALLBACK_PATHS, + ) + }); + + shell_path.map(|shell_path| DetectedShell { + shell_type: ShellType::PowerShell, + shell_path, + }) +} + +fn get_cmd_shell(path: Option<&PathBuf>) -> Option { + let shell_path = get_shell_path(ShellType::Cmd, path, "cmd", &[]); + + shell_path.map(|shell_path| DetectedShell { + shell_type: ShellType::Cmd, + shell_path, + }) +} + +pub fn ultimate_fallback_shell() -> DetectedShell { + if cfg!(windows) { + DetectedShell { + shell_type: ShellType::Cmd, + shell_path: PathBuf::from("cmd.exe"), + } + } else { + DetectedShell { + shell_type: ShellType::Sh, + shell_path: PathBuf::from("/bin/sh"), + } + } +} + +pub fn get_shell_by_model_provided_path(shell_path: &PathBuf) -> DetectedShell { + detect_shell_type(shell_path) + .and_then(|shell_type| get_shell(shell_type, Some(shell_path))) + .unwrap_or_else(ultimate_fallback_shell) +} + +pub fn get_shell(shell_type: ShellType, path: Option<&PathBuf>) -> Option { + match shell_type { + ShellType::Zsh => get_zsh_shell(path), + ShellType::Bash => get_bash_shell(path), + ShellType::PowerShell => get_powershell_shell(path), + ShellType::Sh => get_sh_shell(path), + ShellType::Cmd => get_cmd_shell(path), + } +} + +pub fn default_user_shell() -> DetectedShell { + default_user_shell_from_path(get_user_shell_path()) +} + +pub fn default_user_shell_from_path(user_shell_path: Option) -> DetectedShell { + if cfg!(windows) { + get_shell(ShellType::PowerShell, /*path*/ None).unwrap_or_else(ultimate_fallback_shell) + } else { + let user_default_shell = user_shell_path + .and_then(|shell| detect_shell_type(&shell)) + .and_then(|shell_type| get_shell(shell_type, /*path*/ None)); + + let shell_with_fallback = if cfg!(target_os = "macos") { + user_default_shell + .or_else(|| get_shell(ShellType::Zsh, /*path*/ None)) + .or_else(|| get_shell(ShellType::Bash, /*path*/ None)) + } else { + user_default_shell + .or_else(|| get_shell(ShellType::Bash, /*path*/ None)) + .or_else(|| get_shell(ShellType::Zsh, /*path*/ None)) + }; + + shell_with_fallback.unwrap_or_else(ultimate_fallback_shell) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_detect_shell_type() { + assert_eq!( + detect_shell_type(PathBuf::from("zsh")), + Some(ShellType::Zsh) + ); + assert_eq!( + detect_shell_type(PathBuf::from("bash")), + Some(ShellType::Bash) + ); + assert_eq!( + detect_shell_type(PathBuf::from("pwsh")), + Some(ShellType::PowerShell) + ); + assert_eq!( + detect_shell_type(PathBuf::from("powershell")), + Some(ShellType::PowerShell) + ); + assert_eq!(detect_shell_type(PathBuf::from("fish")), None); + assert_eq!(detect_shell_type(PathBuf::from("other")), None); + assert_eq!( + detect_shell_type(PathBuf::from("/bin/zsh")), + Some(ShellType::Zsh) + ); + assert_eq!( + detect_shell_type(PathBuf::from("/bin/bash")), + Some(ShellType::Bash) + ); + assert_eq!( + detect_shell_type(PathBuf::from("powershell.exe")), + Some(ShellType::PowerShell) + ); + assert_eq!( + detect_shell_type(PathBuf::from(if cfg!(windows) { + "C:\\windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" + } else { + "/usr/local/bin/pwsh" + })), + Some(ShellType::PowerShell) + ); + assert_eq!( + detect_shell_type(PathBuf::from("pwsh.exe")), + Some(ShellType::PowerShell) + ); + assert_eq!( + detect_shell_type(PathBuf::from("/usr/local/bin/pwsh")), + Some(ShellType::PowerShell) + ); + assert_eq!( + detect_shell_type(PathBuf::from("/bin/sh")), + Some(ShellType::Sh) + ); + assert_eq!(detect_shell_type(PathBuf::from("sh")), Some(ShellType::Sh)); + assert_eq!( + detect_shell_type(PathBuf::from("cmd")), + Some(ShellType::Cmd) + ); + assert_eq!( + detect_shell_type(PathBuf::from("cmd.exe")), + Some(ShellType::Cmd) + ); + } +} From 5f4d06ef186b896d316620556e561d59206c3ebf Mon Sep 17 00:00:00 2001 From: jif Date: Fri, 5 Jun 2026 10:25:57 +0200 Subject: [PATCH 38/41] Encrypt multi-agent v2 message payloads (#26210) ## Why Multi-agent v2 currently routes agent instructions through normal tool arguments and inter-agent context. That means the parent model can emit plaintext task text, Codex can persist it in history/rollouts, and the recipient can receive it as ordinary assistant-message JSON. This changes the v2 path so agent instructions stay encrypted between model calls: Responses encrypts the `message` argument returned by the model, Codex forwards only that ciphertext, and Responses decrypts it internally for the recipient model. ## What changed - Mark the v2 `message` parameter as encrypted for `spawn_agent`, `send_message`, and `followup_task`. - Treat multi-agent v2 tool `message` values as ciphertext unconditionally. - Store v2 inter-agent task text in `InterAgentCommunication.encrypted_content` with empty plaintext `content`. - Convert encrypted inter-agent communications into the Responses `agent_message` input item before sending the child request. - Preserve `agent_message` items across history, rollout, compaction, telemetry, and app-server schema paths. - Leave multi-agent v1 unchanged. ## Message shape The model still calls the v2 tools with a `message` argument, but that value is now ciphertext: ```json { "name": "spawn_agent", "arguments": { "task_name": "worker", "message": "" } } ``` Codex stores the task as encrypted inter-agent communication: ```json { "author": "/root", "recipient": "/root/worker", "content": "", "encrypted_content": "", "trigger_turn": true } ``` When Codex builds the recipient request, it forwards the ciphertext using the new Responses input item: ```json { "type": "agent_message", "author": "/root", "recipient": "/root/worker", "content": [ { "type": "encrypted_content", "encrypted_content": "" } ] } ``` Responses decrypts that item internally for the recipient model. ## Context impact - Parent context no longer carries plaintext v2 agent task instructions from these tool arguments. - Codex rollout/history stores ciphertext for v2 agent instructions. - Recipient requests receive an `agent_message` item instead of assistant commentary JSON for encrypted task delivery. - Plaintext completion/status notifications are still plaintext because they are Codex-generated status messages, not encrypted model tool arguments. ## Validation - `just test -p codex-tools` - `just test -p codex-protocol` - `just test -p codex-rollout` - `just test -p codex-rollout-trace` - `just test -p codex-otel` - `just write-app-server-schema` --- .../schema/json/ClientRequest.json | 55 ++++++++++++++ .../PermissionsRequestApprovalParams.json | 6 +- .../schema/json/ServerRequest.json | 6 +- .../codex_app_server_protocol.schemas.json | 55 ++++++++++++++ .../codex_app_server_protocol.v2.schemas.json | 55 ++++++++++++++ .../RawResponseItemCompletedNotification.json | 55 ++++++++++++++ .../schema/json/v2/ThreadResumeParams.json | 55 ++++++++++++++ .../typescript/AgentMessageInputContent.ts | 5 ++ .../schema/typescript/ResponseItem.ts | 3 +- .../schema/typescript/index.ts | 1 + codex-rs/core/src/agent/control.rs | 39 ++++++++-- codex-rs/core/src/agent/control_tests.rs | 56 ++++++++++++++ codex-rs/core/src/agent/registry.rs | 14 ++++ codex-rs/core/src/client_common.rs | 18 ++++- codex-rs/core/src/compact_remote.rs | 1 + codex-rs/core/src/context_manager/history.rs | 8 +- codex-rs/core/src/session/turn.rs | 1 + .../src/tools/handlers/multi_agents_spec.rs | 11 ++- .../tools/handlers/multi_agents_spec_tests.rs | 24 ++++++ .../src/tools/handlers/multi_agents_tests.rs | 25 ++++--- .../src/tools/handlers/multi_agents_v2.rs | 15 ++++ .../handlers/multi_agents_v2/message_tool.rs | 21 +++--- .../tools/handlers/multi_agents_v2/spawn.rs | 22 ++---- codex-rs/core/src/tools/spec_plan_tests.rs | 24 ++++++ codex-rs/core/src/turn_timing.rs | 1 + .../tests/suite/subagent_notifications.rs | 75 +++++++++++++++++++ codex-rs/ext/image-generation/src/tool.rs | 1 + codex-rs/otel/src/events/session_telemetry.rs | 1 + codex-rs/protocol/src/models.rs | 11 +++ codex-rs/protocol/src/protocol.rs | 36 +++++++++ .../rollout-trace/src/reducer/tool/agents.rs | 7 +- codex-rs/rollout/src/policy.rs | 4 +- codex-rs/tools/src/json_schema.rs | 8 ++ codex-rs/tools/src/json_schema_tests.rs | 14 ++++ 34 files changed, 674 insertions(+), 59 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/AgentMessageInputContent.ts diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 861ae1dcbd..c6e1aa3302 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -34,6 +34,30 @@ ], "type": "string" }, + "AgentMessageInputContent": { + "oneOf": [ + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": [ + "encrypted_content" + ], + "title": "EncryptedContentAgentMessageInputContentType", + "type": "string" + } + }, + "required": [ + "encrypted_content", + "type" + ], + "title": "EncryptedContentAgentMessageInputContent", + "type": "object" + } + ] + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ @@ -2120,6 +2144,37 @@ "title": "MessageResponseItem", "type": "object" }, + { + "properties": { + "author": { + "type": "string" + }, + "content": { + "items": { + "$ref": "#/definitions/AgentMessageInputContent" + }, + "type": "array" + }, + "recipient": { + "type": "string" + }, + "type": { + "enum": [ + "agent_message" + ], + "title": "AgentMessageResponseItemType", + "type": "string" + } + }, + "required": [ + "author", + "content", + "recipient", + "type" + ], + "title": "AgentMessageResponseItem", + "type": "object" + }, { "properties": { "content": { diff --git a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json index 961e24c2c2..f2ab783342 100644 --- a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json @@ -288,8 +288,8 @@ "environmentId": { "default": null, "type": [ - "null", - "string" + "string", + "null" ] }, "itemId": { @@ -326,4 +326,4 @@ ], "title": "PermissionsRequestApprovalParams", "type": "object" -} +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index 13bac2e118..dbfca64f4c 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -1593,8 +1593,8 @@ "environmentId": { "default": null, "type": [ - "null", - "string" + "string", + "null" ] }, "itemId": { @@ -2005,4 +2005,4 @@ } ], "title": "ServerRequest" -} +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 4802a52300..bbcbb20be4 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -5900,6 +5900,30 @@ "title": "AgentMessageDeltaNotification", "type": "object" }, + "AgentMessageInputContent": { + "oneOf": [ + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": [ + "encrypted_content" + ], + "title": "EncryptedContentAgentMessageInputContentType", + "type": "string" + } + }, + "required": [ + "encrypted_content", + "type" + ], + "title": "EncryptedContentAgentMessageInputContent", + "type": "object" + } + ] + }, "AgentPath": { "type": "string" }, @@ -14050,6 +14074,37 @@ "title": "MessageResponseItem", "type": "object" }, + { + "properties": { + "author": { + "type": "string" + }, + "content": { + "items": { + "$ref": "#/definitions/v2/AgentMessageInputContent" + }, + "type": "array" + }, + "recipient": { + "type": "string" + }, + "type": { + "enum": [ + "agent_message" + ], + "title": "AgentMessageResponseItemType", + "type": "string" + } + }, + "required": [ + "author", + "content", + "recipient", + "type" + ], + "title": "AgentMessageResponseItem", + "type": "object" + }, { "properties": { "content": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 2b9e30bae7..65d999b334 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -265,6 +265,30 @@ "title": "AgentMessageDeltaNotification", "type": "object" }, + "AgentMessageInputContent": { + "oneOf": [ + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": [ + "encrypted_content" + ], + "title": "EncryptedContentAgentMessageInputContentType", + "type": "string" + } + }, + "required": [ + "encrypted_content", + "type" + ], + "title": "EncryptedContentAgentMessageInputContent", + "type": "object" + } + ] + }, "AgentPath": { "type": "string" }, @@ -10572,6 +10596,37 @@ "title": "MessageResponseItem", "type": "object" }, + { + "properties": { + "author": { + "type": "string" + }, + "content": { + "items": { + "$ref": "#/definitions/AgentMessageInputContent" + }, + "type": "array" + }, + "recipient": { + "type": "string" + }, + "type": { + "enum": [ + "agent_message" + ], + "title": "AgentMessageResponseItemType", + "type": "string" + } + }, + "required": [ + "author", + "content", + "recipient", + "type" + ], + "title": "AgentMessageResponseItem", + "type": "object" + }, { "properties": { "content": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json index f82acbae64..c18f9aeb15 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json @@ -1,6 +1,30 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AgentMessageInputContent": { + "oneOf": [ + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": [ + "encrypted_content" + ], + "title": "EncryptedContentAgentMessageInputContentType", + "type": "string" + } + }, + "required": [ + "encrypted_content", + "type" + ], + "title": "EncryptedContentAgentMessageInputContent", + "type": "object" + } + ] + }, "ContentItem": { "oneOf": [ { @@ -369,6 +393,37 @@ "title": "MessageResponseItem", "type": "object" }, + { + "properties": { + "author": { + "type": "string" + }, + "content": { + "items": { + "$ref": "#/definitions/AgentMessageInputContent" + }, + "type": "array" + }, + "recipient": { + "type": "string" + }, + "type": { + "enum": [ + "agent_message" + ], + "title": "AgentMessageResponseItemType", + "type": "string" + } + }, + "required": [ + "author", + "content", + "recipient", + "type" + ], + "title": "AgentMessageResponseItem", + "type": "object" + }, { "properties": { "content": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index 364cb36617..a6abcc5665 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -1,6 +1,30 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AgentMessageInputContent": { + "oneOf": [ + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": [ + "encrypted_content" + ], + "title": "EncryptedContentAgentMessageInputContentType", + "type": "string" + } + }, + "required": [ + "encrypted_content", + "type" + ], + "title": "EncryptedContentAgentMessageInputContent", + "type": "object" + } + ] + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ @@ -436,6 +460,37 @@ "title": "MessageResponseItem", "type": "object" }, + { + "properties": { + "author": { + "type": "string" + }, + "content": { + "items": { + "$ref": "#/definitions/AgentMessageInputContent" + }, + "type": "array" + }, + "recipient": { + "type": "string" + }, + "type": { + "enum": [ + "agent_message" + ], + "title": "AgentMessageResponseItemType", + "type": "string" + } + }, + "required": [ + "author", + "content", + "recipient", + "type" + ], + "title": "AgentMessageResponseItem", + "type": "object" + }, { "properties": { "content": { diff --git a/codex-rs/app-server-protocol/schema/typescript/AgentMessageInputContent.ts b/codex-rs/app-server-protocol/schema/typescript/AgentMessageInputContent.ts new file mode 100644 index 0000000000..a3bb645597 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/AgentMessageInputContent.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AgentMessageInputContent = { "type": "encrypted_content", encrypted_content: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts index e5e960ff81..b90963db80 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AgentMessageInputContent } from "./AgentMessageInputContent"; import type { ContentItem } from "./ContentItem"; import type { FunctionCallOutputBody } from "./FunctionCallOutputBody"; import type { LocalShellAction } from "./LocalShellAction"; @@ -10,7 +11,7 @@ import type { ReasoningItemContent } from "./ReasoningItemContent"; import type { ReasoningItemReasoningSummary } from "./ReasoningItemReasoningSummary"; import type { WebSearchAction } from "./WebSearchAction"; -export type ResponseItem = { "type": "message", role: string, content: Array, phase?: MessagePhase, } | { "type": "reasoning", summary: Array, content?: Array, encrypted_content: string | null, } | { "type": "local_shell_call", +export type ResponseItem = { "type": "message", role: string, content: Array, phase?: MessagePhase, } | { "type": "agent_message", author: string, recipient: string, content: Array, } | { "type": "reasoning", summary: Array, content?: Array, encrypted_content: string | null, } | { "type": "local_shell_call", /** * Set when using the Responses API. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 458d2e43b9..149b3aec0d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! export type { AbsolutePathBuf } from "./AbsolutePathBuf"; +export type { AgentMessageInputContent } from "./AgentMessageInputContent"; export type { AgentPath } from "./AgentPath"; export type { ApplyPatchApprovalParams } from "./ApplyPatchApprovalParams"; export type { ApplyPatchApprovalResponse } from "./ApplyPatchApprovalResponse"; diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 549852e360..6317dff3b5 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -109,7 +109,8 @@ fn keep_forked_rollout_item(item: &RolloutItem, preserve_reference_context_item: _ => false, }, RolloutItem::ResponseItem( - ResponseItem::Reasoning { .. } + ResponseItem::AgentMessage { .. } + | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::ToolSearchCall { .. } @@ -715,7 +716,12 @@ impl AgentControl { agent_id: ThreadId, initial_operation: Op, ) -> CodexResult { - let last_task_message = render_input_preview(&initial_operation); + let last_task_message = match &initial_operation { + Op::InterAgentCommunication { communication } => { + last_task_message_from_communication(communication) + } + _ => non_empty_task_message(render_input_preview(&initial_operation)), + }; let state = self.upgrade()?; let result = self .handle_thread_request_result( @@ -725,8 +731,12 @@ impl AgentControl { ) .await; if result.is_ok() { - self.state - .update_last_task_message(agent_id, last_task_message); + match last_task_message { + Some(last_task_message) => self + .state + .update_last_task_message(agent_id, last_task_message), + None => self.state.clear_last_task_message(agent_id), + } } result } @@ -736,7 +746,7 @@ impl AgentControl { agent_id: ThreadId, communication: InterAgentCommunication, ) -> CodexResult { - let last_task_message = communication.content.clone(); + let last_task_message = last_task_message_from_communication(&communication); let state = self.upgrade()?; let result = self .handle_thread_request_result( @@ -748,8 +758,12 @@ impl AgentControl { ) .await; if result.is_ok() { - self.state - .update_last_task_message(agent_id, last_task_message); + match last_task_message { + Some(last_task_message) => self + .state + .update_last_task_message(agent_id, last_task_message), + None => self.state.clear_last_task_message(agent_id), + } } result } @@ -1329,6 +1343,17 @@ pub(crate) fn render_input_preview(initial_operation: &Op) -> String { } } +fn last_task_message_from_communication(communication: &InterAgentCommunication) -> Option { + if communication.encrypted_content.is_some() { + return None; + } + non_empty_task_message(communication.content.clone()) +} + +fn non_empty_task_message(message: String) -> Option { + (!message.is_empty()).then_some(message) +} + fn thread_spawn_depth(session_source: &SessionSource) -> Option { match session_source { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) => Some(*depth), diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index f9462e94f6..3866d22013 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -527,6 +527,62 @@ async fn send_inter_agent_communication_without_turn_queues_message_without_trig )); } +#[tokio::test] +async fn encrypted_inter_agent_communication_clears_existing_last_task_message() { + let harness = AgentControlHarness::new().await; + let (parent_thread_id, _) = harness.start_thread().await; + let agent_path = AgentPath::try_from("/root/worker").expect("agent path"); + let spawned_agent = harness + .control + .spawn_agent_with_metadata( + harness.config.clone(), + text_input("old plaintext task"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: Some(agent_path.clone()), + agent_nickname: None, + agent_role: None, + })), + SpawnAgentOptions { + parent_thread_id: Some(parent_thread_id), + ..Default::default() + }, + ) + .await + .expect("spawn_agent should succeed"); + assert_eq!( + harness + .control + .state + .agent_metadata_for_thread(spawned_agent.thread_id) + .and_then(|metadata| metadata.last_task_message), + Some("old plaintext task".to_string()) + ); + + let communication = InterAgentCommunication::new_encrypted( + AgentPath::root(), + agent_path, + Vec::new(), + "encrypted-task".to_string(), + /*trigger_turn*/ true, + ); + harness + .control + .send_inter_agent_communication(spawned_agent.thread_id, communication) + .await + .expect("send_inter_agent_communication should succeed"); + + assert_eq!( + harness + .control + .state + .agent_metadata_for_thread(spawned_agent.thread_id) + .and_then(|metadata| metadata.last_task_message), + None + ); +} + #[tokio::test] async fn spawn_agent_creates_thread_and_sends_prompt() { let harness = AgentControlHarness::new().await; diff --git a/codex-rs/core/src/agent/registry.rs b/codex-rs/core/src/agent/registry.rs index 1acd73085f..43aca201bf 100644 --- a/codex-rs/core/src/agent/registry.rs +++ b/codex-rs/core/src/agent/registry.rs @@ -180,6 +180,20 @@ impl AgentRegistry { } } + pub(crate) fn clear_last_task_message(&self, thread_id: ThreadId) { + let mut active_agents = self + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(metadata) = active_agents + .agent_tree + .values_mut() + .find(|metadata| metadata.agent_id == Some(thread_id)) + { + metadata.last_task_message = None; + } + } + fn register_spawned_thread(&self, agent_metadata: AgentMetadata) { let Some(thread_id) = agent_metadata.agent_id else { return; diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index d775f9c01c..48d0523df4 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -3,6 +3,7 @@ use codex_config::types::Personality; use codex_protocol::error::Result; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::InterAgentCommunication; use codex_tools::ToolSpec; use futures::Stream; use serde_json::Value; @@ -53,7 +54,22 @@ impl Default for Prompt { impl Prompt { pub(crate) fn get_formatted_input(&self) -> Vec { - self.input.clone() + self.input + .iter() + .cloned() + .map(|item| { + let ResponseItem::Message { role, content, .. } = &item else { + return item; + }; + if role != "assistant" { + return item; + } + InterAgentCommunication::from_message_content(content) + .filter(|communication| communication.encrypted_content.is_some()) + .map(|communication| communication.to_model_input_item()) + .unwrap_or(item) + }) + .collect() } } diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index a480c1eba8..c7730ddfb6 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -348,6 +348,7 @@ pub(crate) fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { } ResponseItem::Message { role, .. } if role == "assistant" => true, ResponseItem::Message { .. } => false, + ResponseItem::AgentMessage { .. } => true, ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true, ResponseItem::CompactionTrigger => false, ResponseItem::Reasoning { .. } diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 0be9bbc18e..b8bcf9c846 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -386,6 +386,7 @@ impl ContextManager { output: truncate_function_output_payload(output, policy_with_serialization_budget), }, ResponseItem::Message { .. } + | ResponseItem::AgentMessage { .. } | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } @@ -474,7 +475,8 @@ pub(crate) fn truncate_function_output_payload( fn is_api_message(message: &ResponseItem) -> bool { match message { ResponseItem::Message { role, .. } => role.as_str() != "system", - ResponseItem::FunctionCallOutput { .. } + ResponseItem::AgentMessage { .. } + | ResponseItem::FunctionCallOutput { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::ToolSearchCall { .. } | ResponseItem::ToolSearchOutput { .. } @@ -720,11 +722,15 @@ fn is_model_generated_item(item: &ResponseItem) -> bool { ResponseItem::FunctionCallOutput { .. } | ResponseItem::ToolSearchOutput { .. } | ResponseItem::CustomToolCallOutput { .. } + | ResponseItem::AgentMessage { .. } | ResponseItem::Other => false, } } pub(crate) fn is_user_turn_boundary(item: &ResponseItem) -> bool { + if matches!(item, ResponseItem::AgentMessage { .. }) { + return true; + } let ResponseItem::Message { role, content, .. } = item else { return false; }; diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 4a9316496c..996e59187e 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1924,6 +1924,7 @@ async fn try_run_sampling_request( role == "assistant" && matches!(phase, Some(MessagePhase::Commentary)) } ResponseItem::Reasoning { .. } => true, + ResponseItem::AgentMessage { .. } => false, ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::ToolSearchCall { .. } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs index a15464d9df..891ac5a592 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs @@ -167,7 +167,8 @@ pub fn create_send_message_tool() -> ToolSpec { "message".to_string(), JsonSchema::string(Some( "Message text to queue on the target agent.".to_string(), - )), + )) + .with_encrypted(), ), ]); @@ -199,7 +200,8 @@ pub fn create_followup_task_tool() -> ToolSpec { "message".to_string(), JsonSchema::string(Some( "Message text to send to the target agent.".to_string(), - )), + )) + .with_encrypted(), ), ]); @@ -595,7 +597,10 @@ fn spawn_agent_common_properties_v2(agent_type_description: &str) -> BTreeMap InterAgentCommunication { + InterAgentCommunication::new_encrypted( + author, + recipient, + Vec::new(), + message, + /*trigger_turn*/ true, + ) +} diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs index 258d80c85b..f23d700e9e 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs @@ -1,4 +1,4 @@ -//! Shared argument parsing and dispatch for the v2 text-only agent messaging tools. +//! Shared argument parsing and dispatch for the v2 agent messaging tools. //! //! `send_message` and `followup_task` share the same submission path and differ only in whether the //! resulting `InterAgentCommunication` should wake the target immediately. @@ -55,20 +55,21 @@ fn message_content(message: String) -> Result { Ok(message) } -/// Handles the shared MultiAgentV2 plain-text message flow for both `send_message` and `followup_task`. +/// Handles the shared MultiAgentV2 message flow for both `send_message` and `followup_task`. pub(crate) async fn handle_message_string_tool( invocation: ToolInvocation, mode: MessageDeliveryMode, target: String, message: String, ) -> Result { - let prompt = message_content(message)?; + let message = message_content(message)?; let ToolInvocation { session, turn, call_id, .. } = invocation; + let prompt = String::new(); let receiver_thread_id = resolve_agent_target(&session, &turn, &target).await?; let receiver_agent = session .services @@ -101,15 +102,11 @@ pub(crate) async fn handle_message_string_tool( let receiver_agent_path = receiver_agent.agent_path.clone().ok_or_else(|| { FunctionCallError::RespondToModel("target agent is missing an agent_path".to_string()) })?; - let communication = InterAgentCommunication::new( - turn.session_source - .get_agent_path() - .unwrap_or_else(AgentPath::root), - receiver_agent_path, - Vec::new(), - prompt.clone(), - /*trigger_turn*/ true, - ); + let author = turn + .session_source + .get_agent_path() + .unwrap_or_else(AgentPath::root); + let communication = communication_from_tool_message(author, receiver_agent_path, message); let result = session .services .agent_control diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index 7d5b469a45..b5b7d210f5 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -1,7 +1,6 @@ use super::*; use crate::agent::control::SpawnAgentForkMode; use crate::agent::control::SpawnAgentOptions; -use crate::agent::control::render_input_preview; use crate::agent::next_thread_spawn_depth; use crate::agent::role::DEFAULT_ROLE_NAME; use crate::agent::role::apply_role_to_config; @@ -9,7 +8,6 @@ use crate::tools::handlers::multi_agents_spec::SpawnAgentToolOptions; use crate::tools::handlers::multi_agents_spec::create_spawn_agent_tool_v2; use crate::turn_timing::now_unix_timestamp_ms; use codex_protocol::AgentPath; -use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::Op; use codex_tools::ToolSpec; @@ -61,8 +59,9 @@ async fn handle_spawn_agent( .map(str::trim) .filter(|role| !role.is_empty()); + let message = args.message.clone(); let initial_operation = parse_collab_input(Some(args.message), /*items*/ None)?; - let prompt = render_input_preview(&initial_operation); + let prompt = String::new(); let session_source = turn.session_source.clone(); let child_depth = next_thread_spawn_depth(&session_source); @@ -129,17 +128,12 @@ async fn handle_spawn_agent( .iter() .all(|item| matches!(item, UserInput::Text { .. })) => { - Op::InterAgentCommunication { - communication: InterAgentCommunication::new( - turn.session_source - .get_agent_path() - .unwrap_or_else(AgentPath::root), - recipient, - Vec::new(), - prompt.clone(), - /*trigger_turn*/ true, - ), - } + let author = turn + .session_source + .get_agent_path() + .unwrap_or_else(AgentPath::root); + let communication = communication_from_tool_message(author, recipient, message); + Op::InterAgentCommunication { communication } } (_, initial_operation) => initial_operation, }, diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index bf5b1c01bb..381accb2f6 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -1047,6 +1047,30 @@ async fn multi_agent_feature_selects_one_agent_tool_family() { ); } +#[tokio::test] +async fn multi_agent_v2_message_schemas_are_encrypted() { + let plan = probe(|turn| { + set_feature(turn, Feature::MultiAgentV2, /*enabled*/ true); + }) + .await; + for tool_name in ["spawn_agent", "send_message", "followup_task"] { + let ToolSpec::Function(tool) = plan.visible_spec(tool_name) else { + panic!("expected {tool_name} function spec"); + }; + let properties = tool + .parameters + .properties + .as_ref() + .expect("tool should use object params"); + assert_eq!( + properties + .get("message") + .and_then(|schema| schema.encrypted), + Some(true) + ); + } +} + #[tokio::test] async fn tool_mode_selector_overrides_feature_flags() { let direct = probe(|turn| { diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index 570e4a6e32..bcb3cb8b9e 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -177,6 +177,7 @@ fn response_item_records_turn_ttft(item: &ResponseItem) -> bool { }) }) } + ResponseItem::AgentMessage { .. } => false, ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::CustomToolCall { .. } diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index c39a6bb39d..97d60d67c9 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -17,6 +17,7 @@ use core_test_support::hooks::trust_discovered_hooks; use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_function_call_with_namespace; use core_test_support::responses::ev_response_created; use core_test_support::responses::ev_tool_search_call; @@ -1026,6 +1027,80 @@ async fn spawned_multi_agent_v2_child_inherits_parent_developer_context() -> Res Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result<()> { + let server = start_mock_server().await; + let encrypted_message = "opaque-encrypted-message"; + let spawn_args = serde_json::to_string(&json!({ + "message": encrypted_message, + "task_name": "worker", + }))?; + mount_sse_once_match( + &server, + |req: &wiremock::Request| body_contains(req, TURN_1_PROMPT), + sse(vec![ + ev_response_created("resp-parent-1"), + ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args), + ev_completed("resp-parent-1"), + ]), + ) + .await; + let child_request_log = mount_sse_once_match( + &server, + |req: &wiremock::Request| body_contains(req, "\"type\":\"agent_message\""), + sse(vec![ + ev_response_created("resp-child-1"), + ev_completed("resp-child-1"), + ]), + ) + .await; + mount_sse_once_match( + &server, + |req: &wiremock::Request| { + body_contains(req, SPAWN_CALL_ID) && !body_contains(req, "\"type\":\"agent_message\"") + }, + sse(vec![ + ev_response_created("resp-parent-2"), + ev_assistant_message("msg-parent-2", "done"), + ev_completed("resp-parent-2"), + ]), + ) + .await; + + let mut builder = test_codex().with_model("koffing").with_config(|config| { + config + .features + .enable(Feature::Collab) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + test.submit_turn(TURN_1_PROMPT).await?; + + let child_request = wait_for_requests(&child_request_log) + .await? + .pop() + .expect("child request"); + assert_eq!( + child_request.inputs_of_type("agent_message"), + vec![json!({ + "type": "agent_message", + "author": "/root", + "recipient": "/root/worker", + "content": [{ + "type": "encrypted_content", + "encrypted_content": encrypted_message, + }], + })] + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn skills_toggle_skips_instructions_for_parent_and_spawned_child() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/ext/image-generation/src/tool.rs b/codex-rs/ext/image-generation/src/tool.rs index 9c73e96b23..41c06c8007 100644 --- a/codex-rs/ext/image-generation/src/tool.rs +++ b/codex-rs/ext/image-generation/src/tool.rs @@ -242,6 +242,7 @@ fn edit_images(history: &[ResponseItem]) -> Vec { )); } ResponseItem::Message { .. } + | ResponseItem::AgentMessage { .. } | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index 0003cb04a5..65ca0c4530 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -1217,6 +1217,7 @@ impl SessionTelemetry { fn responses_item_type(item: &ResponseItem) -> String { match item { ResponseItem::Message { role, .. } => format!("message_from_{role}"), + ResponseItem::AgentMessage { .. } => "agent_message".into(), ResponseItem::Reasoning { .. } => "reasoning".into(), ResponseItem::LocalShellCall { .. } => "local_shell_call".into(), ResponseItem::FunctionCall { .. } => "function_call".into(), diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index d95370c061..517c33cac9 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -715,6 +715,12 @@ pub enum ContentItem { }, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum AgentMessageInputContent { + EncryptedContent { encrypted_content: String }, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "lowercase")] pub enum ImageDetail { @@ -758,6 +764,11 @@ pub enum ResponseItem { #[ts(optional)] phase: Option, }, + AgentMessage { + author: String, + recipient: String, + content: Vec, + }, Reasoning { #[serde(default, skip_serializing)] #[ts(skip)] diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 91ef624fa7..b3538a27a1 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -33,6 +33,7 @@ use crate::mcp::CallToolResult; use crate::mcp::RequestId; use crate::memory_citation::MemoryCitation; use crate::models::ActivePermissionProfile; +use crate::models::AgentMessageInputContent; use crate::models::BaseInstructions; use crate::models::ContentItem; use crate::models::ImageDetail; @@ -690,6 +691,9 @@ pub struct InterAgentCommunication { #[serde(default)] pub other_recipients: Vec, pub content: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub encrypted_content: Option, pub trigger_turn: bool, } @@ -706,6 +710,24 @@ impl InterAgentCommunication { recipient, other_recipients, content, + encrypted_content: None, + trigger_turn, + } + } + + pub fn new_encrypted( + author: AgentPath, + recipient: AgentPath, + other_recipients: Vec, + encrypted_content: String, + trigger_turn: bool, + ) -> Self { + Self { + author, + recipient, + other_recipients, + content: String::new(), + encrypted_content: Some(encrypted_content), trigger_turn, } } @@ -720,6 +742,19 @@ impl InterAgentCommunication { } } + pub fn to_model_input_item(&self) -> ResponseItem { + match &self.encrypted_content { + Some(encrypted_content) => ResponseItem::AgentMessage { + author: self.author.to_string(), + recipient: self.recipient.to_string(), + content: vec![AgentMessageInputContent::EncryptedContent { + encrypted_content: encrypted_content.clone(), + }], + }, + None => self.to_response_input_item().into(), + } + } + pub fn is_message_content(content: &[ContentItem]) -> bool { Self::from_message_content(content).is_some() } @@ -4063,6 +4098,7 @@ mod tests { recipient: AgentPath::root().join("reviewer").expect("recipient path"), other_recipients: vec![AgentPath::root().join("worker").expect("recipient path")], content: "review the diff".to_string(), + encrypted_content: None, trigger_turn: true, }; diff --git a/codex-rs/rollout-trace/src/reducer/tool/agents.rs b/codex-rs/rollout-trace/src/reducer/tool/agents.rs index a49b794d14..37f3220c2c 100644 --- a/codex-rs/rollout-trace/src/reducer/tool/agents.rs +++ b/codex-rs/rollout-trace/src/reducer/tool/agents.rs @@ -613,7 +613,12 @@ fn inter_agent_message_fields(item: &ConversationItem) -> Option<(String, String return None; }; let communication = serde_json::from_str::(text).ok()?; - Some((communication.recipient.to_string(), communication.content)) + Some(( + communication.recipient.to_string(), + communication + .encrypted_content + .unwrap_or(communication.content), + )) } #[cfg(test)] diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index 92e5f5690b..d1a0045cd9 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -30,6 +30,7 @@ pub fn persisted_rollout_items(items: &[RolloutItem]) -> Vec { pub fn should_persist_response_item(item: &ResponseItem) -> bool { match item { ResponseItem::Message { .. } + | ResponseItem::AgentMessage { .. } | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } @@ -60,7 +61,8 @@ pub fn should_persist_response_item_for_memories(item: &ResponseItem) -> bool { | ResponseItem::CustomToolCall { .. } | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::WebSearchCall { .. } => true, - ResponseItem::Reasoning { .. } + ResponseItem::AgentMessage { .. } + | ResponseItem::Reasoning { .. } | ResponseItem::ImageGenerationCall { .. } | ResponseItem::Compaction { .. } | ResponseItem::CompactionTrigger diff --git a/codex-rs/tools/src/json_schema.rs b/codex-rs/tools/src/json_schema.rs index 1b53ad9803..5352eaa91d 100644 --- a/codex-rs/tools/src/json_schema.rs +++ b/codex-rs/tools/src/json_schema.rs @@ -43,6 +43,9 @@ pub struct JsonSchema { pub schema_type: Option, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, + /// Responses-only marker for reviewed encrypted tool parameters. + #[serde(skip_serializing_if = "Option::is_none")] + pub encrypted: Option, #[serde(rename = "enum", skip_serializing_if = "Option::is_none")] pub enum_values: Option>, #[serde(skip_serializing_if = "Option::is_none")] @@ -90,6 +93,11 @@ impl JsonSchema { Self::typed(JsonSchemaPrimitiveType::String, description) } + pub fn with_encrypted(mut self) -> Self { + self.encrypted = Some(true); + self + } + pub fn number(description: Option) -> Self { Self::typed(JsonSchemaPrimitiveType::Number, description) } diff --git a/codex-rs/tools/src/json_schema_tests.rs b/codex-rs/tools/src/json_schema_tests.rs index 9315d43c00..6973d06c00 100644 --- a/codex-rs/tools/src/json_schema_tests.rs +++ b/codex-rs/tools/src/json_schema_tests.rs @@ -24,6 +24,20 @@ fn parse_tool_input_schema_coerces_boolean_schemas() { assert_eq!(schema, JsonSchema::string(/*description*/ None)); } +#[test] +fn json_schema_serializes_encrypted_marker() { + let schema = JsonSchema::string(Some("Secret value".to_string())).with_encrypted(); + + assert_eq!( + serde_json::to_value(schema).expect("serialize schema"), + serde_json::json!({ + "type": "string", + "description": "Secret value", + "encrypted": true, + }) + ); +} + #[test] fn parse_tool_input_schema_infers_object_shape_and_defaults_properties() { // Example schema shape: From 6dc28ba6e0acd391a1a006af8bd0237b61fd54a6 Mon Sep 17 00:00:00 2001 From: jif Date: Fri, 5 Jun 2026 11:10:32 +0200 Subject: [PATCH 39/41] nit: doc (#26566) Matching CBv9 --- codex-rs/core/src/tools/handlers/multi_agents_spec.rs | 2 +- codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs index 891ac5a592..1dcb55f436 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs @@ -207,7 +207,7 @@ pub fn create_followup_task_tool() -> ToolSpec { ToolSpec::Function(ResponsesApiTool { name: "followup_task".to_string(), - description: "Send a follow-up task to an existing non-root target agent and trigger a turn in that target. If the target is currently mid-turn, the message is queued and will be used to start the target's next turn, after the current turn completes." + description: "Send a follow-up task to an existing non-root target agent and trigger a turn if it is idle. If the target is already running, deliver the task promptly at message boundaries while sampling, or after the pending tool call completes." .to_string(), strict: false, defer_loading: None, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs index 0ae94079e9..512e318a82 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs @@ -296,6 +296,7 @@ fn send_message_tool_requires_message_and_has_no_output_schema() { fn followup_task_tool_requires_message_and_has_no_output_schema() { let ToolSpec::Function(ResponsesApiTool { name, + description, parameters, output_schema, .. @@ -304,6 +305,10 @@ fn followup_task_tool_requires_message_and_has_no_output_schema() { panic!("followup_task should be a function tool"); }; assert_eq!(name, "followup_task"); + assert_eq!( + description, + "Send a follow-up task to an existing non-root target agent and trigger a turn if it is idle. If the target is already running, deliver the task promptly at message boundaries while sampling, or after the pending tool call completes." + ); assert_eq!( parameters.schema_type, Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object)) From 55aa071b17c825bdb66fac99cde2e7a7acfbdee7 Mon Sep 17 00:00:00 2001 From: carlc-oai Date: Fri, 5 Jun 2026 02:41:06 -0700 Subject: [PATCH 40/41] [codex] Forward turn moderation metadata through app-server (#25710) ## Why First-party backends can supply turn-scoped moderation metadata that app-server clients need for client-side presentation. Exposing this as an experimental typed notification lets opted-in clients consume it without interpreting raw Responses API events. ## What changed - forward `response.metadata.openai_chatgpt_moderation_metadata` from Responses API SSE and WebSocket streams as turn-scoped moderation metadata - emit the experimental app-server v2 `turn/moderationMetadata` notification with `{ threadId, turnId, metadata }` - add app-server integration coverage for the typed moderation metadata notification ## Testing - `just test -p codex-core build_ws_client_metadata_includes_window_lineage_and_turn_metadata` - `just test -p codex-core` (fails locally: 46 failures and 1 timeout, primarily missing `test_stdio_server` and shell snapshot timeouts) - `just test -p codex-app-server-protocol` - `just test -p codex-app-server turn_moderation_metadata_emits_typed_notification_v2` - `just test -p codex-app-server` (fails locally: 792 passed, 10 failed, and 5 timed out; failures are in existing environment-sensitive tests, primarily because nested macOS `sandbox-exec` is not permitted) - `just write-app-server-schema --experimental --schema-root /tmp/codex-app-server-schema-experimental` --- .../schema/json/ServerNotification.json | 37 +++++++++ .../codex_app_server_protocol.schemas.json | 39 +++++++++ .../codex_app_server_protocol.v2.schemas.json | 39 +++++++++ .../TurnModerationMetadataNotification.json | 19 +++++ .../schema/typescript/ServerNotification.ts | 3 +- .../v2/TurnModerationMetadataNotification.ts | 6 ++ .../schema/typescript/v2/index.ts | 1 + .../src/protocol/common.rs | 17 ++++ .../src/protocol/v2/model.rs | 10 +++ codex-rs/app-server/README.md | 1 + .../app-server/src/bespoke_event_handling.rs | 11 +++ codex-rs/app-server/src/outgoing_message.rs | 26 ++++++ .../tests/suite/v2/safety_check_downgrade.rs | 82 +++++++++++++++++++ codex-rs/codex-api/src/common.rs | 3 + .../src/endpoint/responses_websocket.rs | 11 +++ codex-rs/codex-api/src/sse/responses.rs | 57 +++++++++++++ codex-rs/core/src/session/mod.rs | 10 +++ codex-rs/core/src/session/turn.rs | 5 ++ codex-rs/core/src/turn_timing.rs | 1 + codex-rs/mcp-server/src/codex_tool_runner.rs | 3 +- codex-rs/otel/src/events/session_telemetry.rs | 1 + codex-rs/protocol/src/protocol.rs | 8 ++ codex-rs/rollout-trace/src/protocol_event.rs | 2 + codex-rs/rollout/src/policy.rs | 1 + .../tui/src/app/app_server_event_targets.rs | 3 + codex-rs/tui/src/chatwidget/protocol.rs | 1 + 26 files changed, 395 insertions(+), 2 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/TurnModerationMetadataNotification.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/TurnModerationMetadataNotification.ts diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 16656e3a55..010ec33e08 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -4909,6 +4909,23 @@ } ] }, + "TurnModerationMetadataNotification": { + "properties": { + "metadata": true, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "metadata", + "threadId", + "turnId" + ], + "type": "object" + }, "TurnPlanStep": { "properties": { "status": { @@ -6248,6 +6265,26 @@ "title": "Model/verificationNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "turn/moderationMetadata" + ], + "title": "Turn/moderationMetadataNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TurnModerationMetadataNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Turn/moderationMetadataNotification", + "type": "object" + }, { "properties": { "method": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index bbcbb20be4..10bde05597 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -4904,6 +4904,26 @@ "title": "Model/verificationNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "turn/moderationMetadata" + ], + "title": "Turn/moderationMetadataNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/TurnModerationMetadataNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Turn/moderationMetadataNotification", + "type": "object" + }, { "properties": { "method": { @@ -18601,6 +18621,25 @@ } ] }, + "TurnModerationMetadataNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "metadata": true, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "metadata", + "threadId", + "turnId" + ], + "title": "TurnModerationMetadataNotification", + "type": "object" + }, "TurnPlanStep": { "properties": { "status": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 65d999b334..e4c91e7586 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -12420,6 +12420,26 @@ "title": "Model/verificationNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "turn/moderationMetadata" + ], + "title": "Turn/moderationMetadataNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TurnModerationMetadataNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Turn/moderationMetadataNotification", + "type": "object" + }, { "properties": { "method": { @@ -16418,6 +16438,25 @@ } ] }, + "TurnModerationMetadataNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "metadata": true, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "metadata", + "threadId", + "turnId" + ], + "title": "TurnModerationMetadataNotification", + "type": "object" + }, "TurnPlanStep": { "properties": { "status": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnModerationMetadataNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnModerationMetadataNotification.json new file mode 100644 index 0000000000..273bd41061 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnModerationMetadataNotification.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "metadata": true, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "metadata", + "threadId", + "turnId" + ], + "title": "TurnModerationMetadataNotification", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts index 3ed710efc0..2520c71c3c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts @@ -61,6 +61,7 @@ import type { ThreadTokenUsageUpdatedNotification } from "./v2/ThreadTokenUsageU import type { ThreadUnarchivedNotification } from "./v2/ThreadUnarchivedNotification"; import type { TurnCompletedNotification } from "./v2/TurnCompletedNotification"; import type { TurnDiffUpdatedNotification } from "./v2/TurnDiffUpdatedNotification"; +import type { TurnModerationMetadataNotification } from "./v2/TurnModerationMetadataNotification"; import type { TurnPlanUpdatedNotification } from "./v2/TurnPlanUpdatedNotification"; import type { TurnStartedNotification } from "./v2/TurnStartedNotification"; import type { WarningNotification } from "./v2/WarningNotification"; @@ -70,4 +71,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW /** * Notification sent from the server to the client. */ -export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/goal/updated", "params": ThreadGoalUpdatedNotification } | { "method": "thread/goal/cleared", "params": ThreadGoalClearedNotification } | { "method": "thread/settings/updated", "params": ThreadSettingsUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "process/outputDelta", "params": ProcessOutputDeltaNotification } | { "method": "process/exited", "params": ProcessExitedNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "remoteControl/status/changed", "params": RemoteControlStatusChangedNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "model/verification", "params": ModelVerificationNotification } | { "method": "warning", "params": WarningNotification } | { "method": "guardianWarning", "params": GuardianWarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; +export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/goal/updated", "params": ThreadGoalUpdatedNotification } | { "method": "thread/goal/cleared", "params": ThreadGoalClearedNotification } | { "method": "thread/settings/updated", "params": ThreadSettingsUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "process/outputDelta", "params": ProcessOutputDeltaNotification } | { "method": "process/exited", "params": ProcessExitedNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "remoteControl/status/changed", "params": RemoteControlStatusChangedNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "model/verification", "params": ModelVerificationNotification } | { "method": "turn/moderationMetadata", "params": TurnModerationMetadataNotification } | { "method": "warning", "params": WarningNotification } | { "method": "guardianWarning", "params": GuardianWarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnModerationMetadataNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnModerationMetadataNotification.ts new file mode 100644 index 0000000000..1d46d1b4b8 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnModerationMetadataNotification.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "../serde_json/JsonValue"; + +export type TurnModerationMetadataNotification = { threadId: string, turnId: string, metadata: JsonValue, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index b4c65946d5..bb19c92bdf 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -447,6 +447,7 @@ export type { TurnError } from "./TurnError"; export type { TurnInterruptParams } from "./TurnInterruptParams"; export type { TurnInterruptResponse } from "./TurnInterruptResponse"; export type { TurnItemsView } from "./TurnItemsView"; +export type { TurnModerationMetadataNotification } from "./TurnModerationMetadataNotification"; export type { TurnPlanStep } from "./TurnPlanStep"; export type { TurnPlanStepStatus } from "./TurnPlanStepStatus"; export type { TurnPlanUpdatedNotification } from "./TurnPlanUpdatedNotification"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 5a60d8d002..47410551cf 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1549,6 +1549,8 @@ server_notification_definitions! { ContextCompacted => "thread/compacted" (v2::ContextCompactedNotification), ModelRerouted => "model/rerouted" (v2::ModelReroutedNotification), ModelVerification => "model/verification" (v2::ModelVerificationNotification), + #[experimental("turn/moderationMetadata")] + TurnModerationMetadata => "turn/moderationMetadata" (v2::TurnModerationMetadataNotification), Warning => "warning" (v2::WarningNotification), GuardianWarning => "guardianWarning" (v2::GuardianWarningNotification), DeprecationNotice => "deprecationNotice" (v2::DeprecationNoticeNotification), @@ -3222,6 +3224,21 @@ mod tests { ); } + #[test] + fn turn_moderation_metadata_notification_is_marked_experimental() { + let notification = + ServerNotification::TurnModerationMetadata(v2::TurnModerationMetadataNotification { + thread_id: "thr_123".to_string(), + turn_id: "turn_123".to_string(), + metadata: json!({"presentation": "inline"}), + }); + + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(¬ification), + Some("turn/moderationMetadata") + ); + } + #[test] fn thread_realtime_started_notification_is_marked_experimental() { let notification = diff --git a/codex-rs/app-server-protocol/src/protocol/v2/model.rs b/codex-rs/app-server-protocol/src/protocol/v2/model.rs index 7f97d791a6..d8fa249582 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/model.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/model.rs @@ -8,6 +8,7 @@ use codex_protocol::protocol::ModelVerification as CoreModelVerification; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; +use serde_json::Value as JsonValue; use ts_rs::TS; v2_enum_from_core!( @@ -152,3 +153,12 @@ pub struct ModelVerificationNotification { pub turn_id: String, pub verifications: Vec, } + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnModerationMetadataNotification { + pub thread_id: String, + pub turn_id: String, + pub metadata: JsonValue, +} diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 6d705afa98..d112eeff9d 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1244,6 +1244,7 @@ The app-server streams JSON-RPC notifications while a turn is running. Each turn - `turn/plan/updated` — `{ turnId, explanation?, plan }` whenever the agent shares or changes its plan; each `plan` entry is `{ step, status }` with `status` in `pending`, `inProgress`, or `completed`. - `model/rerouted` — `{ threadId, turnId, fromModel, toModel, reason }` when the backend reroutes a request to a different model (for example, due to high-risk cyber safety checks). - `model/verification` — `{ threadId, turnId, verifications }` when the backend flags additional account verification, such as `trustedAccessForCyber`. +- `turn/moderationMetadata` — experimental; `{ threadId, turnId, metadata }` when a first-party backend supplies turn-scoped moderation metadata for client-side presentation. Today both notifications carry an empty `items` array even when item events were streamed; rely on `item/*` notifications for the canonical item list until this is fixed. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index f4a5f3b45f..0cec31b6f2 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -75,6 +75,7 @@ use codex_app_server_protocol::TurnDiffUpdatedNotification; use codex_app_server_protocol::TurnError; use codex_app_server_protocol::TurnInterruptResponse; use codex_app_server_protocol::TurnItemsView; +use codex_app_server_protocol::TurnModerationMetadataNotification; use codex_app_server_protocol::TurnPlanStep; use codex_app_server_protocol::TurnPlanUpdatedNotification; use codex_app_server_protocol::TurnStartedNotification; @@ -337,6 +338,16 @@ pub(crate) async fn apply_bespoke_event_handling( .send_server_notification(ServerNotification::ModelVerification(notification)) .await; } + EventMsg::TurnModerationMetadata(event) => { + let notification = TurnModerationMetadataNotification { + thread_id: conversation_id.to_string(), + turn_id: event_turn_id.clone(), + metadata: event.metadata, + }; + outgoing + .send_server_notification(ServerNotification::TurnModerationMetadata(notification)) + .await; + } EventMsg::RealtimeConversationStarted(event) => { let notification = ThreadRealtimeStartedNotification { thread_id: conversation_id.to_string(), diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index ac7e87741a..2e67e23398 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -725,6 +725,7 @@ mod tests { use codex_app_server_protocol::RateLimitWindow; use codex_app_server_protocol::ServerResponse; use codex_app_server_protocol::ToolRequestUserInputParams; + use codex_app_server_protocol::TurnModerationMetadataNotification; use codex_protocol::ThreadId; use pretty_assertions::assert_eq; use serde_json::json; @@ -951,6 +952,31 @@ mod tests { ); } + #[test] + fn verify_turn_moderation_metadata_notification_serialization() { + let notification = + ServerNotification::TurnModerationMetadata(TurnModerationMetadataNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + metadata: json!({"presentation": "inline"}), + }); + + let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification); + assert_eq!( + json!({ + "method": "turn/moderationMetadata", + "params": { + "threadId": "thread-1", + "turnId": "turn-1", + "metadata": {"presentation": "inline"}, + }, + }), + serde_json::to_value(jsonrpc_notification) + .expect("ensure the notification serializes correctly"), + "ensure the notification serializes correctly" + ); + } + #[test] fn server_request_response_from_result_decodes_typed_response() { let request = ServerRequest::CommandExecutionRequestApproval { diff --git a/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs b/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs index c5b70b2334..ea3cd8dada 100644 --- a/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs +++ b/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs @@ -15,6 +15,7 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnModerationMetadataNotification; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput; @@ -309,6 +310,87 @@ async fn model_verification_emits_typed_notification_and_warning_v2() -> Result< Ok(()) } +#[tokio::test] +async fn turn_moderation_metadata_emits_typed_notification_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + serde_json::json!({ + "type": "response.metadata", + "sequence_number": 1, + "response_id": "resp-1", + "metadata": { + "openai_chatgpt_moderation_metadata": { + "presentation": "inline" + } + } + }), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let response = responses::sse_response(body); + let _response_mock = responses::mount_response_once(&server, response).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some(REQUESTED_MODEL.to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + client_user_message_id: None, + input: vec![UserInput::Text { + text: "trigger moderation metadata".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let turn_start: TurnStartResponse = to_response(turn_resp)?; + + let notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/moderationMetadata"), + ) + .await??; + let metadata: TurnModerationMetadataNotification = + serde_json::from_value(notification.params.ok_or_else(|| { + anyhow::anyhow!("turn/moderationMetadata notifications must include params") + })?)?; + assert_eq!( + metadata, + TurnModerationMetadataNotification { + thread_id: thread.id, + turn_id: turn_start.turn.id, + metadata: serde_json::json!({"presentation": "inline"}), + } + ); + + Ok(()) +} + async fn collect_turn_notifications_and_validate_no_warning_item( mcp: &mut TestAppServer, ) -> Result { diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index 12f8cc55da..17753e799a 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -6,6 +6,7 @@ use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::ModelVerification; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::TokenUsage; +use codex_protocol::protocol::TurnModerationMetadataEvent; use codex_protocol::protocol::W3cTraceContext; use futures::Stream; use serde::Deserialize; @@ -78,6 +79,8 @@ pub enum ResponseEvent { ServerModel(String), /// Emitted when the server recommends additional account verification. ModelVerifications(Vec), + /// Emitted when the server includes moderation metadata for first-party turn presentation. + TurnModerationMetadata(TurnModerationMetadataEvent), /// Emitted when `X-Reasoning-Included: true` is present on the response, /// meaning the server already accounted for past reasoning tokens and the /// client should not re-estimate them. diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index a7366a9871..44cb6a544b 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -683,6 +683,7 @@ async fn run_websocket_response_stream( } }; let model_verifications = event.model_verifications(); + let turn_moderation_metadata = event.turn_moderation_metadata(); if event.kind() == "codex.rate_limits" { if let Some(snapshot) = parse_rate_limit_event(&text) { let _ = tx_event.send(Ok(ResponseEvent::RateLimits(snapshot))).await; @@ -707,6 +708,16 @@ async fn run_websocket_response_stream( "response event consumer dropped".to_string(), )); } + if let Some(metadata) = turn_moderation_metadata + && tx_event + .send(Ok(ResponseEvent::TurnModerationMetadata(metadata))) + .await + .is_err() + { + return Err(ApiError::Stream( + "response event consumer dropped".to_string(), + )); + } match process_responses_event(event) { Ok(Some(event)) => { let is_completed = matches!(event, ResponseEvent::Completed { .. }); diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index c9f35e5a4e..ab1be640df 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -8,6 +8,7 @@ use codex_client::StreamResponse; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::ModelVerification; use codex_protocol::protocol::TokenUsage; +use codex_protocol::protocol::TurnModerationMetadataEvent; use eventsource_stream::Eventsource; use futures::StreamExt; use serde::Deserialize; @@ -193,6 +194,18 @@ impl ResponsesStreamEvent { .and_then(|metadata| metadata.get("openai_verification_recommendation")) .and_then(model_verifications_from_json_value) } + + pub(crate) fn turn_moderation_metadata(&self) -> Option { + if self.kind() != "response.metadata" { + return None; + } + + self.metadata + .as_ref() + .and_then(|metadata| metadata.get("openai_chatgpt_moderation_metadata")) + .cloned() + .map(|metadata| TurnModerationMetadataEvent { metadata }) + } } fn header_openai_model_value_from_json(value: &Value) -> Option { @@ -444,6 +457,7 @@ pub async fn process_sse( } }; let model_verifications = event.model_verifications(); + let turn_moderation_metadata = event.turn_moderation_metadata(); if let Some(model) = event.response_model() && last_server_model.as_deref() != Some(model.as_str()) @@ -465,6 +479,14 @@ pub async fn process_sse( { return; } + if let Some(metadata) = turn_moderation_metadata + && tx_event + .send(Ok(ResponseEvent::TurnModerationMetadata(metadata))) + .await + .is_err() + { + return; + } match process_responses_event(event) { Ok(Some(event)) => { @@ -1215,6 +1237,41 @@ mod tests { ); } + #[tokio::test] + async fn process_sse_emits_turn_moderation_metadata_field() { + let events = run_sse(vec![ + json!({ + "type": "response.metadata", + "metadata": { + "openai_chatgpt_moderation_metadata": { + "presentation": "inline" + } + } + }), + json!({ + "type": "response.completed", + "response": { + "id": "resp-1" + } + }), + ]) + .await; + + assert_matches!( + &events[0], + ResponseEvent::TurnModerationMetadata(result) + if result.metadata == json!({"presentation": "inline"}) + ); + assert_matches!( + &events[1], + ResponseEvent::Completed { + response_id, + token_usage: None, + end_turn: None, + } if response_id == "resp-1" + ); + } + #[test] fn responses_stream_event_response_model_reads_top_level_headers() { let ev: ResponsesStreamEvent = serde_json::from_value(json!({ diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 9aeca1991b..1b40bd7656 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -359,6 +359,7 @@ use codex_protocol::protocol::ThreadMemoryMode; use codex_protocol::protocol::TokenCountEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; +use codex_protocol::protocol::TurnModerationMetadataEvent; use codex_protocol::protocol::WarningEvent; use codex_protocol::user_input::UserInput; use codex_tools::ToolEnvironmentMode; @@ -2637,6 +2638,15 @@ impl Session { .await; } + pub(crate) async fn emit_turn_moderation_metadata( + self: &Arc, + turn_context: &Arc, + metadata: TurnModerationMetadataEvent, + ) { + self.send_event(turn_context, EventMsg::TurnModerationMetadata(metadata)) + .await; + } + pub(crate) async fn replace_history( &self, items: Vec, diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 996e59187e..63195d9fa7 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1392,6 +1392,7 @@ pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option { | EventMsg::RealtimeConversationClosed(_) | EventMsg::ModelReroute(_) | EventMsg::ModelVerification(_) + | EventMsg::TurnModerationMetadata(_) | EventMsg::ContextCompacted(_) | EventMsg::ThreadRolledBack(_) | EventMsg::TurnStarted(_) @@ -2057,6 +2058,10 @@ async fn try_run_sampling_request( .await; } } + ResponseEvent::TurnModerationMetadata(metadata) => { + sess.emit_turn_moderation_metadata(&turn_context, metadata) + .await; + } ResponseEvent::ServerReasoningIncluded(included) => { sess.set_server_reasoning_included(included).await; } diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index bcb3cb8b9e..445fe16214 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -147,6 +147,7 @@ fn response_event_records_turn_ttft(event: &ResponseEvent) -> bool { ResponseEvent::Created | ResponseEvent::ServerModel(_) | ResponseEvent::ModelVerifications(_) + | ResponseEvent::TurnModerationMetadata(_) | ResponseEvent::ServerReasoningIncluded(_) | ResponseEvent::ToolCallInputDelta { .. } | ResponseEvent::Completed { .. } diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 05b995e721..fd56805d4e 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -266,7 +266,8 @@ async fn run_codex_tool_session_inner( } EventMsg::Warning(_) | EventMsg::GuardianWarning(_) - | EventMsg::ModelVerification(_) => { + | EventMsg::ModelVerification(_) + | EventMsg::TurnModerationMetadata(_) => { continue; } EventMsg::GuardianAssessment(_) => { diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index 65ca0c4530..00c13046dd 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -1208,6 +1208,7 @@ impl SessionTelemetry { } ResponseEvent::ServerModel(_) => "server_model".into(), ResponseEvent::ModelVerifications(_) => "model_verifications".into(), + ResponseEvent::TurnModerationMetadata(_) => "turn_moderation_metadata".into(), ResponseEvent::ServerReasoningIncluded(_) => "server_reasoning_included".into(), ResponseEvent::RateLimits(_) => "rate_limits".into(), ResponseEvent::ModelsEtag(_) => "models_etag".into(), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index b3538a27a1..ed278d1843 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1221,6 +1221,9 @@ pub enum EventMsg { /// Backend recommends additional account verification for this turn. ModelVerification(ModelVerificationEvent), + /// Backend moderation metadata intended for first-party turn presentation. + TurnModerationMetadata(TurnModerationMetadataEvent), + /// Conversation history was compacted (either automatically or manually). ContextCompacted(ContextCompactedEvent), @@ -1885,6 +1888,11 @@ pub struct ModelVerificationEvent { pub verifications: Vec, } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] +pub struct TurnModerationMetadataEvent { + pub metadata: Value, +} + #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ContextCompactedEvent; diff --git a/codex-rs/rollout-trace/src/protocol_event.rs b/codex-rs/rollout-trace/src/protocol_event.rs index a862af116c..a3e1f184ec 100644 --- a/codex-rs/rollout-trace/src/protocol_event.rs +++ b/codex-rs/rollout-trace/src/protocol_event.rs @@ -224,6 +224,7 @@ pub(crate) fn tool_runtime_trace_event(event: &EventMsg) -> Option Option<&'static s | EventMsg::RealtimeConversationSdp(_) | EventMsg::ModelReroute(_) | EventMsg::ModelVerification(_) + | EventMsg::TurnModerationMetadata(_) | EventMsg::ContextCompacted(_) | EventMsg::ThreadSettingsApplied(_) | EventMsg::TokenCount(_) diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index d1a0045cd9..17da9e6203 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -117,6 +117,7 @@ pub fn should_persist_event_msg(ev: &EventMsg) -> bool { | EventMsg::RealtimeConversationClosed(_) | EventMsg::ModelReroute(_) | EventMsg::ModelVerification(_) + | EventMsg::TurnModerationMetadata(_) | EventMsg::AgentReasoningSectionBreak(_) | EventMsg::RawResponseItem(_) | EventMsg::SessionConfigured(_) diff --git a/codex-rs/tui/src/app/app_server_event_targets.rs b/codex-rs/tui/src/app/app_server_event_targets.rs index 0b2143b429..ea9fce90fb 100644 --- a/codex-rs/tui/src/app/app_server_event_targets.rs +++ b/codex-rs/tui/src/app/app_server_event_targets.rs @@ -118,6 +118,9 @@ pub(super) fn server_notification_thread_target( ServerNotification::ModelVerification(notification) => { Some(notification.thread_id.as_str()) } + ServerNotification::TurnModerationMetadata(notification) => { + Some(notification.thread_id.as_str()) + } ServerNotification::ThreadRealtimeStarted(notification) => { Some(notification.thread_id.as_str()) } diff --git a/codex-rs/tui/src/chatwidget/protocol.rs b/codex-rs/tui/src/chatwidget/protocol.rs index 6d56330367..1ddbe40d54 100644 --- a/codex-rs/tui/src/chatwidget/protocol.rs +++ b/codex-rs/tui/src/chatwidget/protocol.rs @@ -233,6 +233,7 @@ impl ChatWidget { | ServerNotification::RemoteControlStatusChanged(_) | ServerNotification::ExternalAgentConfigImportCompleted(_) | ServerNotification::FsChanged(_) + | ServerNotification::TurnModerationMetadata(_) | ServerNotification::FuzzyFileSearchSessionUpdated(_) | ServerNotification::FuzzyFileSearchSessionCompleted(_) | ServerNotification::ThreadRealtimeTranscriptDelta(_) From 66232220e21712604b0066f496e966c9a2c1bda8 Mon Sep 17 00:00:00 2001 From: jif Date: Fri, 5 Jun 2026 14:52:51 +0200 Subject: [PATCH 41/41] [codex] Keep v1 spawn metadata visible (#26599) ## Summary - keep the legacy v1 `spawn_agent` role and model selectors visible - add regression coverage for the default v1 tool plan ## Why `hide_spawn_agent_metadata` is a multi-agent v2 setting, but the v1 planning branch also consumed it. After the default changed to `true`, v1 stopped advertising `agent_type`, `model`, `reasoning_effort`, and `service_tier`, preventing configured agents from being selected. This keeps the hidden-metadata default for v2 while opting v1 out of that behavior. Fixes #26363. ## Validation Not run locally, per request; CI will validate the change. --- codex-rs/core/src/tools/spec_plan.rs | 5 +---- codex-rs/core/src/tools/spec_plan_tests.rs | 24 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index eae0644b83..47d2ac6726 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -762,10 +762,7 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu SpawnAgentHandler::new(SpawnAgentToolOptions { available_models: turn_context.available_models.clone(), agent_type_description, - hide_agent_type_model_reasoning: turn_context - .config - .multi_agent_v2 - .hide_spawn_agent_metadata, + hide_agent_type_model_reasoning: false, include_usage_hint: turn_context.config.multi_agent_v2.usage_hint_enabled, usage_hint_text: turn_context.config.multi_agent_v2.usage_hint_text.clone(), max_concurrent_threads_per_session: max_concurrent_threads_per_session( diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 381accb2f6..17dfdc7a74 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -1003,6 +1003,30 @@ async fn multi_agent_feature_selects_one_agent_tool_family() { "wait_agent".to_string(), ] ); + let ToolSpec::Namespace(namespace) = v1.visible_spec(MULTI_AGENT_V1_NAMESPACE) else { + panic!("expected v1 multi-agent namespace"); + }; + let Some(ResponsesApiNamespaceTool::Function(spawn_agent)) = + namespace.tools.iter().find(|tool| { + matches!( + tool, + ResponsesApiNamespaceTool::Function(tool) if tool.name == "spawn_agent" + ) + }) + else { + panic!("expected v1 spawn_agent function"); + }; + let properties = spawn_agent + .parameters + .properties + .as_ref() + .expect("spawn_agent should use object params"); + for property in ["agent_type", "model", "reasoning_effort", "service_tier"] { + assert!( + properties.contains_key(property), + "expected v1 spawn_agent to expose `{property}`" + ); + } let v2 = probe(|turn| { set_feature(turn, Feature::MultiAgentV2, /*enabled*/ true);