From 35d9e4bc4d7a44dc84d86a54f4e815ea5cad6986 Mon Sep 17 00:00:00 2001 From: felixxia-oai Date: Tue, 8 Sep 2026 10:24:17 +0000 Subject: [PATCH] Preserve reasoning effort through compaction and reset it on success (#43796) ## Why With reasoning effort overrides enabled, compaction used the selected effort even when sampling requests still used an earlier pinned effort. Successful compaction also left that old pin active in the new context window. ## What changed - Use the pinned request effort for local and remote compaction when it matches the model. - Resolve fallback models' effort without mutating the live pin, preserving it if compaction fails. - Reset the pin after successful compaction so the next sampling request establishes the selected effort as its baseline without a redundant `configuration_update`. ## Testing Add regression coverage for remote compaction success and failure, fallback model effort selection, pin preservation during fallback lookup, and compaction after resuming with overrides disabled. GitOrigin-RevId: 35e666b0c8026553500715ddbe7bef02cd759f4a --- codex-rs/core/src/compact.rs | 7 +- codex-rs/core/src/compact_remote_request.rs | 8 +- codex-rs/core/src/compact_remote_v2.rs | 7 +- codex-rs/core/src/session/mod.rs | 4 +- codex-rs/core/src/session/reasoning_effort.rs | 51 ++- .../src/session/reasoning_effort_tests.rs | 45 +++ codex-rs/core/src/session/turn.rs | 7 +- codex-rs/core/src/session_startup_prewarm.rs | 3 +- codex-rs/core/src/state/session.rs | 6 +- codex-rs/core/tests/suite/compact.rs | 123 ++++++ .../tests/suite/reasoning_effort_override.rs | 355 ++++++++++++++++++ 11 files changed, 598 insertions(+), 18 deletions(-) create mode 100644 codex-rs/core/src/session/reasoning_effort_tests.rs diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 4aacc97d10..7c5b114283 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -17,6 +17,7 @@ use crate::responses_metadata::CodexResponsesRequestKind; use crate::responses_metadata::CompactionTurnMetadata; #[cfg(test)] use crate::session::PreviousTurnSettings; +use crate::session::RequestEffortUsage; use crate::session::session::Session; use crate::session::step_context::StepContext; use crate::session::turn::get_last_assistant_message_from_turn; @@ -771,7 +772,11 @@ async fn drain_to_completed( prompt, turn_context.model_info(), &turn_context.session_telemetry, - turn_context.reasoning_effort().cloned(), + sess.reasoning_effort_for_request( + &turn_context.initial_settings, + RequestEffortUsage::Compaction, + ) + .await, turn_context.reasoning_summary(), turn_context.config.service_tier.clone(), responses_metadata, diff --git a/codex-rs/core/src/compact_remote_request.rs b/codex-rs/core/src/compact_remote_request.rs index b6b418c7d0..faabcfb00d 100644 --- a/codex-rs/core/src/compact_remote_request.rs +++ b/codex-rs/core/src/compact_remote_request.rs @@ -7,6 +7,7 @@ use crate::client::CompactConversationRequestSettings; use crate::compact::CompactionAnalyticsDetails; use crate::responses_metadata::CodexResponsesRequestKind; use crate::responses_metadata::CompactionTurnMetadata; +use crate::session::RequestEffortUsage; use crate::session::session::Session; use crate::session::step_context::StepContext; use codex_protocol::auth::AuthMode; @@ -83,7 +84,12 @@ pub(super) async fn run_remote_compact_attempt( turn_context.model_info(), turn_state, CompactConversationRequestSettings { - effort: turn_context.reasoning_effort().cloned(), + effort: sess + .reasoning_effort_for_request( + &turn_context.initial_settings, + RequestEffortUsage::Compaction, + ) + .await, 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 f02e2db678..f68b8d47cc 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -26,6 +26,7 @@ use crate::responses_metadata::CompactionTurnMetadata; use crate::responses_retry::ResponsesStreamRequest; use crate::responses_retry::ResponsesStreamRetryState; use crate::responses_retry::handle_retryable_response_stream_error; +use crate::session::RequestEffortUsage; use crate::session::session::Session; use crate::session::step_context::StepContext; use crate::session::turn_context::TurnContext; @@ -388,7 +389,11 @@ async fn run_remote_compaction_request_v2( prompt, turn_context.model_info(), &turn_context.session_telemetry, - turn_context.reasoning_effort().cloned(), + sess.reasoning_effort_for_request( + &turn_context.initial_settings, + RequestEffortUsage::Compaction, + ) + .await, turn_context.reasoning_summary(), step_context.settings.service_tier.clone(), responses_metadata, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 5922f61e61..816f84ae0b 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -224,6 +224,8 @@ mod environment; pub(crate) mod extension_metrics; mod handlers; mod inject; +mod reasoning_effort; +pub(crate) use reasoning_effort::RequestEffortUsage; mod input_queue; mod mcp; mod mcp_prewarm; @@ -231,7 +233,6 @@ mod mcp_refresh; mod mcp_runtime; pub(crate) mod multi_agents; mod realtime_history; -mod reasoning_effort; mod retained_context; mod review; mod rollout_budget; @@ -3831,6 +3832,7 @@ impl Session { ); compacted_item.guardian_history = state.history.guardian_history_checkpoint(); compacted_item.retained_context = Some(state.history.retained_context().clone()); + state.reasoning_effort_pin = ReasoningEffortPin::Compacted; if let Some(world_state) = world_state_baseline { let snapshot = world_state.snapshot(); world_state_item = Some(WorldStateItem::full(snapshot.clone().into_object())); diff --git a/codex-rs/core/src/session/reasoning_effort.rs b/codex-rs/core/src/session/reasoning_effort.rs index 568b7a14ad..9e61936063 100644 --- a/codex-rs/core/src/session/reasoning_effort.rs +++ b/codex-rs/core/src/session/reasoning_effort.rs @@ -1,7 +1,7 @@ -//! Cache-preserving effort updates and the original sampling request effort. +//! Cache-preserving effort updates and the request-effort baseline for a context window. //! -//! Only trusted harness items establish overrides. Replay invalidates the runtime -//! pin so the next send re-establishes the currently selected effort. +//! Only trusted harness items establish overrides. Replay invalidates the runtime pin; +//! successful compaction retires the overrides and allows a fresh request baseline. use super::session::Session; use super::step_context::StepContext; @@ -14,6 +14,13 @@ use codex_protocol::models::ConfigurationReasoning; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort; +/// Sampling can establish a pin; compaction must not change live state before it succeeds. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RequestEffortUsage { + Sampling, + Compaction, +} + impl Session { /// Establishes the selected effort in surviving history, independent of replayed settings. pub(crate) async fn record_reasoning_effort_override(&self, step_context: &StepContext) { @@ -22,7 +29,14 @@ impl Session { return; }; let should_skip = { - let state = self.state.lock().await; + let mut state = self.state.lock().await; + if matches!(state.reasoning_effort_pin, ReasoningEffortPin::Compacted) { + // Replay leaves the pin Unset. Only successful compaction allows the + // request baseline to establish the selection without another update. + state + .reasoning_effort_pin + .pin(&settings.model_info.slug, effort.clone()); + } let established_effort = state .history @@ -68,26 +82,41 @@ impl Session { .await; } - /// Reuses the original request effort while updates carry the selected effort. + /// Sampling and compaction share the original request effort for this context window. pub(crate) async fn reasoning_effort_for_request( &self, settings: &ResolvedStepSettings, + usage: RequestEffortUsage, ) -> Option { let selected_effort = settings.reasoning_effort().cloned(); if !self.enabled(Feature::ReasoningEffortOverride) { return selected_effort; } + if usage == RequestEffortUsage::Compaction + && let Some(pinned) = self + .state + .lock() + .await + .reasoning_effort_pin + .get(&settings.model_info.slug) + { + return Some(pinned); + } let effort = self.effort_for_configuration_update(settings).await; let mut state = self.state.lock().await; let Some(effort) = effort else { - state.reasoning_effort_pin = ReasoningEffortPin::Unset; + if usage == RequestEffortUsage::Sampling { + state.reasoning_effort_pin = ReasoningEffortPin::Unset; + } return selected_effort; }; - Some( - state + Some(match usage { + RequestEffortUsage::Sampling => state .reasoning_effort_pin .pin(&settings.model_info.slug, effort), - ) + // Failed compaction and fallback-model lookups must not mutate the live pin. + RequestEffortUsage::Compaction => effort, + }) } async fn effort_for_configuration_update( @@ -111,3 +140,7 @@ impl Session { Some(effort) } } + +#[cfg(test)] +#[path = "reasoning_effort_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/session/reasoning_effort_tests.rs b/codex-rs/core/src/session/reasoning_effort_tests.rs new file mode 100644 index 0000000000..18c976e54a --- /dev/null +++ b/codex-rs/core/src/session/reasoning_effort_tests.rs @@ -0,0 +1,45 @@ +//! Request-effort lookup must leave live state untouched during compaction. + +use super::RequestEffortUsage; +use crate::session::tests::make_session_and_context; +use codex_features::Feature; +use codex_protocol::openai_models::ReasoningEffort; +use pretty_assertions::assert_eq; +use std::sync::Arc; + +#[tokio::test] +async fn compaction_effort_lookup_preserves_pin_for_fallback_models() { + let (mut session, turn_context) = make_session_and_context().await; + session + .features + .enable(Feature::ReasoningEffortOverride) + .unwrap(); + session + .state + .lock() + .await + .reasoning_effort_pin + .pin("original", ReasoningEffort::Low); + let mut settings = (*turn_context.initial_settings).clone(); + let effort = ReasoningEffort::Medium; + let model = Arc::make_mut(&mut settings.model_info); + model.slug = "fallback".to_string(); + model.use_responses_lite = true; + model.default_reasoning_level = Some(effort.clone()); + + assert_eq!( + session + .reasoning_effort_for_request(&settings, RequestEffortUsage::Compaction) + .await, + Some(effort) + ); + assert_eq!( + session + .state + .lock() + .await + .reasoning_effort_pin + .get("original"), + Some(ReasoningEffort::Low) + ); +} diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 93216d7c83..f9bac7c153 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -2313,8 +2313,11 @@ async fn try_run_sampling_request( prompt, &step_context.settings.model_info, &step_context.session_telemetry, - sess.reasoning_effort_for_request(&step_context.settings) - .await, + sess.reasoning_effort_for_request( + &step_context.settings, + super::RequestEffortUsage::Sampling, + ) + .await, step_context.settings.reasoning_summary, step_context.settings.service_tier.clone(), responses_metadata, diff --git a/codex-rs/core/src/session_startup_prewarm.rs b/codex-rs/core/src/session_startup_prewarm.rs index 02ce6abf1e..e0dff00a6f 100644 --- a/codex-rs/core/src/session_startup_prewarm.rs +++ b/codex-rs/core/src/session_startup_prewarm.rs @@ -15,6 +15,7 @@ use crate::client::ModelClientSession; use crate::guardian::routes_approval_to_guardian; use crate::responses_metadata::CodexResponsesRequestKind; use crate::session::INITIAL_SUBMIT_ID; +use crate::session::RequestEffortUsage; use crate::session::session::Session; use crate::session::turn::build_prompt; use codex_features::Feature; @@ -325,7 +326,7 @@ async fn schedule_startup_prewarm_inner( &step_context.settings.model_info, &step_context.session_telemetry, session - .reasoning_effort_for_request(&step_context.settings) + .reasoning_effort_for_request(&step_context.settings, RequestEffortUsage::Sampling) .await, step_context.settings.reasoning_summary, step_context.settings.service_tier.clone(), diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 901a5a5aca..175c1149ff 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -30,9 +30,11 @@ use codex_protocol::protocol::TurnContextItem; use codex_utils_output_truncation::TruncationPolicy; use tokio_util::task::AbortOnDropHandle; -/// Runtime request effort; startup and replay must re-establish the selected effort. +/// Runtime request effort. A successful compaction can establish a fresh baseline, while +/// startup and replay must explicitly re-establish the selected effort in surviving history. pub(crate) enum ReasoningEffortPin { Unset, + Compacted, Active { model: String, effort: ReasoningEffort, @@ -46,7 +48,7 @@ impl ReasoningEffortPin { model: pinned_model, effort, } if pinned_model == model => Some(effort.clone()), - Self::Unset | Self::Active { .. } => None, + Self::Unset | Self::Compacted | Self::Active { .. } => None, } } diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 09ae3e2cdd..d244368d4f 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -987,6 +987,129 @@ async fn manual_compact_uses_custom_prompt() { } } +#[test_case::test_case(false; "success")] +#[test_case::test_case(true; "failure")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reasoning_effort_override_remote_v2_compaction_resets_pinned_effort( + fail_compaction: bool, +) -> Result<()> { + use codex_protocol::openai_models::ReasoningEffort; + + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let request_log = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_assistant_message("m1", "first reply"), + ev_completed("r1"), + ]), + sse(vec![ + ev_assistant_message("m2", "second reply"), + ev_completed("r2"), + ]), + if fail_compaction { + sse_failed("r3", "server_error", "compaction failed") + } else { + remote_v2_compaction_response() + }, + sse(vec![ + ev_assistant_message("m4", "third reply"), + ev_completed("r4"), + ]), + ], + ) + .await; + + let test = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_model_info_override("gpt-5.4", |model| { + model.use_responses_lite = true; + }) + .with_config(move |config| { + config + .features + .enable(Feature::RemoteCompactionV2) + .expect("enable remote compaction v2"); + config.model_provider.stream_max_retries = Some(0); + config.model_reasoning_effort = Some(ReasoningEffort::Medium); + config + .features + .enable(Feature::ReasoningEffortOverride) + .expect("test config should allow feature update"); + set_test_compact_prompt(config); + }) + .build_with_auto_env(&server) + .await?; + + test.submit_text_turn("first message").await?; + core_test_support::submit_thread_settings( + &test.codex, + ThreadSettingsOverrides { + effort: Some(Some(ReasoningEffort::High)), + ..Default::default() + }, + ) + .await?; + test.submit_text_turn("second message").await?; + test.codex.submit(Op::Compact).await?; + if fail_compaction { + wait_for_event(&test.codex, |event| matches!(event, EventMsg::Error(_))).await; + } + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + test.submit_text_turn("after compaction").await?; + + let requests = request_log.requests(); + assert_eq!(requests.len(), 4); + let first_body = requests[0].body_json(); + let second_body = requests[1].body_json(); + let post_compaction_body = requests[3].body_json(); + assert_eq!(first_body["reasoning"]["effort"], "medium"); + assert_eq!(second_body["reasoning"]["effort"], "medium"); + assert_eq!(requests[2].body_json()["reasoning"]["effort"], "medium"); + assert_eq!( + post_compaction_body["reasoning"]["effort"], + if fail_compaction { "medium" } else { "high" } + ); + let updates = requests + .iter() + .map(|request| { + request + .input() + .into_iter() + .filter(|item| item["type"] == "configuration_update") + .collect::>() + }) + .collect::>(); + let medium = json!({ + "type": "configuration_update", + "reasoning": {"effort": "medium"}, + }); + let high = json!({ + "type": "configuration_update", + "reasoning": {"effort": "high"}, + }); + assert_eq!( + updates, + [ + vec![medium.clone()], + vec![medium.clone(), high.clone()], + vec![medium.clone(), high.clone()], + if fail_compaction { + vec![medium, high] + } else { + vec![] + }, + ] + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn manual_compact_records_durable_and_local_token_usage() { skip_if_no_network!(); diff --git a/codex-rs/core/tests/suite/reasoning_effort_override.rs b/codex-rs/core/tests/suite/reasoning_effort_override.rs index fc834a9356..172d420230 100644 --- a/codex-rs/core/tests/suite/reasoning_effort_override.rs +++ b/codex-rs/core/tests/suite/reasoning_effort_override.rs @@ -6,6 +6,7 @@ use codex_extension_api::ExtensionRegistryBuilder; use codex_extension_api::ThreadIdleInput; use codex_extension_api::ThreadLifecycleContributor; use codex_features::Feature; +use codex_login::CodexAuth; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::protocol::EventMsg; @@ -25,6 +26,7 @@ use std::time::Duration; use test_case::test_case; use tokio::sync::Notify; use tokio::time::timeout; +use wiremock::ResponseTemplate; fn override_builder() -> TestCodexBuilder { test_codex() @@ -495,6 +497,101 @@ async fn reasoning_effort_override_unavailable_uses_request_effort( Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reasoning_effort_override_disabled_on_resume_retires_update_at_compaction() +-> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + let server = responses::start_mock_server().await; + let mut mocks = Vec::new(); + for (id, reply) in [ + ("initial", "initial reply"), + ("resumed", "resumed reply"), + ("after", "after compaction reply"), + ] { + mocks.push( + responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_assistant_message(id, reply), + responses::ev_completed(id), + ]), + ) + .await, + ); + } + let compact = responses::mount_compact_json_once( + &server, + serde_json::json!({ + "output": [{"type": "compaction", "encrypted_content": "compacted-history"}] + }), + ) + .await; + let initial = override_builder().build_with_auto_env(&server).await?; + initial.submit_text_turn("before resume").await?; + let chatgpt_base_url = format!("{}/backend-api", server.uri()); + let resumed = override_builder() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(move |config| { + config + .features + .disable(Feature::ReasoningEffortOverride) + .expect("disable overrides"); + config + .features + .disable(Feature::RemoteCompactionV2) + .expect("disable remote compaction v2"); + config.model_reasoning_effort = Some(ReasoningEffort::High); + config.chatgpt_base_url = chatgpt_base_url; + }) + .restart(&server, &initial) + .await?; + resumed.submit_text_turn("after resume").await?; + resumed.codex.submit(Op::Compact).await?; + wait_for_event(&resumed.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + resumed.submit_text_turn("after compaction").await?; + + let requests = [ + mocks[0].single_request(), + mocks[1].single_request(), + compact.single_request(), + mocks[2].single_request(), + ]; + assert_eq!( + requests + .iter() + .map(|request| ( + request.body_json()["reasoning"]["effort"].clone(), + effort_updates(request), + )) + .collect::>(), + [ + ( + Value::from("medium"), + vec![effort_update(ReasoningEffort::Medium)] + ), + ( + Value::from("high"), + vec![effort_update(ReasoningEffort::Medium)] + ), + ( + Value::from("high"), + vec![effort_update(ReasoningEffort::Medium)] + ), + (Value::from("high"), Vec::new()), + ], + ); + assert!( + requests[3] + .input() + .iter() + .any(|item| item["type"] == "compaction") + ); + Ok(()) +} + #[test_case(1, ReasoningEffort::High; "later turn keep changed selection")] #[test_case(1, ReasoningEffort::Medium; "later turn match restored settings")] #[test_case(2, ReasoningEffort::High; "through first turn")] @@ -734,3 +831,261 @@ async fn reasoning_effort_override_resume_refreshes_selected_effort( ); Ok(()) } + +#[test_case(false; "success")] +#[test_case(true; "failure")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reasoning_effort_override_remote_v1_compaction_uses_pin( + fail_compaction: bool, +) -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + let server = responses::start_mock_server().await; + let first = responses::mount_sse_once( + &server, + responses::sse(vec![responses::ev_completed("first")]), + ) + .await; + let after = responses::mount_sse_once( + &server, + responses::sse(vec![responses::ev_completed("after")]), + ) + .await; + let unchanged = responses::mount_sse_once( + &server, + responses::sse(vec![responses::ev_completed("unchanged")]), + ) + .await; + let changed = responses::mount_sse_once( + &server, + responses::sse(vec![responses::ev_completed("changed")]), + ) + .await; + let compact = responses::mount_compact_response_once( + &server, + if fail_compaction { + ResponseTemplate::new(400).set_body_json(serde_json::json!({ + "error": {"code": "invalid_request_error", "message": "compaction failed"} + })) + } else { + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "output": [{"type": "compaction", "encrypted_content": "compacted-history"}] + })) + }, + ) + .await; + let chatgpt_base_url = format!("{}/backend-api", server.uri()); + let test = override_builder() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(move |config| { + config.chatgpt_base_url = chatgpt_base_url; + config + .features + .disable(Feature::RemoteCompactionV2) + .expect("disable remote compaction v2"); + }) + .build_with_auto_env(&server) + .await?; + test.submit_text_turn("first").await?; + submit_thread_settings( + &test.codex, + ThreadSettingsOverrides { + effort: Some(Some(ReasoningEffort::High)), + ..Default::default() + }, + ) + .await?; + test.codex.submit(Op::Compact).await?; + if fail_compaction { + wait_for_event(&test.codex, |event| matches!(event, EventMsg::Error(_))).await; + } + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + test.submit_text_turn("after compaction").await?; + test.submit_text_turn("unchanged effort").await?; + submit_thread_settings( + &test.codex, + ThreadSettingsOverrides { + effort: Some(Some(ReasoningEffort::Low)), + ..Default::default() + }, + ) + .await?; + test.submit_text_turn("changed effort").await?; + let medium = effort_update(ReasoningEffort::Medium); + let after_updates = if fail_compaction { + vec![medium.clone(), effort_update(ReasoningEffort::High)] + } else { + Vec::new() + }; + let mut changed_updates = after_updates.clone(); + changed_updates.push(effort_update(ReasoningEffort::Low)); + let after_effort = Value::from(if fail_compaction { "medium" } else { "high" }); + assert_eq!( + [ + first.single_request(), + compact.single_request(), + after.single_request(), + unchanged.single_request(), + changed.single_request(), + ] + .map(|request| ( + request.body_json()["reasoning"]["effort"].clone(), + effort_updates(&request), + )), + [ + (Value::from("medium"), vec![medium.clone()]), + (Value::from("medium"), vec![medium]), + (after_effort.clone(), after_updates.clone()), + (after_effort.clone(), after_updates), + (after_effort, changed_updates), + ], + ); + Ok(()) +} + +#[test_case(false; "remote v1")] +#[test_case(true; "remote v2")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reasoning_effort_override_compaction_fallback_uses_each_models_effort( + remote_v2: bool, +) -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + let server = responses::start_mock_server().await; + let first = responses::mount_sse_once( + &server, + responses::sse(vec![responses::ev_completed("first")]), + ) + .await; + let second = responses::mount_sse_once( + &server, + responses::sse(vec![responses::ev_completed("second")]), + ) + .await; + let compaction = serde_json::json!({ + "type": "compaction", "encrypted_content": "fallback-summary" + }); + let failure = ResponseTemplate::new(/*s*/ 400).set_body_json(serde_json::json!({ + "error": {"message": "previous model cannot compact this history"} + })); + let compactions = if remote_v2 { + responses::mount_response_sequence( + &server, + vec![ + failure, + responses::sse_response(responses::sse(vec![ + serde_json::json!({"type": "response.output_item.done", "item": compaction}), + responses::ev_completed("fallback"), + ])), + ], + ) + .await + } else { + responses::mount_compact_response_sequence( + &server, + vec![ + failure, + ResponseTemplate::new(/*s*/ 200).set_body_json(serde_json::json!({ + "output": [compaction] + })), + ], + ) + .await + }; + let after = responses::mount_sse_once( + &server, + responses::sse(vec![responses::ev_completed("after")]), + ) + .await; + let chatgpt_base_url = format!("{}/backend-api", server.uri()); + let test = override_builder() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_model_info_override("gpt-5.4", |model| { + model.comp_hash = Some("original".to_string()); + }) + .with_model_info_override("gpt-5.2", |model| { + model.comp_hash = Some("fallback".to_string()); + model.use_responses_lite = true; + }) + .with_model("gpt-5.4") + .with_config(move |config| { + config.chatgpt_base_url = chatgpt_base_url; + config.model_provider.stream_max_retries = Some(0); + if remote_v2 { + config + .features + .enable(Feature::RemoteCompactionV2) + .expect("enable remote compaction v2"); + } else { + config + .features + .disable(Feature::RemoteCompactionV2) + .expect("disable remote compaction v2"); + } + }) + .build_with_auto_env(&server) + .await?; + test.submit_text_turn("first").await?; + submit_thread_settings( + &test.codex, + ThreadSettingsOverrides { + effort: Some(Some(ReasoningEffort::High)), + ..Default::default() + }, + ) + .await?; + test.submit_text_turn("second").await?; + submit_thread_settings( + &test.codex, + ThreadSettingsOverrides { + model: Some("gpt-5.2".to_string()), + ..Default::default() + }, + ) + .await?; + test.submit_text_turn("switch model").await?; + + let compaction_requests = compactions.requests(); + assert_eq!(compaction_requests.len(), 2); + let requests = [ + first.single_request(), + second.single_request(), + compaction_requests[0].clone(), + compaction_requests[1].clone(), + after.single_request(), + ]; + assert_eq!( + requests.each_ref().map(|request| { + let body = request.body_json(); + serde_json::json!([body["model"], body["reasoning"]["effort"]]) + }), + [ + serde_json::json!(["gpt-5.4", "medium"]), + serde_json::json!(["gpt-5.4", "medium"]), + serde_json::json!(["gpt-5.4", "medium"]), + serde_json::json!(["gpt-5.2", "high"]), + serde_json::json!(["gpt-5.2", "high"]), + ] + ); + assert_eq!( + requests.each_ref().map(effort_updates), + [ + vec![effort_update(ReasoningEffort::Medium)], + vec![ + effort_update(ReasoningEffort::Medium), + effort_update(ReasoningEffort::High) + ], + vec![ + effort_update(ReasoningEffort::Medium), + effort_update(ReasoningEffort::High) + ], + vec![ + effort_update(ReasoningEffort::Medium), + effort_update(ReasoningEffort::High) + ], + vec![], + ] + ); + Ok(()) +}