diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 008ff170ab..08c5a4da28 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4284,6 +4284,31 @@ pub(crate) async fn run_turn( collaboration_mode_kind: turn_context.collaboration_mode.mode, }); sess.send_event(&turn_context, event).await; + + let total_usage_tokens_before_compaction = sess.get_total_token_usage().await; + if let Err(err) = maybe_run_previous_model_inline_compact( + &sess, + &turn_context, + total_usage_tokens_before_compaction, + ) + .await + { + if !pre_turn_context_items.is_empty() { + // Preserve model-visible settings updates even when pre-turn compaction fails + // before we can persist turn input. + sess.record_conversation_items(&turn_context, &pre_turn_context_items) + .await; + } + let compact_error_prefix = if should_use_remote_compact_task(&turn_context.provider) { + "Error running remote compact task" + } else { + "Error running local compact task" + }; + let event = EventMsg::Error(err.to_error_event(Some(compact_error_prefix.to_string()))); + sess.send_event(&turn_context, event).await; + return None; + } + let pre_turn_compaction_outcome = match run_pre_turn_auto_compaction_if_needed( &sess, &turn_context, @@ -4764,14 +4789,6 @@ async fn run_pre_turn_auto_compaction_if_needed( auto_compact_limit: i64, incoming_turn_items: &[ResponseItem], ) -> CodexResult { - let total_usage_tokens_before_compaction = sess.get_total_token_usage().await; - maybe_run_previous_model_inline_compact( - sess, - turn_context, - total_usage_tokens_before_compaction, - ) - .await?; - let total_usage_tokens = sess.get_total_token_usage().await; let incoming_items_tokens_estimate = incoming_turn_items .iter() diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 4ef9fd15de..ee77570414 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1800,6 +1800,130 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pre_sampling_compact_context_window_failure_surfaces_compact_task_error() { + skip_if_no_network!(); + + let server = MockServer::start().await; + let previous_model = "gpt-5.2-codex"; + let next_model = "gpt-5.1-codex-max"; + + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![ + model_info_with_context_window(previous_model, 273_000), + model_info_with_context_window(next_model, 125_000), + ], + }, + ) + .await; + + let request_log = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_assistant_message("m1", "before switch"), + ev_completed_with_tokens("r1", 120_000), + ]), + sse_failed( + "compact-failed", + "context_length_exceeded", + CONTEXT_LIMIT_MESSAGE, + ), + ], + ) + .await; + + let mut model_provider = non_openai_model_provider(&server); + model_provider.stream_max_retries = Some(0); + let mut builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_model(previous_model) + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + config.features.enable(Feature::RemoteModels); + }); + let test = builder.build(&server).await.expect("build test codex"); + + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "before switch".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: previous_model.to_string(), + effort: None, + summary: ReasoningSummary::Auto, + collaboration_mode: None, + personality: None, + }) + .await + .expect("submit first user turn"); + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "after switch".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: next_model.to_string(), + effort: None, + summary: ReasoningSummary::Auto, + collaboration_mode: None, + personality: None, + }) + .await + .expect("submit second user turn"); + + let error_message = wait_for_event_match(&test.codex, |event| match event { + EventMsg::Error(err) => Some(err.message.clone()), + _ => None, + }) + .await; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + assert_eq!(models_mock.requests().len(), 1); + assert!( + error_message.contains("Error running local compact task"), + "expected local compact-task failure prefix, got {error_message}" + ); + assert!( + !error_message.contains( + "Incoming user message and/or turn context is too large to fit in context window" + ), + "model-switch pre-sampling compaction failure should not be misclassified as incoming-input oversize: {error_message}" + ); + + let requests = request_log.requests(); + assert_eq!( + requests.len(), + 2, + "expected first user turn and one pre-sampling compaction request" + ); + let compact_request_body = requests[1].body_json().to_string(); + assert!( + body_contains_text(&compact_request_body, SUMMARIZATION_PROMPT), + "second request should be the pre-sampling compaction request" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { skip_if_no_network!();