From 7164ef282c3502c7c32e8f4b28fbce1edccf6a3f Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Tue, 18 Nov 2025 09:58:56 -0800 Subject: [PATCH 1/3] fix(core) Support changing /approvals before conversation --- codex-rs/core/src/codex.rs | 18 ++++- codex-rs/core/tests/suite/prompt_caching.rs | 84 +++++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 64d06d0571..8bc100639a 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -746,6 +746,21 @@ impl Session { ))) } + async fn current_turn_context_snapshot(&self) -> Arc { + let session_configuration = { + let state = self.state.lock().await; + state.session_configuration.clone() + }; + Arc::new(Self::make_turn_context( + Some(Arc::clone(&self.services.auth_manager)), + &self.services.otel_event_manager, + session_configuration.provider.clone(), + &session_configuration, + self.conversation_id, + INITIAL_SUBMIT_ID.to_string(), + )) + } + /// Persist the event to rollout and send it to clients. pub(crate) async fn send_event(&self, turn_context: &TurnContext, msg: EventMsg) { let legacy_source = msg.clone(); @@ -1315,7 +1330,8 @@ impl Session { } async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiver) { - let mut previous_context: Option> = None; + let mut previous_context: Option> = + Some(sess.current_turn_context_snapshot().await); // To break out of this loop, send Op::Shutdown. while let Ok(sub) = rx_sub.recv().await { debug!(?sub, "Submission"); diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index c858ea20fd..4d7ad8402e 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -4,6 +4,7 @@ use codex_core::config::OPENAI_DEFAULT_MODEL; use codex_core::features::Feature; use codex_core::model_family::find_family_for_model; use codex_core::protocol::AskForApproval; +use codex_core::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; use codex_core::protocol::EventMsg; use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; @@ -420,6 +421,89 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn override_before_first_turn_emits_environment_context() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let req = mount_sse_once(&server, sse_completed("resp-1")).await; + + let TestCodex { codex, .. } = test_codex().build(&server).await?; + + codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: Some(AskForApproval::Never), + sandbox_policy: None, + model: None, + effort: None, + summary: None, + }) + .await?; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "first message".into(), + }], + }) + .await?; + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + let body = req.single_request().body_json(); + let input = body["input"] + .as_array() + .expect("input array must be present"); + assert!( + !input.is_empty(), + "expected at least environment context and user message" + ); + + let env_msg = &input[0]; + let env_text = env_msg["content"][0]["text"] + .as_str() + .expect("environment context text"); + assert!( + env_text.starts_with(ENVIRONMENT_CONTEXT_OPEN_TAG), + "first entry should be environment context, got: {env_text}" + ); + assert!( + env_text.contains("never"), + "environment context should reflect overridden approval policy: {env_text}" + ); + + let env_count = input + .iter() + .filter(|msg| { + msg["content"] + .as_array() + .and_then(|content| { + content.iter().find(|item| { + item["type"].as_str() == Some("input_text") + && item["text"] + .as_str() + .map(|text| text.starts_with(ENVIRONMENT_CONTEXT_OPEN_TAG)) + .unwrap_or(false) + }) + }) + .is_some() + }) + .count(); + assert_eq!( + env_count, 1, + "environment context should appear exactly once, found {env_count}" + ); + + let user_msg = &input[1]; + let user_text = user_msg["content"][0]["text"] + .as_str() + .expect("user message text"); + assert_eq!(user_text, "first message"); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); From 70962e4cafa01e6ec87802d0bf33b7e5440be71d Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Tue, 18 Nov 2025 12:51:10 -0800 Subject: [PATCH 2/3] simplify --- codex-rs/core/src/codex.rs | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 8bc100639a..4cbf0186d6 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -746,21 +746,6 @@ impl Session { ))) } - async fn current_turn_context_snapshot(&self) -> Arc { - let session_configuration = { - let state = self.state.lock().await; - state.session_configuration.clone() - }; - Arc::new(Self::make_turn_context( - Some(Arc::clone(&self.services.auth_manager)), - &self.services.otel_event_manager, - session_configuration.provider.clone(), - &session_configuration, - self.conversation_id, - INITIAL_SUBMIT_ID.to_string(), - )) - } - /// Persist the event to rollout and send it to clients. pub(crate) async fn send_event(&self, turn_context: &TurnContext, msg: EventMsg) { let legacy_source = msg.clone(); @@ -1330,8 +1315,10 @@ impl Session { } async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiver) { + // Seed with context in case there is an OverrideTurnContext first. let mut previous_context: Option> = - Some(sess.current_turn_context_snapshot().await); + Some(sess.new_turn(SessionSettingsUpdate::default()).await); + // To break out of this loop, send Op::Shutdown. while let Ok(sub) = rx_sub.recv().await { debug!(?sub, "Submission"); From bc56b339a6d6a56c71ffabe3d669cca53dde7fd0 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Tue, 18 Nov 2025 15:33:53 -0800 Subject: [PATCH 3/3] fix tests --- codex-rs/core/tests/suite/prompt_caching.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 4d7ad8402e..3f7c818f10 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -460,13 +460,13 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul "expected at least environment context and user message" ); - let env_msg = &input[0]; + let env_msg = &input[1]; let env_text = env_msg["content"][0]["text"] .as_str() .expect("environment context text"); assert!( env_text.starts_with(ENVIRONMENT_CONTEXT_OPEN_TAG), - "first entry should be environment context, got: {env_text}" + "second entry should be environment context, got: {env_text}" ); assert!( env_text.contains("never"), @@ -491,11 +491,11 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul }) .count(); assert_eq!( - env_count, 1, - "environment context should appear exactly once, found {env_count}" + env_count, 2, + "environment context should appear exactly twice, found {env_count}" ); - let user_msg = &input[1]; + let user_msg = &input[2]; let user_text = user_msg["content"][0]["text"] .as_str() .expect("user message text");