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(()) +}