From c40132caf9d6952ce0bfc8381ca89e33bd461d3b Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Thu, 12 Feb 2026 01:28:47 -0800 Subject: [PATCH] compact: preserve incoming items during pre-turn trim retries --- codex-rs/core/src/codex.rs | 4 +- codex-rs/core/src/compact.rs | 8 +- codex-rs/core/tests/suite/compact.rs | 85 ++++++++++++++++++- codex-rs/core/tests/suite/model_switching.rs | 2 +- ...action_context_window_exceeded_shapes.snap | 9 +- 5 files changed, 98 insertions(+), 10 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 3b52bd451f..558f5066ca 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3406,10 +3406,10 @@ mod handlers { // Attempt to inject input into current task. if let Err(SteerInputError::NoActiveTurn(items)) = sess.steer_input(items, None).await { sess.seed_initial_context_if_needed(¤t_context).await; - let resumed_model = sess.take_pending_resume_previous_model().await; + let previous_model = sess.previous_model().await; let pre_turn_context_items = sess.build_settings_update_items( previous_context.as_ref(), - resumed_model.as_deref(), + previous_model.as_deref(), ¤t_context, ); let has_user_input = !items.is_empty(); diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index d21028e90d..40de1b5ec4 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -167,6 +167,12 @@ async fn run_compact_task_inner( &[initial_input_for_turn.into()], turn_context.truncation_policy, ); + // Keep incoming turn items and the compaction prompt pinned at the tail while trimming. + // Pre-turn compaction should fail with ContextWindowExceeded rather than dropping incoming + // items to force compaction to succeed. + let protected_tail_items = incoming_items + .as_ref() + .map_or(1_usize, |items| items.len().saturating_add(1)); let mut truncated_count = 0usize; @@ -224,7 +230,7 @@ async fn run_compact_task_inner( return Err(CodexErr::Interrupted); } Err(e @ CodexErr::ContextWindowExceeded) => { - if turn_input_len > 1 { + if turn_input_len > 1 && history.raw_items().len() > protected_tail_items { // Trim from the beginning to preserve cache (prefix-based) and keep recent // messages intact. error!( diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 08850ec38f..c7da207a6f 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -3235,9 +3235,9 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() { insta::assert_snapshot!( "pre_turn_compaction_context_window_exceeded_shapes", format_labeled_requests_snapshot( - "Pre-turn auto-compaction context-window failure: compaction request excludes the incoming user message and the turn errors.", + "Pre-turn auto-compaction context-window failure: compaction request includes the incoming user message and the turn errors.", &[( - "Local Compaction Request (Incoming User Excluded)", + "Local Compaction Request (Incoming User Included)", &requests[1] ),] ) @@ -3249,6 +3249,87 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pre_turn_local_compaction_trim_retries_keep_incoming_items() { + skip_if_no_network!(); + + let server = start_mock_server().await; + let compact_failed = sse_failed( + "compact-failed", + "context_length_exceeded", + CONTEXT_LIMIT_MESSAGE, + ); + let request_log = mount_sse_sequence( + &server, + vec![ + compact_failed.clone(), + compact_failed.clone(), + compact_failed.clone(), + compact_failed, + ], + ) + .await; + + let model_provider = non_openai_model_provider(&server); + 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(1); + }) + .build(&server) + .await + .expect("build codex") + .codex; + + let incoming_text = "PRETURN_INCOMING_USER"; + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: incoming_text.to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await + .expect("submit user input"); + + 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 compact_attempts = request_log.requests(); + assert_eq!( + compact_attempts.len(), + 4, + "expected pre-turn compaction to retry while trimming seeded history only" + ); + for (index, request) in compact_attempts.iter().enumerate() { + let body = request.body_json().to_string(); + assert!( + body_contains_text(&body, SUMMARIZATION_PROMPT), + "request {index} should be a compaction attempt" + ); + assert!( + request + .message_input_texts("user") + .iter() + .any(|text| text == incoming_text), + "request {index} dropped incoming user text during trim retries" + ); + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn snapshot_request_shape_manual_compact_without_previous_user_messages() { skip_if_no_network!(); diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index cbbbddaac3..d02374f54c 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -139,7 +139,7 @@ async fn settings_only_empty_turn_persists_updates_for_next_non_empty_turn() -> final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, + sandbox_policy: SandboxPolicy::new_read_only_policy(), model: model.clone(), effort: test.config.model_reasoning_effort, summary: ReasoningSummary::Auto, diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_context_window_exceeded_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_context_window_exceeded_shapes.snap index da30b01bd7..89066bacaa 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_context_window_exceeded_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_context_window_exceeded_shapes.snap @@ -1,13 +1,14 @@ --- source: core/tests/suite/compact.rs -expression: "format_labeled_requests_snapshot(\"Pre-turn auto-compaction context-window failure: compaction request excludes the incoming user message and the turn errors.\",\n&[(\"Local Compaction Request (Incoming User Excluded)\", &requests[1]),])" +expression: "format_labeled_requests_snapshot(\"Pre-turn auto-compaction context-window failure: compaction request includes the incoming user message and the turn errors.\",\n&[(\"Local Compaction Request (Incoming User Included)\", &requests[1]),])" --- -Scenario: Pre-turn auto-compaction context-window failure: compaction request excludes the incoming user message and the turn errors. +Scenario: Pre-turn auto-compaction context-window failure: compaction request includes the incoming user message and the turn errors. -## Local Compaction Request (Incoming User Excluded) +## Local Compaction Request (Incoming User Included) 00:message/developer: 01:message/user: 02:message/user:> 03:message/user:USER_ONE 04:message/assistant:FIRST_REPLY -05:message/user: +05:message/user:USER_TWO +06:message/user: