diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 2437501836..25e8849cdf 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4293,21 +4293,26 @@ pub(crate) async fn run_turn( .await { Ok(outcome) => outcome, - Err(CodexErr::ContextWindowExceeded) => { - let incoming_items_tokens_estimate = incoming_turn_items - .iter() - .map(estimate_item_token_count) - .fold(0_i64, i64::saturating_add); - let message = format!( - "Incoming user message and/or turn context is too large to fit in context window. Please reduce the size of your message and try again. (incoming_items_tokens_estimate={incoming_items_tokens_estimate})" - ); - let event = - EventMsg::Error(CodexErr::ContextWindowExceeded.to_error_event(Some(message))); - sess.send_event(&turn_context, event).await; - return None; - } Err(err) => { - let event = EventMsg::Error(err.to_error_event(None)); + 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 event = match err { + CodexErr::ContextWindowExceeded => { + let incoming_items_tokens_estimate = incoming_turn_items + .iter() + .map(estimate_item_token_count) + .fold(0_i64, i64::saturating_add); + let message = format!( + "Incoming user message and/or turn context is too large to fit in context window. Please reduce the size of your message and try again. (incoming_items_tokens_estimate={incoming_items_tokens_estimate})" + ); + EventMsg::Error(CodexErr::ContextWindowExceeded.to_error_event(Some(message))) + } + other => EventMsg::Error(other.to_error_event(None)), + }; sess.send_event(&turn_context, event).await; return None; } diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index c7da207a6f..d504f9ff55 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -3330,6 +3330,139 @@ async fn pre_turn_local_compaction_trim_retries_keep_incoming_items() { } } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pre_turn_compaction_failure_persists_context_updates_for_next_turn() { + skip_if_no_network!(); + + let server = start_mock_server().await; + let compact_failed = sse_failed( + "compact-failed", + "context_length_exceeded", + CONTEXT_LIMIT_MESSAGE, + ); + let third_turn = sse(vec![ + ev_assistant_message("m3", FINAL_REPLY), + ev_completed_with_tokens("r3", 80), + ]); + let request_log = mount_sse_sequence( + &server, + vec![ + compact_failed.clone(), + compact_failed.clone(), + compact_failed.clone(), + compact_failed, + third_turn, + ], + ) + .await; + + let mut model_provider = non_openai_model_provider(&server); + model_provider.stream_max_retries = Some(0); + let codex = test_codex() + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + config.model_auto_compact_token_limit = Some(300); + }) + .build(&server) + .await + .expect("build codex") + .codex; + + // Seed `previous_context` without adding a user turn to history. + codex + .submit(Op::UserTurn { + items: Vec::new(), + final_output_json_schema: None, + cwd: PathBuf::from("."), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + model: "gpt-5.2-codex".to_string(), + effort: None, + summary: ReasoningSummary::Auto, + collaboration_mode: None, + personality: None, + }) + .await + .expect("submit empty settings-only turn"); + + let oversized_input = "X".repeat(2_000); + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: oversized_input, + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: PathBuf::from("."), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: "gpt-5.2-codex".to_string(), + effort: None, + summary: ReasoningSummary::Auto, + collaboration_mode: None, + personality: None, + }) + .await + .expect("submit oversized turn that triggers pre-turn compaction failure"); + let error_message = wait_for_event_match(&codex, |event| match event { + EventMsg::Error(err) => Some(err.message.clone()), + _ => None, + }) + .await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + assert!( + error_message.contains( + "Incoming user message and/or turn context is too large to fit in context window" + ), + "expected oversized incoming-items error, got {error_message}" + ); + + let follow_up_text = "after failed pre-turn compact"; + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: follow_up_text.to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: PathBuf::from("."), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: "gpt-5.2-codex".to_string(), + effort: None, + summary: ReasoningSummary::Auto, + collaboration_mode: None, + personality: None, + }) + .await + .expect("submit follow-up turn"); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let requests = request_log.requests(); + assert_eq!( + requests.len(), + 5, + "expected four failed pre-turn compaction attempts and one follow-up model request" + ); + + let follow_up_request = requests.last().expect("missing follow-up request"); + let follow_up_developer_texts = follow_up_request.message_input_texts("developer"); + assert!( + follow_up_developer_texts + .iter() + .any(|text| text.contains("sandbox_mode` is `danger-full-access`")), + "expected danger-full-access permissions update in follow-up turn after failed pre-turn compaction: {follow_up_developer_texts:?}" + ); + assert!( + follow_up_request + .message_input_texts("user") + .iter() + .any(|text| text == follow_up_text), + "expected follow-up request to include follow-up user message" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn snapshot_request_shape_manual_compact_without_previous_user_messages() { skip_if_no_network!();