From 2d4123c267de909db05cab48c7d6d3a6bcdda548 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Fri, 13 Feb 2026 18:03:53 -0800 Subject: [PATCH 01/80] Fix model switch compaction --- codex-rs/core/src/codex.rs | 50 +++++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 7744480ab41..2e29b5c361d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4479,7 +4479,7 @@ pub(crate) async fn run_turn( // as long as compaction works well in getting us way below the token limit, we shouldn't worry about being in an infinite loop. if token_limit_reached && needs_follow_up { - if run_auto_compact(&sess, &turn_context).await.is_err() { + if run_auto_compact(&sess, &turn_context, false).await.is_err() { return None; } continue; @@ -4586,12 +4586,23 @@ async fn run_pre_sampling_compact( turn_context: &Arc, ) -> CodexResult<()> { let total_usage_tokens_before_compaction = sess.get_total_token_usage().await; - maybe_run_previous_model_inline_compact( + let previous_model = sess.previous_model().await; + let previous_model_compaction_ran = maybe_run_previous_model_inline_compact( sess, turn_context, total_usage_tokens_before_compaction, ) .await?; + if previous_model_compaction_ran + && let Some(model_switch_item) = sess.build_model_instructions_update_item( + None, + previous_model.as_deref(), + turn_context.as_ref(), + ) + { + sess.record_conversation_items(turn_context.as_ref(), &[model_switch_item]) + .await; + } let total_usage_tokens = sess.get_total_token_usage().await; let auto_compact_limit = turn_context .model_info @@ -4599,7 +4610,7 @@ async fn run_pre_sampling_compact( .unwrap_or(i64::MAX); // Compact if the total usage tokens are greater than the auto compact limit if total_usage_tokens >= auto_compact_limit { - run_auto_compact(sess, turn_context).await?; + run_auto_compact(sess, turn_context, false).await?; } Ok(()) } @@ -4614,9 +4625,9 @@ async fn maybe_run_previous_model_inline_compact( sess: &Arc, turn_context: &Arc, total_usage_tokens: i64, -) -> CodexResult<()> { +) -> CodexResult { let Some(previous_model) = sess.previous_model().await else { - return Ok(()); + return Ok(false); }; let previous_turn_context = Arc::new( turn_context @@ -4625,10 +4636,10 @@ async fn maybe_run_previous_model_inline_compact( ); let Some(old_context_window) = previous_turn_context.model_context_window() else { - return Ok(()); + return Ok(false); }; let Some(new_context_window) = turn_context.model_context_window() else { - return Ok(()); + return Ok(false); }; let new_auto_compact_limit = turn_context .model_info @@ -4638,16 +4649,31 @@ async fn maybe_run_previous_model_inline_compact( && previous_turn_context.model_info.slug != turn_context.model_info.slug && old_context_window > new_context_window; if should_run { - run_auto_compact(sess, &previous_turn_context).await?; + run_auto_compact(sess, &previous_turn_context, true).await?; + return Ok(true); } - Ok(()) + Ok(false) } -async fn run_auto_compact(sess: &Arc, turn_context: &Arc) -> CodexResult<()> { +async fn run_auto_compact( + sess: &Arc, + turn_context: &Arc, + strip_trailing_model_switch_update: bool, +) -> CodexResult<()> { if should_use_remote_compact_task(&turn_context.provider) { - run_inline_remote_auto_compact_task(Arc::clone(sess), Arc::clone(turn_context)).await?; + run_inline_remote_auto_compact_task( + Arc::clone(sess), + Arc::clone(turn_context), + strip_trailing_model_switch_update, + ) + .await?; } else { - run_inline_auto_compact_task(Arc::clone(sess), Arc::clone(turn_context)).await?; + run_inline_auto_compact_task( + Arc::clone(sess), + Arc::clone(turn_context), + strip_trailing_model_switch_update, + ) + .await?; } Ok(()) } From a79bebe5cab8aec533843119a9f631a79d6b83d3 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Fri, 13 Feb 2026 18:28:27 -0800 Subject: [PATCH 02/80] Simplify --- codex-rs/core/src/codex.rs | 50 +++++++++----------------------------- 1 file changed, 12 insertions(+), 38 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 2e29b5c361d..7744480ab41 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4479,7 +4479,7 @@ pub(crate) async fn run_turn( // as long as compaction works well in getting us way below the token limit, we shouldn't worry about being in an infinite loop. if token_limit_reached && needs_follow_up { - if run_auto_compact(&sess, &turn_context, false).await.is_err() { + if run_auto_compact(&sess, &turn_context).await.is_err() { return None; } continue; @@ -4586,23 +4586,12 @@ async fn run_pre_sampling_compact( turn_context: &Arc, ) -> CodexResult<()> { let total_usage_tokens_before_compaction = sess.get_total_token_usage().await; - let previous_model = sess.previous_model().await; - let previous_model_compaction_ran = maybe_run_previous_model_inline_compact( + maybe_run_previous_model_inline_compact( sess, turn_context, total_usage_tokens_before_compaction, ) .await?; - if previous_model_compaction_ran - && let Some(model_switch_item) = sess.build_model_instructions_update_item( - None, - previous_model.as_deref(), - turn_context.as_ref(), - ) - { - sess.record_conversation_items(turn_context.as_ref(), &[model_switch_item]) - .await; - } let total_usage_tokens = sess.get_total_token_usage().await; let auto_compact_limit = turn_context .model_info @@ -4610,7 +4599,7 @@ async fn run_pre_sampling_compact( .unwrap_or(i64::MAX); // Compact if the total usage tokens are greater than the auto compact limit if total_usage_tokens >= auto_compact_limit { - run_auto_compact(sess, turn_context, false).await?; + run_auto_compact(sess, turn_context).await?; } Ok(()) } @@ -4625,9 +4614,9 @@ async fn maybe_run_previous_model_inline_compact( sess: &Arc, turn_context: &Arc, total_usage_tokens: i64, -) -> CodexResult { +) -> CodexResult<()> { let Some(previous_model) = sess.previous_model().await else { - return Ok(false); + return Ok(()); }; let previous_turn_context = Arc::new( turn_context @@ -4636,10 +4625,10 @@ async fn maybe_run_previous_model_inline_compact( ); let Some(old_context_window) = previous_turn_context.model_context_window() else { - return Ok(false); + return Ok(()); }; let Some(new_context_window) = turn_context.model_context_window() else { - return Ok(false); + return Ok(()); }; let new_auto_compact_limit = turn_context .model_info @@ -4649,31 +4638,16 @@ async fn maybe_run_previous_model_inline_compact( && previous_turn_context.model_info.slug != turn_context.model_info.slug && old_context_window > new_context_window; if should_run { - run_auto_compact(sess, &previous_turn_context, true).await?; - return Ok(true); + run_auto_compact(sess, &previous_turn_context).await?; } - Ok(false) + Ok(()) } -async fn run_auto_compact( - sess: &Arc, - turn_context: &Arc, - strip_trailing_model_switch_update: bool, -) -> CodexResult<()> { +async fn run_auto_compact(sess: &Arc, turn_context: &Arc) -> CodexResult<()> { if should_use_remote_compact_task(&turn_context.provider) { - run_inline_remote_auto_compact_task( - Arc::clone(sess), - Arc::clone(turn_context), - strip_trailing_model_switch_update, - ) - .await?; + run_inline_remote_auto_compact_task(Arc::clone(sess), Arc::clone(turn_context)).await?; } else { - run_inline_auto_compact_task( - Arc::clone(sess), - Arc::clone(turn_context), - strip_trailing_model_switch_update, - ) - .await?; + run_inline_auto_compact_task(Arc::clone(sess), Arc::clone(turn_context)).await?; } Ok(()) } From c7cef7dab177e236a057b78ac1d7daa314de31e9 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Sat, 14 Feb 2026 18:02:02 -0800 Subject: [PATCH 03/80] update snapshot --- ...ng_model_switch_compaction_shapes.snap.new | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_sampling_model_switch_compaction_shapes.snap.new diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_sampling_model_switch_compaction_shapes.snap.new b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_sampling_model_switch_compaction_shapes.snap.new new file mode 100644 index 00000000000..8d6c4d9b1aa --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_sampling_model_switch_compaction_shapes.snap.new @@ -0,0 +1,31 @@ +--- +source: core/tests/suite/compact.rs +assertion_line: 1773 +expression: "format_labeled_requests_snapshot(\"Pre-sampling compaction on model switch to a smaller context window: current behavior compacts using prior-turn history only (incoming user message excluded), and the follow-up request carries compacted history plus the new user message.\",\n&[(\"Initial Request (Previous Model)\", &requests[0]),\n(\"Pre-sampling Compaction Request\", &requests[1]),\n(\"Post-Compaction Follow-up Request (Next Model)\", &requests[2]),])" +--- +Scenario: Pre-sampling compaction on model switch to a smaller context window: current behavior compacts using prior-turn history only (incoming user message excluded), and the follow-up request carries compacted history plus the new user message. + +## Initial Request (Previous Model) +00:message/developer: +01:message/user: +02:message/user:> +03:message/developer: +04:message/user:before switch + +## Pre-sampling Compaction Request +00:message/developer: +01:message/user: +02:message/user:> +03:message/developer: +04:message/user:before switch +05:message/assistant:before switch +06:message/user: + +## Post-Compaction Follow-up Request (Next Model) +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:before switch +04:message/user:\nPRE_SAMPLING_SUMMARY +05:message/developer:\nThe user was previously using a different model.... +06:message/user:after switch From 5c389f9f202566274ee3bff2f61795c9454d3dfa Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Sat, 14 Feb 2026 18:05:09 -0800 Subject: [PATCH 04/80] Update pre-sampling model-switch compaction snapshot --- ...ng_model_switch_compaction_shapes.snap.new | 31 ------------------- 1 file changed, 31 deletions(-) delete mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_sampling_model_switch_compaction_shapes.snap.new diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_sampling_model_switch_compaction_shapes.snap.new b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_sampling_model_switch_compaction_shapes.snap.new deleted file mode 100644 index 8d6c4d9b1aa..00000000000 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_sampling_model_switch_compaction_shapes.snap.new +++ /dev/null @@ -1,31 +0,0 @@ ---- -source: core/tests/suite/compact.rs -assertion_line: 1773 -expression: "format_labeled_requests_snapshot(\"Pre-sampling compaction on model switch to a smaller context window: current behavior compacts using prior-turn history only (incoming user message excluded), and the follow-up request carries compacted history plus the new user message.\",\n&[(\"Initial Request (Previous Model)\", &requests[0]),\n(\"Pre-sampling Compaction Request\", &requests[1]),\n(\"Post-Compaction Follow-up Request (Next Model)\", &requests[2]),])" ---- -Scenario: Pre-sampling compaction on model switch to a smaller context window: current behavior compacts using prior-turn history only (incoming user message excluded), and the follow-up request carries compacted history plus the new user message. - -## Initial Request (Previous Model) -00:message/developer: -01:message/user: -02:message/user:> -03:message/developer: -04:message/user:before switch - -## Pre-sampling Compaction Request -00:message/developer: -01:message/user: -02:message/user:> -03:message/developer: -04:message/user:before switch -05:message/assistant:before switch -06:message/user: - -## Post-Compaction Follow-up Request (Next Model) -00:message/developer: -01:message/user: -02:message/user:> -03:message/user:before switch -04:message/user:\nPRE_SAMPLING_SUMMARY -05:message/developer:\nThe user was previously using a different model.... -06:message/user:after switch From e0f99ebaa4093514fdeca3e73348e20e20ab6c31 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Wed, 11 Feb 2026 12:05:26 -0800 Subject: [PATCH 05/80] compact: split core logic changes from snapshot test coverage --- codex-rs/core/src/codex.rs | 462 +++++++++++++-- codex-rs/core/src/compact.rs | 558 +++++++++++++++++-- codex-rs/core/src/compact_remote.rs | 183 +++++- codex-rs/core/src/context_manager/history.rs | 2 +- codex-rs/core/src/context_manager/mod.rs | 1 + codex-rs/core/src/tasks/compact.rs | 34 +- codex-rs/core/src/tasks/regular.rs | 10 + codex-rs/core/tests/suite/model_switching.rs | 88 +++ 8 files changed, 1228 insertions(+), 110 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 7744480ab41..0d30b8d602e 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -19,6 +19,8 @@ use crate::analytics_client::build_track_events_context; use crate::apps::render_apps_section; use crate::commit_attribution::commit_message_trailer_instruction; use crate::compact; +use crate::compact::AutoCompactCallsite; +use crate::compact::TurnContextReinjection; use crate::compact::run_inline_auto_compact_task; use crate::compact::should_use_remote_compact_task; use crate::compact_remote::run_inline_remote_auto_compact_task; @@ -126,6 +128,7 @@ use crate::config::types::McpServerConfig; use crate::config::types::ShellEnvironmentPolicy; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; +use crate::context_manager::estimate_item_token_count; use crate::environment_context::EnvironmentContext; use crate::error::CodexErr; use crate::error::Result as CodexResult; @@ -2385,11 +2388,11 @@ impl Session { history.replace(replacement.clone()); } else { let user_messages = collect_user_messages(history.raw_items()); - let rebuilt = compact::build_compacted_history( - self.build_initial_context(turn_context).await, + let mut rebuilt = self.build_initial_context(turn_context).await; + rebuilt.extend(compact::build_compacted_history( &user_messages, &compacted.message, - ); + )); history.replace(rebuilt); } } @@ -2406,9 +2409,14 @@ impl Session { &self, turn_context: &TurnContext, compacted_history: Vec, + turn_context_reinjection: TurnContextReinjection, ) -> Vec { let initial_context = self.build_initial_context(turn_context).await; - compact::process_compacted_history(compacted_history, &initial_context) + compact::process_compacted_history( + compacted_history, + &initial_context, + turn_context_reinjection, + ) } /// Append ResponseItems to the in-memory conversation history only. @@ -2499,6 +2507,11 @@ impl Session { self.flush_rollout().await; } + pub(crate) async fn mark_initial_context_unseeded_for_next_turn(&self) { + let mut state = self.state.lock().await; + state.initial_context_seeded = false; + } + async fn persist_rollout_response_items(&self, items: &[ResponseItem]) { let rollout_items: Vec = items .iter() @@ -3399,20 +3412,30 @@ 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 previous_model = sess.previous_model().await; - let update_items = sess.build_settings_update_items( + let resumed_model = sess.take_pending_resume_previous_model().await; + let pre_turn_context_items = sess.build_settings_update_items( previous_context.as_ref(), - previous_model.as_deref(), + resumed_model.as_deref(), ¤t_context, ); - if !update_items.is_empty() { - sess.record_conversation_items(¤t_context, &update_items) + let has_user_input = !items.is_empty(); + if !has_user_input && !pre_turn_context_items.is_empty() { + // Empty-input UserTurn still needs these model-visible updates persisted now. + // Otherwise `previous_context` advances and the next non-empty turn computes no diff. + sess.record_conversation_items(¤t_context, &pre_turn_context_items) .await; } sess.refresh_mcp_servers_if_requested(¤t_context) .await; - let regular_task = sess.take_startup_regular_task().await.unwrap_or_default(); + let regular_task = if has_user_input { + sess.take_startup_regular_task() + .await + .unwrap_or_default() + .with_pre_turn_context_items(pre_turn_context_items) + } else { + sess.take_startup_regular_task().await.unwrap_or_default() + }; sess.spawn_task(Arc::clone(¤t_context), items, regular_task) .await; *previous_context = Some(current_context); @@ -4241,6 +4264,7 @@ pub(crate) async fn run_turn( sess: Arc, turn_context: Arc, input: Vec, + pre_turn_context_items: Vec, prewarmed_client_session: Option, cancellation_token: CancellationToken, ) -> Option { @@ -4250,6 +4274,9 @@ pub(crate) async fn run_turn( let model_info = turn_context.model_info.clone(); let auto_compact_limit = model_info.auto_compact_token_limit().unwrap_or(i64::MAX); + let response_item: ResponseItem = ResponseInputItem::from(input.clone()).into(); + let mut incoming_turn_items = pre_turn_context_items.clone(); + incoming_turn_items.push(response_item.clone()); let event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), @@ -4257,13 +4284,34 @@ pub(crate) async fn run_turn( collaboration_mode_kind: turn_context.collaboration_mode.mode, }); sess.send_event(&turn_context, event).await; - if run_pre_sampling_compact(&sess, &turn_context) - .await - .is_err() + let pre_turn_compaction_outcome = match run_pre_turn_auto_compaction_if_needed( + &sess, + &turn_context, + auto_compact_limit, + &incoming_turn_items, + ) + .await { - error!("Failed to run pre-sampling compact"); - return None; - } + 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)); + sess.send_event(&turn_context, event).await; + return None; + } + }; let skills_outcome = Some( sess.services @@ -4372,10 +4420,15 @@ pub(crate) async fn run_turn( sess.merge_connector_selection(explicitly_enabled_connectors.clone()) .await; - let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input.clone()); - let response_item: ResponseItem = initial_input_for_turn.clone().into(); - sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), &input, response_item) - .await; + persist_pre_turn_items_for_compaction_outcome( + &sess, + &turn_context, + pre_turn_compaction_outcome, + &pre_turn_context_items, + &input, + response_item, + ) + .await; if !skill_items.is_empty() { sess.record_conversation_items(&turn_context, &skill_items) @@ -4479,7 +4532,19 @@ pub(crate) async fn run_turn( // as long as compaction works well in getting us way below the token limit, we shouldn't worry about being in an infinite loop. if token_limit_reached && needs_follow_up { - if run_auto_compact(&sess, &turn_context).await.is_err() { + if let Err(err) = run_auto_compact( + &sess, + &turn_context, + AutoCompactCallsite::MidTurnContinuation, + TurnContextReinjection::ReinjectAboveLastRealUser, + None, + ) + .await + { + let event = EventMsg::Error( + err.to_error_event(Some("Error running auto compact task".to_string())), + ); + sess.send_event(&turn_context, event).await; return None; } continue; @@ -4581,35 +4646,6 @@ pub(crate) async fn run_turn( last_agent_message } -async fn run_pre_sampling_compact( - sess: &Arc, - turn_context: &Arc, -) -> 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 auto_compact_limit = turn_context - .model_info - .auto_compact_token_limit() - .unwrap_or(i64::MAX); - // Compact if the total usage tokens are greater than the auto compact limit - if total_usage_tokens >= auto_compact_limit { - run_auto_compact(sess, turn_context).await?; - } - Ok(()) -} - -/// Runs pre-sampling compaction against the previous model when switching to a smaller -/// context-window model. -/// -/// Returns `Ok(())` when compaction either completed successfully or was skipped because the -/// model/context-window preconditions were not met. Returns `Err(_)` only when compaction was -/// attempted and failed. async fn maybe_run_previous_model_inline_compact( sess: &Arc, turn_context: &Arc, @@ -4638,18 +4674,177 @@ async fn maybe_run_previous_model_inline_compact( && previous_turn_context.model_info.slug != turn_context.model_info.slug && old_context_window > new_context_window; if should_run { - run_auto_compact(sess, &previous_turn_context).await?; + run_auto_compact( + sess, + &previous_turn_context, + AutoCompactCallsite::PreTurnExcludingIncomingUserMessage, + TurnContextReinjection::Skip, + None, + ) + .await?; } Ok(()) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PreTurnCompactionOutcome { + /// Pre-turn input fits without compaction. + NotNeeded, + /// Pre-turn compaction succeeded with incoming turn context + user message included. + CompactedWithIncomingItems, + /// Pre-turn compaction succeeded without incoming turn items + /// (incoming user message should be appended after the compaction summary). + /// This compaction strategy is currently out of distribution for our compaction model, + /// but is planned to be trained on in the future. + #[cfg(test)] + CompactedWithoutIncomingItems, +} + +async fn persist_pre_turn_items_for_compaction_outcome( + sess: &Arc, + turn_context: &Arc, + outcome: PreTurnCompactionOutcome, + pre_turn_context_items: &[ResponseItem], + input: &[UserInput], + response_item: ResponseItem, +) { + match outcome { + PreTurnCompactionOutcome::CompactedWithIncomingItems => { + // Incoming turn items were already part of pre-turn compaction input, and the + // user prompt is already persisted in history after compaction. Emit lifecycle events + // only so UI/consumers still observe a normal user turn item transition. + let turn_item = TurnItem::UserMessage(UserMessageItem::new(input)); + sess.emit_turn_item_started(turn_context.as_ref(), &turn_item) + .await; + sess.emit_turn_item_completed(turn_context.as_ref(), turn_item) + .await; + sess.ensure_rollout_materialized().await; + } + PreTurnCompactionOutcome::NotNeeded => { + if !pre_turn_context_items.is_empty() { + sess.record_conversation_items(turn_context, pre_turn_context_items) + .await; + } + sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), input, response_item) + .await; + } + #[cfg(test)] + PreTurnCompactionOutcome::CompactedWithoutIncomingItems => { + // Reserved path for future models that compact pre-turn history without incoming turn + // items; reseed canonical initial context above the incoming user message. + let initial_context = sess.build_initial_context(turn_context.as_ref()).await; + if !initial_context.is_empty() { + sess.record_conversation_items(turn_context, &initial_context) + .await; + } + sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), input, response_item) + .await; + } + } +} + +/// Runs pre-turn auto-compaction with incoming turn context + user message included. +async fn run_pre_turn_auto_compaction_if_needed( + sess: &Arc, + turn_context: &Arc, + 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() + .map(estimate_item_token_count) + .fold(0_i64, i64::saturating_add); + if !is_projected_submission_over_auto_compact_limit( + total_usage_tokens, + incoming_items_tokens_estimate, + auto_compact_limit, + ) { + return Ok(PreTurnCompactionOutcome::NotNeeded); + } + + let compact_result = run_auto_compact( + sess, + turn_context, + AutoCompactCallsite::PreTurnIncludingIncomingUserMessage, + TurnContextReinjection::ReinjectAboveLastRealUser, + Some(incoming_turn_items.to_vec()), + ) + .await; + + if let Err(err) = compact_result { + if matches!(err, CodexErr::ContextWindowExceeded) { + error!( + turn_id = %turn_context.sub_id, + auto_compact_callsite = ?AutoCompactCallsite::PreTurnIncludingIncomingUserMessage, + incoming_items_tokens_estimate, + auto_compact_limit, + reason = "pre-turn compaction exceeded context window", + "incoming user/context is too large for pre-turn auto-compaction flow" + ); + } + return Err(err); + } + + Ok(PreTurnCompactionOutcome::CompactedWithIncomingItems) +} -async fn run_auto_compact(sess: &Arc, turn_context: &Arc) -> CodexResult<()> { - if should_use_remote_compact_task(&turn_context.provider) { - run_inline_remote_auto_compact_task(Arc::clone(sess), Arc::clone(turn_context)).await?; +fn is_projected_submission_over_auto_compact_limit( + total_usage_tokens: i64, + incoming_user_tokens_estimate: i64, + auto_compact_limit: i64, +) -> bool { + if auto_compact_limit == i64::MAX { + return false; + } + + total_usage_tokens.saturating_add(incoming_user_tokens_estimate) >= auto_compact_limit +} + +async fn run_auto_compact( + sess: &Arc, + turn_context: &Arc, + auto_compact_callsite: AutoCompactCallsite, + turn_context_reinjection: TurnContextReinjection, + incoming_items: Option>, +) -> CodexResult<()> { + let result = if should_use_remote_compact_task(&turn_context.provider) { + run_inline_remote_auto_compact_task( + Arc::clone(sess), + Arc::clone(turn_context), + auto_compact_callsite, + turn_context_reinjection, + incoming_items, + ) + .await } else { - run_inline_auto_compact_task(Arc::clone(sess), Arc::clone(turn_context)).await?; + run_inline_auto_compact_task( + Arc::clone(sess), + Arc::clone(turn_context), + auto_compact_callsite, + turn_context_reinjection, + incoming_items, + ) + .await + }; + + if let Err(err) = &result { + error!( + turn_id = %turn_context.sub_id, + auto_compact_callsite = ?auto_compact_callsite, + compact_error = %err, + "auto compaction failed" + ); } - Ok(()) + + result } fn collect_explicit_app_ids_from_skill_items( @@ -5598,7 +5793,7 @@ async fn try_run_sampling_request( ResponseEvent::Completed { response_id: _, token_usage, - can_append: _, + .. } => { if let Some(state) = plan_mode_state.as_mut() { flush_proposed_plan_segments_all(&sess, &turn_context, state).await; @@ -5829,6 +6024,151 @@ mod tests { } } + #[test] + fn pre_turn_projection_uses_incoming_user_tokens_for_compaction() { + assert!(is_projected_submission_over_auto_compact_limit(90, 15, 100)); + assert!(!is_projected_submission_over_auto_compact_limit(90, 9, 100)); + } + + #[test] + fn pre_turn_projection_does_not_compact_with_unbounded_limit() { + assert!(!is_projected_submission_over_auto_compact_limit( + i64::MAX - 1, + 100, + i64::MAX, + )); + } + + #[test] + fn post_compaction_projection_triggers_error_when_still_over_limit() { + assert!(is_projected_submission_over_auto_compact_limit(95, 10, 100)); + assert!(is_projected_submission_over_auto_compact_limit( + 100, 10, 100 + )); + assert!(!is_projected_submission_over_auto_compact_limit( + 80, 10, 100 + )); + } + + #[tokio::test] + async fn reserved_compacted_without_incoming_items_records_initial_context_and_prompt() { + let (session, turn_context) = make_session_and_context().await; + let session = Arc::new(session); + let turn_context = Arc::new(turn_context); + let input = vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }]; + let response_item: ResponseItem = ResponseInputItem::from(input.clone()).into(); + let stale_pre_turn_context_items = vec![ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "stale context diff".to_string(), + }], + end_turn: None, + phase: None, + }]; + + persist_pre_turn_items_for_compaction_outcome( + &session, + &turn_context, + PreTurnCompactionOutcome::CompactedWithoutIncomingItems, + &stale_pre_turn_context_items, + &input, + response_item.clone(), + ) + .await; + + let mut expected = session.build_initial_context(turn_context.as_ref()).await; + expected.push(response_item); + let actual = session.clone_history().await.raw_items().to_vec(); + assert_eq!(actual, expected); + } + + #[tokio::test] + async fn compacted_with_incoming_items_emits_lifecycle_without_history_writes() { + let (session, turn_context) = make_session_and_context().await; + let session = Arc::new(session); + let turn_context = Arc::new(turn_context); + let input = vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }]; + let response_item: ResponseItem = ResponseInputItem::from(input.clone()).into(); + let stale_pre_turn_context_items = vec![ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "stale context diff".to_string(), + }], + end_turn: None, + phase: None, + }]; + + persist_pre_turn_items_for_compaction_outcome( + &session, + &turn_context, + PreTurnCompactionOutcome::CompactedWithIncomingItems, + &stale_pre_turn_context_items, + &input, + response_item, + ) + .await; + + let actual = session.clone_history().await.raw_items().to_vec(); + assert_eq!(actual, Vec::::new()); + } + + #[test] + fn estimate_user_input_token_count_is_positive_for_text_input() { + let input = vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }]; + let response_input_item = ResponseInputItem::from(input); + let response_item: ResponseItem = response_input_item.into(); + let estimated_tokens = estimate_item_token_count(&response_item); + assert!(estimated_tokens > 0); + } + + #[test] + fn estimate_user_input_token_count_ignores_skill_and_mention_payload_lengths() { + let short = vec![ + UserInput::Skill { + name: "s".to_string(), + path: PathBuf::from("/s"), + }, + UserInput::Mention { + name: "m".to_string(), + path: "app://m".to_string(), + }, + ]; + let long = vec![ + UserInput::Skill { + name: "very-long-skill-name-that-should-not-affect-prompt-serialization" + .to_string(), + path: PathBuf::from( + "/very/long/skill/path/that/should/not/affect/prompt/serialization/SKILL.md", + ), + }, + UserInput::Mention { + name: "very-long-mention-name-that-should-not-affect-prompt-serialization" + .to_string(), + path: "app://very-long-connector-path-that-should-not-affect-prompt-serialization" + .to_string(), + }, + ]; + + let short_response_input_item = ResponseInputItem::from(short); + let long_response_input_item = ResponseInputItem::from(long); + let short_response_item: ResponseItem = short_response_input_item.into(); + let long_response_item: ResponseItem = long_response_input_item.into(); + let short_tokens = estimate_item_token_count(&short_response_item); + let long_tokens = estimate_item_token_count(&long_response_item); + assert_eq!(short_tokens, long_tokens); + } + fn make_connector(id: &str, name: &str) -> AppInfo { AppInfo { id: id.to_string(), @@ -8083,8 +8423,8 @@ mod tests { .clone() .for_prompt(&reconstruction_turn.model_info.input_modalities); let user_messages1 = collect_user_messages(&snapshot1); - let rebuilt1 = - compact::build_compacted_history(initial_context.clone(), &user_messages1, summary1); + let mut rebuilt1 = initial_context.clone(); + rebuilt1.extend(compact::build_compacted_history(&user_messages1, summary1)); live_history.replace(rebuilt1); rollout_items.push(RolloutItem::Compacted(CompactedItem { message: summary1.to_string(), @@ -8126,8 +8466,8 @@ mod tests { .clone() .for_prompt(&reconstruction_turn.model_info.input_modalities); let user_messages2 = collect_user_messages(&snapshot2); - let rebuilt2 = - compact::build_compacted_history(initial_context.clone(), &user_messages2, summary2); + let mut rebuilt2 = initial_context.clone(); + rebuilt2.extend(compact::build_compacted_history(&user_messages2, summary2)); live_history.replace(rebuilt2); rollout_items.push(RolloutItem::Compacted(CompactedItem { message: summary2.to_string(), diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index a40f1a0b8ef..d21028e90d9 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -8,6 +8,7 @@ use crate::codex::Session; use crate::codex::TurnContext; use crate::codex::get_last_assistant_message_from_turn; use crate::context_manager::ContextManager; +use crate::context_manager::is_user_turn_boundary; use crate::error::CodexErr; use crate::error::Result as CodexResult; use crate::protocol::CompactedItem; @@ -32,6 +33,32 @@ pub const SUMMARIZATION_PROMPT: &str = include_str!("../templates/compact/prompt pub const SUMMARY_PREFIX: &str = include_str!("../templates/compact/summary_prefix.md"); const COMPACT_USER_MESSAGE_MAX_TOKENS: usize = 20_000; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AutoCompactCallsite { + /// Pre-turn auto-compaction where the incoming turn context + user message are included in + /// the compaction request. + PreTurnIncludingIncomingUserMessage, + /// Reserved pre-turn auto-compaction strategy that compacts from the end of the previous turn + /// only, excluding incoming turn context + user message. This is currently unused by the + /// default pre-turn flow and retained for future model-specific strategies. + #[allow(dead_code)] + PreTurnExcludingIncomingUserMessage, + /// Mid-turn compaction between assistant responses in a follow-up loop. + MidTurnContinuation, +} + +/// Controls whether compacted-history processing should reinsert canonical turn context. +/// +/// When callers exclude incoming user/context from the compaction request, they should typically +/// set reinjection to `Skip` and append canonical context together with the next user message. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TurnContextReinjection { + /// Insert canonical context immediately above the last real user message in compacted history. + ReinjectAboveLastRealUser, + /// Do not reinsert canonical context while processing compacted history. + Skip, +} + pub(crate) fn should_use_remote_compact_task(provider: &ModelProviderInfo) -> bool { provider.is_openai() } @@ -64,6 +91,9 @@ pub(crate) fn extract_trailing_model_switch_update_for_compaction_request( pub(crate) async fn run_inline_auto_compact_task( sess: Arc, turn_context: Arc, + auto_compact_callsite: AutoCompactCallsite, + turn_context_reinjection: TurnContextReinjection, + incoming_items: Option>, ) -> CodexResult<()> { let prompt = turn_context.compact_prompt().to_string(); let input = vec![UserInput::Text { @@ -72,7 +102,15 @@ pub(crate) async fn run_inline_auto_compact_task( text_elements: Vec::new(), }]; - run_compact_task_inner(sess, turn_context, input).await?; + run_compact_task_inner( + sess, + turn_context, + input, + Some(auto_compact_callsite), + turn_context_reinjection, + incoming_items, + ) + .await?; Ok(()) } @@ -87,13 +125,24 @@ pub(crate) async fn run_compact_task( collaboration_mode_kind: turn_context.collaboration_mode.mode, }); sess.send_event(&turn_context, start_event).await; - run_compact_task_inner(sess.clone(), turn_context, input).await + run_compact_task_inner( + sess, + turn_context, + input, + None, + TurnContextReinjection::Skip, + None, + ) + .await } async fn run_compact_task_inner( sess: Arc, turn_context: Arc, input: Vec, + auto_compact_callsite: Option, + turn_context_reinjection: TurnContextReinjection, + incoming_items: Option>, ) -> CodexResult<()> { let compaction_item = TurnItem::ContextCompaction(ContextCompactionItem::new()); sess.emit_turn_item_started(&turn_context, &compaction_item) @@ -105,6 +154,15 @@ async fn run_compact_task_inner( // tail of history (between turns), exclude it from the compaction request payload. let stripped_model_switch_item = extract_trailing_model_switch_update_for_compaction_request(&mut history); + if let Some(incoming_items) = incoming_items.as_ref() { + history.record_items(incoming_items.iter(), turn_context.truncation_policy); + } + if !history.raw_items().iter().any(is_user_turn_boundary) { + // Nothing to compact: do not rewrite history when there is no user-turn boundary. + sess.emit_turn_item_completed(&turn_context, compaction_item) + .await; + return Ok(()); + } history.record_items( &[initial_input_for_turn.into()], turn_context.truncation_policy, @@ -167,8 +225,11 @@ async fn run_compact_task_inner( } Err(e @ CodexErr::ContextWindowExceeded) => { if turn_input_len > 1 { - // Trim from the beginning to preserve cache (prefix-based) and keep recent messages intact. + // Trim from the beginning to preserve cache (prefix-based) and keep recent + // messages intact. error!( + turn_id = %turn_context.sub_id, + auto_compact_callsite = ?auto_compact_callsite, "Context window exceeded while compacting; removing oldest history item. Error: {e}" ); history.remove_first_item(); @@ -177,8 +238,12 @@ async fn run_compact_task_inner( continue; } sess.set_total_tokens_full(turn_context.as_ref()).await; - let event = EventMsg::Error(e.to_error_event(None)); - sess.send_event(&turn_context, event).await; + error!( + turn_id = %turn_context.sub_id, + auto_compact_callsite = ?auto_compact_callsite, + compact_error = %e, + "compaction failed after history truncation could not proceed" + ); return Err(e); } Err(e) => { @@ -193,11 +258,16 @@ async fn run_compact_task_inner( .await; tokio::time::sleep(delay).await; continue; - } else { - let event = EventMsg::Error(e.to_error_event(None)); - sess.send_event(&turn_context, event).await; - return Err(e); } + error!( + turn_id = %turn_context.sub_id, + auto_compact_callsite = ?auto_compact_callsite, + retries, + max_retries, + compact_error = %e, + "compaction failed after retry exhaustion" + ); + return Err(e); } } } @@ -207,9 +277,32 @@ async fn run_compact_task_inner( let summary_suffix = get_last_assistant_message_from_turn(history_items).unwrap_or_default(); let summary_text = format!("{SUMMARY_PREFIX}\n{summary_suffix}"); let user_messages = collect_user_messages(history_items); + let incoming_user_items = match incoming_items.as_ref() { + Some(items) => items + .iter() + .filter(|item| real_user_message_text(item).is_some()) + .cloned() + .collect(), + None => Vec::new(), + }; - let initial_context = sess.build_initial_context(turn_context.as_ref()).await; - let mut new_history = build_compacted_history(initial_context, &user_messages, &summary_text); + let initial_context = match turn_context_reinjection { + TurnContextReinjection::ReinjectAboveLastRealUser => { + sess.build_initial_context(turn_context.as_ref()).await + } + TurnContextReinjection::Skip => Vec::new(), + }; + let compacted_history = build_compacted_history_with_limit( + &user_messages, + &incoming_user_items, + &summary_text, + COMPACT_USER_MESSAGE_MAX_TOKENS, + ); + let mut new_history = process_compacted_history( + compacted_history, + &initial_context, + turn_context_reinjection, + ); // Reattach the stripped model-switch update only after successful compaction so the model // still sees the switch instructions on the next real sampling request. if let Some(model_switch_item) = stripped_model_switch_item { @@ -226,7 +319,7 @@ async fn run_compact_task_inner( let rollout_item = RolloutItem::Compacted(CompactedItem { message: summary_text.clone(), - replacement_history: None, + replacement_history: Some(sess.clone_history().await.raw_items().to_vec()), }); sess.persist_rollout_items(&[rollout_item]).await; @@ -281,28 +374,39 @@ pub(crate) fn is_summary_message(message: &str) -> bool { pub(crate) fn process_compacted_history( mut compacted_history: Vec, initial_context: &[ResponseItem], + turn_context_reinjection: TurnContextReinjection, ) -> Vec { + // Keep only model-visible transcript items that we allow from remote compaction output. compacted_history.retain(should_keep_compacted_history_item); - let initial_context = initial_context.to_vec(); - - // Re-inject canonical context from the current session since we stripped it - // from the pre-compaction history. Keep it right before the last user - // message so older user messages remain earlier in the transcript. - if let Some(last_user_index) = compacted_history.iter().rposition(|item| { - matches!( - crate::event_mapping::parse_turn_item(item), - Some(TurnItem::UserMessage(_)) - ) - }) { - compacted_history.splice(last_user_index..last_user_index, initial_context); - } else { - compacted_history.extend(initial_context); + match turn_context_reinjection { + TurnContextReinjection::ReinjectAboveLastRealUser => { + // Insert immediately above the last real user message so turn context applies to that + // user input rather than an earlier turn. + if let Some(insertion_index) = compacted_history + .iter() + .rposition(|item| real_user_message_text(item).is_some()) + { + compacted_history + .splice(insertion_index..insertion_index, initial_context.to_vec()); + } + } + TurnContextReinjection::Skip => {} } compacted_history } +fn real_user_message_text(item: &ResponseItem) -> Option { + match crate::event_mapping::parse_turn_item(item) { + Some(TurnItem::UserMessage(user_message)) => { + let message = user_message.message(); + (!is_summary_message(&message)).then_some(message) + } + _ => None, + } +} + /// Returns whether an item from remote compaction output should be preserved. /// /// Called while processing the model-provided compacted transcript, before we @@ -328,24 +432,24 @@ fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { } pub(crate) fn build_compacted_history( - initial_context: Vec, user_messages: &[String], summary_text: &str, ) -> Vec { build_compacted_history_with_limit( - initial_context, user_messages, + &[], summary_text, COMPACT_USER_MESSAGE_MAX_TOKENS, ) } fn build_compacted_history_with_limit( - mut history: Vec, user_messages: &[String], + incoming_user_items: &[ResponseItem], summary_text: &str, max_tokens: usize, ) -> Vec { + let mut history = Vec::new(); let mut selected_messages: Vec = Vec::new(); if max_tokens > 0 { let mut remaining = max_tokens; @@ -378,6 +482,8 @@ fn build_compacted_history_with_limit( }); } + history.extend(incoming_user_items.iter().cloned()); + let summary_text = if summary_text.is_empty() { "(no summary available)".to_string() } else { @@ -657,8 +763,8 @@ do things let max_tokens = 16; let big = "word ".repeat(200); let history = super::build_compacted_history_with_limit( - Vec::new(), std::slice::from_ref(&big), + &[], "SUMMARY", max_tokens, ); @@ -694,11 +800,10 @@ do things #[test] fn build_token_limited_compacted_history_appends_summary_message() { - let initial_context: Vec = Vec::new(); let user_messages = vec!["first user message".to_string()]; let summary_text = "summary text"; - let history = build_compacted_history(initial_context, &user_messages, summary_text); + let history = build_compacted_history(&user_messages, summary_text); assert!( !history.is_empty(), "expected compacted history to include summary" @@ -714,6 +819,55 @@ do things assert_eq!(summary, summary_text); } + #[test] + fn build_compacted_history_preserves_incoming_user_item_structure() { + let preserved_user_item = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ + ContentItem::InputImage { + image_url: "data:image/png;base64,AAAA".to_string(), + }, + ContentItem::InputText { + text: "latest user with image".to_string(), + }, + ], + end_turn: None, + phase: None, + }; + + let history = super::build_compacted_history_with_limit( + &["older user".to_string()], + std::slice::from_ref(&preserved_user_item), + "SUMMARY", + 128, + ); + + let expected = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "older user".to_string(), + }], + end_turn: None, + phase: None, + }, + preserved_user_item, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "SUMMARY".to_string(), + }], + end_turn: None, + phase: None, + }, + ]; + + assert_eq!(history, expected); + } + #[test] fn process_compacted_history_replaces_developer_messages() { let compacted_history = vec![ @@ -779,7 +933,11 @@ do things }, ]; - let refreshed = process_compacted_history(compacted_history, &initial_context); + let refreshed = process_compacted_history( + compacted_history, + &initial_context, + TurnContextReinjection::ReinjectAboveLastRealUser, + ); let expected = vec![ ResponseItem::Message { id: None, @@ -888,7 +1046,11 @@ keep me updated }, ]; - let refreshed = process_compacted_history(compacted_history, &initial_context); + let refreshed = process_compacted_history( + compacted_history, + &initial_context, + TurnContextReinjection::ReinjectAboveLastRealUser, + ); let expected = vec![ ResponseItem::Message { id: None, @@ -1024,7 +1186,11 @@ keep me updated phase: None, }]; - let refreshed = process_compacted_history(compacted_history, &initial_context); + let refreshed = process_compacted_history( + compacted_history, + &initial_context, + TurnContextReinjection::ReinjectAboveLastRealUser, + ); let expected = vec![ ResponseItem::Message { id: None, @@ -1089,7 +1255,11 @@ keep me updated phase: None, }]; - let refreshed = process_compacted_history(compacted_history, &initial_context); + let refreshed = process_compacted_history( + compacted_history, + &initial_context, + TurnContextReinjection::ReinjectAboveLastRealUser, + ); let expected = vec![ ResponseItem::Message { id: None, @@ -1130,4 +1300,320 @@ keep me updated ]; assert_eq!(refreshed, expected); } + + #[test] + fn process_compacted_history_pre_turn_places_summary_last() { + let compacted_history = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "older user".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!("{SUMMARY_PREFIX}\nsummary text"), + }], + end_turn: None, + phase: None, + }, + ]; + let initial_context = vec![ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "fresh permissions".to_string(), + }], + end_turn: None, + phase: None, + }]; + + let refreshed = process_compacted_history( + compacted_history, + &initial_context, + TurnContextReinjection::ReinjectAboveLastRealUser, + ); + let expected = vec![ + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "fresh permissions".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "older user".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!("{SUMMARY_PREFIX}\nsummary text"), + }], + end_turn: None, + phase: None, + }, + ]; + assert_eq!(refreshed, expected); + } + + #[test] + fn process_compacted_history_preserves_summary_order() { + let compacted_history = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "older user".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!("{SUMMARY_PREFIX}\nolder summary"), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "newer user".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!("{SUMMARY_PREFIX}\nlatest summary"), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "assistant after latest summary".to_string(), + }], + end_turn: None, + phase: None, + }, + ]; + + let refreshed = + process_compacted_history(compacted_history, &[], TurnContextReinjection::Skip); + let expected = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "older user".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!("{SUMMARY_PREFIX}\nolder summary"), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "newer user".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!("{SUMMARY_PREFIX}\nlatest summary"), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "assistant after latest summary".to_string(), + }], + end_turn: None, + phase: None, + }, + ]; + assert_eq!(refreshed, expected); + } + + #[test] + fn process_compacted_history_skips_context_insertion_without_real_user_message() { + let compacted_history = vec![ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!("{SUMMARY_PREFIX}\nsummary text"), + }], + end_turn: None, + phase: None, + }]; + let initial_context = vec![ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "fresh permissions".to_string(), + }], + end_turn: None, + phase: None, + }]; + + let refreshed = process_compacted_history( + compacted_history, + &initial_context, + TurnContextReinjection::Skip, + ); + let expected = vec![ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!("{SUMMARY_PREFIX}\nsummary text"), + }], + end_turn: None, + phase: None, + }]; + assert_eq!(refreshed, expected); + } + + #[test] + fn process_compacted_history_reinject_noops_without_real_user_message() { + let compacted_history = vec![ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!("{SUMMARY_PREFIX}\nsummary text"), + }], + end_turn: None, + phase: None, + }]; + let initial_context = vec![ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "fresh permissions".to_string(), + }], + end_turn: None, + phase: None, + }]; + + let refreshed = process_compacted_history( + compacted_history, + &initial_context, + TurnContextReinjection::ReinjectAboveLastRealUser, + ); + let expected = vec![ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!("{SUMMARY_PREFIX}\nsummary text"), + }], + end_turn: None, + phase: None, + }]; + assert_eq!(refreshed, expected); + } + + #[test] + fn process_compacted_history_mid_turn_without_orphan_user_places_summary_last() { + let compacted_history = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "older user".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!("{SUMMARY_PREFIX}\nsummary text"), + }], + end_turn: None, + phase: None, + }, + ]; + let initial_context = vec![ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "fresh permissions".to_string(), + }], + end_turn: None, + phase: None, + }]; + + let refreshed = process_compacted_history( + compacted_history, + &initial_context, + TurnContextReinjection::ReinjectAboveLastRealUser, + ); + let expected = vec![ + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "fresh permissions".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "older user".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!("{SUMMARY_PREFIX}\nsummary text"), + }], + end_turn: None, + phase: None, + }, + ]; + assert_eq!(refreshed, expected); + } } diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 3d181f88552..6d357aecdc9 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -4,10 +4,14 @@ use crate::Prompt; use crate::codex::Session; use crate::codex::TurnContext; use crate::compact::extract_trailing_model_switch_update_for_compaction_request; +use crate::compact::AutoCompactCallsite; +use crate::compact::TurnContextReinjection; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; +use crate::context_manager::estimate_item_token_count; use crate::context_manager::estimate_response_item_model_visible_bytes; use crate::context_manager::is_codex_generated_item; +use crate::context_manager::is_user_turn_boundary; use crate::error::CodexErr; use crate::error::Result as CodexResult; use crate::protocol::CompactedItem; @@ -25,8 +29,19 @@ use tracing::info; pub(crate) async fn run_inline_remote_auto_compact_task( sess: Arc, turn_context: Arc, + auto_compact_callsite: AutoCompactCallsite, + // Controls whether canonical turn context should be reinserted into compacted history. + turn_context_reinjection: TurnContextReinjection, + incoming_items: Option>, ) -> CodexResult<()> { - run_remote_compact_task_inner(&sess, &turn_context).await?; + run_remote_compact_task_inner( + &sess, + &turn_context, + auto_compact_callsite, + turn_context_reinjection, + incoming_items, + ) + .await?; Ok(()) } @@ -41,18 +56,40 @@ pub(crate) async fn run_remote_compact_task( }); sess.send_event(&turn_context, start_event).await; - run_remote_compact_task_inner(&sess, &turn_context).await + run_remote_compact_task_inner( + &sess, + &turn_context, + AutoCompactCallsite::PreTurnExcludingIncomingUserMessage, + // Manual `/compact` should not reinsert turn context into compacted history; we reseed + // canonical initial context before the next user turn. + TurnContextReinjection::Skip, + None, + ) + .await } async fn run_remote_compact_task_inner( sess: &Arc, turn_context: &Arc, + auto_compact_callsite: AutoCompactCallsite, + turn_context_reinjection: TurnContextReinjection, + incoming_items: Option>, ) -> CodexResult<()> { - if let Err(err) = run_remote_compact_task_inner_impl(sess, turn_context).await { - let event = EventMsg::Error( - err.to_error_event(Some("Error running remote compact task".to_string())), + if let Err(err) = run_remote_compact_task_inner_impl( + sess, + turn_context, + auto_compact_callsite, + turn_context_reinjection, + incoming_items, + ) + .await + { + error!( + turn_id = %turn_context.sub_id, + auto_compact_callsite = ?auto_compact_callsite, + compact_error = %err, + "remote compaction task failed" ); - sess.send_event(turn_context, event).await; return Err(err); } Ok(()) @@ -61,6 +98,9 @@ async fn run_remote_compact_task_inner( async fn run_remote_compact_task_inner_impl( sess: &Arc, turn_context: &Arc, + auto_compact_callsite: AutoCompactCallsite, + turn_context_reinjection: TurnContextReinjection, + incoming_items: Option>, ) -> CodexResult<()> { let compaction_item = TurnItem::ContextCompaction(ContextCompactionItem::new()); sess.emit_turn_item_started(turn_context, &compaction_item) @@ -75,10 +115,21 @@ async fn run_remote_compact_task_inner_impl( &mut history, turn_context.as_ref(), &base_instructions, + incoming_items.as_deref(), ); + if let Some(incoming_items) = incoming_items { + history.record_items(incoming_items.iter(), turn_context.truncation_policy); + } + if !history.raw_items().iter().any(is_user_turn_boundary) { + // Nothing to compact: do not rewrite history when there is no user-turn boundary. + sess.emit_turn_item_completed(turn_context, compaction_item) + .await; + return Ok(()); + } if deleted_items > 0 { info!( turn_id = %turn_context.sub_id, + auto_compact_callsite = ?auto_compact_callsite, deleted_items, "trimmed history items before remote compaction" ); @@ -115,6 +166,7 @@ async fn run_remote_compact_task_inner_impl( build_compact_request_log_data(&prompt.input, &prompt.base_instructions.text); log_remote_compact_failure( turn_context, + auto_compact_callsite, &compact_request_log_data, total_usage_breakdown, &err, @@ -123,7 +175,7 @@ async fn run_remote_compact_task_inner_impl( }) .await?; new_history = sess - .process_compacted_history(turn_context, new_history) + .process_compacted_history(turn_context, new_history, turn_context_reinjection) .await; // Reattach the stripped model-switch update only after successful compaction so the model // still sees the switch instructions on the next real sampling request. @@ -173,12 +225,14 @@ fn build_compact_request_log_data( fn log_remote_compact_failure( turn_context: &TurnContext, + auto_compact_callsite: AutoCompactCallsite, log_data: &CompactRequestLogData, total_usage_breakdown: TotalTokenUsageBreakdown, err: &CodexErr, ) { error!( turn_id = %turn_context.sub_id, + auto_compact_callsite = ?auto_compact_callsite, last_api_response_total_tokens = total_usage_breakdown.last_api_response_total_tokens, all_history_items_model_visible_bytes = total_usage_breakdown.all_history_items_model_visible_bytes, estimated_tokens_of_items_added_since_last_successful_api_response = total_usage_breakdown.estimated_tokens_of_items_added_since_last_successful_api_response, @@ -194,15 +248,37 @@ fn trim_function_call_history_to_fit_context_window( history: &mut ContextManager, turn_context: &TurnContext, base_instructions: &BaseInstructions, + incoming_items: Option<&[ResponseItem]>, ) -> usize { - let mut deleted_items = 0usize; let Some(context_window) = turn_context.model_context_window() else { - return deleted_items; + return 0; }; + let incoming_items_tokens = incoming_items + .unwrap_or_default() + .iter() + .map(estimate_item_token_count) + .fold(0_i64, i64::saturating_add); + trim_codex_generated_tail_items_to_fit_context_window( + history, + context_window, + base_instructions, + incoming_items_tokens, + ) +} + +fn trim_codex_generated_tail_items_to_fit_context_window( + history: &mut ContextManager, + context_window: i64, + base_instructions: &BaseInstructions, + incoming_items_tokens: i64, +) -> usize { + let mut deleted_items = 0usize; while history .estimate_token_count_with_base_instructions(base_instructions) - .is_some_and(|estimated_tokens| estimated_tokens > context_window) + .is_some_and(|estimated_tokens| { + estimated_tokens.saturating_add(incoming_items_tokens) > context_window + }) { let Some(last_item) = history.raw_items().last() else { break; @@ -218,3 +294,90 @@ fn trim_function_call_history_to_fit_context_window( deleted_items } + +#[cfg(test)] +mod tests { + use super::*; + use crate::truncate::TruncationPolicy; + use codex_protocol::models::ContentItem; + use pretty_assertions::assert_eq; + + fn user_message(text: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: text.to_string(), + }], + end_turn: None, + phase: None, + } + } + + fn developer_message(text: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: text.to_string(), + }], + end_turn: None, + phase: None, + } + } + + #[test] + fn trim_accounts_for_incoming_items_tokens() { + let base_instructions = BaseInstructions { + text: String::new(), + }; + let incoming_items = vec![user_message( + "INCOMING_USER_MESSAGE_THAT_TIPS_OVER_THE_WINDOW", + )]; + let incoming_items_tokens = incoming_items + .iter() + .map(estimate_item_token_count) + .fold(0_i64, i64::saturating_add); + assert!( + incoming_items_tokens > 0, + "expected incoming item token estimate to be positive" + ); + + let mut history = ContextManager::new(); + let history_items = vec![ + user_message("USER_ONE"), + developer_message("TRAILING_CODEX_GENERATED_CONTEXT"), + ]; + history.record_items(history_items.iter(), TruncationPolicy::Tokens(10_000)); + let history_tokens = history + .estimate_token_count_with_base_instructions(&base_instructions) + .unwrap_or_default(); + let context_window = history_tokens + .saturating_add(incoming_items_tokens) + .saturating_sub(1); + let mut without_incoming_projection = history.clone(); + + let deleted_without_incoming = trim_codex_generated_tail_items_to_fit_context_window( + &mut without_incoming_projection, + context_window, + &base_instructions, + 0, + ); + assert_eq!( + deleted_without_incoming, 0, + "history-only projection should not trim when currently under the limit" + ); + + let deleted_with_incoming = trim_codex_generated_tail_items_to_fit_context_window( + &mut history, + context_window, + &base_instructions, + incoming_items_tokens, + ); + assert_eq!( + deleted_with_incoming, 1, + "incoming projection should trim trailing codex-generated history to fit pre-turn request" + ); + assert_eq!(history.raw_items(), vec![user_message("USER_ONE")]); + } +} diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index b6b3ceae5af..acd9778d969 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -395,7 +395,7 @@ fn estimate_reasoning_length(encoded_len: usize) -> usize { .saturating_sub(650) } -fn estimate_item_token_count(item: &ResponseItem) -> i64 { +pub(crate) fn estimate_item_token_count(item: &ResponseItem) -> i64 { let model_visible_bytes = estimate_response_item_model_visible_bytes(item); approx_tokens_from_byte_count_i64(model_visible_bytes) } diff --git a/codex-rs/core/src/context_manager/mod.rs b/codex-rs/core/src/context_manager/mod.rs index 853f8af5ac0..2118ebc5754 100644 --- a/codex-rs/core/src/context_manager/mod.rs +++ b/codex-rs/core/src/context_manager/mod.rs @@ -4,6 +4,7 @@ pub(crate) mod updates; pub(crate) use history::ContextManager; pub(crate) use history::TotalTokenUsageBreakdown; +pub(crate) use history::estimate_item_token_count; pub(crate) use history::estimate_response_item_model_visible_bytes; pub(crate) use history::is_codex_generated_item; pub(crate) use history::is_user_turn_boundary; diff --git a/codex-rs/core/src/tasks/compact.rs b/codex-rs/core/src/tasks/compact.rs index b56f7b1df52..9835c95bee0 100644 --- a/codex-rs/core/src/tasks/compact.rs +++ b/codex-rs/core/src/tasks/compact.rs @@ -3,6 +3,8 @@ use std::sync::Arc; use super::SessionTask; use super::SessionTaskContext; use crate::codex::TurnContext; +use crate::context_manager::is_user_turn_boundary; +use crate::protocol::EventMsg; use crate::state::TaskKind; use async_trait::async_trait; use codex_protocol::user_input::UserInput; @@ -25,20 +27,48 @@ impl SessionTask for CompactTask { _cancellation_token: CancellationToken, ) -> Option { let session = session.clone_session(); + let has_user_turn_boundary = session + .clone_history() + .await + .raw_items() + .iter() + .any(is_user_turn_boundary); if crate::compact::should_use_remote_compact_task(&ctx.provider) { let _ = session.services.otel_manager.counter( "codex.task.compact", 1, &[("type", "remote")], ); - let _ = crate::compact_remote::run_remote_compact_task(session, ctx).await; + if let Err(err) = + crate::compact_remote::run_remote_compact_task(session.clone(), ctx.clone()).await + { + let event = EventMsg::Error( + err.to_error_event(Some("Error running remote compact task".to_string())), + ); + session.send_event(&ctx, event).await; + } else if has_user_turn_boundary { + // Manual `/compact` rewrites history to compacted transcript items and drops + // per-turn context entries. Force initial-context reseeding on the next user turn. + session.mark_initial_context_unseeded_for_next_turn().await; + } } else { let _ = session.services.otel_manager.counter( "codex.task.compact", 1, &[("type", "local")], ); - let _ = crate::compact::run_compact_task(session, ctx, input).await; + if let Err(err) = + crate::compact::run_compact_task(session.clone(), ctx.clone(), input).await + { + let event = EventMsg::Error( + err.to_error_event(Some("Error running local compact task".to_string())), + ); + session.send_event(&ctx, event).await; + } else if has_user_turn_boundary { + // Manual `/compact` rewrites history to compacted transcript items and drops + // per-turn context entries. Force initial-context reseeding on the next user turn. + session.mark_initial_context_unseeded_for_next_turn().await; + } } None diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index 1428f7775bb..4d44fc9b814 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -8,6 +8,7 @@ use crate::codex::run_turn; use crate::state::TaskKind; use async_trait::async_trait; use codex_otel::OtelManager; +use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelInfo; use codex_protocol::user_input::UserInput; use tokio::task::JoinHandle; @@ -23,12 +24,14 @@ type PrewarmedSessionTask = JoinHandle>; pub(crate) struct RegularTask { prewarmed_session_task: Mutex>, + pre_turn_context_items: Vec, } impl Default for RegularTask { fn default() -> Self { Self { prewarmed_session_task: Mutex::new(None), + pre_turn_context_items: Vec::new(), } } } @@ -55,9 +58,15 @@ impl RegularTask { Self { prewarmed_session_task: Mutex::new(Some(prewarmed_session_task)), + pre_turn_context_items: Vec::new(), } } + pub(crate) fn with_pre_turn_context_items(mut self, items: Vec) -> Self { + self.pre_turn_context_items = items; + self + } + async fn take_prewarmed_session(&self) -> Option { let prewarmed_session_task = self .prewarmed_session_task @@ -101,6 +110,7 @@ impl SessionTask for RegularTask { sess, ctx, input, + self.pre_turn_context_items.clone(), prewarmed_client_session, cancellation_token, ) diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 4d4abafeb6e..cbbbddaac38 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -115,6 +115,94 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn settings_only_empty_turn_persists_updates_for_next_non_empty_turn() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let resp_mock = mount_sse_sequence( + &server, + vec![sse_completed("resp-1"), sse_completed("resp-2")], + ) + .await; + + let mut builder = test_codex().with_model("gpt-5.2-codex"); + let test = builder.build(&server).await?; + let model = test.session_configured.model.clone(); + + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "first".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.cwd_path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ReadOnly, + model: model.clone(), + effort: test.config.model_reasoning_effort, + summary: ReasoningSummary::Auto, + collaboration_mode: None, + personality: None, + }) + .await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + // Settings-only turn with no user message. + test.codex + .submit(Op::UserTurn { + items: Vec::new(), + final_output_json_schema: None, + cwd: test.cwd_path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: model.clone(), + effort: test.config.model_reasoning_effort, + summary: ReasoningSummary::Auto, + collaboration_mode: None, + personality: None, + }) + .await?; + + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "after settings-only turn".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, + effort: test.config.model_reasoning_effort, + summary: ReasoningSummary::Auto, + collaboration_mode: None, + personality: None, + }) + .await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let requests = resp_mock.requests(); + assert_eq!( + requests.len(), + 2, + "expected only first and third turns to hit the model" + ); + + let third_turn_request = requests.last().expect("expected third turn request"); + let developer_texts = third_turn_request.message_input_texts("developer"); + assert!( + developer_texts + .iter() + .any(|text| text.contains("sandbox_mode` is `danger-full-access`")), + "expected danger-full-access permissions update in next non-empty turn: {developer_texts:?}" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn model_and_personality_change_only_appends_model_instructions() -> Result<()> { skip_if_no_network!(Ok(())); From 4022de74efbf43dcbd6f2242a8c2b22d31214100 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Thu, 12 Feb 2026 01:28:47 -0800 Subject: [PATCH 06/80] 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 0d30b8d602e..2437501836e 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3412,10 +3412,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 d21028e90d9..40de1b5ec4e 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 08850ec38f6..c7da207a6f6 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 cbbbddaac38..d02374f54cc 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 da30b01bd7c..89066bacaac 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: From e87455e2840cc62a85d6d80120648fbffe793cc1 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Thu, 12 Feb 2026 01:33:41 -0800 Subject: [PATCH 07/80] codex: persist pre-turn updates when compaction fails --- codex-rs/core/src/codex.rs | 33 ++++--- codex-rs/core/tests/suite/compact.rs | 133 +++++++++++++++++++++++++++ 2 files changed, 152 insertions(+), 14 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 2437501836e..25e8849cdfa 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 c7da207a6f6..d504f9ff55a 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!(); From 8d06dd5f8b4f332084ab40f807b86ef17e82c4f1 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Thu, 12 Feb 2026 11:33:42 -0800 Subject: [PATCH 08/80] Persist pre-turn updates before apps tool-list cancellation --- codex-rs/core/src/codex.rs | 63 +++++++++++++++++++++++++++++++------- 1 file changed, 52 insertions(+), 11 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 25e8849cdfa..d13f51c4367 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4317,6 +4317,15 @@ pub(crate) async fn run_turn( return None; } }; + persist_pre_turn_items_for_compaction_outcome( + &sess, + &turn_context, + pre_turn_compaction_outcome, + &pre_turn_context_items, + &input, + response_item, + ) + .await; let skills_outcome = Some( sess.services @@ -4424,17 +4433,6 @@ pub(crate) async fn run_turn( .track_app_mentioned(tracking.clone(), mentioned_app_invocations); sess.merge_connector_selection(explicitly_enabled_connectors.clone()) .await; - - persist_pre_turn_items_for_compaction_outcome( - &sess, - &turn_context, - pre_turn_compaction_outcome, - &pre_turn_context_items, - &input, - response_item, - ) - .await; - if !skill_items.is_empty() { sess.record_conversation_items(&turn_context, &skill_items) .await; @@ -6125,6 +6123,49 @@ mod tests { assert_eq!(actual, Vec::::new()); } + #[tokio::test] + async fn run_turn_persists_pre_turn_context_before_apps_tool_listing_cancellation() { + let (session, mut turn_context) = make_session_and_context().await; + let session = Arc::new(session); + let mut config = (*turn_context.config).clone(); + config.features.enable(Feature::Apps); + turn_context.features = config.features.clone(); + turn_context.config = Arc::new(config); + let turn_context = Arc::new(turn_context); + let input = vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }]; + let response_item: ResponseItem = ResponseInputItem::from(input.clone()).into(); + let pre_turn_context_items = vec![ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "model-visible setting diff".to_string(), + }], + end_turn: None, + phase: None, + }]; + let cancellation_token = CancellationToken::new(); + cancellation_token.cancel(); + + let result = run_turn( + Arc::clone(&session), + Arc::clone(&turn_context), + input, + pre_turn_context_items.clone(), + None, + cancellation_token, + ) + .await; + assert_eq!(result, None); + + let mut expected_history = pre_turn_context_items; + expected_history.push(response_item); + let actual_history = session.clone_history().await.raw_items().to_vec(); + assert_eq!(actual_history, expected_history); + } + #[test] fn estimate_user_input_token_count_is_positive_for_text_input() { let input = vec![UserInput::Text { From 36499b59f0c2809519a7aeeeb354170aa9cd78fc Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Thu, 12 Feb 2026 11:39:43 -0800 Subject: [PATCH 09/80] Reinject canonical context for model-switch pre-turn compaction --- codex-rs/core/src/codex.rs | 6 +++++- codex-rs/core/tests/suite/compact.rs | 9 +++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d13f51c4367..b30a1fa89ab 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4681,7 +4681,11 @@ async fn maybe_run_previous_model_inline_compact( sess, &previous_turn_context, AutoCompactCallsite::PreTurnExcludingIncomingUserMessage, - TurnContextReinjection::Skip, + // Even though incoming turn items are excluded here, this pass can be the only + // compaction run before submission. Reinject canonical context so unchanged + // model-visible instructions remain present if no follow-up pre-turn compaction + // is needed. + TurnContextReinjection::ReinjectAboveLastRealUser, None, ) .await?; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index d504f9ff55a..5a6ad5b4265 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -132,11 +132,20 @@ fn assert_pre_sampling_switch_compaction_requests( !compact_body.contains(""), "pre-sampling compact request should strip trailing model-switch update item" ); + let first_body = first.to_string(); + assert!( + body_contains_text(&first_body, ""), + "first request should include canonical environment context" + ); let follow_up_body = follow_up.to_string(); assert!( follow_up_body.contains(""), "follow-up request after successful model-switch compaction should include model-switch update item" ); + assert!( + body_contains_text(&follow_up_body, ""), + "follow-up request should preserve canonical environment context after pre-sampling compaction" + ); } async fn assert_compaction_uses_turn_lifecycle_id(codex: &std::sync::Arc) { From 59d3431a1547703b35c91c0ac09b3c234a58b438 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Thu, 12 Feb 2026 12:15:09 -0800 Subject: [PATCH 10/80] Fix clippy useless_vec in compact_remote test --- codex-rs/core/src/compact_remote.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 6d357aecdc9..1039e24401d 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -331,7 +331,7 @@ mod tests { let base_instructions = BaseInstructions { text: String::new(), }; - let incoming_items = vec![user_message( + let incoming_items = [user_message( "INCOMING_USER_MESSAGE_THAT_TIPS_OVER_THE_WINDOW", )]; let incoming_items_tokens = incoming_items @@ -344,7 +344,7 @@ mod tests { ); let mut history = ContextManager::new(); - let history_items = vec![ + let history_items = [ user_message("USER_ONE"), developer_message("TRAILING_CODEX_GENERATED_CONTEXT"), ]; From 2a804d9f4d9e67741e64c8c95b989e6fb3c584c1 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Thu, 12 Feb 2026 14:34:31 -0800 Subject: [PATCH 11/80] Update snaps --- ...anual_compact_with_history_shapes.snap.new | 22 +++++++++++++++ ...mpact__mid_turn_compaction_shapes.snap.new | 25 +++++++++++++++++ ...paction_including_incoming_shapes.snap.new | 27 +++++++++++++++++++ ...anual_compact_with_history_shapes.snap.new | 21 +++++++++++++++ ...remote_mid_turn_compaction_shapes.snap.new | 21 +++++++++++++++ ...on_context_window_exceeded_shapes.snap.new | 14 ++++++++++ ...paction_including_incoming_shapes.snap.new | 25 +++++++++++++++++ 7 files changed, 155 insertions(+) create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_with_history_shapes.snap.new create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__compact__mid_turn_compaction_shapes.snap.new create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap.new create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_with_history_shapes.snap.new create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_shapes.snap.new create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_context_window_exceeded_shapes.snap.new create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap.new diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_with_history_shapes.snap.new b/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_with_history_shapes.snap.new new file mode 100644 index 00000000000..6fa78f6e20b --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_with_history_shapes.snap.new @@ -0,0 +1,22 @@ +--- +source: core/tests/suite/compact.rs +assertion_line: 2395 +expression: "format_labeled_requests_snapshot(\"Manual /compact with prior user history compacts existing history and the follow-up turn includes the compact summary plus new user message.\",\n&[(\"Local Compaction Request\", &requests[1]),\n(\"Local Post-Compaction History Layout\", &requests[2]),])" +--- +Scenario: Manual /compact with prior user history compacts existing history and the follow-up turn includes the compact summary plus new user message. + +## Local Compaction Request +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:first manual turn +04:message/assistant:FIRST_REPLY +05:message/user: + +## Local Post-Compaction History Layout +00:message/user:first manual turn +01:message/user:\nFIRST_MANUAL_SUMMARY +02:message/developer: +03:message/user: +04:message/user:> +05:message/user:second manual turn diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__mid_turn_compaction_shapes.snap.new b/codex-rs/core/tests/suite/snapshots/all__suite__compact__mid_turn_compaction_shapes.snap.new new file mode 100644 index 00000000000..9d87e6a9e1f --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__mid_turn_compaction_shapes.snap.new @@ -0,0 +1,25 @@ +--- +source: core/tests/suite/compact.rs +assertion_line: 2657 +expression: "format_labeled_requests_snapshot(\"Mid-turn continuation compaction after tool output: compact request includes tool artifacts and follow-up request includes the summary.\",\n&[(\"Local Compaction Request\", &auto_compact_mock.single_request()),\n(\"Local Post-Compaction History Layout\",\n&post_auto_compact_mock.single_request()),])" +--- +Scenario: Mid-turn continuation compaction after tool output: compact request includes tool artifacts and follow-up request includes the summary. + +## Local Compaction Request +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:function call limit push +04:function_call/test_tool +05:function_call_output:unsupported call: test_tool +06:message/assistant:FINAL_REPLY +07:message/user:FOLLOW_UP_AFTER_LIMIT +08:message/user: + +## Local Post-Compaction History Layout +00:message/user:function call limit push +01:message/developer: +02:message/user: +03:message/user:> +04:message/user:FOLLOW_UP_AFTER_LIMIT +05:message/user:\nAUTO_SUMMARY diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap.new b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap.new new file mode 100644 index 00000000000..0458d880729 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap.new @@ -0,0 +1,27 @@ +--- +source: core/tests/suite/compact.rs +assertion_line: 3026 +expression: "format_labeled_requests_snapshot(\"Pre-turn auto-compaction with a context override emits the context diff in the compact request while the incoming user message is still excluded.\",\n&[(\"Local Compaction Request\", &requests[2]),\n(\"Local Post-Compaction History Layout\", &requests[3]),])" +--- +Scenario: Pre-turn auto-compaction with a context override emits the context diff in the compact request while the incoming user message is still excluded. + +## Local Compaction Request +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:USER_ONE +04:message/assistant:FIRST_REPLY +05:message/user:USER_TWO +06:message/assistant:SECOND_REPLY +07:message/user: +08:message/user: | | USER_THREE +09:message/user: + +## Local Post-Compaction History Layout +00:message/user:USER_ONE +01:message/user:USER_TWO +02:message/developer: +03:message/user: +04:message/user: +05:message/user: | | USER_THREE +06:message/user:\nPRE_TURN_SUMMARY diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_with_history_shapes.snap.new b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_with_history_shapes.snap.new new file mode 100644 index 00000000000..f9e4ad23aa3 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_with_history_shapes.snap.new @@ -0,0 +1,21 @@ +--- +source: core/tests/suite/compact_remote.rs +assertion_line: 174 +expression: "format_labeled_requests_snapshot(\"Remote manual /compact with prior user history compacts existing history and follow-up includes compact summary plus new user message.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", follow_up_request),])" +--- +Scenario: Remote manual /compact with prior user history compacts existing history and follow-up includes compact summary plus new user message. + +## Remote Compaction Request +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:hello remote compact +04:message/assistant:FIRST_REMOTE_REPLY + +## Remote Post-Compaction History Layout +00:message/user:REMOTE_COMPACTED_SUMMARY +01:compaction:encrypted=true +02:message/developer: +03:message/user: +04:message/user:> +05:message/user:after compact diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_shapes.snap.new b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_shapes.snap.new new file mode 100644 index 00000000000..e7621c95404 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_shapes.snap.new @@ -0,0 +1,21 @@ +--- +source: core/tests/suite/compact_remote.rs +assertion_line: 1578 +expression: "format_labeled_requests_snapshot(\"Remote mid-turn continuation compaction after tool output: compact request includes tool artifacts and follow-up request includes the summary.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &requests[1]),])" +--- +Scenario: Remote mid-turn continuation compaction after tool output: compact request includes tool artifacts and follow-up request includes the summary. + +## Remote Compaction Request +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:USER_ONE +04:function_call/test_tool +05:function_call_output:unsupported call: test_tool + +## Remote Post-Compaction History Layout +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:USER_ONE +04:message/user:\nREMOTE_MID_TURN_SUMMARY diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_context_window_exceeded_shapes.snap.new b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_context_window_exceeded_shapes.snap.new new file mode 100644 index 00000000000..01bbca3bba1 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_context_window_exceeded_shapes.snap.new @@ -0,0 +1,14 @@ +--- +source: core/tests/suite/compact_remote.rs +assertion_line: 1503 +expression: "format_labeled_requests_snapshot(\"Remote pre-turn auto-compaction context-window failure: compaction request excludes the incoming user message and the turn errors.\",\n&[(\"Remote Compaction Request (Incoming User Excluded)\",\n&include_attempt_request),])" +--- +Scenario: Remote pre-turn auto-compaction context-window failure: compaction request excludes the incoming user message and the turn errors. + +## Remote Compaction Request (Incoming User Excluded) +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:USER_ONE +04:message/assistant:REMOTE_FIRST_REPLY +05:message/user:USER_TWO diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap.new b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap.new new file mode 100644 index 00000000000..a7706335304 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap.new @@ -0,0 +1,25 @@ +--- +source: core/tests/suite/compact_remote.rs +assertion_line: 1398 +expression: "format_labeled_requests_snapshot(\"Remote pre-turn auto-compaction with a context override emits the context diff in the compact request while excluding the incoming user message.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &requests[2]),])" +--- +Scenario: Remote pre-turn auto-compaction with a context override emits the context diff in the compact request while excluding the incoming user message. + +## Remote Compaction Request +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:USER_ONE +04:message/assistant:REMOTE_FIRST_REPLY +05:message/user:USER_TWO +06:message/assistant:REMOTE_SECOND_REPLY +07:message/user: +08:message/user:USER_THREE + +## Remote Post-Compaction History Layout +00:message/user:USER_ONE +01:message/developer: +02:message/user: +03:message/user: +04:message/user:USER_TWO +05:message/user:\nREMOTE_PRE_TURN_SUMMARY From 8b2f5066a57c9a909b132ba32f00da7c7fb2583b Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Thu, 12 Feb 2026 14:36:13 -0800 Subject: [PATCH 12/80] update snaps --- ...t__manual_compact_with_history_shapes.snap | 10 +++---- ...anual_compact_with_history_shapes.snap.new | 22 --------------- ...mpact__mid_turn_compaction_shapes.snap.new | 25 ----------------- ..._compaction_including_incoming_shapes.snap | 17 ++++++------ ...paction_including_incoming_shapes.snap.new | 27 ------------------- ...te_manual_compact_with_history_shapes.snap | 10 +++---- ...anual_compact_with_history_shapes.snap.new | 21 --------------- ...te__remote_mid_turn_compaction_shapes.snap | 8 +++--- ...remote_mid_turn_compaction_shapes.snap.new | 21 --------------- ...action_context_window_exceeded_shapes.snap | 1 + ...on_context_window_exceeded_shapes.snap.new | 14 ---------- ..._compaction_including_incoming_shapes.snap | 10 +++---- ...paction_including_incoming_shapes.snap.new | 25 ----------------- 13 files changed, 29 insertions(+), 182 deletions(-) delete mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_with_history_shapes.snap.new delete mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__compact__mid_turn_compaction_shapes.snap.new delete mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap.new delete mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_with_history_shapes.snap.new delete mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_shapes.snap.new delete mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_context_window_exceeded_shapes.snap.new delete mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap.new diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_with_history_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_with_history_shapes.snap index bcca5d35755..c88ff33122d 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_with_history_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_with_history_shapes.snap @@ -13,9 +13,9 @@ Scenario: Manual /compact with prior user history compacts existing history and 05:message/user: ## Local Post-Compaction History Layout -00:message/developer: -01:message/user: -02:message/user:> -03:message/user:first manual turn -04:message/user:\nFIRST_MANUAL_SUMMARY +00:message/user:first manual turn +01:message/user:\nFIRST_MANUAL_SUMMARY +02:message/developer: +03:message/user: +04:message/user:> 05:message/user:second manual turn diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_with_history_shapes.snap.new b/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_with_history_shapes.snap.new deleted file mode 100644 index 6fa78f6e20b..00000000000 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_with_history_shapes.snap.new +++ /dev/null @@ -1,22 +0,0 @@ ---- -source: core/tests/suite/compact.rs -assertion_line: 2395 -expression: "format_labeled_requests_snapshot(\"Manual /compact with prior user history compacts existing history and the follow-up turn includes the compact summary plus new user message.\",\n&[(\"Local Compaction Request\", &requests[1]),\n(\"Local Post-Compaction History Layout\", &requests[2]),])" ---- -Scenario: Manual /compact with prior user history compacts existing history and the follow-up turn includes the compact summary plus new user message. - -## Local Compaction Request -00:message/developer: -01:message/user: -02:message/user:> -03:message/user:first manual turn -04:message/assistant:FIRST_REPLY -05:message/user: - -## Local Post-Compaction History Layout -00:message/user:first manual turn -01:message/user:\nFIRST_MANUAL_SUMMARY -02:message/developer: -03:message/user: -04:message/user:> -05:message/user:second manual turn diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__mid_turn_compaction_shapes.snap.new b/codex-rs/core/tests/suite/snapshots/all__suite__compact__mid_turn_compaction_shapes.snap.new deleted file mode 100644 index 9d87e6a9e1f..00000000000 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__mid_turn_compaction_shapes.snap.new +++ /dev/null @@ -1,25 +0,0 @@ ---- -source: core/tests/suite/compact.rs -assertion_line: 2657 -expression: "format_labeled_requests_snapshot(\"Mid-turn continuation compaction after tool output: compact request includes tool artifacts and follow-up request includes the summary.\",\n&[(\"Local Compaction Request\", &auto_compact_mock.single_request()),\n(\"Local Post-Compaction History Layout\",\n&post_auto_compact_mock.single_request()),])" ---- -Scenario: Mid-turn continuation compaction after tool output: compact request includes tool artifacts and follow-up request includes the summary. - -## Local Compaction Request -00:message/developer: -01:message/user: -02:message/user:> -03:message/user:function call limit push -04:function_call/test_tool -05:function_call_output:unsupported call: test_tool -06:message/assistant:FINAL_REPLY -07:message/user:FOLLOW_UP_AFTER_LIMIT -08:message/user: - -## Local Post-Compaction History Layout -00:message/user:function call limit push -01:message/developer: -02:message/user: -03:message/user:> -04:message/user:FOLLOW_UP_AFTER_LIMIT -05:message/user:\nAUTO_SUMMARY diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap index e586c8521a0..553ee5b5202 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap @@ -13,13 +13,14 @@ Scenario: Pre-turn auto-compaction with a context override emits the context dif 05:message/user:USER_TWO 06:message/assistant:SECOND_REPLY 07:message/user: -08:message/user: +08:message/user: | | USER_THREE +09:message/user: ## Local Post-Compaction History Layout -00:message/developer: -01:message/user: -02:message/user: -03:message/user:USER_ONE -04:message/user:USER_TWO -05:message/user:\nPRE_TURN_SUMMARY -06:message/user: | | | USER_THREE +00:message/user:USER_ONE +01:message/user:USER_TWO +02:message/developer: +03:message/user: +04:message/user: +05:message/user: | | USER_THREE +06:message/user:\nPRE_TURN_SUMMARY diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap.new b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap.new deleted file mode 100644 index 0458d880729..00000000000 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap.new +++ /dev/null @@ -1,27 +0,0 @@ ---- -source: core/tests/suite/compact.rs -assertion_line: 3026 -expression: "format_labeled_requests_snapshot(\"Pre-turn auto-compaction with a context override emits the context diff in the compact request while the incoming user message is still excluded.\",\n&[(\"Local Compaction Request\", &requests[2]),\n(\"Local Post-Compaction History Layout\", &requests[3]),])" ---- -Scenario: Pre-turn auto-compaction with a context override emits the context diff in the compact request while the incoming user message is still excluded. - -## Local Compaction Request -00:message/developer: -01:message/user: -02:message/user:> -03:message/user:USER_ONE -04:message/assistant:FIRST_REPLY -05:message/user:USER_TWO -06:message/assistant:SECOND_REPLY -07:message/user: -08:message/user: | | USER_THREE -09:message/user: - -## Local Post-Compaction History Layout -00:message/user:USER_ONE -01:message/user:USER_TWO -02:message/developer: -03:message/user: -04:message/user: -05:message/user: | | USER_THREE -06:message/user:\nPRE_TURN_SUMMARY diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_with_history_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_with_history_shapes.snap index bc795a19f44..f332d65d797 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_with_history_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_with_history_shapes.snap @@ -13,9 +13,9 @@ Scenario: Remote manual /compact where remote compact output is summary-only: fo 04:message/assistant:FIRST_REMOTE_REPLY ## Remote Post-Compaction History Layout -00:message/developer: -01:message/user: -02:message/user:> -03:message/user:REMOTE_COMPACTED_SUMMARY -04:compaction:encrypted=true +00:message/user:REMOTE_COMPACTED_SUMMARY +01:compaction:encrypted=true +02:message/developer: +03:message/user: +04:message/user:> 05:message/user:after compact diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_with_history_shapes.snap.new b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_with_history_shapes.snap.new deleted file mode 100644 index f9e4ad23aa3..00000000000 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_with_history_shapes.snap.new +++ /dev/null @@ -1,21 +0,0 @@ ---- -source: core/tests/suite/compact_remote.rs -assertion_line: 174 -expression: "format_labeled_requests_snapshot(\"Remote manual /compact with prior user history compacts existing history and follow-up includes compact summary plus new user message.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", follow_up_request),])" ---- -Scenario: Remote manual /compact with prior user history compacts existing history and follow-up includes compact summary plus new user message. - -## Remote Compaction Request -00:message/developer: -01:message/user: -02:message/user:> -03:message/user:hello remote compact -04:message/assistant:FIRST_REMOTE_REPLY - -## Remote Post-Compaction History Layout -00:message/user:REMOTE_COMPACTED_SUMMARY -01:compaction:encrypted=true -02:message/developer: -03:message/user: -04:message/user:> -05:message/user:after compact diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_shapes.snap index 8ef8701673b..488bee0bef9 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_shapes.snap @@ -13,8 +13,8 @@ Scenario: Remote mid-turn continuation compaction after tool output: compact req 05:function_call_output:unsupported call: test_tool ## Remote Post-Compaction History Layout -00:message/user:USER_ONE -01:message/developer: -02:message/user: -03:message/user:> +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:USER_ONE 04:message/user:\nREMOTE_MID_TURN_SUMMARY diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_shapes.snap.new b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_shapes.snap.new deleted file mode 100644 index e7621c95404..00000000000 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_shapes.snap.new +++ /dev/null @@ -1,21 +0,0 @@ ---- -source: core/tests/suite/compact_remote.rs -assertion_line: 1578 -expression: "format_labeled_requests_snapshot(\"Remote mid-turn continuation compaction after tool output: compact request includes tool artifacts and follow-up request includes the summary.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &requests[1]),])" ---- -Scenario: Remote mid-turn continuation compaction after tool output: compact request includes tool artifacts and follow-up request includes the summary. - -## Remote Compaction Request -00:message/developer: -01:message/user: -02:message/user:> -03:message/user:USER_ONE -04:function_call/test_tool -05:function_call_output:unsupported call: test_tool - -## Remote Post-Compaction History Layout -00:message/developer: -01:message/user: -02:message/user:> -03:message/user:USER_ONE -04:message/user:\nREMOTE_MID_TURN_SUMMARY diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_context_window_exceeded_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_context_window_exceeded_shapes.snap index e718bfda886..2bac15dfda7 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_context_window_exceeded_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_context_window_exceeded_shapes.snap @@ -10,3 +10,4 @@ Scenario: Remote pre-turn auto-compaction context-window failure: compaction req 02:message/user:> 03:message/user:USER_ONE 04:message/assistant:REMOTE_FIRST_REPLY +05:message/user:USER_TWO diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_context_window_exceeded_shapes.snap.new b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_context_window_exceeded_shapes.snap.new deleted file mode 100644 index 01bbca3bba1..00000000000 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_context_window_exceeded_shapes.snap.new +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: core/tests/suite/compact_remote.rs -assertion_line: 1503 -expression: "format_labeled_requests_snapshot(\"Remote pre-turn auto-compaction context-window failure: compaction request excludes the incoming user message and the turn errors.\",\n&[(\"Remote Compaction Request (Incoming User Excluded)\",\n&include_attempt_request),])" ---- -Scenario: Remote pre-turn auto-compaction context-window failure: compaction request excludes the incoming user message and the turn errors. - -## Remote Compaction Request (Incoming User Excluded) -00:message/developer: -01:message/user: -02:message/user:> -03:message/user:USER_ONE -04:message/assistant:REMOTE_FIRST_REPLY -05:message/user:USER_TWO diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap index 00aaaeaa92c..88fa34fa85e 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap @@ -13,12 +13,12 @@ Scenario: Remote pre-turn auto-compaction with a context override emits the cont 05:message/user:USER_TWO 06:message/assistant:REMOTE_SECOND_REPLY 07:message/user: +08:message/user:USER_THREE ## Remote Post-Compaction History Layout 00:message/user:USER_ONE -01:message/user:USER_TWO -02:message/developer: -03:message/user: -04:message/user: +01:message/developer: +02:message/user: +03:message/user: +04:message/user:USER_TWO 05:message/user:\nREMOTE_PRE_TURN_SUMMARY -06:message/user:USER_THREE diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap.new b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap.new deleted file mode 100644 index a7706335304..00000000000 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap.new +++ /dev/null @@ -1,25 +0,0 @@ ---- -source: core/tests/suite/compact_remote.rs -assertion_line: 1398 -expression: "format_labeled_requests_snapshot(\"Remote pre-turn auto-compaction with a context override emits the context diff in the compact request while excluding the incoming user message.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &requests[2]),])" ---- -Scenario: Remote pre-turn auto-compaction with a context override emits the context diff in the compact request while excluding the incoming user message. - -## Remote Compaction Request -00:message/developer: -01:message/user: -02:message/user:> -03:message/user:USER_ONE -04:message/assistant:REMOTE_FIRST_REPLY -05:message/user:USER_TWO -06:message/assistant:REMOTE_SECOND_REPLY -07:message/user: -08:message/user:USER_THREE - -## Remote Post-Compaction History Layout -00:message/user:USER_ONE -01:message/developer: -02:message/user: -03:message/user: -04:message/user:USER_TWO -05:message/user:\nREMOTE_PRE_TURN_SUMMARY From 15730c2fcb4d6a0af3aa409a9dd68f38971e6d68 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Thu, 12 Feb 2026 15:07:54 -0800 Subject: [PATCH 13/80] Align compaction tests with incoming-item and empty-history behavior --- .../app-server/tests/suite/v2/compaction.rs | 5 - codex-rs/core/src/codex.rs | 10 +- codex-rs/core/tests/suite/compact.rs | 138 ++-- codex-rs/core/tests/suite/compact_remote.rs | 31 +- .../core/tests/suite/compact_resume_fork.rs | 616 ++---------------- ...nual_compact_without_prev_user_shapes.snap | 13 +- ..._compaction_including_incoming_shapes.snap | 4 +- ...nual_compact_without_prev_user_shapes.snap | 9 +- ...te_pre_turn_compaction_failure_shapes.snap | 7 +- ..._compaction_including_incoming_shapes.snap | 15 +- 10 files changed, 176 insertions(+), 672 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/compaction.rs b/codex-rs/app-server/tests/suite/v2/compaction.rs index 5b5faa02d6d..09d75879f71 100644 --- a/codex-rs/app-server/tests/suite/v2/compaction.rs +++ b/codex-rs/app-server/tests/suite/v2/compaction.rs @@ -203,11 +203,6 @@ async fn thread_compact_start_triggers_compaction_and_returns_empty_response() - skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; - let sse = responses::sse(vec![ - responses::ev_assistant_message("m1", "MANUAL_COMPACT_SUMMARY"), - responses::ev_completed_with_tokens("r1", 200), - ]); - responses::mount_sse_sequence(&server, vec![sse]).await; let codex_home = TempDir::new()?; write_mock_responses_config_toml( diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index b30a1fa89ab..11b1e9f9820 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4311,7 +4311,15 @@ pub(crate) async fn run_turn( ); EventMsg::Error(CodexErr::ContextWindowExceeded.to_error_event(Some(message))) } - other => EventMsg::Error(other.to_error_event(None)), + other => { + let compact_error_prefix = + if should_use_remote_compact_task(&turn_context.provider) { + "Error running remote compact task" + } else { + "Error running local compact task" + }; + EventMsg::Error(other.to_error_event(Some(compact_error_prefix.to_string()))) + } }; 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 5a6ad5b4265..4ef9fd15de3 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -510,6 +510,10 @@ async fn manual_compact_emits_api_and_local_token_usage_events() { let server = start_mock_server().await; + let sse_turn = sse(vec![ + ev_assistant_message("m0", FIRST_REPLY), + ev_completed_with_tokens("r0", 0), + ]); // Compact run where the API reports zero tokens in usage. Our local // estimator should still compute a non-zero context size for the compacted // history. @@ -517,7 +521,7 @@ async fn manual_compact_emits_api_and_local_token_usage_events() { ev_assistant_message("m1", SUMMARY_TEXT), ev_completed_with_tokens("r1", 0), ]); - mount_sse_once(&server, sse_compact).await; + mount_sse_sequence(&server, vec![sse_turn, sse_compact]).await; let model_provider = non_openai_model_provider(&server); let mut builder = test_codex().with_config(move |config| { @@ -526,39 +530,42 @@ async fn manual_compact_emits_api_and_local_token_usage_events() { }); let codex = builder.build(&server).await.unwrap().codex; + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "seed compact history".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await + .unwrap(); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + // Trigger manual compact and collect TokenCount events for the compact turn. codex.submit(Op::Compact).await.unwrap(); - // First TokenCount: from the compact API call (usage.total_tokens = 0). - let first = wait_for_event_match(&codex, |ev| match ev { - EventMsg::TokenCount(tc) => tc - .info - .as_ref() - .map(|info| info.last_token_usage.total_tokens), - _ => None, - }) - .await; - - // Second TokenCount: from the local post-compaction estimate. - let last = wait_for_event_match(&codex, |ev| match ev { - EventMsg::TokenCount(tc) => tc - .info - .as_ref() - .map(|info| info.last_token_usage.total_tokens), - _ => None, - }) - .await; - - // Ensure the compact task itself completes. - wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + let mut compact_turn_token_totals = Vec::new(); + loop { + let event = wait_for_event(&codex, |_| true).await; + match event { + EventMsg::TokenCount(tc) => { + if let Some(info) = tc.info { + compact_turn_token_totals.push(info.last_token_usage.total_tokens); + } + } + EventMsg::TurnComplete(_) => break, + _ => {} + } + } - assert_eq!( - first, 0, - "expected first TokenCount from compact API usage to be zero" + assert!( + compact_turn_token_totals.contains(&0), + "expected compact turn token events to include API-reported zero usage" ); assert!( - last > 0, - "second TokenCount should reflect a non-zero estimated context size after compaction" + compact_turn_token_totals.iter().any(|total| *total > 0), + "expected compact turn token events to include a non-zero local estimate" ); } @@ -2414,35 +2421,33 @@ async fn manual_compact_twice_preserves_latest_user_messages() { "compact requests should consistently include or omit the summarization prompt" ); - let first_request_user_texts = requests[0].message_input_texts("user"); - let first_turn_user_index = first_request_user_texts - .len() - .checked_sub(1) - .unwrap_or_else(|| panic!("first turn request missing user messages")); - assert_eq!( - first_request_user_texts[first_turn_user_index], first_user_message, - "first turn request should end with the submitted user message" - ); - let seeded_user_prefix = &first_request_user_texts[..first_turn_user_index]; - let final_request_user_texts = requests .last() .unwrap_or_else(|| panic!("final turn request missing for {final_user_message}")) .message_input_texts("user"); - assert!( - final_request_user_texts - .as_slice() - .starts_with(seeded_user_prefix), - "final request should start with seeded user prefix from first request: {seeded_user_prefix:?}" - ); - let final_output = &final_request_user_texts[seeded_user_prefix.len()..]; + let Some(first_user_index) = final_request_user_texts + .iter() + .position(|text| text == first_user_message) + else { + panic!("final request missing first user message: {final_request_user_texts:?}"); + }; + let final_output = &final_request_user_texts[first_user_index..]; let expected = vec![ first_user_message.to_string(), second_user_message.to_string(), expected_second_summary, final_user_message.to_string(), ]; - assert_eq!(final_output, expected.as_slice()); + let mut final_output_iter = final_output.iter(); + for expected_text in &expected { + final_output_iter + .position(|text| text == expected_text) + .unwrap_or_else(|| { + panic!( + "final request should preserve expected user-message order; missing `{expected_text}` in {final_output:?}" + ) + }); + } } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -2713,8 +2718,8 @@ async fn auto_compact_clamps_config_limit_to_context_window() { let auto_compact_body = auto_compact_mock.single_request().body_json().to_string(); assert!( - body_contains_text(&auto_compact_body, SUMMARIZATION_PROMPT), - "auto compact should run with the summarization prompt when config limit exceeds context" + body_contains_text(&auto_compact_body, "OVER_LIMIT_TURN"), + "auto compact should run when the configured limit clamps to the model context window" ); } @@ -2928,7 +2933,6 @@ async fn auto_compact_runs_when_reasoning_header_clears_between_turns() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -// TODO(ccunningham): Update once pre-turn compaction includes incoming user input. async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_message() { skip_if_no_network!(); @@ -3016,7 +3020,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess insta::assert_snapshot!( "pre_turn_compaction_including_incoming_shapes", format_labeled_requests_snapshot( - "Pre-turn auto-compaction with a context override emits the context diff in the compact request while the incoming user message is still excluded.", + "Pre-turn auto-compaction with a context override includes incoming user content in the compact request and preserves it after compaction.", &[ ("Local Compaction Request", &requests[2]), ("Local Post-Compaction History Layout", &requests[3]), @@ -3025,10 +3029,17 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess ); let compact_request_user_texts = requests[2].message_input_texts("user"); assert!( - !compact_request_user_texts + compact_request_user_texts .iter() .any(|text| text == "USER_THREE"), - "current behavior excludes incoming user message from pre-turn compaction input" + "pre-turn compaction request should include the incoming user message" + ); + let compact_request_user_images = requests[2].message_input_image_urls("user"); + assert!( + compact_request_user_images + .iter() + .any(|url| url == image_url.as_str()), + "pre-turn compaction request should include incoming user image content" ); let follow_up_user_texts = requests[3].message_input_texts("user"); assert!( @@ -3478,15 +3489,11 @@ async fn snapshot_request_shape_manual_compact_without_previous_user_messages() let server = start_mock_server().await; - let compact_turn = sse(vec![ - ev_assistant_message("m1", "MANUAL_EMPTY_SUMMARY"), - ev_completed_with_tokens("r1", 90), - ]); let follow_up_turn = sse(vec![ - ev_assistant_message("m2", FINAL_REPLY), - ev_completed_with_tokens("r2", 80), + ev_assistant_message("m1", FINAL_REPLY), + ev_completed_with_tokens("r1", 80), ]); - let request_log = mount_sse_sequence(&server, vec![compact_turn, follow_up_turn]).await; + let request_log = mount_sse_once(&server, follow_up_turn).await; let model_provider = non_openai_model_provider(&server); let codex = test_codex() @@ -3517,18 +3524,15 @@ async fn snapshot_request_shape_manual_compact_without_previous_user_messages() let requests = request_log.requests(); assert_eq!( requests.len(), - 2, - "expected manual /compact request and follow-up turn request" + 1, + "manual /compact with no prior user should be a no-op; only the follow-up turn should hit /responses" ); insta::assert_snapshot!( "manual_compact_without_prev_user_shapes", format_labeled_requests_snapshot( - "Manual /compact with no prior user turn currently still issues a compaction request; follow-up turn carries canonical context and the new user message.", - &[ - ("Local Compaction Request", &requests[0]), - ("Local Post-Compaction History Layout", &requests[1]), - ] + "Manual /compact with no prior user turn is a no-op; follow-up turn carries canonical context and the new user message.", + &[("Local Post-Compaction History Layout", &requests[0]),] ) ); } diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index c9acde41ab0..e125b29d7a9 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -579,9 +579,9 @@ async fn auto_remote_compact_failure_stops_agent_loop() -> Result<()> { insta::assert_snapshot!( "remote_pre_turn_compaction_failure_shapes", format_labeled_requests_snapshot( - "Remote pre-turn auto-compaction parse failure: compaction request excludes the incoming user message and the turn stops.", + "Remote pre-turn auto-compaction parse failure: compaction request includes incoming user content and the turn stops.", &[( - "Remote Compaction Request (Incoming User Excluded)", + "Remote Compaction Request (Incoming User Included)", &first_compact_mock.single_request() ),] ) @@ -1310,7 +1310,6 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -// TODO(ccunningham): Update once remote pre-turn compaction includes incoming user input. async fn snapshot_request_shape_remote_pre_turn_compaction_including_incoming_user_message() -> Result<()> { skip_if_no_network!(Ok(())); @@ -1390,13 +1389,20 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_including_incoming_us insta::assert_snapshot!( "remote_pre_turn_compaction_including_incoming_shapes", format_labeled_requests_snapshot( - "Remote pre-turn auto-compaction with a context override emits the context diff in the compact request while excluding the incoming user message.", + "Remote pre-turn auto-compaction with a context override includes incoming user content in the compact request and preserves it after compaction.", &[ ("Remote Compaction Request", &compact_request), ("Remote Post-Compaction History Layout", &requests[2]), ] ) ); + assert!( + compact_request + .message_input_texts("user") + .iter() + .any(|text| text == "USER_THREE"), + "remote pre-turn compaction request should include incoming user message" + ); assert_eq!( requests[2] .message_input_texts("user") @@ -1928,10 +1934,6 @@ async fn snapshot_request_shape_remote_manual_compact_without_previous_user_mess ) .await; - let compact_mock = - responses::mount_compact_json_once(harness.server(), serde_json::json!({ "output": [] })) - .await; - codex.submit(Op::Compact).await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -1946,21 +1948,12 @@ async fn snapshot_request_shape_remote_manual_compact_without_previous_user_mess .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - assert_eq!( - compact_mock.requests().len(), - 1, - "current behavior still issues remote compaction for manual /compact without prior user" - ); - let compact_request = compact_mock.single_request(); let follow_up_request = responses_mock.single_request(); insta::assert_snapshot!( "remote_manual_compact_without_prev_user_shapes", format_labeled_requests_snapshot( - "Remote manual /compact with no prior user turn still issues a compact request; follow-up turn carries canonical context and new user message.", - &[ - ("Remote Compaction Request", &compact_request), - ("Remote Post-Compaction History Layout", &follow_up_request), - ] + "Remote manual /compact with no prior user turn is a no-op; follow-up turn carries canonical context and the new user message.", + &[("Remote Post-Compaction History Layout", &follow_up_request),] ) ); diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index fc77a2621fc..9c9618a9783 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -200,24 +200,11 @@ async fn compact_resume_and_fork_preserve_model_history_view() { &fork_arr[..compact_arr.len()] ); - let expected_model = requests[0]["model"] - .as_str() - .unwrap_or_default() - .to_string(); - let prompt = requests[0]["instructions"] - .as_str() - .unwrap_or_default() - .to_string(); - let permissions_message = requests[0]["input"][0].clone(); - let user_instructions = requests[0]["input"][1]["content"][0]["text"] - .as_str() - .unwrap_or_default() - .to_string(); - let environment_context = requests[0]["input"][2]["content"][0]["text"] - .as_str() - .unwrap_or_default() - .to_string(); - let tool_calls = json!(requests[0]["tools"].as_array()); + assert_eq!(requests.len(), 5); + let expected_model = requests[0]["model"].as_str(); + for request in &requests { + assert_eq!(request["model"].as_str(), expected_model); + } let prompt_cache_key = requests[0]["prompt_cache_key"] .as_str() .unwrap_or_default() @@ -226,433 +213,46 @@ async fn compact_resume_and_fork_preserve_model_history_view() { .as_str() .unwrap_or_default() .to_string(); + assert_ne!( + prompt_cache_key, fork_prompt_cache_key, + "forked request should use a new prompt cache key" + ); let summary_after_compact = extract_summary_message(&requests[2], SUMMARY_TEXT); let summary_after_resume = extract_summary_message(&requests[3], SUMMARY_TEXT); let summary_after_fork = extract_summary_message(&requests[4], SUMMARY_TEXT); - let user_turn_1 = json!( - { - "model": expected_model, - "instructions": prompt, - "input": [ - permissions_message, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": user_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": environment_context - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "hello world" - } - ] - } - ], - "tools": tool_calls, - "tool_choice": "auto", - "parallel_tool_calls": false, - "reasoning": { - "effort": "medium", - "summary": "auto" - }, - "store": false, - "stream": true, - "include": [ - "reasoning.encrypted_content" - ], - "prompt_cache_key": prompt_cache_key - }); - let compact_1 = json!( - { - "model": expected_model, - "instructions": prompt, - "input": [ - permissions_message, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": user_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": environment_context - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "hello world" - } - ] - }, - { - "type": "message", - "role": "assistant", - "content": [ - { - "type": "output_text", - "text": "FIRST_REPLY" - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": SUMMARIZATION_PROMPT - } - ] - } - ], - "tools": [], - "tool_choice": "auto", - "parallel_tool_calls": false, - "reasoning": { - "effort": "medium", - "summary": "auto" - }, - "store": false, - "stream": true, - "include": [ - "reasoning.encrypted_content" - ], - "prompt_cache_key": prompt_cache_key - }); - let user_turn_2_after_compact = json!( - { - "model": expected_model, - "instructions": prompt, - "input": [ - permissions_message, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": user_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": environment_context - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "hello world" - } - ] - }, - summary_after_compact, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "AFTER_COMPACT" - } - ] - } - ], - "tools": tool_calls, - "tool_choice": "auto", - "parallel_tool_calls": false, - "reasoning": { - "effort": "medium", - "summary": "auto" - }, - "store": false, - "stream": true, - "include": [ - "reasoning.encrypted_content" - ], - "prompt_cache_key": prompt_cache_key - }); - let usert_turn_3_after_resume = json!( - { - "model": expected_model, - "instructions": prompt, - "input": [ - permissions_message, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": user_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": environment_context - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "hello world" - } - ] - }, - summary_after_resume, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "AFTER_COMPACT" - } - ] - }, - { - "type": "message", - "role": "assistant", - "content": [ - { - "type": "output_text", - "text": "AFTER_COMPACT_REPLY" - } - ] - }, - permissions_message, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": user_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": environment_context - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "AFTER_RESUME" - } - ] - } - ], - "tools": tool_calls, - "tool_choice": "auto", - "parallel_tool_calls": false, - "reasoning": { - "effort": "medium", - "summary": "auto" - }, - "store": false, - "stream": true, - "include": [ - "reasoning.encrypted_content" - ], - "prompt_cache_key": prompt_cache_key - }); - let user_turn_3_after_fork = json!( - { - "model": expected_model, - "instructions": prompt, - "input": [ - permissions_message, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": user_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": environment_context - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "hello world" - } - ] - }, - summary_after_fork, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "AFTER_COMPACT" - } - ] - }, - { - "type": "message", - "role": "assistant", - "content": [ - { - "type": "output_text", - "text": "AFTER_COMPACT_REPLY" - } - ] - }, - permissions_message, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": user_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": environment_context - } - ] - }, - permissions_message, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": user_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": environment_context - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "AFTER_FORK" - } - ] - } - ], - "tools": tool_calls, - "tool_choice": "auto", - "parallel_tool_calls": false, - "reasoning": { - "effort": "medium", - "summary": "auto" - }, - "store": false, - "stream": true, - "include": [ - "reasoning.encrypted_content" - ], - "prompt_cache_key": fork_prompt_cache_key - }); - let mut expected = json!([ - user_turn_1, - compact_1, - user_turn_2_after_compact, - usert_turn_3_after_resume, - user_turn_3_after_fork - ]); - normalize_line_endings(&mut expected); - if let Some(arr) = expected.as_array_mut() { - normalize_compact_prompts(arr); + for summary in [ + &summary_after_compact, + &summary_after_resume, + &summary_after_fork, + ] { + assert_eq!(summary["role"].as_str(), Some("user")); + assert!( + summary["content"][0]["text"] + .as_str() + .unwrap_or_default() + .contains(SUMMARY_TEXT), + "summary message should include compacted summary text" + ); } - assert_eq!(requests.len(), 5); - assert_eq!(json!(requests), expected); + let request_2_body = requests[2].to_string(); + assert!( + request_2_body.contains("\"text\":\"AFTER_COMPACT\""), + "post-compact request should include AFTER_COMPACT" + ); + let request_3_body = requests[3].to_string(); + assert!( + request_3_body.contains("\"text\":\"AFTER_RESUME\""), + "post-resume request should include AFTER_RESUME" + ); + let request_4_body = requests[4].to_string(); + assert!( + request_4_body.contains("\"text\":\"AFTER_FORK\""), + "post-fork request should include AFTER_FORK" + ); + assert!( + !request_4_body.contains("\"text\":\"AFTER_RESUME\""), + "forked request should not include resumed-branch user input" + ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -725,118 +325,32 @@ async fn compact_resume_after_second_compaction_preserves_history() { compact_filtered.as_slice(), &resume_filtered[..compact_filtered.len()] ); - // hard coded test - let prompt = requests[0]["instructions"] - .as_str() - .unwrap_or_default() - .to_string(); - let permissions_message = requests[0]["input"][0].clone(); - let user_instructions = requests[0]["input"][1]["content"][0]["text"] - .as_str() - .unwrap_or_default() - .to_string(); - let environment_instructions = requests[0]["input"][2]["content"][0]["text"] - .as_str() - .unwrap_or_default() - .to_string(); - - // Build expected final request input: initial context + forked user message + - // compacted summary + post-compact user message + resumed user message. + // Final resumed request should include the fork branch history, the second compaction + // summary, and the resumed-again user message. let summary_after_second_compact = extract_summary_message(&requests[requests.len() - 3], SUMMARY_TEXT); - - let mut expected = json!([ - { - "instructions": prompt, - "input": [ - permissions_message, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": user_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": environment_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "AFTER_FORK" - } - ] - }, - summary_after_second_compact, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "AFTER_COMPACT_2" - } - ] - }, - permissions_message, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": user_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": environment_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "AFTER_SECOND_RESUME" - } - ] - } - ], - } - ]); - normalize_line_endings(&mut expected); - let mut last_request_after_2_compacts = json!([{ - "instructions": requests[requests.len() -1]["instructions"], - "input": requests[requests.len() -1]["input"], - }]); - if let Some(arr) = expected.as_array_mut() { - normalize_compact_prompts(arr); - } - if let Some(arr) = last_request_after_2_compacts.as_array_mut() { - normalize_compact_prompts(arr); - } - assert_eq!(expected, last_request_after_2_compacts); + assert_eq!(summary_after_second_compact["role"].as_str(), Some("user")); + assert!( + summary_after_second_compact["content"][0]["text"] + .as_str() + .unwrap_or_default() + .contains(SUMMARY_TEXT), + "second compaction summary should include compacted summary text" + ); + let last_request_after_two_compacts = &requests[requests.len() - 1]; + let last_request_body = last_request_after_two_compacts.to_string(); + assert!( + last_request_body.contains("\"text\":\"AFTER_FORK\""), + "last request should retain fork-branch user message" + ); + assert!( + last_request_body.contains("\"text\":\"AFTER_COMPACT_2\""), + "last request should include post-second-compaction user message" + ); + assert!( + last_request_body.contains(&format!("\"text\":\"{AFTER_SECOND_RESUME}\"")), + "last request should include resumed-again user message" + ); } fn normalize_line_endings(value: &mut Value) { diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_without_prev_user_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_without_prev_user_shapes.snap index bdb4fe9baef..b0898ee0280 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_without_prev_user_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__manual_compact_without_prev_user_shapes.snap @@ -1,18 +1,11 @@ --- source: core/tests/suite/compact.rs -expression: "format_labeled_requests_snapshot(\"Manual /compact with no prior user turn currently still issues a compaction request; follow-up turn carries canonical context and the new user message.\",\n&[(\"Local Compaction Request\", &requests[0]),\n(\"Local Post-Compaction History Layout\", &requests[1]),])" +expression: "format_labeled_requests_snapshot(\"Manual /compact with no prior user turn is a no-op; follow-up turn carries canonical context and the new user message.\",\n&[(\"Local Post-Compaction History Layout\", &requests[0]),])" --- -Scenario: Manual /compact with no prior user turn currently still issues a compaction request; follow-up turn carries canonical context and the new user message. - -## Local Compaction Request -00:message/developer: -01:message/user: -02:message/user:> -03:message/user: +Scenario: Manual /compact with no prior user turn is a no-op; follow-up turn carries canonical context and the new user message. ## Local Post-Compaction History Layout 00:message/developer: 01:message/user: 02:message/user:> -03:message/user:\nMANUAL_EMPTY_SUMMARY -04:message/user:AFTER_MANUAL_EMPTY_COMPACT +03:message/user:AFTER_MANUAL_EMPTY_COMPACT diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap index 553ee5b5202..57d461c0260 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap @@ -1,8 +1,8 @@ --- source: core/tests/suite/compact.rs -expression: "format_labeled_requests_snapshot(\"Pre-turn auto-compaction with a context override emits the context diff in the compact request while the incoming user message is still excluded.\",\n&[(\"Local Compaction Request\", &requests[2]),\n(\"Local Post-Compaction History Layout\", &requests[3]),])" +expression: "format_labeled_requests_snapshot(\"Pre-turn auto-compaction with a context override includes incoming user content in the compact request and preserves it after compaction.\",\n&[(\"Local Compaction Request\", &requests[2]),\n(\"Local Post-Compaction History Layout\", &requests[3]),])" --- -Scenario: Pre-turn auto-compaction with a context override emits the context diff in the compact request while the incoming user message is still excluded. +Scenario: Pre-turn auto-compaction with a context override includes incoming user content in the compact request and preserves it after compaction. ## Local Compaction Request 00:message/developer: diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_without_prev_user_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_without_prev_user_shapes.snap index ce62806d99c..46eb3b76c5a 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_without_prev_user_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_without_prev_user_shapes.snap @@ -1,13 +1,8 @@ --- source: core/tests/suite/compact_remote.rs -expression: "format_labeled_requests_snapshot(\"Remote manual /compact with no prior user turn still issues a compact request; follow-up turn carries canonical context and new user message.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &follow_up_request),])" +expression: "format_labeled_requests_snapshot(\"Remote manual /compact with no prior user turn is a no-op; follow-up turn carries canonical context and the new user message.\",\n&[(\"Remote Post-Compaction History Layout\", &follow_up_request),])" --- -Scenario: Remote manual /compact with no prior user turn still issues a compact request; follow-up turn carries canonical context and new user message. - -## Remote Compaction Request -00:message/developer: -01:message/user: -02:message/user:> +Scenario: Remote manual /compact with no prior user turn is a no-op; follow-up turn carries canonical context and the new user message. ## Remote Post-Compaction History Layout 00:message/developer: diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_failure_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_failure_shapes.snap index f8aa74e9a1f..864c06b4b8a 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_failure_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_failure_shapes.snap @@ -1,12 +1,13 @@ --- source: core/tests/suite/compact_remote.rs -expression: "format_labeled_requests_snapshot(\"Remote pre-turn auto-compaction parse failure: compaction request excludes the incoming user message and the turn stops.\",\n&[(\"Remote Compaction Request (Incoming User Excluded)\",\n&first_compact_mock.single_request()),])" +expression: "format_labeled_requests_snapshot(\"Remote pre-turn auto-compaction parse failure: compaction request includes incoming user content and the turn stops.\",\n&[(\"Remote Compaction Request (Incoming User Included)\",\n&first_compact_mock.single_request()),])" --- -Scenario: Remote pre-turn auto-compaction parse failure: compaction request excludes the incoming user message and the turn stops. +Scenario: Remote pre-turn auto-compaction parse failure: compaction request includes incoming user content and the turn stops. -## Remote Compaction Request (Incoming User Excluded) +## Remote Compaction Request (Incoming User Included) 00:message/developer: 01:message/user: 02:message/user:> 03:message/user:turn that exceeds token threshold 04:message/assistant:initial turn complete +05:message/user:turn that triggers auto compact diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap index 88fa34fa85e..ec30aa08614 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap @@ -1,8 +1,8 @@ --- source: core/tests/suite/compact_remote.rs -expression: "format_labeled_requests_snapshot(\"Remote pre-turn auto-compaction with a context override emits the context diff in the compact request while excluding the incoming user message.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &requests[2]),])" +expression: "format_labeled_requests_snapshot(\"Remote pre-turn auto-compaction with a context override includes incoming user content in the compact request and preserves it after compaction.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &requests[2]),])" --- -Scenario: Remote pre-turn auto-compaction with a context override emits the context diff in the compact request while excluding the incoming user message. +Scenario: Remote pre-turn auto-compaction with a context override includes incoming user content in the compact request and preserves it after compaction. ## Remote Compaction Request 00:message/developer: @@ -17,8 +17,9 @@ Scenario: Remote pre-turn auto-compaction with a context override emits the cont ## Remote Post-Compaction History Layout 00:message/user:USER_ONE -01:message/developer: -02:message/user: -03:message/user: -04:message/user:USER_TWO -05:message/user:\nREMOTE_PRE_TURN_SUMMARY +01:message/user:USER_TWO +02:message/developer: +03:message/user: +04:message/user: +05:message/user:USER_THREE +06:message/user:\nREMOTE_PRE_TURN_SUMMARY From c92190f130328dd8c43883e099ca56a252ca630b Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Thu, 12 Feb 2026 15:10:11 -0800 Subject: [PATCH 14/80] Avoid wildcard pattern in ResponseEvent::Completed match --- codex-rs/core/src/codex.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 11b1e9f9820..008ff170ab7 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -5806,9 +5806,9 @@ async fn try_run_sampling_request( sess.services.models_manager.refresh_if_new_etag(etag).await; } ResponseEvent::Completed { - response_id: _, + response_id: _response_id, token_usage, - .. + can_append: _can_append, } => { if let Some(state) = plan_mode_state.as_mut() { flush_proposed_plan_segments_all(&sess, &turn_context, state).await; From bddd53bae5d987653bdd3a3d4c071edaa2f30676 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Sat, 14 Feb 2026 13:28:59 -0800 Subject: [PATCH 15/80] Rebase: align compaction snapshots and imports --- codex-rs/core/src/compact_remote.rs | 2 +- ...ompact__pre_turn_compaction_including_incoming_shapes.snap | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 1039e24401d..b338686ea05 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -3,9 +3,9 @@ use std::sync::Arc; use crate::Prompt; use crate::codex::Session; use crate::codex::TurnContext; -use crate::compact::extract_trailing_model_switch_update_for_compaction_request; use crate::compact::AutoCompactCallsite; use crate::compact::TurnContextReinjection; +use crate::compact::extract_trailing_model_switch_update_for_compaction_request; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::context_manager::estimate_item_token_count; diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap index 57d461c0260..bd4eb41236c 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap @@ -13,7 +13,7 @@ Scenario: Pre-turn auto-compaction with a context override includes incoming use 05:message/user:USER_TWO 06:message/assistant:SECOND_REPLY 07:message/user: -08:message/user: | | USER_THREE +08:message/user: | | | USER_THREE 09:message/user: ## Local Post-Compaction History Layout @@ -22,5 +22,5 @@ Scenario: Pre-turn auto-compaction with a context override includes incoming use 02:message/developer: 03:message/user: 04:message/user: -05:message/user: | | USER_THREE +05:message/user: | | | USER_THREE 06:message/user:\nPRE_TURN_SUMMARY From d9b02b6a9e26f45a4842cc67d6527b5fe61634a0 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Sat, 14 Feb 2026 17:02:01 -0800 Subject: [PATCH 16/80] Differentiate model-switch compaction failures from oversize input --- codex-rs/core/src/codex.rs | 33 +++++-- codex-rs/core/tests/suite/compact.rs | 124 +++++++++++++++++++++++++++ 2 files changed, 149 insertions(+), 8 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 008ff170ab7..08c5a4da288 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 4ef9fd15de3..ee77570414c 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!(); From ce7777801d68f40851eb8622213bf038bb5e3013 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Sun, 15 Feb 2026 15:28:19 -0800 Subject: [PATCH 17/80] Enable compaction tests and remove stale TODO markers --- codex-rs/core/tests/suite/compact.rs | 5 ----- codex-rs/core/tests/suite/compact_remote.rs | 5 ----- 2 files changed, 10 deletions(-) diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index ee77570414c..b29ba0184a4 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -2312,9 +2312,6 @@ async fn manual_compact_retries_after_context_window_error() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -// TODO(ccunningham): Re-enable after the follow-up compaction behavior PR lands. -// Current main behavior around non-context manual /compact failures is known-incorrect. -#[ignore = "behavior change covered in follow-up compaction PR"] async fn manual_compact_non_context_failure_retries_then_emits_task_error() { skip_if_no_network!(); @@ -3180,8 +3177,6 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -// TODO(ccunningham): Update once pre-turn compaction context-overflow handling includes incoming -// user input and emits richer oversized-input messaging. async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch() { skip_if_no_network!(); diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index e125b29d7a9..d6cd6112119 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -930,9 +930,6 @@ async fn remote_manual_compact_failure_emits_task_error_event() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -// TODO(ccunningham): Re-enable after the follow-up compaction behavior PR lands. -// Current main behavior for rollout replacement-history persistence is known-incorrect. -#[ignore = "behavior change covered in follow-up compaction PR"] async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> { skip_if_no_network!(Ok(())); @@ -1553,8 +1550,6 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -// TODO(ccunningham): Update once remote pre-turn compaction context-overflow handling includes -// incoming user input and emits richer oversized-input messaging. async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceeded() -> Result<()> { skip_if_no_network!(Ok(())); From 9e6ef13d4ddadb849fb322c70e7ea19509c33baa Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Sun, 15 Feb 2026 15:32:30 -0800 Subject: [PATCH 18/80] Add comment --- codex-rs/core/src/tasks/compact.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/codex-rs/core/src/tasks/compact.rs b/codex-rs/core/src/tasks/compact.rs index 9835c95bee0..c011edccbae 100644 --- a/codex-rs/core/src/tasks/compact.rs +++ b/codex-rs/core/src/tasks/compact.rs @@ -49,6 +49,7 @@ impl SessionTask for CompactTask { } else if has_user_turn_boundary { // Manual `/compact` rewrites history to compacted transcript items and drops // per-turn context entries. Force initial-context reseeding on the next user turn. + // TODO(ccunningham): Eliminate this when we have better TurnContextItem diffing (compaction aware) session.mark_initial_context_unseeded_for_next_turn().await; } } else { @@ -67,6 +68,7 @@ impl SessionTask for CompactTask { } else if has_user_turn_boundary { // Manual `/compact` rewrites history to compacted transcript items and drops // per-turn context entries. Force initial-context reseeding on the next user turn. + // TODO(ccunningham): Eliminate this when we have better TurnContextItem diffing (compaction aware) session.mark_initial_context_unseeded_for_next_turn().await; } } From b7b50d6e98dd5b9d4d627d851b371792a4bc55c8 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Sun, 15 Feb 2026 16:38:55 -0800 Subject: [PATCH 19/80] Add TODO for legacy compacted history context reinjection --- codex-rs/core/src/codex.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 08c5a4da288..86084938513 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -2387,6 +2387,10 @@ impl Session { if let Some(replacement) = &compacted.replacement_history { history.replace(replacement.clone()); } else { + // TODO(ccunningham): When we have TurnContextItem-based legacy reconstruction, + // build historical turn context from those items and inject it at the correct + // point in compacted history instead of prepending the current initial context. + // This matters for legacy rollouts with replacement_history=None. let user_messages = collect_user_messages(history.raw_items()); let mut rebuilt = self.build_initial_context(turn_context).await; rebuilt.extend(compact::build_compacted_history( From 5682f7255130b641e2caf6c371cc80455edc8f7c Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Sun, 15 Feb 2026 16:56:23 -0800 Subject: [PATCH 20/80] Move previous-model compact failure handling into helper --- codex-rs/core/src/codex.rs | 73 +++++++++++++++++++++----------------- 1 file changed, 41 insertions(+), 32 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 86084938513..e6b1576203f 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4290,26 +4290,14 @@ pub(crate) async fn run_turn( 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( + if !maybe_run_previous_model_inline_compact( &sess, &turn_context, total_usage_tokens_before_compaction, + &pre_turn_context_items, ) .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; } @@ -4690,9 +4678,10 @@ async fn maybe_run_previous_model_inline_compact( sess: &Arc, turn_context: &Arc, total_usage_tokens: i64, -) -> CodexResult<()> { + pre_turn_context_items: &[ResponseItem], +) -> bool { let Some(previous_model) = sess.previous_model().await else { - return Ok(()); + return true; }; let previous_turn_context = Arc::new( turn_context @@ -4701,10 +4690,10 @@ async fn maybe_run_previous_model_inline_compact( ); let Some(old_context_window) = previous_turn_context.model_context_window() else { - return Ok(()); + return true; }; let Some(new_context_window) = turn_context.model_context_window() else { - return Ok(()); + return true; }; let new_auto_compact_limit = turn_context .model_info @@ -4713,21 +4702,41 @@ async fn maybe_run_previous_model_inline_compact( let should_run = total_usage_tokens > new_auto_compact_limit && previous_turn_context.model_info.slug != turn_context.model_info.slug && old_context_window > new_context_window; - if should_run { - run_auto_compact( - sess, - &previous_turn_context, - AutoCompactCallsite::PreTurnExcludingIncomingUserMessage, - // Even though incoming turn items are excluded here, this pass can be the only - // compaction run before submission. Reinject canonical context so unchanged - // model-visible instructions remain present if no follow-up pre-turn compaction - // is needed. - TurnContextReinjection::ReinjectAboveLastRealUser, - None, - ) - .await?; + if !should_run { + return true; + } + + match run_auto_compact( + sess, + &previous_turn_context, + AutoCompactCallsite::PreTurnExcludingIncomingUserMessage, + // Even though incoming turn items are excluded here, this pass can be the only + // compaction run before submission. Reinject canonical context so unchanged + // model-visible instructions remain present if no follow-up pre-turn compaction + // is needed. + TurnContextReinjection::ReinjectAboveLastRealUser, + None, + ) + .await + { + Ok(()) => true, + Err(err) => { + 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; + false + } } - Ok(()) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PreTurnCompactionOutcome { From e3efe3a02080a143505711ec73d70afaca98bf8e Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Sun, 15 Feb 2026 17:00:13 -0800 Subject: [PATCH 21/80] Return sentinel error from model-switch compaction helper --- codex-rs/core/src/codex.rs | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index e6b1576203f..310fce3838a 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4290,14 +4290,16 @@ pub(crate) async fn run_turn( sess.send_event(&turn_context, event).await; let total_usage_tokens_before_compaction = sess.get_total_token_usage().await; - if !maybe_run_previous_model_inline_compact( + if maybe_run_previous_model_inline_compact( &sess, &turn_context, total_usage_tokens_before_compaction, &pre_turn_context_items, ) .await + .is_err() { + // Error messaging is emitted inside maybe_run_previous_model_inline_compact. return None; } @@ -4674,14 +4676,18 @@ pub(crate) async fn run_turn( last_agent_message } +/// Runs the pre-sampling model-switch compaction pass when needed. +/// +/// On failure this function emits any user-visible error event itself and returns `Err(())` as a +/// sentinel so callers can stop the turn without duplicating error messaging logic. async fn maybe_run_previous_model_inline_compact( sess: &Arc, turn_context: &Arc, total_usage_tokens: i64, pre_turn_context_items: &[ResponseItem], -) -> bool { +) -> Result<(), ()> { let Some(previous_model) = sess.previous_model().await else { - return true; + return Ok(()); }; let previous_turn_context = Arc::new( turn_context @@ -4690,10 +4696,10 @@ async fn maybe_run_previous_model_inline_compact( ); let Some(old_context_window) = previous_turn_context.model_context_window() else { - return true; + return Ok(()); }; let Some(new_context_window) = turn_context.model_context_window() else { - return true; + return Ok(()); }; let new_auto_compact_limit = turn_context .model_info @@ -4703,7 +4709,7 @@ async fn maybe_run_previous_model_inline_compact( && previous_turn_context.model_info.slug != turn_context.model_info.slug && old_context_window > new_context_window; if !should_run { - return true; + return Ok(()); } match run_auto_compact( @@ -4719,7 +4725,7 @@ async fn maybe_run_previous_model_inline_compact( ) .await { - Ok(()) => true, + Ok(()) => Ok(()), Err(err) => { if !pre_turn_context_items.is_empty() { // Preserve model-visible settings updates even when pre-turn compaction fails @@ -4734,7 +4740,7 @@ async fn maybe_run_previous_model_inline_compact( }; let event = EventMsg::Error(err.to_error_event(Some(compact_error_prefix.to_string()))); sess.send_event(turn_context, event).await; - false + Err(()) } } } From 85956640929b8fd4ad5fbeaa1f1192368bf4dfc4 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Sun, 15 Feb 2026 17:04:32 -0800 Subject: [PATCH 22/80] Move pre-turn auto-compact error handling into helper --- codex-rs/core/src/codex.rs | 87 ++++++++++++++++++-------------------- 1 file changed, 42 insertions(+), 45 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 310fce3838a..1110fa3d51a 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4303,46 +4303,17 @@ pub(crate) async fn run_turn( return None; } - let pre_turn_compaction_outcome = match run_pre_turn_auto_compaction_if_needed( + let Ok(pre_turn_compaction_outcome) = run_pre_turn_auto_compaction_if_needed( &sess, &turn_context, auto_compact_limit, &incoming_turn_items, + &pre_turn_context_items, ) .await - { - Ok(outcome) => outcome, - Err(err) => { - 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 => { - let compact_error_prefix = - if should_use_remote_compact_task(&turn_context.provider) { - "Error running remote compact task" - } else { - "Error running local compact task" - }; - EventMsg::Error(other.to_error_event(Some(compact_error_prefix.to_string()))) - } - }; - sess.send_event(&turn_context, event).await; - return None; - } + else { + // Error messaging is emitted inside run_pre_turn_auto_compaction_if_needed. + return None; }; persist_pre_turn_items_for_compaction_outcome( &sess, @@ -4802,12 +4773,16 @@ async fn persist_pre_turn_items_for_compaction_outcome( } /// Runs pre-turn auto-compaction with incoming turn context + user message included. +/// +/// On failure this function emits any user-visible error event itself and returns `Err(())` as a +/// sentinel so callers can stop the turn without duplicating error messaging logic. async fn run_pre_turn_auto_compaction_if_needed( sess: &Arc, turn_context: &Arc, auto_compact_limit: i64, incoming_turn_items: &[ResponseItem], -) -> CodexResult { + pre_turn_context_items: &[ResponseItem], +) -> Result { let total_usage_tokens = sess.get_total_token_usage().await; let incoming_items_tokens_estimate = incoming_turn_items .iter() @@ -4831,17 +4806,39 @@ async fn run_pre_turn_auto_compaction_if_needed( .await; if let Err(err) = compact_result { - if matches!(err, CodexErr::ContextWindowExceeded) { - error!( - turn_id = %turn_context.sub_id, - auto_compact_callsite = ?AutoCompactCallsite::PreTurnIncludingIncomingUserMessage, - incoming_items_tokens_estimate, - auto_compact_limit, - reason = "pre-turn compaction exceeded context window", - "incoming user/context is too large for pre-turn auto-compaction flow" - ); + 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; } - return Err(err); + let event = match err { + CodexErr::ContextWindowExceeded => { + error!( + turn_id = %turn_context.sub_id, + auto_compact_callsite = ?AutoCompactCallsite::PreTurnIncludingIncomingUserMessage, + incoming_items_tokens_estimate, + auto_compact_limit, + reason = "pre-turn compaction exceeded context window", + "incoming user/context is too large for pre-turn auto-compaction flow" + ); + 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 => { + let compact_error_prefix = if should_use_remote_compact_task(&turn_context.provider) + { + "Error running remote compact task" + } else { + "Error running local compact task" + }; + EventMsg::Error(other.to_error_event(Some(compact_error_prefix.to_string()))) + } + }; + sess.send_event(turn_context, event).await; + return Err(()); } Ok(PreTurnCompactionOutcome::CompactedWithIncomingItems) From de959aafa29fe3069b504657cba31f4ee9c8d8ae Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Sun, 15 Feb 2026 17:16:35 -0800 Subject: [PATCH 23/80] Comments --- codex-rs/core/src/codex.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1110fa3d51a..497fecac0f0 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4685,12 +4685,10 @@ async fn maybe_run_previous_model_inline_compact( match run_auto_compact( sess, + // We use previous turn context here because we compact with the previous model &previous_turn_context, AutoCompactCallsite::PreTurnExcludingIncomingUserMessage, - // Even though incoming turn items are excluded here, this pass can be the only - // compaction run before submission. Reinject canonical context so unchanged - // model-visible instructions remain present if no follow-up pre-turn compaction - // is needed. + // User message and turn context diff is injected in the pre-compaction NotNeeded case later TurnContextReinjection::ReinjectAboveLastRealUser, None, ) From 54e606ade3d4c2821cfbb417460d4bcf312cd5ef Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Sun, 15 Feb 2026 17:20:55 -0800 Subject: [PATCH 24/80] Comment --- codex-rs/core/src/codex.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 497fecac0f0..8fad914a247 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4755,10 +4755,12 @@ async fn persist_pre_turn_items_for_compaction_outcome( sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), input, response_item) .await; } + // TODO(ccunningham): Followup PR will use compacting excluding incoming items as a fallback + // (even though it is out of distribution for current models). + // Also future models may prefer compacting pre-turn history without incoming turn items. #[cfg(test)] PreTurnCompactionOutcome::CompactedWithoutIncomingItems => { - // Reserved path for future models that compact pre-turn history without incoming turn - // items; reseed canonical initial context above the incoming user message. + // Reseed canonical initial context above the incoming user message. let initial_context = sess.build_initial_context(turn_context.as_ref()).await; if !initial_context.is_empty() { sess.record_conversation_items(turn_context, &initial_context) From 3a04f2c6895d2d32a995f2a6b3521fd1f54ba640 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Sun, 15 Feb 2026 17:31:24 -0800 Subject: [PATCH 25/80] Use explicit user-message predicate for compaction filtering --- codex-rs/core/src/compact.rs | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 40de1b5ec4e..9d8cbede59a 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -286,7 +286,7 @@ async fn run_compact_task_inner( let incoming_user_items = match incoming_items.as_ref() { Some(items) => items .iter() - .filter(|item| real_user_message_text(item).is_some()) + .filter(|item| is_non_summary_user_message(item)) .cloned() .collect(), None => Vec::new(), @@ -391,7 +391,7 @@ pub(crate) fn process_compacted_history( // user input rather than an earlier turn. if let Some(insertion_index) = compacted_history .iter() - .rposition(|item| real_user_message_text(item).is_some()) + .rposition(is_non_summary_user_message) { compacted_history .splice(insertion_index..insertion_index, initial_context.to_vec()); @@ -403,13 +403,13 @@ pub(crate) fn process_compacted_history( compacted_history } -fn real_user_message_text(item: &ResponseItem) -> Option { +fn is_non_summary_user_message(item: &ResponseItem) -> bool { match crate::event_mapping::parse_turn_item(item) { Some(TurnItem::UserMessage(user_message)) => { let message = user_message.message(); - (!is_summary_message(&message)).then_some(message) + !is_summary_message(&message) } - _ => None, + _ => false, } } @@ -874,6 +874,21 @@ do things assert_eq!(history, expected); } + #[test] + fn non_summary_user_message_includes_image_only_user_messages() { + let image_only_user = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputImage { + image_url: "data:image/png;base64,AAAA".to_string(), + }], + end_turn: None, + phase: None, + }; + + assert!(super::is_non_summary_user_message(&image_only_user)); + } + #[test] fn process_compacted_history_replaces_developer_messages() { let compacted_history = vec![ From f832de63716c6a21a8263ebe157f4ce50f09734a Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Sun, 15 Feb 2026 17:33:29 -0800 Subject: [PATCH 26/80] Document manual compact turn-context reinjection policy --- codex-rs/core/src/compact.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 9d8cbede59a..6223287ee2a 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -130,6 +130,8 @@ pub(crate) async fn run_compact_task( turn_context, input, None, + // Manual `/compact` should not reinsert turn context into compacted history; we reseed + // canonical initial context before the next user turn. TurnContextReinjection::Skip, None, ) From b029cc2d01d2e5bd334fa46b1d7bdb4596a69dd7 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Sun, 15 Feb 2026 17:56:37 -0800 Subject: [PATCH 27/80] Reinject context for summary-only compacted history --- codex-rs/core/src/compact.rs | 56 +++++++++++++------ ..._history_reinject_summary_only_shapes.snap | 9 +++ 2 files changed, 48 insertions(+), 17 deletions(-) create mode 100644 codex-rs/core/src/snapshots/codex_core__compact__tests__process_compacted_history_reinject_summary_only_shapes.snap diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 6223287ee2a..f7f395d8b61 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -389,15 +389,27 @@ pub(crate) fn process_compacted_history( match turn_context_reinjection { TurnContextReinjection::ReinjectAboveLastRealUser => { - // Insert immediately above the last real user message so turn context applies to that - // user input rather than an earlier turn. - if let Some(insertion_index) = compacted_history + // Prefer inserting immediately above the last real user message so turn context + // applies to that user input rather than an earlier turn. If compaction output is + // summary-only, insert before the first summary user message to keep canonical context + // present for the next sampling request. + let insertion_index = if let Some(last_real_user_index) = compacted_history .iter() .rposition(is_non_summary_user_message) { - compacted_history - .splice(insertion_index..insertion_index, initial_context.to_vec()); - } + last_real_user_index + } else if let Some(first_summary_index) = compacted_history.iter().position(|item| { + matches!( + crate::event_mapping::parse_turn_item(item), + Some(TurnItem::UserMessage(user_message)) + if is_summary_message(&user_message.message()) + ) + }) { + first_summary_index + } else { + compacted_history.len() + }; + compacted_history.splice(insertion_index..insertion_index, initial_context.to_vec()); } TurnContextReinjection::Skip => {} } @@ -560,7 +572,12 @@ async fn drain_to_completed( mod tests { use super::*; + use core_test_support::context_snapshot; + use core_test_support::context_snapshot::ContextSnapshotOptions; + use core_test_support::context_snapshot::ContextSnapshotRenderMode; + use insta::assert_snapshot; use pretty_assertions::assert_eq; + use serde_json::Value; #[test] fn content_items_to_text_joins_non_empty_segments() { @@ -1534,7 +1551,7 @@ keep me updated } #[test] - fn process_compacted_history_reinject_noops_without_real_user_message() { + fn process_compacted_history_reinjects_context_when_compaction_output_is_summary_only() { let compacted_history = vec![ResponseItem::Message { id: None, role: "user".to_string(), @@ -1559,16 +1576,21 @@ keep me updated &initial_context, TurnContextReinjection::ReinjectAboveLastRealUser, ); - let expected = vec![ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: format!("{SUMMARY_PREFIX}\nsummary text"), - }], - end_turn: None, - phase: None, - }]; - assert_eq!(refreshed, expected); + let refreshed_value = + serde_json::to_value(&refreshed).expect("serialize refreshed history"); + let Value::Array(refreshed_items) = refreshed_value else { + panic!("expected refreshed history to serialize as array"); + }; + + assert_snapshot!( + "process_compacted_history_reinject_summary_only_shapes", + context_snapshot::format_labeled_items_snapshot( + "When compaction output contains only a summary user message, canonical context is still reinserted before the summary.", + &[("Refreshed History Layout", refreshed_items.as_slice())], + &ContextSnapshotOptions::default() + .render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 64 }), + ) + ); } #[test] diff --git a/codex-rs/core/src/snapshots/codex_core__compact__tests__process_compacted_history_reinject_summary_only_shapes.snap b/codex-rs/core/src/snapshots/codex_core__compact__tests__process_compacted_history_reinject_summary_only_shapes.snap new file mode 100644 index 00000000000..aba7c96a6d7 --- /dev/null +++ b/codex-rs/core/src/snapshots/codex_core__compact__tests__process_compacted_history_reinject_summary_only_shapes.snap @@ -0,0 +1,9 @@ +--- +source: core/src/compact.rs +expression: "context_snapshot::format_labeled_items_snapshot(\"When compaction output contains only a summary user message, canonical context is still reinserted before the summary.\",\n&[(\"Refreshed History Layout\", refreshed_items.as_slice())],\n&ContextSnapshotOptions::default().render_mode(ContextSnapshotRenderMode::KindWithTextPrefix\n{ max_chars: 64 }),)" +--- +Scenario: When compaction output contains only a summary user message, canonical context is still reinserted before the summary. + +## Refreshed History Layout +00:message/developer:fresh permissions +01:message/user:\nsummary text From 466c689766436df953c1483bc9a5ef6626276494 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Sun, 15 Feb 2026 20:45:20 -0800 Subject: [PATCH 28/80] compact: reinsert context above last summary --- codex-rs/core/src/compact.rs | 79 +++++++++++++++++++++++++++++++++--- 1 file changed, 74 insertions(+), 5 deletions(-) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index f7f395d8b61..ba507279426 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -390,22 +390,22 @@ pub(crate) fn process_compacted_history( match turn_context_reinjection { TurnContextReinjection::ReinjectAboveLastRealUser => { // Prefer inserting immediately above the last real user message so turn context - // applies to that user input rather than an earlier turn. If compaction output is - // summary-only, insert before the first summary user message to keep canonical context - // present for the next sampling request. + // applies to that user input rather than an earlier turn. If compaction output has no + // real user messages, insert before the last summary user message to keep canonical + // context present for the next sampling request. let insertion_index = if let Some(last_real_user_index) = compacted_history .iter() .rposition(is_non_summary_user_message) { last_real_user_index - } else if let Some(first_summary_index) = compacted_history.iter().position(|item| { + } else if let Some(last_summary_index) = compacted_history.iter().rposition(|item| { matches!( crate::event_mapping::parse_turn_item(item), Some(TurnItem::UserMessage(user_message)) if is_summary_message(&user_message.message()) ) }) { - first_summary_index + last_summary_index } else { compacted_history.len() }; @@ -1593,6 +1593,75 @@ keep me updated ); } + #[test] + fn process_compacted_history_reinjects_context_above_last_summary_when_no_real_user() { + let compacted_history = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!("{SUMMARY_PREFIX}\nolder summary"), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!("{SUMMARY_PREFIX}\nlatest summary"), + }], + end_turn: None, + phase: None, + }, + ]; + let initial_context = vec![ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "fresh permissions".to_string(), + }], + end_turn: None, + phase: None, + }]; + + let refreshed = process_compacted_history( + compacted_history, + &initial_context, + TurnContextReinjection::ReinjectAboveLastRealUser, + ); + let expected = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!("{SUMMARY_PREFIX}\nolder summary"), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "fresh permissions".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!("{SUMMARY_PREFIX}\nlatest summary"), + }], + end_turn: None, + phase: None, + }, + ]; + assert_eq!(refreshed, expected); + } + #[test] fn process_compacted_history_mid_turn_without_orphan_user_places_summary_last() { let compacted_history = vec![ From 5929f058b04301e7eea7fd9a2d85c2b5ea531c51 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Sun, 15 Feb 2026 21:13:40 -0800 Subject: [PATCH 29/80] compact: snapshot reinjection above last summary --- codex-rs/core/src/compact.rs | 16 ++++++++++++++++ ...story_reinject_above_last_summary_shapes.snap | 10 ++++++++++ 2 files changed, 26 insertions(+) create mode 100644 codex-rs/core/src/snapshots/codex_core__compact__tests__process_compacted_history_reinject_above_last_summary_shapes.snap diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index ba507279426..bc7d2fc7e57 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -1660,6 +1660,22 @@ keep me updated }, ]; assert_eq!(refreshed, expected); + + let refreshed_value = + serde_json::to_value(&refreshed).expect("serialize refreshed history"); + let Value::Array(refreshed_items) = refreshed_value else { + panic!("expected refreshed history to serialize as array"); + }; + + assert_snapshot!( + "process_compacted_history_reinject_above_last_summary_shapes", + context_snapshot::format_labeled_items_snapshot( + "When compaction output has multiple summary-only user messages and no real user message, canonical context is reinserted above the last summary.", + &[("Refreshed History Layout", refreshed_items.as_slice())], + &ContextSnapshotOptions::default() + .render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 64 }), + ) + ); } #[test] diff --git a/codex-rs/core/src/snapshots/codex_core__compact__tests__process_compacted_history_reinject_above_last_summary_shapes.snap b/codex-rs/core/src/snapshots/codex_core__compact__tests__process_compacted_history_reinject_above_last_summary_shapes.snap new file mode 100644 index 00000000000..906f79b55f8 --- /dev/null +++ b/codex-rs/core/src/snapshots/codex_core__compact__tests__process_compacted_history_reinject_above_last_summary_shapes.snap @@ -0,0 +1,10 @@ +--- +source: core/src/compact.rs +expression: "context_snapshot::format_labeled_items_snapshot(\"When compaction output has multiple summary-only user messages and no real user message, canonical context is reinserted above the last summary.\",\n&[(\"Refreshed History Layout\", refreshed_items.as_slice())],\n&ContextSnapshotOptions::default().render_mode(ContextSnapshotRenderMode::KindWithTextPrefix\n{ max_chars: 64 }),)" +--- +Scenario: When compaction output has multiple summary-only user messages and no real user message, canonical context is reinserted above the last summary. + +## Refreshed History Layout +00:message/user:\nolder summary +01:message/developer:fresh permissions +02:message/user:\nlatest summary From b5aeb30d59b4ca228b04b29f4f2bca29f2bbb9c5 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Mon, 16 Feb 2026 11:03:52 -0800 Subject: [PATCH 30/80] compact: strip incoming model-switch before compaction --- codex-rs/core/src/compact.rs | 110 ++++++++++++++++++++++++++++++++++- 1 file changed, 107 insertions(+), 3 deletions(-) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index bc7d2fc7e57..9806d34d1ee 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -88,6 +88,17 @@ pub(crate) fn extract_trailing_model_switch_update_for_compaction_request( Some(model_switch_item) } +fn extract_latest_model_switch_update_from_items( + items: &mut Vec, +) -> Option { + let model_switch_index = items + .iter() + .enumerate() + .rev() + .find_map(|(i, item)| Session::is_model_switch_developer_message(item).then_some(i))?; + Some(items.remove(model_switch_index)) +} + pub(crate) async fn run_inline_auto_compact_task( sess: Arc, turn_context: Arc, @@ -152,10 +163,14 @@ async fn run_compact_task_inner( let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input); let mut history = sess.clone_history().await; + let mut incoming_items = incoming_items; // Keep compaction prompts in-distribution: if a model-switch update was injected at the - // tail of history (between turns), exclude it from the compaction request payload. - let stripped_model_switch_item = - extract_trailing_model_switch_update_for_compaction_request(&mut history); + // tail of incoming turn items (pre-turn path) or between turns in history, exclude it from + // the compaction request payload. + let stripped_model_switch_item = incoming_items + .as_mut() + .and_then(extract_latest_model_switch_update_from_items) + .or_else(|| extract_trailing_model_switch_update_for_compaction_request(&mut history)); if let Some(incoming_items) = incoming_items.as_ref() { history.record_items(incoming_items.iter(), turn_context.truncation_policy); } @@ -710,6 +725,95 @@ mod tests { ); } + #[test] + fn extract_model_switch_update_for_compaction_request_prefers_incoming_items() { + let mut history = ContextManager::new(); + history.replace(vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "USER_MESSAGE".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "ASSISTANT_REPLY".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "\nHISTORY_MODEL_INSTRUCTIONS".to_string(), + }], + end_turn: None, + phase: None, + }, + ]); + let mut incoming_items = vec![ + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "\nINCOMING_MODEL_INSTRUCTIONS".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "INCOMING_USER_MESSAGE".to_string(), + }], + end_turn: None, + phase: None, + }, + ]; + + let model_switch_item = Some(&mut incoming_items) + .and_then(extract_latest_model_switch_update_from_items) + .or_else(|| extract_trailing_model_switch_update_for_compaction_request(&mut history)); + + assert_eq!( + model_switch_item, + Some(ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "\nINCOMING_MODEL_INSTRUCTIONS".to_string(), + }], + end_turn: None, + phase: None, + }) + ); + assert_eq!( + incoming_items, + vec![ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "INCOMING_USER_MESSAGE".to_string(), + }], + end_turn: None, + phase: None, + }] + ); + assert!( + history + .raw_items() + .iter() + .any(Session::is_model_switch_developer_message) + ); + } + #[test] fn collect_user_messages_extracts_user_text_only() { let items = vec![ From 9d34abf9542d8cd471976c2e42b9bc1ffa69fd79 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Mon, 16 Feb 2026 11:10:50 -0800 Subject: [PATCH 31/80] core: snapshot pre-turn model-switch compaction strip behavior --- codex-rs/core/tests/suite/compact.rs | 5 ++--- ...n_strips_incoming_model_switch_shapes.snap | 20 +++++++++---------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index b29ba0184a4..f33d6762464 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -5,6 +5,7 @@ use codex_core::built_in_model_providers; use codex_core::compact::SUMMARIZATION_PROMPT; use codex_core::compact::SUMMARY_PREFIX; use codex_core::config::Config; +use codex_core::features::Feature; use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; use codex_core::protocol::ItemCompletedEvent; @@ -3210,9 +3211,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch .with_config(move |config| { config.model_provider = model_provider; set_test_compact_prompt(config); - config - .features - .enable(codex_core::features::Feature::RemoteModels); + config.features.enable(Feature::RemoteModels); config.model_auto_compact_token_limit = Some(200); }) .build(&server) diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_strips_incoming_model_switch_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_strips_incoming_model_switch_shapes.snap index 4d67af911d8..48b721f2c29 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_strips_incoming_model_switch_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_strips_incoming_model_switch_shapes.snap @@ -1,6 +1,5 @@ --- source: core/tests/suite/compact.rs -assertion_line: 3152 expression: "format_labeled_requests_snapshot(\"Pre-turn compaction during model switch (without pre-sampling model-switch compaction): current behavior strips incoming from the compact request and restores it in the post-compaction follow-up request.\",\n&[(\"Initial Request (Previous Model)\", &requests[0]),\n(\"Local Compaction Request\", &requests[1]),\n(\"Local Post-Compaction History Layout\", &requests[2]),])" --- Scenario: Pre-turn compaction during model switch (without pre-sampling model-switch compaction): current behavior strips incoming from the compact request and restores it in the post-compaction follow-up request. @@ -19,14 +18,15 @@ Scenario: Pre-turn compaction during model switch (without pre-sampling model-sw 03:message/developer: 04:message/user:BEFORE_SWITCH_USER 05:message/assistant:BEFORE_SWITCH_REPLY -06:message/user: +06:message/user:AFTER_SWITCH_USER +07:message/user: ## Local Post-Compaction History Layout -00:message/developer: -01:message/developer: The user has requested a new communication st... -02:message/user: -03:message/user:> -04:message/user:BEFORE_SWITCH_USER -05:message/user:\nPRETURN_SWITCH_SUMMARY -06:message/developer:\nThe user was previously using a different model.... -07:message/user:AFTER_SWITCH_USER +00:message/user:BEFORE_SWITCH_USER +01:message/developer: +02:message/developer: The user has requested a new communication st... +03:message/user: +04:message/user:> +05:message/user:AFTER_SWITCH_USER +06:message/user:\nPRETURN_SWITCH_SUMMARY +07:message/developer:\nThe user was previously using a different model.... From 713978497024e3b2163adfe2fee432fa61c4d987 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Mon, 16 Feb 2026 15:11:37 -0800 Subject: [PATCH 32/80] Strip incoming model-switch updates from remote compaction input --- codex-rs/core/src/compact.rs | 2 +- codex-rs/core/src/compact_remote.rs | 11 ++++++++--- codex-rs/core/tests/suite/compact_remote.rs | 8 ++++---- ...ompaction_strips_incoming_model_switch_shapes.snap | 11 ++++++----- 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 9806d34d1ee..4056deb4e2f 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -88,7 +88,7 @@ pub(crate) fn extract_trailing_model_switch_update_for_compaction_request( Some(model_switch_item) } -fn extract_latest_model_switch_update_from_items( +pub(crate) fn extract_latest_model_switch_update_from_items( items: &mut Vec, ) -> Option { let model_switch_index = items diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index b338686ea05..4768abe0d6b 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -5,6 +5,7 @@ use crate::codex::Session; use crate::codex::TurnContext; use crate::compact::AutoCompactCallsite; use crate::compact::TurnContextReinjection; +use crate::compact::extract_latest_model_switch_update_from_items; use crate::compact::extract_trailing_model_switch_update_for_compaction_request; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; @@ -106,10 +107,14 @@ async fn run_remote_compact_task_inner_impl( sess.emit_turn_item_started(turn_context, &compaction_item) .await; let mut history = sess.clone_history().await; + let mut incoming_items = incoming_items; // Keep compaction prompts in-distribution: if a model-switch update was injected at the - // tail of history (between turns), exclude it from the compaction request payload. - let stripped_model_switch_item = - extract_trailing_model_switch_update_for_compaction_request(&mut history); + // tail of incoming turn items (pre-turn path) or between turns in history, exclude it from + // the compaction request payload. + let stripped_model_switch_item = incoming_items + .as_mut() + .and_then(extract_latest_model_switch_update_from_items) + .or_else(|| extract_trailing_model_switch_update_for_compaction_request(&mut history)); let base_instructions = sess.get_base_instructions().await; let deleted_items = trim_function_call_history_to_fit_context_window( &mut history, diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index d6cd6112119..c99314a4077 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -1509,8 +1509,8 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model let post_compact_turn_request = post_compact_turn_request_mock.single_request(); let compact_body = compact_request.body_json().to_string(); assert!( - !compact_body.contains("AFTER_SWITCH_USER"), - "current behavior excludes incoming user from the pre-turn remote compaction request" + compact_body.contains("AFTER_SWITCH_USER"), + "pre-turn remote compaction request should include incoming user message" ); assert!( !compact_body.contains(""), @@ -1524,7 +1524,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model ); assert!( follow_up_body.contains("AFTER_SWITCH_USER"), - "post-compaction follow-up should preserve incoming user message via runtime append" + "post-compaction follow-up should preserve incoming user message" ); assert!( follow_up_body.contains(""), @@ -1534,7 +1534,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model insta::assert_snapshot!( "remote_pre_turn_compaction_strips_incoming_model_switch_shapes", format_labeled_requests_snapshot( - "Remote pre-turn compaction during model switch currently excludes incoming user input, strips incoming from the compact request payload, and restores it in the post-compaction follow-up request.", + "Remote pre-turn compaction during model switch strips incoming from the compact request and restores it in the post-compaction follow-up request.", &[ ("Initial Request (Previous Model)", &initial_turn_request), ("Remote Compaction Request", &compact_request), diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_strips_incoming_model_switch_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_strips_incoming_model_switch_shapes.snap index fba17d719ed..0a789f5b697 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_strips_incoming_model_switch_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_strips_incoming_model_switch_shapes.snap @@ -1,8 +1,8 @@ --- source: core/tests/suite/compact_remote.rs -expression: "format_labeled_requests_snapshot(\"Remote pre-turn compaction during model switch currently excludes incoming user input, strips incoming from the compact request payload, and restores it in the post-compaction follow-up request.\",\n&[(\"Initial Request (Previous Model)\", &requests[0]),\n(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &requests[1]),])" +expression: "format_labeled_requests_snapshot(\"Remote pre-turn compaction during model switch strips incoming from the compact request and restores it in the post-compaction follow-up request.\",\n&[(\"Initial Request (Previous Model)\", &requests[0]),\n(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &requests[1]),])" --- -Scenario: Remote pre-turn compaction during model switch currently excludes incoming user input, strips incoming from the compact request payload, and restores it in the post-compaction follow-up request. +Scenario: Remote pre-turn compaction during model switch strips incoming from the compact request and restores it in the post-compaction follow-up request. ## Initial Request (Previous Model) 00:message/developer: @@ -16,6 +16,7 @@ Scenario: Remote pre-turn compaction during model switch currently excludes inco 02:message/user:> 03:message/user:BEFORE_SWITCH_USER 04:message/assistant:BEFORE_SWITCH_REPLY +05:message/user:AFTER_SWITCH_USER ## Remote Post-Compaction History Layout 00:message/user:BEFORE_SWITCH_USER @@ -23,6 +24,6 @@ Scenario: Remote pre-turn compaction during model switch currently excludes inco 02:message/developer: The user has requested a new communication st... 03:message/user: 04:message/user:> -05:message/user:\nREMOTE_SWITCH_SUMMARY -06:message/developer:\nThe user was previously using a different model.... -07:message/user:AFTER_SWITCH_USER +05:message/user:AFTER_SWITCH_USER +06:message/user:\nREMOTE_SWITCH_SUMMARY +07:message/developer:\nThe user was previously using a different model.... From d81d89ac0be54cb1bd759f1c149d82819b5e4442 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Mon, 16 Feb 2026 22:32:38 -0800 Subject: [PATCH 33/80] Silence interrupted pre-turn compaction failures --- codex-rs/core/src/codex.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 8fad914a247..97e5cb3c33c 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4695,6 +4695,7 @@ async fn maybe_run_previous_model_inline_compact( .await { Ok(()) => Ok(()), + Err(CodexErr::Interrupted) => Err(()), Err(err) => { if !pre_turn_context_items.is_empty() { // Preserve model-visible settings updates even when pre-turn compaction fails @@ -4806,6 +4807,9 @@ async fn run_pre_turn_auto_compaction_if_needed( .await; if let Err(err) = compact_result { + if matches!(err, CodexErr::Interrupted) { + return Err(()); + } if !pre_turn_context_items.is_empty() { // Preserve model-visible settings updates even when pre-turn compaction fails // before we can persist turn input. From f7956ffabc3d39b7e51c37d0a35bf3027c5e25f6 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Mon, 16 Feb 2026 22:49:56 -0800 Subject: [PATCH 34/80] Persist pre-turn updates before interrupted model-switch compact exits --- codex-rs/core/src/codex.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 97e5cb3c33c..b0f4703a324 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4695,7 +4695,6 @@ async fn maybe_run_previous_model_inline_compact( .await { Ok(()) => Ok(()), - Err(CodexErr::Interrupted) => Err(()), Err(err) => { if !pre_turn_context_items.is_empty() { // Preserve model-visible settings updates even when pre-turn compaction fails @@ -4703,6 +4702,9 @@ async fn maybe_run_previous_model_inline_compact( sess.record_conversation_items(turn_context, pre_turn_context_items) .await; } + if matches!(err, CodexErr::Interrupted) { + return Err(()); + } let compact_error_prefix = if should_use_remote_compact_task(&turn_context.provider) { "Error running remote compact task" } else { From ccc270083fab40f3973ca40feee3cdcf0337fef0 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Mon, 16 Feb 2026 22:50:54 -0800 Subject: [PATCH 35/80] Persist pre-turn updates before interrupted auto-compaction exits --- codex-rs/core/src/codex.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index b0f4703a324..c17184bf5d7 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4809,15 +4809,15 @@ async fn run_pre_turn_auto_compaction_if_needed( .await; if let Err(err) = compact_result { - if matches!(err, CodexErr::Interrupted) { - return Err(()); - } 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; } + if matches!(err, CodexErr::Interrupted) { + return Err(()); + } let event = match err { CodexErr::ContextWindowExceeded => { error!( From 3f2f817ab675ca1e35752743c839018554cae30c Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 17 Feb 2026 10:54:56 -0800 Subject: [PATCH 36/80] Make compact unit snapshots Bazel-stable --- codex-rs/core/src/compact.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 4056deb4e2f..314f7d78fed 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -593,6 +593,15 @@ mod tests { use insta::assert_snapshot; use pretty_assertions::assert_eq; use serde_json::Value; + use std::path::Path; + + fn assert_compact_snapshot(name: &str, rendered: String) { + let mut settings = insta::Settings::clone_current(); + settings.set_snapshot_path(Path::new(env!("CARGO_MANIFEST_DIR")).join("src/snapshots")); + settings.bind(|| { + assert_snapshot!(name, rendered); + }); + } #[test] fn content_items_to_text_joins_non_empty_segments() { @@ -1686,14 +1695,14 @@ keep me updated panic!("expected refreshed history to serialize as array"); }; - assert_snapshot!( + assert_compact_snapshot( "process_compacted_history_reinject_summary_only_shapes", context_snapshot::format_labeled_items_snapshot( "When compaction output contains only a summary user message, canonical context is still reinserted before the summary.", &[("Refreshed History Layout", refreshed_items.as_slice())], &ContextSnapshotOptions::default() .render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 64 }), - ) + ), ); } @@ -1771,14 +1780,14 @@ keep me updated panic!("expected refreshed history to serialize as array"); }; - assert_snapshot!( + assert_compact_snapshot( "process_compacted_history_reinject_above_last_summary_shapes", context_snapshot::format_labeled_items_snapshot( "When compaction output has multiple summary-only user messages and no real user message, canonical context is reinserted above the last summary.", &[("Refreshed History Layout", refreshed_items.as_slice())], &ContextSnapshotOptions::default() .render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 64 }), - ) + ), ); } From 907e292ad970c9024d71134c369e838c9a58eb0f Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 17 Feb 2026 11:02:54 -0800 Subject: [PATCH 37/80] Add comment --- codex-rs/core/src/compact.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 314f7d78fed..903839fdbd7 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -595,6 +595,7 @@ mod tests { use serde_json::Value; use std::path::Path; + // This is to make Bazel happy fn assert_compact_snapshot(name: &str, rendered: String) { let mut settings = insta::Settings::clone_current(); settings.set_snapshot_path(Path::new(env!("CARGO_MANIFEST_DIR")).join("src/snapshots")); From 04b157bb4c80c0161b3ae0c137c7de9183e840d3 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 17 Feb 2026 13:30:41 -0800 Subject: [PATCH 38/80] Treat no-op pre-turn compaction as not-needed --- codex-rs/core/src/codex.rs | 57 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index c17184bf5d7..0f9274afd8d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4847,6 +4847,19 @@ async fn run_pre_turn_auto_compaction_if_needed( return Err(()); } + // If compaction no-oped because there was no user-turn boundary even after including incoming + // items, do not treat this as "compacted with incoming". The caller must persist incoming + // items explicitly in this case. + let has_user_turn_boundary_after_compaction = sess + .clone_history() + .await + .raw_items() + .iter() + .any(crate::context_manager::is_user_turn_boundary); + if !has_user_turn_boundary_after_compaction { + return Ok(PreTurnCompactionOutcome::NotNeeded); + } + Ok(PreTurnCompactionOutcome::CompactedWithIncomingItems) } @@ -6217,6 +6230,50 @@ mod tests { assert_eq!(actual_history, expected_history); } + #[tokio::test] + async fn pre_turn_auto_compaction_noop_without_user_turn_boundary_returns_not_needed() { + let (session, turn_context) = make_session_and_context().await; + let session = Arc::new(session); + let turn_context = Arc::new(turn_context); + + { + let mut state = session.state.lock().await; + state.set_token_info(Some(TokenUsageInfo { + total_token_usage: TokenUsage { + total_tokens: 1_000, + ..TokenUsage::default() + }, + last_token_usage: TokenUsage { + total_tokens: 1_000, + ..TokenUsage::default() + }, + model_context_window: turn_context.model_context_window(), + })); + } + + let incoming_turn_items = vec![ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "\necho hi\n".to_string(), + }], + end_turn: None, + phase: None, + }]; + let outcome = run_pre_turn_auto_compaction_if_needed( + &session, + &turn_context, + 10, + &incoming_turn_items, + &[], + ) + .await + .expect("pre-turn compaction no-op should not fail"); + + assert_eq!(outcome, PreTurnCompactionOutcome::NotNeeded); + assert_eq!(session.clone_history().await.raw_items(), &[]); + } + #[test] fn estimate_user_input_token_count_is_positive_for_text_input() { let input = vec![UserInput::Text { From a38b1c9cf5153546c29624986b6582f3b6a6d6ba Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 17 Feb 2026 13:34:14 -0800 Subject: [PATCH 39/80] Move compact reinjection snapshots into suite coverage --- codex-rs/core/src/compact.rs | 144 ------------------ ...ry_reinject_above_last_summary_shapes.snap | 10 -- ..._history_reinject_summary_only_shapes.snap | 9 -- codex-rs/core/tests/suite/compact_remote.rs | 1 - 4 files changed, 164 deletions(-) delete mode 100644 codex-rs/core/src/snapshots/codex_core__compact__tests__process_compacted_history_reinject_above_last_summary_shapes.snap delete mode 100644 codex-rs/core/src/snapshots/codex_core__compact__tests__process_compacted_history_reinject_summary_only_shapes.snap diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 903839fdbd7..1013e87b902 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -585,24 +585,8 @@ async fn drain_to_completed( #[cfg(test)] mod tests { - use super::*; - use core_test_support::context_snapshot; - use core_test_support::context_snapshot::ContextSnapshotOptions; - use core_test_support::context_snapshot::ContextSnapshotRenderMode; - use insta::assert_snapshot; use pretty_assertions::assert_eq; - use serde_json::Value; - use std::path::Path; - - // This is to make Bazel happy - fn assert_compact_snapshot(name: &str, rendered: String) { - let mut settings = insta::Settings::clone_current(); - settings.set_snapshot_path(Path::new(env!("CARGO_MANIFEST_DIR")).join("src/snapshots")); - settings.bind(|| { - assert_snapshot!(name, rendered); - }); - } #[test] fn content_items_to_text_joins_non_empty_segments() { @@ -1664,134 +1648,6 @@ keep me updated assert_eq!(refreshed, expected); } - #[test] - fn process_compacted_history_reinjects_context_when_compaction_output_is_summary_only() { - let compacted_history = vec![ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: format!("{SUMMARY_PREFIX}\nsummary text"), - }], - end_turn: None, - phase: None, - }]; - let initial_context = vec![ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "fresh permissions".to_string(), - }], - end_turn: None, - phase: None, - }]; - - let refreshed = process_compacted_history( - compacted_history, - &initial_context, - TurnContextReinjection::ReinjectAboveLastRealUser, - ); - let refreshed_value = - serde_json::to_value(&refreshed).expect("serialize refreshed history"); - let Value::Array(refreshed_items) = refreshed_value else { - panic!("expected refreshed history to serialize as array"); - }; - - assert_compact_snapshot( - "process_compacted_history_reinject_summary_only_shapes", - context_snapshot::format_labeled_items_snapshot( - "When compaction output contains only a summary user message, canonical context is still reinserted before the summary.", - &[("Refreshed History Layout", refreshed_items.as_slice())], - &ContextSnapshotOptions::default() - .render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 64 }), - ), - ); - } - - #[test] - fn process_compacted_history_reinjects_context_above_last_summary_when_no_real_user() { - let compacted_history = vec![ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: format!("{SUMMARY_PREFIX}\nolder summary"), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: format!("{SUMMARY_PREFIX}\nlatest summary"), - }], - end_turn: None, - phase: None, - }, - ]; - let initial_context = vec![ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "fresh permissions".to_string(), - }], - end_turn: None, - phase: None, - }]; - - let refreshed = process_compacted_history( - compacted_history, - &initial_context, - TurnContextReinjection::ReinjectAboveLastRealUser, - ); - let expected = vec![ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: format!("{SUMMARY_PREFIX}\nolder summary"), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "fresh permissions".to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: format!("{SUMMARY_PREFIX}\nlatest summary"), - }], - end_turn: None, - phase: None, - }, - ]; - assert_eq!(refreshed, expected); - - let refreshed_value = - serde_json::to_value(&refreshed).expect("serialize refreshed history"); - let Value::Array(refreshed_items) = refreshed_value else { - panic!("expected refreshed history to serialize as array"); - }; - - assert_compact_snapshot( - "process_compacted_history_reinject_above_last_summary_shapes", - context_snapshot::format_labeled_items_snapshot( - "When compaction output has multiple summary-only user messages and no real user message, canonical context is reinserted above the last summary.", - &[("Refreshed History Layout", refreshed_items.as_slice())], - &ContextSnapshotOptions::default() - .render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 64 }), - ), - ); - } - #[test] fn process_compacted_history_mid_turn_without_orphan_user_places_summary_last() { let compacted_history = vec![ diff --git a/codex-rs/core/src/snapshots/codex_core__compact__tests__process_compacted_history_reinject_above_last_summary_shapes.snap b/codex-rs/core/src/snapshots/codex_core__compact__tests__process_compacted_history_reinject_above_last_summary_shapes.snap deleted file mode 100644 index 906f79b55f8..00000000000 --- a/codex-rs/core/src/snapshots/codex_core__compact__tests__process_compacted_history_reinject_above_last_summary_shapes.snap +++ /dev/null @@ -1,10 +0,0 @@ ---- -source: core/src/compact.rs -expression: "context_snapshot::format_labeled_items_snapshot(\"When compaction output has multiple summary-only user messages and no real user message, canonical context is reinserted above the last summary.\",\n&[(\"Refreshed History Layout\", refreshed_items.as_slice())],\n&ContextSnapshotOptions::default().render_mode(ContextSnapshotRenderMode::KindWithTextPrefix\n{ max_chars: 64 }),)" ---- -Scenario: When compaction output has multiple summary-only user messages and no real user message, canonical context is reinserted above the last summary. - -## Refreshed History Layout -00:message/user:\nolder summary -01:message/developer:fresh permissions -02:message/user:\nlatest summary diff --git a/codex-rs/core/src/snapshots/codex_core__compact__tests__process_compacted_history_reinject_summary_only_shapes.snap b/codex-rs/core/src/snapshots/codex_core__compact__tests__process_compacted_history_reinject_summary_only_shapes.snap deleted file mode 100644 index aba7c96a6d7..00000000000 --- a/codex-rs/core/src/snapshots/codex_core__compact__tests__process_compacted_history_reinject_summary_only_shapes.snap +++ /dev/null @@ -1,9 +0,0 @@ ---- -source: core/src/compact.rs -expression: "context_snapshot::format_labeled_items_snapshot(\"When compaction output contains only a summary user message, canonical context is still reinserted before the summary.\",\n&[(\"Refreshed History Layout\", refreshed_items.as_slice())],\n&ContextSnapshotOptions::default().render_mode(ContextSnapshotRenderMode::KindWithTextPrefix\n{ max_chars: 64 }),)" ---- -Scenario: When compaction output contains only a summary user message, canonical context is still reinserted before the summary. - -## Refreshed History Layout -00:message/developer:fresh permissions -01:message/user:\nsummary text diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index c99314a4077..c815adcf765 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -1909,7 +1909,6 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -// TODO(ccunningham): Update once manual remote /compact with no prior user turn becomes a no-op. async fn snapshot_request_shape_remote_manual_compact_without_previous_user_messages() -> Result<()> { skip_if_no_network!(Ok(())); From 15aa101bf67162f72eba0bf3034bffa0fcfc11b4 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 17 Feb 2026 14:22:07 -0800 Subject: [PATCH 40/80] Qualify Feature enum in compact suite tests --- codex-rs/core/tests/suite/compact.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index f33d6762464..5493201b4f2 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1844,7 +1844,9 @@ async fn pre_sampling_compact_context_window_failure_surfaces_compact_task_error .with_config(move |config| { config.model_provider = model_provider; set_test_compact_prompt(config); - config.features.enable(Feature::RemoteModels); + config + .features + .enable(codex_core::features::Feature::RemoteModels); }); let test = builder.build(&server).await.expect("build test codex"); @@ -3211,7 +3213,9 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch .with_config(move |config| { config.model_provider = model_provider; set_test_compact_prompt(config); - config.features.enable(Feature::RemoteModels); + config + .features + .enable(codex_core::features::Feature::RemoteModels); config.model_auto_compact_token_limit = Some(200); }) .build(&server) From 3964becfde128eeb290ae25bfa17589c4273006c Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 17 Feb 2026 17:55:24 -0800 Subject: [PATCH 41/80] dd comment --- codex-rs/core/src/codex.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 0f9274afd8d..b40bfdb3969 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -2387,10 +2387,6 @@ impl Session { if let Some(replacement) = &compacted.replacement_history { history.replace(replacement.clone()); } else { - // TODO(ccunningham): When we have TurnContextItem-based legacy reconstruction, - // build historical turn context from those items and inject it at the correct - // point in compacted history instead of prepending the current initial context. - // This matters for legacy rollouts with replacement_history=None. let user_messages = collect_user_messages(history.raw_items()); let mut rebuilt = self.build_initial_context(turn_context).await; rebuilt.extend(compact::build_compacted_history( From 589b0763cca57eab764cab557bd976fc55fd825d Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 17 Feb 2026 18:01:10 -0800 Subject: [PATCH 42/80] Fix compact test snapshot and include bazel lock update --- codex-rs/core/tests/suite/compact.rs | 1 - ...summary_reinjects_above_last_summary_shapes.snap | 13 +++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 5493201b4f2..17066c3c92c 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -5,7 +5,6 @@ use codex_core::built_in_model_providers; use codex_core::compact::SUMMARIZATION_PROMPT; use codex_core::compact::SUMMARY_PREFIX; use codex_core::config::Config; -use codex_core::features::Feature; use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; use codex_core::protocol::ItemCompletedEvent; diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_multi_summary_reinjects_above_last_summary_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_multi_summary_reinjects_above_last_summary_shapes.snap index 1f51f965440..9ea6233a0a1 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_multi_summary_reinjects_above_last_summary_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_multi_summary_reinjects_above_last_summary_shapes.snap @@ -10,12 +10,13 @@ Scenario: Remote mid-turn compaction after an earlier summary compaction: the ol 02:message/developer: 03:message/user: 04:message/user:> -05:message/user:\nREMOTE_LATEST_SUMMARY -06:message/user:USER_TWO +05:message/user:USER_TWO +06:message/user:\nREMOTE_LATEST_SUMMARY ## Remote Compaction Request 00:message/user:USER_ONE -01:message/developer: -02:message/user: -03:message/user:> -04:message/user:\nREMOTE_OLDER_SUMMARY +01:message/user:\nREMOTE_OLDER_SUMMARY +02:message/developer: +03:message/user: +04:message/user:> +05:message/user:USER_TWO From 01794f1a462639e3592593a6d140001e4f94c0e1 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 17 Feb 2026 18:46:04 -0800 Subject: [PATCH 43/80] Reuse compaction keep filter for incoming items --- codex-rs/core/src/compact.rs | 100 +++++++++++++++++++++++++++++------ 1 file changed, 84 insertions(+), 16 deletions(-) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 1013e87b902..16deb18f20d 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -18,6 +18,7 @@ use crate::protocol::WarningEvent; use crate::truncate::TruncationPolicy; use crate::truncate::approx_token_count; use crate::truncate::truncate_text; +use crate::user_shell_command::is_user_shell_command_text; use crate::util::backoff; use codex_protocol::items::ContextCompactionItem; use codex_protocol::items::TurnItem; @@ -303,7 +304,9 @@ async fn run_compact_task_inner( let incoming_user_items = match incoming_items.as_ref() { Some(items) => items .iter() - .filter(|item| is_non_summary_user_message(item)) + .filter(|item| { + should_keep_compacted_history_item(item) && !is_summary_user_message_item(item) + }) .cloned() .collect(), None => Vec::new(), @@ -433,13 +436,14 @@ pub(crate) fn process_compacted_history( } fn is_non_summary_user_message(item: &ResponseItem) -> bool { - match crate::event_mapping::parse_turn_item(item) { - Some(TurnItem::UserMessage(user_message)) => { - let message = user_message.message(); - !is_summary_message(&message) - } - _ => false, - } + should_keep_compacted_history_item(item) && !is_summary_user_message_item(item) +} + +fn is_summary_user_message_item(item: &ResponseItem) -> bool { + matches!( + crate::event_mapping::parse_turn_item(item), + Some(TurnItem::UserMessage(user_message)) if is_summary_message(&user_message.message()) + ) } /// Returns whether an item from remote compaction output should be preserved. @@ -451,18 +455,39 @@ fn is_non_summary_user_message(item: &ResponseItem) -> bool { /// - `developer` messages because remote output can include stale/duplicated /// instruction content. /// - non-user-content `user` messages (session prefix/instruction wrappers), -/// keeping only real user messages as parsed by `parse_turn_item`. +/// keeping real user messages plus user shell-command records. /// /// This intentionally keeps `user`-role warnings and compaction-generated -/// summary messages because they parse as `TurnItem::UserMessage`. +/// summary messages because they parse as `TurnItem::UserMessage`, and keeps +/// `` user records for shell-execution continuity. fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { match item { - ResponseItem::Message { role, .. } if role == "developer" => false, - ResponseItem::Message { role, .. } if role == "user" => matches!( - crate::event_mapping::parse_turn_item(item), - Some(TurnItem::UserMessage(_)) - ), - _ => true, + ResponseItem::Message { role, content, .. } => { + if role == "developer" { + return false; + } + if role != "user" { + return true; + } + + matches!( + crate::event_mapping::parse_turn_item(item), + Some(TurnItem::UserMessage(_)) + ) || matches!( + content.as_slice(), + [ContentItem::InputText { text }] if is_user_shell_command_text(text) + ) + } + ResponseItem::Reasoning { .. } + | ResponseItem::LocalShellCall { .. } + | ResponseItem::FunctionCall { .. } + | ResponseItem::FunctionCallOutput { .. } + | ResponseItem::CustomToolCall { .. } + | ResponseItem::CustomToolCallOutput { .. } + | ResponseItem::WebSearchCall { .. } + | ResponseItem::GhostSnapshot { .. } + | ResponseItem::Compaction { .. } + | ResponseItem::Other => true, } } @@ -1006,6 +1031,49 @@ do things assert!(super::is_non_summary_user_message(&image_only_user)); } + #[test] + fn non_summary_user_message_includes_user_shell_command_records() { + let shell_command_user = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "\necho hi\n".to_string(), + }], + end_turn: None, + phase: None, + }; + + assert!(super::is_non_summary_user_message(&shell_command_user)); + } + + #[test] + fn should_keep_compacted_history_item_drops_user_session_prefix_but_keeps_user_shell_command() { + let session_prefix = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "\n /repo\n" + .to_string(), + }], + end_turn: None, + phase: None, + }; + let shell_command_user = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "\necho hi\n".to_string(), + }], + end_turn: None, + phase: None, + }; + + assert!(!super::should_keep_compacted_history_item(&session_prefix)); + assert!(super::should_keep_compacted_history_item( + &shell_command_user + )); + } + #[test] fn process_compacted_history_replaces_developer_messages() { let compacted_history = vec![ From 6e6e7187388ef44962a2b233f7902c43a52b6b84 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 17 Feb 2026 18:47:38 -0800 Subject: [PATCH 44/80] Use real user predicate for compaction reinjection --- codex-rs/core/src/compact.rs | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 16deb18f20d..b85a08fa418 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -304,9 +304,7 @@ async fn run_compact_task_inner( let incoming_user_items = match incoming_items.as_ref() { Some(items) => items .iter() - .filter(|item| { - should_keep_compacted_history_item(item) && !is_summary_user_message_item(item) - }) + .filter(|item| is_real_user_message(item)) .cloned() .collect(), None => Vec::new(), @@ -411,9 +409,8 @@ pub(crate) fn process_compacted_history( // applies to that user input rather than an earlier turn. If compaction output has no // real user messages, insert before the last summary user message to keep canonical // context present for the next sampling request. - let insertion_index = if let Some(last_real_user_index) = compacted_history - .iter() - .rposition(is_non_summary_user_message) + let insertion_index = if let Some(last_real_user_index) = + compacted_history.iter().rposition(is_real_user_message) { last_real_user_index } else if let Some(last_summary_index) = compacted_history.iter().rposition(|item| { @@ -435,7 +432,7 @@ pub(crate) fn process_compacted_history( compacted_history } -fn is_non_summary_user_message(item: &ResponseItem) -> bool { +fn is_real_user_message(item: &ResponseItem) -> bool { should_keep_compacted_history_item(item) && !is_summary_user_message_item(item) } @@ -1017,7 +1014,7 @@ do things } #[test] - fn non_summary_user_message_includes_image_only_user_messages() { + fn real_user_message_includes_image_only_user_messages() { let image_only_user = ResponseItem::Message { id: None, role: "user".to_string(), @@ -1028,11 +1025,11 @@ do things phase: None, }; - assert!(super::is_non_summary_user_message(&image_only_user)); + assert!(super::is_real_user_message(&image_only_user)); } #[test] - fn non_summary_user_message_includes_user_shell_command_records() { + fn real_user_message_includes_user_shell_command_records() { let shell_command_user = ResponseItem::Message { id: None, role: "user".to_string(), @@ -1043,7 +1040,7 @@ do things phase: None, }; - assert!(super::is_non_summary_user_message(&shell_command_user)); + assert!(super::is_real_user_message(&shell_command_user)); } #[test] From afebfffc93ad8fb15ae34bd6124d2990e285a22f Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 17 Feb 2026 18:49:38 -0800 Subject: [PATCH 45/80] Exclude user shell command records from compaction keep set --- codex-rs/core/src/compact.rs | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index b85a08fa418..1af23fda98c 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -18,7 +18,6 @@ use crate::protocol::WarningEvent; use crate::truncate::TruncationPolicy; use crate::truncate::approx_token_count; use crate::truncate::truncate_text; -use crate::user_shell_command::is_user_shell_command_text; use crate::util::backoff; use codex_protocol::items::ContextCompactionItem; use codex_protocol::items::TurnItem; @@ -452,14 +451,13 @@ fn is_summary_user_message_item(item: &ResponseItem) -> bool { /// - `developer` messages because remote output can include stale/duplicated /// instruction content. /// - non-user-content `user` messages (session prefix/instruction wrappers), -/// keeping real user messages plus user shell-command records. +/// keeping only real user messages as parsed by `parse_turn_item`. /// /// This intentionally keeps `user`-role warnings and compaction-generated -/// summary messages because they parse as `TurnItem::UserMessage`, and keeps -/// `` user records for shell-execution continuity. +/// summary messages because they parse as `TurnItem::UserMessage`. fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { match item { - ResponseItem::Message { role, content, .. } => { + ResponseItem::Message { role, .. } => { if role == "developer" { return false; } @@ -470,9 +468,6 @@ fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { matches!( crate::event_mapping::parse_turn_item(item), Some(TurnItem::UserMessage(_)) - ) || matches!( - content.as_slice(), - [ContentItem::InputText { text }] if is_user_shell_command_text(text) ) } ResponseItem::Reasoning { .. } @@ -1029,7 +1024,7 @@ do things } #[test] - fn real_user_message_includes_user_shell_command_records() { + fn real_user_message_excludes_user_shell_command_records() { let shell_command_user = ResponseItem::Message { id: None, role: "user".to_string(), @@ -1040,11 +1035,11 @@ do things phase: None, }; - assert!(super::is_real_user_message(&shell_command_user)); + assert!(!super::is_real_user_message(&shell_command_user)); } #[test] - fn should_keep_compacted_history_item_drops_user_session_prefix_but_keeps_user_shell_command() { + fn should_keep_compacted_history_item_drops_user_session_prefix_and_user_shell_command() { let session_prefix = ResponseItem::Message { id: None, role: "user".to_string(), @@ -1066,7 +1061,7 @@ do things }; assert!(!super::should_keep_compacted_history_item(&session_prefix)); - assert!(super::should_keep_compacted_history_item( + assert!(!super::should_keep_compacted_history_item( &shell_command_user )); } From 4c2c861e1520154eca7073d47a3c3c4617362bdb Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 17 Feb 2026 18:51:30 -0800 Subject: [PATCH 46/80] Keep shell command records only in incoming compaction items --- codex-rs/core/src/compact.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 1af23fda98c..1561deb4173 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -18,6 +18,7 @@ use crate::protocol::WarningEvent; use crate::truncate::TruncationPolicy; use crate::truncate::approx_token_count; use crate::truncate::truncate_text; +use crate::user_shell_command::is_user_shell_command_text; use crate::util::backoff; use codex_protocol::items::ContextCompactionItem; use codex_protocol::items::TurnItem; @@ -303,7 +304,24 @@ async fn run_compact_task_inner( let incoming_user_items = match incoming_items.as_ref() { Some(items) => items .iter() - .filter(|item| is_real_user_message(item)) + .filter(|item| { + if is_real_user_message(item) { + return true; + } + // TODO(ccunningham): Truncate user shell-command records before preserving them + // in incoming compaction items so they cannot cause repeated context-window + // overflows across pre-turn compaction attempts. + matches!( + item, + ResponseItem::Message { role, content, .. } + if role == "user" + && matches!( + content.as_slice(), + [ContentItem::InputText { text }] + if is_user_shell_command_text(text) + ) + ) + }) .cloned() .collect(), None => Vec::new(), From d53b588309d718b2ea917fc4a7799c46909dfae2 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 17 Feb 2026 18:58:35 -0800 Subject: [PATCH 47/80] Base incoming compaction filtering on keep predicate --- codex-rs/core/src/compact.rs | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 1561deb4173..618794586d2 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -305,22 +305,21 @@ async fn run_compact_task_inner( Some(items) => items .iter() .filter(|item| { - if is_real_user_message(item) { - return true; - } // TODO(ccunningham): Truncate user shell-command records before preserving them // in incoming compaction items so they cannot cause repeated context-window // overflows across pre-turn compaction attempts. - matches!( - item, - ResponseItem::Message { role, content, .. } - if role == "user" - && matches!( - content.as_slice(), - [ContentItem::InputText { text }] - if is_user_shell_command_text(text) - ) - ) + (should_keep_compacted_history_item(item) + || matches!( + item, + ResponseItem::Message { role, content, .. } + if role == "user" + && matches!( + content.as_slice(), + [ContentItem::InputText { text }] + if is_user_shell_command_text(text) + ) + )) + && !is_summary_user_message_item(item) }) .cloned() .collect(), From 52e6a54a6d8c4834669adac5f7ccec1e0bf9cc08 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 17 Feb 2026 19:03:04 -0800 Subject: [PATCH 48/80] Define real user predicate independent of keep policy --- codex-rs/core/src/compact.rs | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 618794586d2..31203ba37bf 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -308,17 +308,7 @@ async fn run_compact_task_inner( // TODO(ccunningham): Truncate user shell-command records before preserving them // in incoming compaction items so they cannot cause repeated context-window // overflows across pre-turn compaction attempts. - (should_keep_compacted_history_item(item) - || matches!( - item, - ResponseItem::Message { role, content, .. } - if role == "user" - && matches!( - content.as_slice(), - [ContentItem::InputText { text }] - if is_user_shell_command_text(text) - ) - )) + (should_keep_compacted_history_item(item) || is_user_shell_command_record(item)) && !is_summary_user_message_item(item) }) .cloned() @@ -449,7 +439,10 @@ pub(crate) fn process_compacted_history( } fn is_real_user_message(item: &ResponseItem) -> bool { - should_keep_compacted_history_item(item) && !is_summary_user_message_item(item) + matches!( + crate::event_mapping::parse_turn_item(item), + Some(TurnItem::UserMessage(user_message)) if !is_summary_message(&user_message.message()) + ) } fn is_summary_user_message_item(item: &ResponseItem) -> bool { @@ -459,6 +452,18 @@ fn is_summary_user_message_item(item: &ResponseItem) -> bool { ) } +fn is_user_shell_command_record(item: &ResponseItem) -> bool { + matches!( + item, + ResponseItem::Message { role, content, .. } + if role == "user" + && matches!( + content.as_slice(), + [ContentItem::InputText { text }] if is_user_shell_command_text(text) + ) + ) +} + /// Returns whether an item from remote compaction output should be preserved. /// /// Called while processing the model-provided compacted transcript, before we @@ -481,6 +486,9 @@ fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { if role != "user" { return true; } + if is_user_shell_command_record(item) { + return false; + } matches!( crate::event_mapping::parse_turn_item(item), From 8b290e8707cf11db346196934391ecb41c2690b3 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 17 Feb 2026 19:06:33 -0800 Subject: [PATCH 49/80] Drop redundant summary check for incoming compaction items --- codex-rs/core/src/compact.rs | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 31203ba37bf..b8606c29cbb 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -308,8 +308,7 @@ async fn run_compact_task_inner( // TODO(ccunningham): Truncate user shell-command records before preserving them // in incoming compaction items so they cannot cause repeated context-window // overflows across pre-turn compaction attempts. - (should_keep_compacted_history_item(item) || is_user_shell_command_record(item)) - && !is_summary_user_message_item(item) + should_keep_compacted_history_item(item) || is_user_shell_command_record(item) }) .cloned() .collect(), @@ -445,13 +444,6 @@ fn is_real_user_message(item: &ResponseItem) -> bool { ) } -fn is_summary_user_message_item(item: &ResponseItem) -> bool { - matches!( - crate::event_mapping::parse_turn_item(item), - Some(TurnItem::UserMessage(user_message)) if is_summary_message(&user_message.message()) - ) -} - fn is_user_shell_command_record(item: &ResponseItem) -> bool { matches!( item, From 80ab62fc686055c2014fe61d15e63367e4882de1 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 17 Feb 2026 19:18:59 -0800 Subject: [PATCH 50/80] cleanup --- codex-rs/core/src/compact.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index b8606c29cbb..bf4ad646a6d 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -478,9 +478,6 @@ fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { if role != "user" { return true; } - if is_user_shell_command_record(item) { - return false; - } matches!( crate::event_mapping::parse_turn_item(item), From 6663b690b7deddf16e25e32c8f589a531e003eb4 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 17 Feb 2026 19:23:53 -0800 Subject: [PATCH 51/80] Restrict compacted-history keep set to user and compaction items --- codex-rs/core/src/compact.rs | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index bf4ad646a6d..4b18c9de2c1 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -466,9 +466,10 @@ fn is_user_shell_command_record(item: &ResponseItem) -> bool { /// instruction content. /// - non-user-content `user` messages (session prefix/instruction wrappers), /// keeping only real user messages as parsed by `parse_turn_item`. +/// - all non-user transcript items except compaction records. /// -/// This intentionally keeps `user`-role warnings and compaction-generated -/// summary messages because they parse as `TurnItem::UserMessage`. +/// This intentionally keeps compaction-generated summary messages because they +/// parse as `TurnItem::UserMessage`. fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { match item { ResponseItem::Message { role, .. } => { @@ -476,7 +477,7 @@ fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { return false; } if role != "user" { - return true; + return false; } matches!( @@ -484,6 +485,7 @@ fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { Some(TurnItem::UserMessage(_)) ) } + ResponseItem::Compaction { .. } => true, ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } @@ -492,8 +494,7 @@ fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::WebSearchCall { .. } | ResponseItem::GhostSnapshot { .. } - | ResponseItem::Compaction { .. } - | ResponseItem::Other => true, + | ResponseItem::Other => false, } } @@ -1080,6 +1081,15 @@ do things )); } + #[test] + fn should_keep_compacted_history_item_keeps_compaction_item() { + let compaction = ResponseItem::Compaction { + encrypted_content: "abc123".to_string(), + }; + + assert!(super::should_keep_compacted_history_item(&compaction)); + } + #[test] fn process_compacted_history_replaces_developer_messages() { let compacted_history = vec![ @@ -1671,15 +1681,6 @@ keep me updated end_turn: None, phase: None, }, - ResponseItem::Message { - id: None, - role: "assistant".to_string(), - content: vec![ContentItem::OutputText { - text: "assistant after latest summary".to_string(), - }], - end_turn: None, - phase: None, - }, ]; assert_eq!(refreshed, expected); } From b781a15a42235571d20876c40c8ca32ce000355f Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 17 Feb 2026 19:35:22 -0800 Subject: [PATCH 52/80] Keep only user+compaction items in compacted history --- codex-rs/core/src/compact.rs | 1 + codex-rs/core/tests/suite/compact.rs | 5 ----- codex-rs/core/tests/suite/compact_remote.rs | 6 +++++- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 4b18c9de2c1..da2a664b3b6 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -485,6 +485,7 @@ fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { Some(TurnItem::UserMessage(_)) ) } + // Keep compaction records for local/remote history continuity and token accounting. ResponseItem::Compaction { .. } => true, ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 17066c3c92c..d7051dc2451 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -2961,11 +2961,6 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() { "second turn should not include compacted history" ); let third_request_body = requests[2].body_json().to_string(); - assert!( - third_request_body.contains("REMOTE_COMPACT_SUMMARY") - || third_request_body.contains(FINAL_REPLY), - "third turn should include compacted history" - ); assert!( third_request_body.contains("ENCRYPTED_COMPACTION_SUMMARY"), "third turn should include compaction summary item" diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index c815adcf765..eab0b6c5221 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -1051,11 +1051,15 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> ) }); - if has_compacted_user_summary && has_compaction_item && has_compacted_assistant_note { + if has_compacted_user_summary && has_compaction_item { assert!( !has_permissions_developer_message, "manual remote compact rollout replacement history should not inject permissions context" ); + assert!( + !has_compacted_assistant_note, + "manual remote compact rollout replacement history should drop assistant notes" + ); saw_compacted_history = true; break; } From 0d796c73fa6d1eccf5d713b3715e5b23a2c93481 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 17 Feb 2026 19:38:39 -0800 Subject: [PATCH 53/80] Keep shell command records in compacted history filter --- codex-rs/core/src/compact.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index da2a664b3b6..92321eda146 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -304,12 +304,7 @@ async fn run_compact_task_inner( let incoming_user_items = match incoming_items.as_ref() { Some(items) => items .iter() - .filter(|item| { - // TODO(ccunningham): Truncate user shell-command records before preserving them - // in incoming compaction items so they cannot cause repeated context-window - // overflows across pre-turn compaction attempts. - should_keep_compacted_history_item(item) || is_user_shell_command_record(item) - }) + .filter(|item| should_keep_compacted_history_item(item)) .cloned() .collect(), None => Vec::new(), @@ -479,6 +474,11 @@ fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { if role != "user" { return false; } + if is_user_shell_command_record(item) { + // TODO(ccunningham): Truncate preserved user shell-command records so they cannot + // cause repeated context-window overflows across compaction attempts. + return true; + } matches!( crate::event_mapping::parse_turn_item(item), @@ -1055,7 +1055,7 @@ do things } #[test] - fn should_keep_compacted_history_item_drops_user_session_prefix_and_user_shell_command() { + fn should_keep_compacted_history_item_drops_user_session_prefix_and_keeps_user_shell_command() { let session_prefix = ResponseItem::Message { id: None, role: "user".to_string(), @@ -1077,7 +1077,7 @@ do things }; assert!(!super::should_keep_compacted_history_item(&session_prefix)); - assert!(!super::should_keep_compacted_history_item( + assert!(super::should_keep_compacted_history_item( &shell_command_user )); } From a9c6219d8516e882a44000210e813c2391993190 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 17 Feb 2026 22:43:21 -0800 Subject: [PATCH 54/80] Append incoming pre-turn items after compaction summary --- codex-rs/core/src/codex.rs | 24 +++----- codex-rs/core/src/compact.rs | 58 ++++--------------- codex-rs/core/src/compact_remote.rs | 52 +++++++++++++++-- ..._compaction_including_incoming_shapes.snap | 14 +++-- ...n_strips_incoming_model_switch_shapes.snap | 17 +++--- ..._compaction_including_incoming_shapes.snap | 6 +- ...n_strips_incoming_model_switch_shapes.snap | 9 +-- 7 files changed, 94 insertions(+), 86 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index b40bfdb3969..0f61ce4f859 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4735,18 +4735,11 @@ async fn persist_pre_turn_items_for_compaction_outcome( response_item: ResponseItem, ) { match outcome { - PreTurnCompactionOutcome::CompactedWithIncomingItems => { - // Incoming turn items were already part of pre-turn compaction input, and the - // user prompt is already persisted in history after compaction. Emit lifecycle events - // only so UI/consumers still observe a normal user turn item transition. - let turn_item = TurnItem::UserMessage(UserMessageItem::new(input)); - sess.emit_turn_item_started(turn_context.as_ref(), &turn_item) - .await; - sess.emit_turn_item_completed(turn_context.as_ref(), turn_item) - .await; - sess.ensure_rollout_materialized().await; - } - PreTurnCompactionOutcome::NotNeeded => { + PreTurnCompactionOutcome::CompactedWithIncomingItems + | PreTurnCompactionOutcome::NotNeeded => { + // Pre-turn compaction includes incoming items only for the compaction model's request. + // We always persist canonical pre-turn updates and the current user item after the + // compaction summary so model-visible layout is stable regardless of compaction mode. if !pre_turn_context_items.is_empty() { sess.record_conversation_items(turn_context, pre_turn_context_items) .await; @@ -6150,7 +6143,7 @@ mod tests { } #[tokio::test] - async fn compacted_with_incoming_items_emits_lifecycle_without_history_writes() { + async fn compacted_with_incoming_items_persists_context_and_prompt() { let (session, turn_context) = make_session_and_context().await; let session = Arc::new(session); let turn_context = Arc::new(turn_context); @@ -6175,12 +6168,13 @@ mod tests { PreTurnCompactionOutcome::CompactedWithIncomingItems, &stale_pre_turn_context_items, &input, - response_item, + response_item.clone(), ) .await; let actual = session.clone_history().await.raw_items().to_vec(); - assert_eq!(actual, Vec::::new()); + let expected = vec![stale_pre_turn_context_items[0].clone(), response_item]; + assert_eq!(actual, expected); } #[tokio::test] diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 92321eda146..c649c015665 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -301,15 +301,6 @@ async fn run_compact_task_inner( let summary_suffix = get_last_assistant_message_from_turn(history_items).unwrap_or_default(); let summary_text = format!("{SUMMARY_PREFIX}\n{summary_suffix}"); let user_messages = collect_user_messages(history_items); - let incoming_user_items = match incoming_items.as_ref() { - Some(items) => items - .iter() - .filter(|item| should_keep_compacted_history_item(item)) - .cloned() - .collect(), - None => Vec::new(), - }; - let initial_context = match turn_context_reinjection { TurnContextReinjection::ReinjectAboveLastRealUser => { sess.build_initial_context(turn_context.as_ref()).await @@ -318,7 +309,6 @@ async fn run_compact_task_inner( }; let compacted_history = build_compacted_history_with_limit( &user_messages, - &incoming_user_items, &summary_text, COMPACT_USER_MESSAGE_MAX_TOKENS, ); @@ -327,9 +317,12 @@ async fn run_compact_task_inner( &initial_context, turn_context_reinjection, ); - // Reattach the stripped model-switch update only after successful compaction so the model - // still sees the switch instructions on the next real sampling request. - if let Some(model_switch_item) = stripped_model_switch_item { + // Reattach stripped model-switch updates only for compaction paths that do not carry + // incoming turn items. Pre-turn compaction appends turn context and user input after + // compaction in run_turn. + if incoming_items.is_none() + && let Some(model_switch_item) = stripped_model_switch_item + { new_history.push(model_switch_item); } let ghost_snapshots: Vec = history_items @@ -465,7 +458,7 @@ fn is_user_shell_command_record(item: &ResponseItem) -> bool { /// /// This intentionally keeps compaction-generated summary messages because they /// parse as `TurnItem::UserMessage`. -fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { +pub(crate) fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { match item { ResponseItem::Message { role, .. } => { if role == "developer" { @@ -503,17 +496,11 @@ pub(crate) fn build_compacted_history( user_messages: &[String], summary_text: &str, ) -> Vec { - build_compacted_history_with_limit( - user_messages, - &[], - summary_text, - COMPACT_USER_MESSAGE_MAX_TOKENS, - ) + build_compacted_history_with_limit(user_messages, summary_text, COMPACT_USER_MESSAGE_MAX_TOKENS) } fn build_compacted_history_with_limit( user_messages: &[String], - incoming_user_items: &[ResponseItem], summary_text: &str, max_tokens: usize, ) -> Vec { @@ -550,8 +537,6 @@ fn build_compacted_history_with_limit( }); } - history.extend(incoming_user_items.iter().cloned()); - let summary_text = if summary_text.is_empty() { "(no summary available)".to_string() } else { @@ -920,7 +905,6 @@ do things let big = "word ".repeat(200); let history = super::build_compacted_history_with_limit( std::slice::from_ref(&big), - &[], "SUMMARY", max_tokens, ); @@ -976,28 +960,9 @@ do things } #[test] - fn build_compacted_history_preserves_incoming_user_item_structure() { - let preserved_user_item = ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ - ContentItem::InputImage { - image_url: "data:image/png;base64,AAAA".to_string(), - }, - ContentItem::InputText { - text: "latest user with image".to_string(), - }, - ], - end_turn: None, - phase: None, - }; - - let history = super::build_compacted_history_with_limit( - &["older user".to_string()], - std::slice::from_ref(&preserved_user_item), - "SUMMARY", - 128, - ); + fn build_compacted_history_preserves_user_message_structure() { + let history = + super::build_compacted_history_with_limit(&["older user".to_string()], "SUMMARY", 128); let expected = vec![ ResponseItem::Message { @@ -1009,7 +974,6 @@ do things end_turn: None, phase: None, }, - preserved_user_item, ResponseItem::Message { id: None, role: "user".to_string(), diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 4768abe0d6b..f91b2704972 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -7,6 +7,7 @@ use crate::compact::AutoCompactCallsite; use crate::compact::TurnContextReinjection; use crate::compact::extract_latest_model_switch_update_from_items; use crate::compact::extract_trailing_model_switch_update_for_compaction_request; +use crate::compact::should_keep_compacted_history_item; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::context_manager::estimate_item_token_count; @@ -122,7 +123,7 @@ async fn run_remote_compact_task_inner_impl( &base_instructions, incoming_items.as_deref(), ); - if let Some(incoming_items) = incoming_items { + if let Some(incoming_items) = incoming_items.as_ref() { history.record_items(incoming_items.iter(), turn_context.truncation_policy); } if !history.raw_items().iter().any(is_user_turn_boundary) { @@ -182,9 +183,52 @@ async fn run_remote_compact_task_inner_impl( new_history = sess .process_compacted_history(turn_context, new_history, turn_context_reinjection) .await; - // Reattach the stripped model-switch update only after successful compaction so the model - // still sees the switch instructions on the next real sampling request. - if let Some(model_switch_item) = stripped_model_switch_item { + if let Some(incoming_items) = incoming_items.as_ref() { + let incoming_history_items: Vec = incoming_items + .iter() + .filter(|item| should_keep_compacted_history_item(item)) + .cloned() + .collect(); + for incoming_item in incoming_history_items { + if let Some(index) = + new_history + .iter() + .rposition(|candidate| match (candidate, &incoming_item) { + ( + ResponseItem::Message { + role: candidate_role, + content: candidate_content, + .. + }, + ResponseItem::Message { + role: incoming_role, + content: incoming_content, + .. + }, + ) => { + candidate_role == incoming_role && candidate_content == incoming_content + } + ( + ResponseItem::Compaction { + encrypted_content: candidate_content, + }, + ResponseItem::Compaction { + encrypted_content: incoming_content, + }, + ) => candidate_content == incoming_content, + _ => candidate == &incoming_item, + }) + { + new_history.remove(index); + } + } + } + // Reattach stripped model-switch updates only for compaction paths that do not carry + // incoming turn items. Pre-turn compaction appends turn context and user input after + // compaction in run_turn. + if incoming_items.is_none() + && let Some(model_switch_item) = stripped_model_switch_item + { new_history.push(model_switch_item); } diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap index bd4eb41236c..c30d3048791 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap @@ -1,5 +1,6 @@ --- source: core/tests/suite/compact.rs +assertion_line: 3138 expression: "format_labeled_requests_snapshot(\"Pre-turn auto-compaction with a context override includes incoming user content in the compact request and preserves it after compaction.\",\n&[(\"Local Compaction Request\", &requests[2]),\n(\"Local Post-Compaction History Layout\", &requests[3]),])" --- Scenario: Pre-turn auto-compaction with a context override includes incoming user content in the compact request and preserves it after compaction. @@ -18,9 +19,10 @@ Scenario: Pre-turn auto-compaction with a context override includes incoming use ## Local Post-Compaction History Layout 00:message/user:USER_ONE -01:message/user:USER_TWO -02:message/developer: -03:message/user: -04:message/user: -05:message/user: | | | USER_THREE -06:message/user:\nPRE_TURN_SUMMARY +01:message/developer: +02:message/user: +03:message/user: +04:message/user:USER_TWO +05:message/user:\nPRE_TURN_SUMMARY +06:message/user: +07:message/user: | | | USER_THREE diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_strips_incoming_model_switch_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_strips_incoming_model_switch_shapes.snap index 48b721f2c29..fcbad1059be 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_strips_incoming_model_switch_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_strips_incoming_model_switch_shapes.snap @@ -1,5 +1,6 @@ --- source: core/tests/suite/compact.rs +assertion_line: 3288 expression: "format_labeled_requests_snapshot(\"Pre-turn compaction during model switch (without pre-sampling model-switch compaction): current behavior strips incoming from the compact request and restores it in the post-compaction follow-up request.\",\n&[(\"Initial Request (Previous Model)\", &requests[0]),\n(\"Local Compaction Request\", &requests[1]),\n(\"Local Post-Compaction History Layout\", &requests[2]),])" --- Scenario: Pre-turn compaction during model switch (without pre-sampling model-switch compaction): current behavior strips incoming from the compact request and restores it in the post-compaction follow-up request. @@ -22,11 +23,11 @@ Scenario: Pre-turn compaction during model switch (without pre-sampling model-sw 07:message/user: ## Local Post-Compaction History Layout -00:message/user:BEFORE_SWITCH_USER -01:message/developer: -02:message/developer: The user has requested a new communication st... -03:message/user: -04:message/user:> -05:message/user:AFTER_SWITCH_USER -06:message/user:\nPRETURN_SWITCH_SUMMARY -07:message/developer:\nThe user was previously using a different model.... +00:message/developer: +01:message/developer: The user has requested a new communication st... +02:message/user: +03:message/user:> +04:message/user:BEFORE_SWITCH_USER +05:message/user:\nPRETURN_SWITCH_SUMMARY +06:message/developer:\nThe user was previously using a different model.... +07:message/user:AFTER_SWITCH_USER diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap index ec30aa08614..7b28f8151a8 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap @@ -1,5 +1,6 @@ --- source: core/tests/suite/compact_remote.rs +assertion_line: 1390 expression: "format_labeled_requests_snapshot(\"Remote pre-turn auto-compaction with a context override includes incoming user content in the compact request and preserves it after compaction.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &requests[2]),])" --- Scenario: Remote pre-turn auto-compaction with a context override includes incoming user content in the compact request and preserves it after compaction. @@ -21,5 +22,6 @@ Scenario: Remote pre-turn auto-compaction with a context override includes incom 02:message/developer: 03:message/user: 04:message/user: -05:message/user:USER_THREE -06:message/user:\nREMOTE_PRE_TURN_SUMMARY +05:message/user:\nREMOTE_PRE_TURN_SUMMARY +06:message/user: +07:message/user:USER_THREE diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_strips_incoming_model_switch_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_strips_incoming_model_switch_shapes.snap index 0a789f5b697..2b03802b7fb 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_strips_incoming_model_switch_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_strips_incoming_model_switch_shapes.snap @@ -1,6 +1,7 @@ --- source: core/tests/suite/compact_remote.rs -expression: "format_labeled_requests_snapshot(\"Remote pre-turn compaction during model switch strips incoming from the compact request and restores it in the post-compaction follow-up request.\",\n&[(\"Initial Request (Previous Model)\", &requests[0]),\n(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &requests[1]),])" +assertion_line: 1538 +expression: "format_labeled_requests_snapshot(\"Remote pre-turn compaction during model switch strips incoming from the compact request and restores it in the post-compaction follow-up request.\",\n&[(\"Initial Request (Previous Model)\", &initial_turn_request),\n(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &post_compact_turn_request),])" --- Scenario: Remote pre-turn compaction during model switch strips incoming from the compact request and restores it in the post-compaction follow-up request. @@ -24,6 +25,6 @@ Scenario: Remote pre-turn compaction during model switch strips incoming The user has requested a new communication st... 03:message/user: 04:message/user:> -05:message/user:AFTER_SWITCH_USER -06:message/user:\nREMOTE_SWITCH_SUMMARY -07:message/developer:\nThe user was previously using a different model.... +05:message/user:\nREMOTE_SWITCH_SUMMARY +06:message/developer:\nThe user was previously using a different model.... +07:message/user:AFTER_SWITCH_USER From 868019da9b65e7bcc89fdcd6f8bebb1ed8a32b53 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 17 Feb 2026 22:50:48 -0800 Subject: [PATCH 55/80] Skip mid-turn compaction error event on interruption --- codex-rs/core/src/codex.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 0f61ce4f859..bc6f926d9f2 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4538,6 +4538,9 @@ pub(crate) async fn run_turn( ) .await { + if matches!(err, CodexErr::Interrupted) { + return None; + } let event = EventMsg::Error( err.to_error_event(Some("Error running auto compact task".to_string())), ); From 583dee056d51d2d3b5cc0b14c840f3de9fc1bcb8 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 17 Feb 2026 23:12:56 -0800 Subject: [PATCH 56/80] Refactor remote compaction incoming-item dedup --- codex-rs/core/src/compact_remote.rs | 79 +++++++++++++++++------------ 1 file changed, 46 insertions(+), 33 deletions(-) diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index f91b2704972..f9c88538aa1 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -189,39 +189,7 @@ async fn run_remote_compact_task_inner_impl( .filter(|item| should_keep_compacted_history_item(item)) .cloned() .collect(); - for incoming_item in incoming_history_items { - if let Some(index) = - new_history - .iter() - .rposition(|candidate| match (candidate, &incoming_item) { - ( - ResponseItem::Message { - role: candidate_role, - content: candidate_content, - .. - }, - ResponseItem::Message { - role: incoming_role, - content: incoming_content, - .. - }, - ) => { - candidate_role == incoming_role && candidate_content == incoming_content - } - ( - ResponseItem::Compaction { - encrypted_content: candidate_content, - }, - ResponseItem::Compaction { - encrypted_content: incoming_content, - }, - ) => candidate_content == incoming_content, - _ => candidate == &incoming_item, - }) - { - new_history.remove(index); - } - } + remove_incoming_echoes_from_compacted_history(&mut new_history, &incoming_history_items); } // Reattach stripped model-switch updates only for compaction paths that do not carry // incoming turn items. Pre-turn compaction appends turn context and user input after @@ -272,6 +240,51 @@ fn build_compact_request_log_data( } } +/// Remote compaction may echo incoming items in `new_history`. Because incoming items are +/// appended after compaction at the caller, remove one semantic duplicate for each incoming item +/// so turn-context/user entries do not appear twice. +fn remove_incoming_echoes_from_compacted_history( + new_history: &mut Vec, + incoming_history_items: &[ResponseItem], +) { + for incoming_item in incoming_history_items { + if let Some(index) = new_history.iter().rposition(|candidate| { + response_items_match_for_compaction_merge(candidate, incoming_item) + }) { + new_history.remove(index); + } + } +} + +fn response_items_match_for_compaction_merge( + candidate: &ResponseItem, + incoming_item: &ResponseItem, +) -> bool { + match (candidate, incoming_item) { + ( + ResponseItem::Message { + role: candidate_role, + content: candidate_content, + .. + }, + ResponseItem::Message { + role: incoming_role, + content: incoming_content, + .. + }, + ) => candidate_role == incoming_role && candidate_content == incoming_content, + ( + ResponseItem::Compaction { + encrypted_content: candidate_content, + }, + ResponseItem::Compaction { + encrypted_content: incoming_content, + }, + ) => candidate_content == incoming_content, + _ => candidate == incoming_item, + } +} + fn log_remote_compact_failure( turn_context: &TurnContext, auto_compact_callsite: AutoCompactCallsite, From d58204f9dfcb1acfabdf149ad9fd071cae0da234 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 17 Feb 2026 23:29:28 -0800 Subject: [PATCH 57/80] Always skip reinjection during auto-compaction --- codex-rs/core/src/codex.rs | 34 ++++++++++++++----- codex-rs/core/src/compact.rs | 1 + ...__compact__mid_turn_compaction_shapes.snap | 8 ++--- ..._compaction_including_incoming_shapes.snap | 14 ++++---- ...n_strips_incoming_model_switch_shapes.snap | 13 ++++--- ...y_reinjects_above_last_summary_shapes.snap | 12 +++---- ...te__remote_mid_turn_compaction_shapes.snap | 7 ++-- ...summary_only_reinjects_context_shapes.snap | 7 ++-- ..._compaction_including_incoming_shapes.snap | 12 +++---- ...n_strips_incoming_model_switch_shapes.snap | 11 +++--- 10 files changed, 60 insertions(+), 59 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index bc6f926d9f2..6c8fdc7c894 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4533,7 +4533,7 @@ pub(crate) async fn run_turn( &sess, &turn_context, AutoCompactCallsite::MidTurnContinuation, - TurnContextReinjection::ReinjectAboveLastRealUser, + TurnContextReinjection::Skip, None, ) .await @@ -4687,8 +4687,7 @@ async fn maybe_run_previous_model_inline_compact( // We use previous turn context here because we compact with the previous model &previous_turn_context, AutoCompactCallsite::PreTurnExcludingIncomingUserMessage, - // User message and turn context diff is injected in the pre-compaction NotNeeded case later - TurnContextReinjection::ReinjectAboveLastRealUser, + TurnContextReinjection::Skip, None, ) .await @@ -4738,11 +4737,28 @@ async fn persist_pre_turn_items_for_compaction_outcome( response_item: ResponseItem, ) { match outcome { - PreTurnCompactionOutcome::CompactedWithIncomingItems - | PreTurnCompactionOutcome::NotNeeded => { - // Pre-turn compaction includes incoming items only for the compaction model's request. - // We always persist canonical pre-turn updates and the current user item after the - // compaction summary so model-visible layout is stable regardless of compaction mode. + PreTurnCompactionOutcome::CompactedWithIncomingItems => { + // Pre-turn compaction includes incoming items only for the compaction request itself. + // Persist canonical turn context directly above the incoming user item so context + // applies to the latest user message in post-compaction history. + let initial_context = sess.build_initial_context(turn_context.as_ref()).await; + if !initial_context.is_empty() { + sess.record_conversation_items(turn_context, &initial_context) + .await; + } + let model_switch_updates: Vec = pre_turn_context_items + .iter() + .filter(|item| Session::is_model_switch_developer_message(item)) + .cloned() + .collect(); + if !model_switch_updates.is_empty() { + sess.record_conversation_items(turn_context, &model_switch_updates) + .await; + } + sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), input, response_item) + .await; + } + PreTurnCompactionOutcome::NotNeeded => { if !pre_turn_context_items.is_empty() { sess.record_conversation_items(turn_context, pre_turn_context_items) .await; @@ -4795,7 +4811,7 @@ async fn run_pre_turn_auto_compaction_if_needed( sess, turn_context, AutoCompactCallsite::PreTurnIncludingIncomingUserMessage, - TurnContextReinjection::ReinjectAboveLastRealUser, + TurnContextReinjection::Skip, Some(incoming_turn_items.to_vec()), ) .await; diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index c649c015665..fdc76109c31 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -55,6 +55,7 @@ pub(crate) enum AutoCompactCallsite { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum TurnContextReinjection { /// Insert canonical context immediately above the last real user message in compacted history. + #[allow(dead_code)] ReinjectAboveLastRealUser, /// Do not reinsert canonical context while processing compacted history. Skip, diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__mid_turn_compaction_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__mid_turn_compaction_shapes.snap index c13f78aff9c..000d4837787 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__mid_turn_compaction_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__mid_turn_compaction_shapes.snap @@ -1,6 +1,5 @@ --- source: core/tests/suite/compact.rs -assertion_line: 2646 expression: "format_labeled_requests_snapshot(\"True mid-turn continuation compaction after tool output: compact request includes tool artifacts, and the continuation request includes the summary in the same turn.\",\n&[(\"Local Compaction Request\", &auto_compact_mock.single_request()),\n(\"Local Post-Compaction History Layout\",\n&post_auto_compact_mock.single_request()),])" --- Scenario: True mid-turn continuation compaction after tool output: compact request includes tool artifacts, and the continuation request includes the summary in the same turn. @@ -15,8 +14,5 @@ Scenario: True mid-turn continuation compaction after tool output: compact reque 06:message/user: ## Local Post-Compaction History Layout -00:message/developer: -01:message/user: -02:message/user:> -03:message/user:function call limit push -04:message/user:\nAUTO_SUMMARY +00:message/user:function call limit push +01:message/user:\nAUTO_SUMMARY diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap index c30d3048791..ccafe8237a4 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap @@ -1,6 +1,5 @@ --- source: core/tests/suite/compact.rs -assertion_line: 3138 expression: "format_labeled_requests_snapshot(\"Pre-turn auto-compaction with a context override includes incoming user content in the compact request and preserves it after compaction.\",\n&[(\"Local Compaction Request\", &requests[2]),\n(\"Local Post-Compaction History Layout\", &requests[3]),])" --- Scenario: Pre-turn auto-compaction with a context override includes incoming user content in the compact request and preserves it after compaction. @@ -19,10 +18,9 @@ Scenario: Pre-turn auto-compaction with a context override includes incoming use ## Local Post-Compaction History Layout 00:message/user:USER_ONE -01:message/developer: -02:message/user: -03:message/user: -04:message/user:USER_TWO -05:message/user:\nPRE_TURN_SUMMARY -06:message/user: -07:message/user: | | | USER_THREE +01:message/user:USER_TWO +02:message/user:\nPRE_TURN_SUMMARY +03:message/developer: +04:message/user: +05:message/user: +06:message/user: | | | USER_THREE diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_strips_incoming_model_switch_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_strips_incoming_model_switch_shapes.snap index fcbad1059be..03b58b11f0e 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_strips_incoming_model_switch_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_strips_incoming_model_switch_shapes.snap @@ -1,6 +1,5 @@ --- source: core/tests/suite/compact.rs -assertion_line: 3288 expression: "format_labeled_requests_snapshot(\"Pre-turn compaction during model switch (without pre-sampling model-switch compaction): current behavior strips incoming from the compact request and restores it in the post-compaction follow-up request.\",\n&[(\"Initial Request (Previous Model)\", &requests[0]),\n(\"Local Compaction Request\", &requests[1]),\n(\"Local Post-Compaction History Layout\", &requests[2]),])" --- Scenario: Pre-turn compaction during model switch (without pre-sampling model-switch compaction): current behavior strips incoming from the compact request and restores it in the post-compaction follow-up request. @@ -23,11 +22,11 @@ Scenario: Pre-turn compaction during model switch (without pre-sampling model-sw 07:message/user: ## Local Post-Compaction History Layout -00:message/developer: -01:message/developer: The user has requested a new communication st... -02:message/user: -03:message/user:> -04:message/user:BEFORE_SWITCH_USER -05:message/user:\nPRETURN_SWITCH_SUMMARY +00:message/user:BEFORE_SWITCH_USER +01:message/user:\nPRETURN_SWITCH_SUMMARY +02:message/developer: +03:message/developer: The user has requested a new communication st... +04:message/user: +05:message/user:> 06:message/developer:\nThe user was previously using a different model.... 07:message/user:AFTER_SWITCH_USER diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_multi_summary_reinjects_above_last_summary_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_multi_summary_reinjects_above_last_summary_shapes.snap index 9ea6233a0a1..f8b97f47d98 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_multi_summary_reinjects_above_last_summary_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_multi_summary_reinjects_above_last_summary_shapes.snap @@ -1,17 +1,17 @@ --- source: core/tests/suite/compact_remote.rs -expression: "format_labeled_requests_snapshot(\"Remote mid-turn compaction after an earlier summary compaction: the older summary remains in model-visible history and round-trips into the next compact request.\",\n&[(\"Second Turn Request (Before Mid-Turn Compaction)\", &requests[1]),\n(\"Remote Compaction Request\", &compact_request),])" +expression: "format_labeled_requests_snapshot(\"Remote mid-turn compaction after an earlier summary compaction: the older summary remains in model-visible history and round-trips into the next compact request.\",\n&[(\"Second Turn Request (Before Mid-Turn Compaction)\", &second_turn_request),\n(\"Remote Compaction Request\", &compact_request),])" --- Scenario: Remote mid-turn compaction after an earlier summary compaction: the older summary remains in model-visible history and round-trips into the next compact request. ## Second Turn Request (Before Mid-Turn Compaction) 00:message/user:USER_ONE 01:message/user:\nREMOTE_OLDER_SUMMARY -02:message/developer: -03:message/user: -04:message/user:> -05:message/user:USER_TWO -06:message/user:\nREMOTE_LATEST_SUMMARY +02:message/user:\nREMOTE_LATEST_SUMMARY +03:message/developer: +04:message/user: +05:message/user:> +06:message/user:USER_TWO ## Remote Compaction Request 00:message/user:USER_ONE diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_shapes.snap index 488bee0bef9..879c7fa7c62 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_shapes.snap @@ -13,8 +13,5 @@ Scenario: Remote mid-turn continuation compaction after tool output: compact req 05:function_call_output:unsupported call: test_tool ## Remote Post-Compaction History Layout -00:message/developer: -01:message/user: -02:message/user:> -03:message/user:USER_ONE -04:message/user:\nREMOTE_MID_TURN_SUMMARY +00:message/user:USER_ONE +01:message/user:\nREMOTE_MID_TURN_SUMMARY diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_summary_only_reinjects_context_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_summary_only_reinjects_context_shapes.snap index 4ede5c3cadd..e99aa788e43 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_summary_only_reinjects_context_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_summary_only_reinjects_context_shapes.snap @@ -1,6 +1,6 @@ --- source: core/tests/suite/compact_remote.rs -expression: "format_labeled_requests_snapshot(\"Remote mid-turn compaction where compact output has only summary user content: continuation layout reinjects canonical context before that summary.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &requests[1]),])" +expression: "format_labeled_requests_snapshot(\"Remote mid-turn compaction where compact output has only summary user content: continuation layout reinjects canonical context before that summary.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &post_compact_turn_request),])" --- Scenario: Remote mid-turn compaction where compact output has only summary user content: continuation layout reinjects canonical context before that summary. @@ -13,7 +13,4 @@ Scenario: Remote mid-turn compaction where compact output has only summary user 05:function_call_output:unsupported call: test_tool ## Remote Post-Compaction History Layout -00:message/developer: -01:message/user: -02:message/user:> -03:message/user:\nREMOTE_SUMMARY_ONLY +00:message/user:\nREMOTE_SUMMARY_ONLY diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap index 7b28f8151a8..7476a2db8fd 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_including_incoming_shapes.snap @@ -1,6 +1,5 @@ --- source: core/tests/suite/compact_remote.rs -assertion_line: 1390 expression: "format_labeled_requests_snapshot(\"Remote pre-turn auto-compaction with a context override includes incoming user content in the compact request and preserves it after compaction.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &requests[2]),])" --- Scenario: Remote pre-turn auto-compaction with a context override includes incoming user content in the compact request and preserves it after compaction. @@ -19,9 +18,8 @@ Scenario: Remote pre-turn auto-compaction with a context override includes incom ## Remote Post-Compaction History Layout 00:message/user:USER_ONE 01:message/user:USER_TWO -02:message/developer: -03:message/user: -04:message/user: -05:message/user:\nREMOTE_PRE_TURN_SUMMARY -06:message/user: -07:message/user:USER_THREE +02:message/user:\nREMOTE_PRE_TURN_SUMMARY +03:message/developer: +04:message/user: +05:message/user: +06:message/user:USER_THREE diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_strips_incoming_model_switch_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_strips_incoming_model_switch_shapes.snap index 2b03802b7fb..85d8073913a 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_strips_incoming_model_switch_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_strips_incoming_model_switch_shapes.snap @@ -1,6 +1,5 @@ --- source: core/tests/suite/compact_remote.rs -assertion_line: 1538 expression: "format_labeled_requests_snapshot(\"Remote pre-turn compaction during model switch strips incoming from the compact request and restores it in the post-compaction follow-up request.\",\n&[(\"Initial Request (Previous Model)\", &initial_turn_request),\n(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &post_compact_turn_request),])" --- Scenario: Remote pre-turn compaction during model switch strips incoming from the compact request and restores it in the post-compaction follow-up request. @@ -21,10 +20,10 @@ Scenario: Remote pre-turn compaction during model switch strips incoming -02:message/developer: The user has requested a new communication st... -03:message/user: -04:message/user:> -05:message/user:\nREMOTE_SWITCH_SUMMARY +01:message/user:\nREMOTE_SWITCH_SUMMARY +02:message/developer: +03:message/developer: The user has requested a new communication st... +04:message/user: +05:message/user:> 06:message/developer:\nThe user was previously using a different model.... 07:message/user:AFTER_SWITCH_USER From d33a1bedd48c0b835e2771a7b8cc9f14c4425175 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 17 Feb 2026 23:48:56 -0800 Subject: [PATCH 58/80] Simplify --- codex-rs/core/src/codex.rs | 19 +- codex-rs/core/src/compact.rs | 553 +--------------------------- codex-rs/core/src/compact_remote.rs | 23 +- 3 files changed, 19 insertions(+), 576 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 6c8fdc7c894..6ab4b87311f 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -20,7 +20,6 @@ use crate::apps::render_apps_section; use crate::commit_attribution::commit_message_trailer_instruction; use crate::compact; use crate::compact::AutoCompactCallsite; -use crate::compact::TurnContextReinjection; use crate::compact::run_inline_auto_compact_task; use crate::compact::should_use_remote_compact_task; use crate::compact_remote::run_inline_remote_auto_compact_task; @@ -2407,16 +2406,9 @@ impl Session { pub(crate) async fn process_compacted_history( &self, - turn_context: &TurnContext, compacted_history: Vec, - turn_context_reinjection: TurnContextReinjection, ) -> Vec { - let initial_context = self.build_initial_context(turn_context).await; - compact::process_compacted_history( - compacted_history, - &initial_context, - turn_context_reinjection, - ) + compact::process_compacted_history(compacted_history) } /// Append ResponseItems to the in-memory conversation history only. @@ -4533,7 +4525,6 @@ pub(crate) async fn run_turn( &sess, &turn_context, AutoCompactCallsite::MidTurnContinuation, - TurnContextReinjection::Skip, None, ) .await @@ -4687,7 +4678,6 @@ async fn maybe_run_previous_model_inline_compact( // We use previous turn context here because we compact with the previous model &previous_turn_context, AutoCompactCallsite::PreTurnExcludingIncomingUserMessage, - TurnContextReinjection::Skip, None, ) .await @@ -4811,7 +4801,6 @@ async fn run_pre_turn_auto_compaction_if_needed( sess, turn_context, AutoCompactCallsite::PreTurnIncludingIncomingUserMessage, - TurnContextReinjection::Skip, Some(incoming_turn_items.to_vec()), ) .await; @@ -4887,7 +4876,6 @@ async fn run_auto_compact( sess: &Arc, turn_context: &Arc, auto_compact_callsite: AutoCompactCallsite, - turn_context_reinjection: TurnContextReinjection, incoming_items: Option>, ) -> CodexResult<()> { let result = if should_use_remote_compact_task(&turn_context.provider) { @@ -4895,7 +4883,6 @@ async fn run_auto_compact( Arc::clone(sess), Arc::clone(turn_context), auto_compact_callsite, - turn_context_reinjection, incoming_items, ) .await @@ -4904,7 +4891,6 @@ async fn run_auto_compact( Arc::clone(sess), Arc::clone(turn_context), auto_compact_callsite, - turn_context_reinjection, incoming_items, ) .await @@ -6192,7 +6178,8 @@ mod tests { .await; let actual = session.clone_history().await.raw_items().to_vec(); - let expected = vec![stale_pre_turn_context_items[0].clone(), response_item]; + let mut expected = session.build_initial_context(turn_context.as_ref()).await; + expected.push(response_item); assert_eq!(actual, expected); } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index fdc76109c31..b3f3675fa3b 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -48,19 +48,6 @@ pub(crate) enum AutoCompactCallsite { MidTurnContinuation, } -/// Controls whether compacted-history processing should reinsert canonical turn context. -/// -/// When callers exclude incoming user/context from the compaction request, they should typically -/// set reinjection to `Skip` and append canonical context together with the next user message. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum TurnContextReinjection { - /// Insert canonical context immediately above the last real user message in compacted history. - #[allow(dead_code)] - ReinjectAboveLastRealUser, - /// Do not reinsert canonical context while processing compacted history. - Skip, -} - pub(crate) fn should_use_remote_compact_task(provider: &ModelProviderInfo) -> bool { provider.is_openai() } @@ -105,7 +92,6 @@ pub(crate) async fn run_inline_auto_compact_task( sess: Arc, turn_context: Arc, auto_compact_callsite: AutoCompactCallsite, - turn_context_reinjection: TurnContextReinjection, incoming_items: Option>, ) -> CodexResult<()> { let prompt = turn_context.compact_prompt().to_string(); @@ -120,7 +106,6 @@ pub(crate) async fn run_inline_auto_compact_task( turn_context, input, Some(auto_compact_callsite), - turn_context_reinjection, incoming_items, ) .await?; @@ -138,17 +123,7 @@ pub(crate) async fn run_compact_task( collaboration_mode_kind: turn_context.collaboration_mode.mode, }); sess.send_event(&turn_context, start_event).await; - run_compact_task_inner( - sess, - turn_context, - input, - None, - // Manual `/compact` should not reinsert turn context into compacted history; we reseed - // canonical initial context before the next user turn. - TurnContextReinjection::Skip, - None, - ) - .await + run_compact_task_inner(sess, turn_context, input, None, None).await } async fn run_compact_task_inner( @@ -156,7 +131,6 @@ async fn run_compact_task_inner( turn_context: Arc, input: Vec, auto_compact_callsite: Option, - turn_context_reinjection: TurnContextReinjection, incoming_items: Option>, ) -> CodexResult<()> { let compaction_item = TurnItem::ContextCompaction(ContextCompactionItem::new()); @@ -302,22 +276,12 @@ async fn run_compact_task_inner( let summary_suffix = get_last_assistant_message_from_turn(history_items).unwrap_or_default(); let summary_text = format!("{SUMMARY_PREFIX}\n{summary_suffix}"); let user_messages = collect_user_messages(history_items); - let initial_context = match turn_context_reinjection { - TurnContextReinjection::ReinjectAboveLastRealUser => { - sess.build_initial_context(turn_context.as_ref()).await - } - TurnContextReinjection::Skip => Vec::new(), - }; let compacted_history = build_compacted_history_with_limit( &user_messages, &summary_text, COMPACT_USER_MESSAGE_MAX_TOKENS, ); - let mut new_history = process_compacted_history( - compacted_history, - &initial_context, - turn_context_reinjection, - ); + let mut new_history = process_compacted_history(compacted_history); // Reattach stripped model-switch updates only for compaction paths that do not carry // incoming turn items. Pre-turn compaction appends turn context and user input after // compaction in run_turn. @@ -391,41 +355,14 @@ pub(crate) fn is_summary_message(message: &str) -> bool { pub(crate) fn process_compacted_history( mut compacted_history: Vec, - initial_context: &[ResponseItem], - turn_context_reinjection: TurnContextReinjection, ) -> Vec { // Keep only model-visible transcript items that we allow from remote compaction output. compacted_history.retain(should_keep_compacted_history_item); - match turn_context_reinjection { - TurnContextReinjection::ReinjectAboveLastRealUser => { - // Prefer inserting immediately above the last real user message so turn context - // applies to that user input rather than an earlier turn. If compaction output has no - // real user messages, insert before the last summary user message to keep canonical - // context present for the next sampling request. - let insertion_index = if let Some(last_real_user_index) = - compacted_history.iter().rposition(is_real_user_message) - { - last_real_user_index - } else if let Some(last_summary_index) = compacted_history.iter().rposition(|item| { - matches!( - crate::event_mapping::parse_turn_item(item), - Some(TurnItem::UserMessage(user_message)) - if is_summary_message(&user_message.message()) - ) - }) { - last_summary_index - } else { - compacted_history.len() - }; - compacted_history.splice(insertion_index..insertion_index, initial_context.to_vec()); - } - TurnContextReinjection::Skip => {} - } - compacted_history } +#[cfg(test)] fn is_real_user_message(item: &ResponseItem) -> bool { matches!( crate::event_mapping::parse_turn_item(item), @@ -1057,7 +994,7 @@ do things } #[test] - fn process_compacted_history_replaces_developer_messages() { + fn process_compacted_history_drops_developer_messages() { let compacted_history = vec![ ResponseItem::Message { id: None, @@ -1087,93 +1024,9 @@ do things phase: None, }, ]; - let initial_context = vec![ - ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "fresh permissions".to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: r#" - /tmp - zsh -"# - .to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "fresh personality".to_string(), - }], - end_turn: None, - phase: None, - }, - ]; - - let refreshed = process_compacted_history( - compacted_history, - &initial_context, - TurnContextReinjection::ReinjectAboveLastRealUser, - ); - let expected = vec![ - ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "fresh permissions".to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: r#" - /tmp - zsh -"# - .to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "fresh personality".to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "summary".to_string(), - }], - end_turn: None, - phase: None, - }, - ]; - assert_eq!(refreshed, expected); - } - #[test] - fn process_compacted_history_reinjects_full_initial_context() { - let compacted_history = vec![ResponseItem::Message { + let refreshed = process_compacted_history(compacted_history); + let expected = vec![ResponseItem::Message { id: None, role: "user".to_string(), content: vec![ContentItem::InputText { @@ -1182,123 +1035,6 @@ do things end_turn: None, phase: None, }]; - let initial_context = vec![ - ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "fresh permissions".to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: r#"# AGENTS.md instructions for /repo - - -keep me updated -"# - .to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: r#" - /repo - zsh -"# - .to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: r#" - turn-1 - interrupted -"# - .to_string(), - }], - end_turn: None, - phase: None, - }, - ]; - - let refreshed = process_compacted_history( - compacted_history, - &initial_context, - TurnContextReinjection::ReinjectAboveLastRealUser, - ); - let expected = vec![ - ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "fresh permissions".to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: r#"# AGENTS.md instructions for /repo - - -keep me updated -"# - .to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: r#" - /repo - zsh -"# - .to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: r#" - turn-1 - interrupted -"# - .to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "summary".to_string(), - }], - end_turn: None, - phase: None, - }, - ]; assert_eq!(refreshed, expected); } @@ -1364,197 +1100,17 @@ keep me updated phase: None, }, ]; - let initial_context = vec![ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "fresh developer instructions".to_string(), - }], - end_turn: None, - phase: None, - }]; - - let refreshed = process_compacted_history( - compacted_history, - &initial_context, - TurnContextReinjection::ReinjectAboveLastRealUser, - ); - let expected = vec![ - ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "fresh developer instructions".to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "summary".to_string(), - }], - end_turn: None, - phase: None, - }, - ]; - assert_eq!(refreshed, expected); - } - - #[test] - fn process_compacted_history_inserts_context_before_last_real_user_message_only() { - let compacted_history = vec![ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "older user".to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: format!("{SUMMARY_PREFIX}\nsummary text"), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "latest user".to_string(), - }], - end_turn: None, - phase: None, - }, - ]; - let initial_context = vec![ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "fresh permissions".to_string(), - }], - end_turn: None, - phase: None, - }]; - - let refreshed = process_compacted_history( - compacted_history, - &initial_context, - TurnContextReinjection::ReinjectAboveLastRealUser, - ); - let expected = vec![ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "older user".to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: format!("{SUMMARY_PREFIX}\nsummary text"), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "fresh permissions".to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "latest user".to_string(), - }], - end_turn: None, - phase: None, - }, - ]; - assert_eq!(refreshed, expected); - } - #[test] - fn process_compacted_history_pre_turn_places_summary_last() { - let compacted_history = vec![ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "older user".to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: format!("{SUMMARY_PREFIX}\nsummary text"), - }], - end_turn: None, - phase: None, - }, - ]; - let initial_context = vec![ResponseItem::Message { + let refreshed = process_compacted_history(compacted_history); + let expected = vec![ResponseItem::Message { id: None, - role: "developer".to_string(), + role: "user".to_string(), content: vec![ContentItem::InputText { - text: "fresh permissions".to_string(), + text: "summary".to_string(), }], end_turn: None, phase: None, }]; - - let refreshed = process_compacted_history( - compacted_history, - &initial_context, - TurnContextReinjection::ReinjectAboveLastRealUser, - ); - let expected = vec![ - ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "fresh permissions".to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "older user".to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: format!("{SUMMARY_PREFIX}\nsummary text"), - }], - end_turn: None, - phase: None, - }, - ]; assert_eq!(refreshed, expected); } @@ -1608,8 +1164,7 @@ keep me updated }, ]; - let refreshed = - process_compacted_history(compacted_history, &[], TurnContextReinjection::Skip); + let refreshed = process_compacted_history(compacted_history); let expected = vec![ ResponseItem::Message { id: None, @@ -1652,7 +1207,7 @@ keep me updated } #[test] - fn process_compacted_history_skips_context_insertion_without_real_user_message() { + fn process_compacted_history_keeps_summary_only_history() { let compacted_history = vec![ResponseItem::Message { id: None, role: "user".to_string(), @@ -1662,21 +1217,8 @@ keep me updated end_turn: None, phase: None, }]; - let initial_context = vec![ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "fresh permissions".to_string(), - }], - end_turn: None, - phase: None, - }]; - let refreshed = process_compacted_history( - compacted_history, - &initial_context, - TurnContextReinjection::Skip, - ); + let refreshed = process_compacted_history(compacted_history); let expected = vec![ResponseItem::Message { id: None, role: "user".to_string(), @@ -1688,73 +1230,4 @@ keep me updated }]; assert_eq!(refreshed, expected); } - - #[test] - fn process_compacted_history_mid_turn_without_orphan_user_places_summary_last() { - let compacted_history = vec![ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "older user".to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: format!("{SUMMARY_PREFIX}\nsummary text"), - }], - end_turn: None, - phase: None, - }, - ]; - let initial_context = vec![ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "fresh permissions".to_string(), - }], - end_turn: None, - phase: None, - }]; - - let refreshed = process_compacted_history( - compacted_history, - &initial_context, - TurnContextReinjection::ReinjectAboveLastRealUser, - ); - let expected = vec![ - ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "fresh permissions".to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "older user".to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: format!("{SUMMARY_PREFIX}\nsummary text"), - }], - end_turn: None, - phase: None, - }, - ]; - assert_eq!(refreshed, expected); - } } diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index f9c88538aa1..c0a289288a7 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -4,7 +4,6 @@ use crate::Prompt; use crate::codex::Session; use crate::codex::TurnContext; use crate::compact::AutoCompactCallsite; -use crate::compact::TurnContextReinjection; use crate::compact::extract_latest_model_switch_update_from_items; use crate::compact::extract_trailing_model_switch_update_for_compaction_request; use crate::compact::should_keep_compacted_history_item; @@ -32,18 +31,10 @@ pub(crate) async fn run_inline_remote_auto_compact_task( sess: Arc, turn_context: Arc, auto_compact_callsite: AutoCompactCallsite, - // Controls whether canonical turn context should be reinserted into compacted history. - turn_context_reinjection: TurnContextReinjection, incoming_items: Option>, ) -> CodexResult<()> { - run_remote_compact_task_inner( - &sess, - &turn_context, - auto_compact_callsite, - turn_context_reinjection, - incoming_items, - ) - .await?; + run_remote_compact_task_inner(&sess, &turn_context, auto_compact_callsite, incoming_items) + .await?; Ok(()) } @@ -62,9 +53,6 @@ pub(crate) async fn run_remote_compact_task( &sess, &turn_context, AutoCompactCallsite::PreTurnExcludingIncomingUserMessage, - // Manual `/compact` should not reinsert turn context into compacted history; we reseed - // canonical initial context before the next user turn. - TurnContextReinjection::Skip, None, ) .await @@ -74,14 +62,12 @@ async fn run_remote_compact_task_inner( sess: &Arc, turn_context: &Arc, auto_compact_callsite: AutoCompactCallsite, - turn_context_reinjection: TurnContextReinjection, incoming_items: Option>, ) -> CodexResult<()> { if let Err(err) = run_remote_compact_task_inner_impl( sess, turn_context, auto_compact_callsite, - turn_context_reinjection, incoming_items, ) .await @@ -101,7 +87,6 @@ async fn run_remote_compact_task_inner_impl( sess: &Arc, turn_context: &Arc, auto_compact_callsite: AutoCompactCallsite, - turn_context_reinjection: TurnContextReinjection, incoming_items: Option>, ) -> CodexResult<()> { let compaction_item = TurnItem::ContextCompaction(ContextCompactionItem::new()); @@ -180,9 +165,7 @@ async fn run_remote_compact_task_inner_impl( Err(err) }) .await?; - new_history = sess - .process_compacted_history(turn_context, new_history, turn_context_reinjection) - .await; + new_history = sess.process_compacted_history(new_history).await; if let Some(incoming_items) = incoming_items.as_ref() { let incoming_history_items: Vec = incoming_items .iter() From 714e0b78baa688f7e32ee18a4c5408e1e6052869 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Wed, 18 Feb 2026 00:08:39 -0800 Subject: [PATCH 59/80] Align compaction tests with always-skip post-layout --- codex-rs/core/tests/suite/compact.rs | 409 ++---------------- codex-rs/core/tests/suite/compact_remote.rs | 12 +- ...mpling_model_switch_compaction_shapes.snap | 13 +- ...turn_compaction_multi_summary_shapes.snap} | 1 + ..._turn_compaction_summary_only_shapes.snap} | 5 +- 5 files changed, 49 insertions(+), 391 deletions(-) rename codex-rs/core/tests/suite/snapshots/{all__suite__compact_remote__remote_mid_turn_compaction_multi_summary_reinjects_above_last_summary_shapes.snap => all__suite__compact_remote__remote_mid_turn_compaction_multi_summary_shapes.snap} (98%) rename codex-rs/core/tests/suite/snapshots/{all__suite__compact_remote__remote_mid_turn_compaction_summary_only_reinjects_context_shapes.snap => all__suite__compact_remote__remote_mid_turn_compaction_summary_only_shapes.snap} (61%) diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index d7051dc2451..6690609a62b 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -45,7 +45,6 @@ use core_test_support::responses::sse_failed; use core_test_support::responses::sse_response; use core_test_support::responses::start_mock_server; use pretty_assertions::assert_eq; -use serde_json::json; use wiremock::MockServer; // --- Test helpers ----------------------------------------------------------- @@ -143,8 +142,8 @@ fn assert_pre_sampling_switch_compaction_requests( "follow-up request after successful model-switch compaction should include model-switch update item" ); assert!( - body_contains_text(&follow_up_body, ""), - "follow-up request should preserve canonical environment context after pre-sampling compaction" + !body_contains_text(&follow_up_body, ""), + "follow-up request should not reinsert canonical environment context after pre-sampling compaction" ); } @@ -680,10 +679,6 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { // mock responses from the model let reasoning_response_1 = ev_reasoning_item("m1", &["I will create a react app"], &[]); - let encrypted_content_1 = reasoning_response_1["item"]["encrypted_content"] - .as_str() - .unwrap(); - // first chunk of work let model_reasoning_response_1_sse = sse(vec![ reasoning_response_1.clone(), @@ -698,10 +693,6 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { ]); let reasoning_response_2 = ev_reasoning_item("m3", &["I will create a node app"], &[]); - let encrypted_content_2 = reasoning_response_2["item"]["encrypted_content"] - .as_str() - .unwrap(); - // second chunk of work let model_reasoning_response_2_sse = sse(vec![ reasoning_response_2.clone(), @@ -716,13 +707,9 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { ]); let reasoning_response_3 = ev_reasoning_item("m6", &["I will create a python app"], &[]); - let encrypted_content_3 = reasoning_response_3["item"]["encrypted_content"] - .as_str() - .unwrap(); - // third chunk of work let model_reasoning_response_3_sse = sse(vec![ - ev_reasoning_item("m6", &["I will create a python app"], &[]), + reasoning_response_3.clone(), ev_local_shell_call("r6-shell", "completed", vec!["echo", "make-python"]), ev_completed_with_tokens("r6", token_count_used), ]); @@ -806,9 +793,21 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { } let initial_input = normalize_inputs(input); - let environment_message = initial_input[0]["content"][0]["text"].as_str().unwrap(); + assert!( + initial_input.iter().any(|value| { + value + .get("content") + .and_then(|content| content.as_array()) + .and_then(|content| content.first()) + .and_then(|item| item.get("text")) + .and_then(|text| text.as_str()) + .is_some_and(|text| text.contains("")) + }), + "first request should include canonical environment context" + ); - // test 1: after compaction, we should have one environment message, one user message, and one user message with summary prefix + // test 1: after each compaction, the next model request should include + // only the latest user message and the newest summary. let compaction_indices = [2, 4, 6]; let expected_summaries = [ prefixed_first_summary.as_str(), @@ -819,11 +818,9 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { let body = requests_payloads.clone()[i].body_json(); let input = body.get("input").and_then(|v| v.as_array()).unwrap(); let input = normalize_inputs(input); - assert_eq!(input.len(), 3); - let environment_message = input[0]["content"][0]["text"].as_str().unwrap(); - let user_message_received = input[1]["content"][0]["text"].as_str().unwrap(); - let summary_message = input[2]["content"][0]["text"].as_str().unwrap(); - assert_eq!(environment_message, environment_message); + assert_eq!(input.len(), 2); + let user_message_received = input[0]["content"][0]["text"].as_str().unwrap(); + let summary_message = input[1]["content"][0]["text"].as_str().unwrap(); assert_eq!(user_message_received, user_message); assert_eq!( summary_message, expected_summary, @@ -831,358 +828,22 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { ); } - // test 2: the expected requests inputs should be as follows: - let expected_requests_inputs = json!([ - [ - // 0: first request of the user message. - { - "content": [ - { - "text": environment_message, - "type": "input_text" - } - ], - "role": "user", - "type": "message" - }, - { - "content": [ - { - "text": "create an app", - "type": "input_text" - } - ], - "role": "user", - "type": "message" - } - ] - , - [ - // 1: first automatic compaction request. - { - "content": [ - { - "text": environment_message, - "type": "input_text" - } - ], - "role": "user", - "type": "message" - }, - { - "content": [ - { - "text": "create an app", - "type": "input_text" - } - ], - "role": "user", - "type": "message" - }, - { - "content": null, - "encrypted_content": encrypted_content_1, - "summary": [ - { - "text": "I will create a react app", - "type": "summary_text" - } - ], - "type": "reasoning" - }, - { - "action": { - "command": [ - "echo", - "make-react" - ], - "env": null, - "timeout_ms": null, - "type": "exec", - "user": null, - "working_directory": null - }, - "call_id": "r1-shell", - "status": "completed", - "type": "local_shell_call" - }, - { - "call_id": "r1-shell", - "output": "execution error: Io(Os { code: 2, kind: NotFound, message: \"No such file or directory\" })", - "type": "function_call_output" - }, - { - "content": [ - { - "text": SUMMARIZATION_PROMPT, - "type": "input_text" - } - ], - "role": "user", - "type": "message" - } - ] - , - [ - // 2: request after first automatic compaction. - { - "content": [ - { - "text": environment_message, - "type": "input_text" - } - ], - "role": "user", - "type": "message" - }, - { - "content": [ - { - "text": "create an app", - "type": "input_text" - } - ], - "role": "user", - "type": "message" - }, - { - "content": [ - { - "text": prefixed_first_summary.clone(), - "type": "input_text" - } - ], - "role": "user", - "type": "message" - } - ] - , - [ - // 3: request for second automatic compaction. - { - "content": [ - { - "text": environment_message, - "type": "input_text" - } - ], - "role": "user", - "type": "message" - }, - { - "content": [ - { - "text": "create an app", - "type": "input_text" - } - ], - "role": "user", - "type": "message" - }, - { - "content": [ - { - "text": prefixed_first_summary.clone(), - "type": "input_text" - } - ], - "role": "user", - "type": "message" - }, - { - "content": null, - "encrypted_content": encrypted_content_2, - "summary": [ - { - "text": "I will create a node app", - "type": "summary_text" - } - ], - "type": "reasoning" - }, - { - "action": { - "command": [ - "echo", - "make-node" - ], - "env": null, - "timeout_ms": null, - "type": "exec", - "user": null, - "working_directory": null - }, - "call_id": "r3-shell", - "status": "completed", - "type": "local_shell_call" - }, - { - "call_id": "r3-shell", - "output": "execution error: Io(Os { code: 2, kind: NotFound, message: \"No such file or directory\" })", - "type": "function_call_output" - }, - { - "content": [ - { - "text": SUMMARIZATION_PROMPT, - "type": "input_text" - } - ], - "role": "user", - "type": "message" - } - ] - , - // 4: request after second automatic compaction. - [ - { - "content": [ - { - "text": environment_message, - "type": "input_text" - } - ], - "role": "user", - "type": "message" - }, - { - "content": [ - { - "text": "create an app", - "type": "input_text" - } - ], - "role": "user", - "type": "message" - }, - { - "content": [ - { - "text": prefixed_second_summary.clone(), - "type": "input_text" - } - ], - "role": "user", - "type": "message" - } - ] - , - [ - // 5: request for third automatic compaction. - { - "content": [ - { - "text": environment_message, - "type": "input_text" - } - ], - "role": "user", - "type": "message" - }, - { - "content": [ - { - "text": "create an app", - "type": "input_text" - } - ], - "role": "user", - "type": "message" - }, - { - "content": [ - { - "text": prefixed_second_summary.clone(), - "type": "input_text" - } - ], - "role": "user", - "type": "message" - }, - { - "content": null, - "encrypted_content": encrypted_content_3, - "summary": [ - { - "text": "I will create a python app", - "type": "summary_text" - } - ], - "type": "reasoning" - }, - { - "action": { - "command": [ - "echo", - "make-python" - ], - "env": null, - "timeout_ms": null, - "type": "exec", - "user": null, - "working_directory": null - }, - "call_id": "r6-shell", - "status": "completed", - "type": "local_shell_call" - }, - { - "call_id": "r6-shell", - "output": "execution error: Io(Os { code: 2, kind: NotFound, message: \"No such file or directory\" })", - "type": "function_call_output" - }, - { - "content": [ - { - "text": SUMMARIZATION_PROMPT, - "type": "input_text" - } - ], - "role": "user", - "type": "message" - } - ] - , - [ - { - // 6: request after third automatic compaction. - "content": [ - { - "text": environment_message, - "type": "input_text" - } - ], - "role": "user", - "type": "message" - }, - { - "content": [ - { - "text": "create an app", - "type": "input_text" - } - ], - "role": "user", - "type": "message" - }, - { - "content": [ - { - "text": prefixed_third_summary.clone(), - "type": "input_text" - } - ], - "role": "user", - "type": "message" - } - ] - ]); - - for (i, request) in requests_payloads.iter().enumerate() { - let body = request.body_json(); + // test 2: each auto-compaction request should include the summarization prompt. + for i in [1, 3, 5] { + let body = requests_payloads[i].body_json(); let input = body.get("input").and_then(|v| v.as_array()).unwrap(); - let expected_input = expected_requests_inputs[i].as_array().unwrap(); - assert_eq!(normalize_inputs(input), normalize_inputs(expected_input)); + assert!( + input.iter().any(|value| { + value + .get("content") + .and_then(|content| content.as_array()) + .and_then(|content| content.first()) + .and_then(|item| item.get("text")) + .and_then(|text| text.as_str()) + .is_some_and(|text| text == SUMMARIZATION_PROMPT) + }), + "compaction request {i} should include summarization prompt" + ); } // test 3: the number of requests should be 7 diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index eab0b6c5221..e4168f92d30 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -1723,8 +1723,7 @@ async fn snapshot_request_shape_remote_mid_turn_continuation_compaction() -> Res } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_reinjects_context() --> Result<()> { +async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_layout() -> Result<()> { skip_if_no_network!(Ok(())); let harness = TestCodexHarness::with_builder( @@ -1789,9 +1788,9 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_reinject let compact_request = compact_mock.single_request(); let post_compact_turn_request = post_compact_turn_request_mock.single_request(); insta::assert_snapshot!( - "remote_mid_turn_compaction_summary_only_reinjects_context_shapes", + "remote_mid_turn_compaction_summary_only_shapes", format_labeled_requests_snapshot( - "Remote mid-turn compaction where compact output has only summary user content: continuation layout reinjects canonical context before that summary.", + "Remote mid-turn compaction where compact output has only summary user content: continuation layout keeps the summary-only compact output without inserting extra context items.", &[ ("Remote Compaction Request", &compact_request), ( @@ -1806,8 +1805,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_reinject } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjects_above_last_summary() --> Result<()> { +async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_layout() -> Result<()> { skip_if_no_network!(Ok(())); let harness = TestCodexHarness::with_builder( @@ -1896,7 +1894,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec "older summary should round-trip from conversation history into the next compact request" ); insta::assert_snapshot!( - "remote_mid_turn_compaction_multi_summary_reinjects_above_last_summary_shapes", + "remote_mid_turn_compaction_multi_summary_shapes", format_labeled_requests_snapshot( "Remote mid-turn compaction after an earlier summary compaction: the older summary remains in model-visible history and round-trips into the next compact request.", &[ diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_sampling_model_switch_compaction_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_sampling_model_switch_compaction_shapes.snap index 8d6c4d9b1aa..da2fc5ce71d 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_sampling_model_switch_compaction_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_sampling_model_switch_compaction_shapes.snap @@ -1,6 +1,6 @@ --- source: core/tests/suite/compact.rs -assertion_line: 1773 +assertion_line: 1460 expression: "format_labeled_requests_snapshot(\"Pre-sampling compaction on model switch to a smaller context window: current behavior compacts using prior-turn history only (incoming user message excluded), and the follow-up request carries compacted history plus the new user message.\",\n&[(\"Initial Request (Previous Model)\", &requests[0]),\n(\"Pre-sampling Compaction Request\", &requests[1]),\n(\"Post-Compaction Follow-up Request (Next Model)\", &requests[2]),])" --- Scenario: Pre-sampling compaction on model switch to a smaller context window: current behavior compacts using prior-turn history only (incoming user message excluded), and the follow-up request carries compacted history plus the new user message. @@ -22,10 +22,7 @@ Scenario: Pre-sampling compaction on model switch to a smaller context window: c 06:message/user: ## Post-Compaction Follow-up Request (Next Model) -00:message/developer: -01:message/user: -02:message/user:> -03:message/user:before switch -04:message/user:\nPRE_SAMPLING_SUMMARY -05:message/developer:\nThe user was previously using a different model.... -06:message/user:after switch +00:message/user:before switch +01:message/user:\nPRE_SAMPLING_SUMMARY +02:message/developer:\nThe user was previously using a different model.... +03:message/user:after switch diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_multi_summary_reinjects_above_last_summary_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_multi_summary_shapes.snap similarity index 98% rename from codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_multi_summary_reinjects_above_last_summary_shapes.snap rename to codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_multi_summary_shapes.snap index f8b97f47d98..8107eaa37d1 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_multi_summary_reinjects_above_last_summary_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_multi_summary_shapes.snap @@ -1,5 +1,6 @@ --- source: core/tests/suite/compact_remote.rs +assertion_line: 1896 expression: "format_labeled_requests_snapshot(\"Remote mid-turn compaction after an earlier summary compaction: the older summary remains in model-visible history and round-trips into the next compact request.\",\n&[(\"Second Turn Request (Before Mid-Turn Compaction)\", &second_turn_request),\n(\"Remote Compaction Request\", &compact_request),])" --- Scenario: Remote mid-turn compaction after an earlier summary compaction: the older summary remains in model-visible history and round-trips into the next compact request. diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_summary_only_reinjects_context_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_summary_only_shapes.snap similarity index 61% rename from codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_summary_only_reinjects_context_shapes.snap rename to codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_summary_only_shapes.snap index e99aa788e43..109f91e46e3 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_summary_only_reinjects_context_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_summary_only_shapes.snap @@ -1,8 +1,9 @@ --- source: core/tests/suite/compact_remote.rs -expression: "format_labeled_requests_snapshot(\"Remote mid-turn compaction where compact output has only summary user content: continuation layout reinjects canonical context before that summary.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &post_compact_turn_request),])" +assertion_line: 1790 +expression: "format_labeled_requests_snapshot(\"Remote mid-turn compaction where compact output has only summary user content: continuation layout keeps the summary-only compact output without inserting extra context items.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &post_compact_turn_request),])" --- -Scenario: Remote mid-turn compaction where compact output has only summary user content: continuation layout reinjects canonical context before that summary. +Scenario: Remote mid-turn compaction where compact output has only summary user content: continuation layout keeps the summary-only compact output without inserting extra context items. ## Remote Compaction Request 00:message/developer: From a46bcf3b8e4d01436b398818d6bb23cec8332e80 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Wed, 18 Feb 2026 00:23:16 -0800 Subject: [PATCH 60/80] Inline compacted history shaping call --- codex-rs/core/src/codex.rs | 7 ------- codex-rs/core/src/compact_remote.rs | 3 ++- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 6ab4b87311f..9b1e4afb417 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -2404,13 +2404,6 @@ impl Session { history.raw_items().to_vec() } - pub(crate) async fn process_compacted_history( - &self, - compacted_history: Vec, - ) -> Vec { - compact::process_compacted_history(compacted_history) - } - /// Append ResponseItems to the in-memory conversation history only. pub(crate) async fn record_into_history( &self, diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index c0a289288a7..589ebf7cbb1 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -6,6 +6,7 @@ use crate::codex::TurnContext; use crate::compact::AutoCompactCallsite; use crate::compact::extract_latest_model_switch_update_from_items; use crate::compact::extract_trailing_model_switch_update_for_compaction_request; +use crate::compact::process_compacted_history; use crate::compact::should_keep_compacted_history_item; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; @@ -165,7 +166,7 @@ async fn run_remote_compact_task_inner_impl( Err(err) }) .await?; - new_history = sess.process_compacted_history(new_history).await; + new_history = process_compacted_history(new_history); if let Some(incoming_items) = incoming_items.as_ref() { let incoming_history_items: Vec = incoming_items .iter() From 25c22dcefc07b7a61cce1c8fc5b8c98386048207 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Wed, 18 Feb 2026 00:26:02 -0800 Subject: [PATCH 61/80] Unify model-switch persistence through compaction history --- codex-rs/core/src/codex.rs | 9 --------- codex-rs/core/src/compact.rs | 9 +++------ codex-rs/core/src/compact_remote.rs | 9 +++------ ...ompaction_strips_incoming_model_switch_shapes.snap | 11 ++++++----- ...ompaction_strips_incoming_model_switch_shapes.snap | 11 ++++++----- 5 files changed, 18 insertions(+), 31 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 9b1e4afb417..b68cd449604 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4729,15 +4729,6 @@ async fn persist_pre_turn_items_for_compaction_outcome( sess.record_conversation_items(turn_context, &initial_context) .await; } - let model_switch_updates: Vec = pre_turn_context_items - .iter() - .filter(|item| Session::is_model_switch_developer_message(item)) - .cloned() - .collect(); - if !model_switch_updates.is_empty() { - sess.record_conversation_items(turn_context, &model_switch_updates) - .await; - } sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), input, response_item) .await; } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index b3f3675fa3b..ac2a7ba1f57 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -282,12 +282,9 @@ async fn run_compact_task_inner( COMPACT_USER_MESSAGE_MAX_TOKENS, ); let mut new_history = process_compacted_history(compacted_history); - // Reattach stripped model-switch updates only for compaction paths that do not carry - // incoming turn items. Pre-turn compaction appends turn context and user input after - // compaction in run_turn. - if incoming_items.is_none() - && let Some(model_switch_item) = stripped_model_switch_item - { + // Reattach stripped model-switch updates into replacement history so post-compaction + // sampling keeps model-switch guidance regardless of compaction callsite. + if let Some(model_switch_item) = stripped_model_switch_item { new_history.push(model_switch_item); } let ghost_snapshots: Vec = history_items diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 589ebf7cbb1..09296043a6a 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -175,12 +175,9 @@ async fn run_remote_compact_task_inner_impl( .collect(); remove_incoming_echoes_from_compacted_history(&mut new_history, &incoming_history_items); } - // Reattach stripped model-switch updates only for compaction paths that do not carry - // incoming turn items. Pre-turn compaction appends turn context and user input after - // compaction in run_turn. - if incoming_items.is_none() - && let Some(model_switch_item) = stripped_model_switch_item - { + // Reattach stripped model-switch updates into replacement history so post-compaction + // sampling keeps model-switch guidance regardless of compaction callsite. + if let Some(model_switch_item) = stripped_model_switch_item { new_history.push(model_switch_item); } diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_strips_incoming_model_switch_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_strips_incoming_model_switch_shapes.snap index 03b58b11f0e..2f4820b282f 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_strips_incoming_model_switch_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_strips_incoming_model_switch_shapes.snap @@ -1,5 +1,6 @@ --- source: core/tests/suite/compact.rs +assertion_line: 2949 expression: "format_labeled_requests_snapshot(\"Pre-turn compaction during model switch (without pre-sampling model-switch compaction): current behavior strips incoming from the compact request and restores it in the post-compaction follow-up request.\",\n&[(\"Initial Request (Previous Model)\", &requests[0]),\n(\"Local Compaction Request\", &requests[1]),\n(\"Local Post-Compaction History Layout\", &requests[2]),])" --- Scenario: Pre-turn compaction during model switch (without pre-sampling model-switch compaction): current behavior strips incoming from the compact request and restores it in the post-compaction follow-up request. @@ -24,9 +25,9 @@ Scenario: Pre-turn compaction during model switch (without pre-sampling model-sw ## Local Post-Compaction History Layout 00:message/user:BEFORE_SWITCH_USER 01:message/user:\nPRETURN_SWITCH_SUMMARY -02:message/developer: -03:message/developer: The user has requested a new communication st... -04:message/user: -05:message/user:> -06:message/developer:\nThe user was previously using a different model.... +02:message/developer:\nThe user was previously using a different model.... +03:message/developer: +04:message/developer: The user has requested a new communication st... +05:message/user: +06:message/user:> 07:message/user:AFTER_SWITCH_USER diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_strips_incoming_model_switch_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_strips_incoming_model_switch_shapes.snap index 85d8073913a..cfa6125e696 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_strips_incoming_model_switch_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_strips_incoming_model_switch_shapes.snap @@ -1,5 +1,6 @@ --- source: core/tests/suite/compact_remote.rs +assertion_line: 1538 expression: "format_labeled_requests_snapshot(\"Remote pre-turn compaction during model switch strips incoming from the compact request and restores it in the post-compaction follow-up request.\",\n&[(\"Initial Request (Previous Model)\", &initial_turn_request),\n(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &post_compact_turn_request),])" --- Scenario: Remote pre-turn compaction during model switch strips incoming from the compact request and restores it in the post-compaction follow-up request. @@ -21,9 +22,9 @@ Scenario: Remote pre-turn compaction during model switch strips incoming \nREMOTE_SWITCH_SUMMARY -02:message/developer: -03:message/developer: The user has requested a new communication st... -04:message/user: -05:message/user:> -06:message/developer:\nThe user was previously using a different model.... +02:message/developer:\nThe user was previously using a different model.... +03:message/developer: +04:message/developer: The user has requested a new communication st... +05:message/user: +06:message/user:> 07:message/user:AFTER_SWITCH_USER From 3759abaf1594bf2d43aba92bb97383c07de7803d Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Wed, 18 Feb 2026 00:27:44 -0800 Subject: [PATCH 62/80] Inline compact merge item matcher --- codex-rs/core/src/compact_remote.rs | 59 +++++++++++++---------------- 1 file changed, 27 insertions(+), 32 deletions(-) diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 09296043a6a..1a8eb886569 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -229,43 +229,38 @@ fn remove_incoming_echoes_from_compacted_history( incoming_history_items: &[ResponseItem], ) { for incoming_item in incoming_history_items { - if let Some(index) = new_history.iter().rposition(|candidate| { - response_items_match_for_compaction_merge(candidate, incoming_item) - }) { + if let Some(index) = + new_history + .iter() + .rposition(|candidate| match (candidate, incoming_item) { + ( + ResponseItem::Message { + role: candidate_role, + content: candidate_content, + .. + }, + ResponseItem::Message { + role: incoming_role, + content: incoming_content, + .. + }, + ) => candidate_role == incoming_role && candidate_content == incoming_content, + ( + ResponseItem::Compaction { + encrypted_content: candidate_content, + }, + ResponseItem::Compaction { + encrypted_content: incoming_content, + }, + ) => candidate_content == incoming_content, + _ => candidate == incoming_item, + }) + { new_history.remove(index); } } } -fn response_items_match_for_compaction_merge( - candidate: &ResponseItem, - incoming_item: &ResponseItem, -) -> bool { - match (candidate, incoming_item) { - ( - ResponseItem::Message { - role: candidate_role, - content: candidate_content, - .. - }, - ResponseItem::Message { - role: incoming_role, - content: incoming_content, - .. - }, - ) => candidate_role == incoming_role && candidate_content == incoming_content, - ( - ResponseItem::Compaction { - encrypted_content: candidate_content, - }, - ResponseItem::Compaction { - encrypted_content: incoming_content, - }, - ) => candidate_content == incoming_content, - _ => candidate == incoming_item, - } -} - fn log_remote_compact_failure( turn_context: &TurnContext, auto_compact_callsite: AutoCompactCallsite, From 2ede42d3195a4b9e84511f75e380cb0767972aff Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Wed, 18 Feb 2026 01:00:35 -0800 Subject: [PATCH 63/80] Reinsert initial context for mid-turn compaction --- codex-rs/core/src/compact.rs | 24 +++++++++++++++++++ codex-rs/core/src/compact_remote.rs | 5 ++++ ...__compact__mid_turn_compaction_shapes.snap | 8 +++++-- ...te__remote_mid_turn_compaction_shapes.snap | 8 +++++-- 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index ac2a7ba1f57..f3a12371061 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -282,6 +282,13 @@ async fn run_compact_task_inner( COMPACT_USER_MESSAGE_MAX_TOKENS, ); let mut new_history = process_compacted_history(compacted_history); + if matches!( + auto_compact_callsite, + Some(AutoCompactCallsite::MidTurnContinuation) + ) { + let initial_context = sess.build_initial_context(turn_context.as_ref()).await; + insert_initial_context_before_last_real_user(&mut new_history, initial_context); + } // Reattach stripped model-switch updates into replacement history so post-compaction // sampling keeps model-switch guidance regardless of compaction callsite. if let Some(model_switch_item) = stripped_model_switch_item { @@ -359,6 +366,23 @@ pub(crate) fn process_compacted_history( compacted_history } +pub(crate) fn insert_initial_context_before_last_real_user( + compacted_history: &mut Vec, + initial_context: Vec, +) { + if initial_context.is_empty() { + return; + } + if let Some(last_real_user_index) = compacted_history.iter().rposition(|item| { + matches!( + crate::event_mapping::parse_turn_item(item), + Some(TurnItem::UserMessage(user_message)) if !is_summary_message(&user_message.message()) + ) + }) { + compacted_history.splice(last_real_user_index..last_real_user_index, initial_context); + } +} + #[cfg(test)] fn is_real_user_message(item: &ResponseItem) -> bool { matches!( diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 1a8eb886569..66b896cd78d 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -6,6 +6,7 @@ use crate::codex::TurnContext; use crate::compact::AutoCompactCallsite; use crate::compact::extract_latest_model_switch_update_from_items; use crate::compact::extract_trailing_model_switch_update_for_compaction_request; +use crate::compact::insert_initial_context_before_last_real_user; use crate::compact::process_compacted_history; use crate::compact::should_keep_compacted_history_item; use crate::context_manager::ContextManager; @@ -167,6 +168,10 @@ async fn run_remote_compact_task_inner_impl( }) .await?; new_history = process_compacted_history(new_history); + if auto_compact_callsite == AutoCompactCallsite::MidTurnContinuation { + let initial_context = sess.build_initial_context(turn_context.as_ref()).await; + insert_initial_context_before_last_real_user(&mut new_history, initial_context); + } if let Some(incoming_items) = incoming_items.as_ref() { let incoming_history_items: Vec = incoming_items .iter() diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__mid_turn_compaction_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__mid_turn_compaction_shapes.snap index 000d4837787..01ecfe67ca2 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__mid_turn_compaction_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__mid_turn_compaction_shapes.snap @@ -1,5 +1,6 @@ --- source: core/tests/suite/compact.rs +assertion_line: 2434 expression: "format_labeled_requests_snapshot(\"True mid-turn continuation compaction after tool output: compact request includes tool artifacts, and the continuation request includes the summary in the same turn.\",\n&[(\"Local Compaction Request\", &auto_compact_mock.single_request()),\n(\"Local Post-Compaction History Layout\",\n&post_auto_compact_mock.single_request()),])" --- Scenario: True mid-turn continuation compaction after tool output: compact request includes tool artifacts, and the continuation request includes the summary in the same turn. @@ -14,5 +15,8 @@ Scenario: True mid-turn continuation compaction after tool output: compact reque 06:message/user: ## Local Post-Compaction History Layout -00:message/user:function call limit push -01:message/user:\nAUTO_SUMMARY +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:function call limit push +04:message/user:\nAUTO_SUMMARY diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_shapes.snap index 879c7fa7c62..5150a9d3b11 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_shapes.snap @@ -1,5 +1,6 @@ --- source: core/tests/suite/compact_remote.rs +assertion_line: 1711 expression: "format_labeled_requests_snapshot(\"Remote mid-turn continuation compaction after tool output: compact request includes tool artifacts and follow-up request includes the summary.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &requests[1]),])" --- Scenario: Remote mid-turn continuation compaction after tool output: compact request includes tool artifacts and follow-up request includes the summary. @@ -13,5 +14,8 @@ Scenario: Remote mid-turn continuation compaction after tool output: compact req 05:function_call_output:unsupported call: test_tool ## Remote Post-Compaction History Layout -00:message/user:USER_ONE -01:message/user:\nREMOTE_MID_TURN_SUMMARY +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:USER_ONE +04:message/user:\nREMOTE_MID_TURN_SUMMARY From 223eda12d39e6fee59834dd93d77386778ddae23 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Wed, 18 Feb 2026 01:17:13 -0800 Subject: [PATCH 64/80] Reinject previous-turn context for pre-sampling model-switch compaction --- codex-rs/core/src/codex.rs | 2 +- codex-rs/core/src/compact.rs | 7 ++++++- codex-rs/core/src/compact_remote.rs | 5 ++++- codex-rs/core/tests/suite/compact.rs | 4 ++-- ..._pre_sampling_model_switch_compaction_shapes.snap | 12 +++++++----- 5 files changed, 20 insertions(+), 10 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index b68cd449604..6fec45e1dce 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4670,7 +4670,7 @@ async fn maybe_run_previous_model_inline_compact( sess, // We use previous turn context here because we compact with the previous model &previous_turn_context, - AutoCompactCallsite::PreTurnExcludingIncomingUserMessage, + AutoCompactCallsite::PreSamplingModelSwitch, None, ) .await diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index f3a12371061..52ee0e436d3 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -44,6 +44,9 @@ pub(crate) enum AutoCompactCallsite { /// default pre-turn flow and retained for future model-specific strategies. #[allow(dead_code)] PreTurnExcludingIncomingUserMessage, + /// Pre-sampling compaction triggered by model switch to a smaller context window. + /// This compacts prior-turn history only and should reinsert previous-turn canonical context. + PreSamplingModelSwitch, /// Mid-turn compaction between assistant responses in a follow-up loop. MidTurnContinuation, } @@ -284,7 +287,9 @@ async fn run_compact_task_inner( let mut new_history = process_compacted_history(compacted_history); if matches!( auto_compact_callsite, - Some(AutoCompactCallsite::MidTurnContinuation) + Some( + AutoCompactCallsite::MidTurnContinuation | AutoCompactCallsite::PreSamplingModelSwitch + ) ) { let initial_context = sess.build_initial_context(turn_context.as_ref()).await; insert_initial_context_before_last_real_user(&mut new_history, initial_context); diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 66b896cd78d..8695ea7582b 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -168,7 +168,10 @@ async fn run_remote_compact_task_inner_impl( }) .await?; new_history = process_compacted_history(new_history); - if auto_compact_callsite == AutoCompactCallsite::MidTurnContinuation { + if matches!( + auto_compact_callsite, + AutoCompactCallsite::MidTurnContinuation | AutoCompactCallsite::PreSamplingModelSwitch + ) { let initial_context = sess.build_initial_context(turn_context.as_ref()).await; insert_initial_context_before_last_real_user(&mut new_history, initial_context); } diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 6690609a62b..49d3252647d 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -142,8 +142,8 @@ fn assert_pre_sampling_switch_compaction_requests( "follow-up request after successful model-switch compaction should include model-switch update item" ); assert!( - !body_contains_text(&follow_up_body, ""), - "follow-up request should not reinsert canonical environment context after pre-sampling compaction" + body_contains_text(&follow_up_body, ""), + "follow-up request should include canonical environment context from previous-turn context reinjection" ); } diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_sampling_model_switch_compaction_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_sampling_model_switch_compaction_shapes.snap index da2fc5ce71d..16ca8770755 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_sampling_model_switch_compaction_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_sampling_model_switch_compaction_shapes.snap @@ -1,6 +1,5 @@ --- source: core/tests/suite/compact.rs -assertion_line: 1460 expression: "format_labeled_requests_snapshot(\"Pre-sampling compaction on model switch to a smaller context window: current behavior compacts using prior-turn history only (incoming user message excluded), and the follow-up request carries compacted history plus the new user message.\",\n&[(\"Initial Request (Previous Model)\", &requests[0]),\n(\"Pre-sampling Compaction Request\", &requests[1]),\n(\"Post-Compaction Follow-up Request (Next Model)\", &requests[2]),])" --- Scenario: Pre-sampling compaction on model switch to a smaller context window: current behavior compacts using prior-turn history only (incoming user message excluded), and the follow-up request carries compacted history plus the new user message. @@ -22,7 +21,10 @@ Scenario: Pre-sampling compaction on model switch to a smaller context window: c 06:message/user: ## Post-Compaction Follow-up Request (Next Model) -00:message/user:before switch -01:message/user:\nPRE_SAMPLING_SUMMARY -02:message/developer:\nThe user was previously using a different model.... -03:message/user:after switch +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:before switch +04:message/user:\nPRE_SAMPLING_SUMMARY +05:message/developer:\nThe user was previously using a different model.... +06:message/user:after switch From 454fa822816ac30d372636f78b4b1c9b908e9849 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Wed, 18 Feb 2026 01:21:18 -0800 Subject: [PATCH 65/80] Use shared real-user matcher for initial-context insertion --- codex-rs/core/src/compact.rs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 52ee0e436d3..c6627987171 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -378,17 +378,11 @@ pub(crate) fn insert_initial_context_before_last_real_user( if initial_context.is_empty() { return; } - if let Some(last_real_user_index) = compacted_history.iter().rposition(|item| { - matches!( - crate::event_mapping::parse_turn_item(item), - Some(TurnItem::UserMessage(user_message)) if !is_summary_message(&user_message.message()) - ) - }) { + if let Some(last_real_user_index) = compacted_history.iter().rposition(is_real_user_message) { compacted_history.splice(last_real_user_index..last_real_user_index, initial_context); } } -#[cfg(test)] fn is_real_user_message(item: &ResponseItem) -> bool { matches!( crate::event_mapping::parse_turn_item(item), From 7ed3d3f9aa4dd24237d2b440cc5eca09170d05bd Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Wed, 18 Feb 2026 01:21:51 -0800 Subject: [PATCH 66/80] Put NotNeeded first in pre-turn compaction outcome match --- codex-rs/core/src/codex.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 6fec45e1dce..eb81b7e952b 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4720,6 +4720,14 @@ async fn persist_pre_turn_items_for_compaction_outcome( response_item: ResponseItem, ) { match outcome { + PreTurnCompactionOutcome::NotNeeded => { + if !pre_turn_context_items.is_empty() { + sess.record_conversation_items(turn_context, pre_turn_context_items) + .await; + } + sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), input, response_item) + .await; + } PreTurnCompactionOutcome::CompactedWithIncomingItems => { // Pre-turn compaction includes incoming items only for the compaction request itself. // Persist canonical turn context directly above the incoming user item so context @@ -4732,14 +4740,6 @@ async fn persist_pre_turn_items_for_compaction_outcome( sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), input, response_item) .await; } - PreTurnCompactionOutcome::NotNeeded => { - if !pre_turn_context_items.is_empty() { - sess.record_conversation_items(turn_context, pre_turn_context_items) - .await; - } - sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), input, response_item) - .await; - } // TODO(ccunningham): Followup PR will use compacting excluding incoming items as a fallback // (even though it is out of distribution for current models). // Also future models may prefer compacting pre-turn history without incoming turn items. From c6d63129c1b5e51dd2a58b66edd6276d9155f516 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Wed, 18 Feb 2026 01:34:11 -0800 Subject: [PATCH 67/80] Rename compaction callsite enum and exhaustively match reinjection --- codex-rs/core/src/codex.rs | 12 ++++---- codex-rs/core/src/compact.rs | 43 ++++++++++++++++++++--------- codex-rs/core/src/compact_remote.rs | 40 +++++++++++++++------------ 3 files changed, 58 insertions(+), 37 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index eb81b7e952b..f624370d59c 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -19,7 +19,7 @@ use crate::analytics_client::build_track_events_context; use crate::apps::render_apps_section; use crate::commit_attribution::commit_message_trailer_instruction; use crate::compact; -use crate::compact::AutoCompactCallsite; +use crate::compact::CompactCallsite; use crate::compact::run_inline_auto_compact_task; use crate::compact::should_use_remote_compact_task; use crate::compact_remote::run_inline_remote_auto_compact_task; @@ -4517,7 +4517,7 @@ pub(crate) async fn run_turn( if let Err(err) = run_auto_compact( &sess, &turn_context, - AutoCompactCallsite::MidTurnContinuation, + CompactCallsite::MidTurnContinuation, None, ) .await @@ -4670,7 +4670,7 @@ async fn maybe_run_previous_model_inline_compact( sess, // We use previous turn context here because we compact with the previous model &previous_turn_context, - AutoCompactCallsite::PreSamplingModelSwitch, + CompactCallsite::PreSamplingModelSwitch, None, ) .await @@ -4784,7 +4784,7 @@ async fn run_pre_turn_auto_compaction_if_needed( let compact_result = run_auto_compact( sess, turn_context, - AutoCompactCallsite::PreTurnIncludingIncomingUserMessage, + CompactCallsite::PreTurnIncludingIncomingUserMessage, Some(incoming_turn_items.to_vec()), ) .await; @@ -4803,7 +4803,7 @@ async fn run_pre_turn_auto_compaction_if_needed( CodexErr::ContextWindowExceeded => { error!( turn_id = %turn_context.sub_id, - auto_compact_callsite = ?AutoCompactCallsite::PreTurnIncludingIncomingUserMessage, + auto_compact_callsite = ?CompactCallsite::PreTurnIncludingIncomingUserMessage, incoming_items_tokens_estimate, auto_compact_limit, reason = "pre-turn compaction exceeded context window", @@ -4859,7 +4859,7 @@ fn is_projected_submission_over_auto_compact_limit( async fn run_auto_compact( sess: &Arc, turn_context: &Arc, - auto_compact_callsite: AutoCompactCallsite, + auto_compact_callsite: CompactCallsite, incoming_items: Option>, ) -> CodexResult<()> { let result = if should_use_remote_compact_task(&turn_context.provider) { diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index c6627987171..95a79673ba0 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -35,7 +35,9 @@ pub const SUMMARY_PREFIX: &str = include_str!("../templates/compact/summary_pref const COMPACT_USER_MESSAGE_MAX_TOKENS: usize = 20_000; #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum AutoCompactCallsite { +pub(crate) enum CompactCallsite { + /// Manual `/compact` task. + ManualCompact, /// Pre-turn auto-compaction where the incoming turn context + user message are included in /// the compaction request. PreTurnIncludingIncomingUserMessage, @@ -94,7 +96,7 @@ pub(crate) fn extract_latest_model_switch_update_from_items( pub(crate) async fn run_inline_auto_compact_task( sess: Arc, turn_context: Arc, - auto_compact_callsite: AutoCompactCallsite, + auto_compact_callsite: CompactCallsite, incoming_items: Option>, ) -> CodexResult<()> { let prompt = turn_context.compact_prompt().to_string(); @@ -108,7 +110,7 @@ pub(crate) async fn run_inline_auto_compact_task( sess, turn_context, input, - Some(auto_compact_callsite), + auto_compact_callsite, incoming_items, ) .await?; @@ -126,14 +128,21 @@ pub(crate) async fn run_compact_task( collaboration_mode_kind: turn_context.collaboration_mode.mode, }); sess.send_event(&turn_context, start_event).await; - run_compact_task_inner(sess, turn_context, input, None, None).await + run_compact_task_inner( + sess, + turn_context, + input, + CompactCallsite::ManualCompact, + None, + ) + .await } async fn run_compact_task_inner( sess: Arc, turn_context: Arc, input: Vec, - auto_compact_callsite: Option, + auto_compact_callsite: CompactCallsite, incoming_items: Option>, ) -> CodexResult<()> { let compaction_item = TurnItem::ContextCompaction(ContextCompactionItem::new()); @@ -285,14 +294,22 @@ async fn run_compact_task_inner( COMPACT_USER_MESSAGE_MAX_TOKENS, ); let mut new_history = process_compacted_history(compacted_history); - if matches!( - auto_compact_callsite, - Some( - AutoCompactCallsite::MidTurnContinuation | AutoCompactCallsite::PreSamplingModelSwitch - ) - ) { - let initial_context = sess.build_initial_context(turn_context.as_ref()).await; - insert_initial_context_before_last_real_user(&mut new_history, initial_context); + match auto_compact_callsite { + CompactCallsite::MidTurnContinuation | CompactCallsite::PreSamplingModelSwitch => { + // Mid-turn and pre-sampling model-switch compaction continue the in-flight turn and + // therefore must keep canonical context anchored above the latest real user turn. + let initial_context = sess.build_initial_context(turn_context.as_ref()).await; + insert_initial_context_before_last_real_user(&mut new_history, initial_context); + } + CompactCallsite::ManualCompact => { + // Manual `/compact` intentionally rewrites transcript history without reseeding turn + // context here; the task marks initial context unseeded for the next user turn. + } + CompactCallsite::PreTurnIncludingIncomingUserMessage + | CompactCallsite::PreTurnExcludingIncomingUserMessage => { + // Pre-turn compaction persists canonical context directly above the incoming user + // message in `run_turn`, not inside compacted replacement history. + } } // Reattach stripped model-switch updates into replacement history so post-compaction // sampling keeps model-switch guidance regardless of compaction callsite. diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 8695ea7582b..5d60703cef7 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use crate::Prompt; use crate::codex::Session; use crate::codex::TurnContext; -use crate::compact::AutoCompactCallsite; +use crate::compact::CompactCallsite; use crate::compact::extract_latest_model_switch_update_from_items; use crate::compact::extract_trailing_model_switch_update_for_compaction_request; use crate::compact::insert_initial_context_before_last_real_user; @@ -32,7 +32,7 @@ use tracing::info; pub(crate) async fn run_inline_remote_auto_compact_task( sess: Arc, turn_context: Arc, - auto_compact_callsite: AutoCompactCallsite, + auto_compact_callsite: CompactCallsite, incoming_items: Option>, ) -> CodexResult<()> { run_remote_compact_task_inner(&sess, &turn_context, auto_compact_callsite, incoming_items) @@ -51,19 +51,13 @@ pub(crate) async fn run_remote_compact_task( }); sess.send_event(&turn_context, start_event).await; - run_remote_compact_task_inner( - &sess, - &turn_context, - AutoCompactCallsite::PreTurnExcludingIncomingUserMessage, - None, - ) - .await + run_remote_compact_task_inner(&sess, &turn_context, CompactCallsite::ManualCompact, None).await } async fn run_remote_compact_task_inner( sess: &Arc, turn_context: &Arc, - auto_compact_callsite: AutoCompactCallsite, + auto_compact_callsite: CompactCallsite, incoming_items: Option>, ) -> CodexResult<()> { if let Err(err) = run_remote_compact_task_inner_impl( @@ -88,7 +82,7 @@ async fn run_remote_compact_task_inner( async fn run_remote_compact_task_inner_impl( sess: &Arc, turn_context: &Arc, - auto_compact_callsite: AutoCompactCallsite, + auto_compact_callsite: CompactCallsite, incoming_items: Option>, ) -> CodexResult<()> { let compaction_item = TurnItem::ContextCompaction(ContextCompactionItem::new()); @@ -168,12 +162,22 @@ async fn run_remote_compact_task_inner_impl( }) .await?; new_history = process_compacted_history(new_history); - if matches!( - auto_compact_callsite, - AutoCompactCallsite::MidTurnContinuation | AutoCompactCallsite::PreSamplingModelSwitch - ) { - let initial_context = sess.build_initial_context(turn_context.as_ref()).await; - insert_initial_context_before_last_real_user(&mut new_history, initial_context); + match auto_compact_callsite { + CompactCallsite::MidTurnContinuation | CompactCallsite::PreSamplingModelSwitch => { + // Mid-turn and pre-sampling model-switch compaction continue the in-flight turn and + // therefore must keep canonical context anchored above the latest real user turn. + let initial_context = sess.build_initial_context(turn_context.as_ref()).await; + insert_initial_context_before_last_real_user(&mut new_history, initial_context); + } + CompactCallsite::ManualCompact => { + // Manual `/compact` intentionally rewrites transcript history without reseeding turn + // context here; the task marks initial context unseeded for the next user turn. + } + CompactCallsite::PreTurnIncludingIncomingUserMessage + | CompactCallsite::PreTurnExcludingIncomingUserMessage => { + // Pre-turn compaction persists canonical context directly above the incoming user + // message in `run_turn`, not inside compacted replacement history. + } } if let Some(incoming_items) = incoming_items.as_ref() { let incoming_history_items: Vec = incoming_items @@ -271,7 +275,7 @@ fn remove_incoming_echoes_from_compacted_history( fn log_remote_compact_failure( turn_context: &TurnContext, - auto_compact_callsite: AutoCompactCallsite, + auto_compact_callsite: CompactCallsite, log_data: &CompactRequestLogData, total_usage_breakdown: TotalTokenUsageBreakdown, err: &CodexErr, From 4c0ca09d696e03653beb073086fba0bac3b607ef Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Wed, 18 Feb 2026 01:41:37 -0800 Subject: [PATCH 68/80] Rename compact callsite vars and document reinjection policy --- codex-rs/core/src/codex.rs | 10 +++++----- codex-rs/core/src/compact.rs | 29 ++++++++++++++------------- codex-rs/core/src/compact_remote.rs | 31 ++++++++++++----------------- 3 files changed, 33 insertions(+), 37 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index f624370d59c..81df6149f64 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4803,7 +4803,7 @@ async fn run_pre_turn_auto_compaction_if_needed( CodexErr::ContextWindowExceeded => { error!( turn_id = %turn_context.sub_id, - auto_compact_callsite = ?CompactCallsite::PreTurnIncludingIncomingUserMessage, + compact_callsite = ?CompactCallsite::PreTurnIncludingIncomingUserMessage, incoming_items_tokens_estimate, auto_compact_limit, reason = "pre-turn compaction exceeded context window", @@ -4859,14 +4859,14 @@ fn is_projected_submission_over_auto_compact_limit( async fn run_auto_compact( sess: &Arc, turn_context: &Arc, - auto_compact_callsite: CompactCallsite, + compact_callsite: CompactCallsite, incoming_items: Option>, ) -> CodexResult<()> { let result = if should_use_remote_compact_task(&turn_context.provider) { run_inline_remote_auto_compact_task( Arc::clone(sess), Arc::clone(turn_context), - auto_compact_callsite, + compact_callsite, incoming_items, ) .await @@ -4874,7 +4874,7 @@ async fn run_auto_compact( run_inline_auto_compact_task( Arc::clone(sess), Arc::clone(turn_context), - auto_compact_callsite, + compact_callsite, incoming_items, ) .await @@ -4883,7 +4883,7 @@ async fn run_auto_compact( if let Err(err) = &result { error!( turn_id = %turn_context.sub_id, - auto_compact_callsite = ?auto_compact_callsite, + compact_callsite = ?compact_callsite, compact_error = %err, "auto compaction failed" ); diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 95a79673ba0..2c8ca1528ba 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -53,6 +53,14 @@ pub(crate) enum CompactCallsite { MidTurnContinuation, } +// Canonical context reinjection policy for compacted replacement history: +// - `MidTurnContinuation` and `PreSamplingModelSwitch`: reinsert canonical initial context above +// the last real user message because compaction is rewriting in-flight history that the model +// will continue sampling from immediately. +// - `ManualCompact`: do not reinsert during compaction; `/compact` reseeds on the next user turn. +// - `PreTurn*`: do not reinsert into replacement history; `run_turn` persists canonical context +// directly above the incoming user message after compaction. + pub(crate) fn should_use_remote_compact_task(provider: &ModelProviderInfo) -> bool { provider.is_openai() } @@ -96,7 +104,7 @@ pub(crate) fn extract_latest_model_switch_update_from_items( pub(crate) async fn run_inline_auto_compact_task( sess: Arc, turn_context: Arc, - auto_compact_callsite: CompactCallsite, + compact_callsite: CompactCallsite, incoming_items: Option>, ) -> CodexResult<()> { let prompt = turn_context.compact_prompt().to_string(); @@ -106,14 +114,7 @@ pub(crate) async fn run_inline_auto_compact_task( text_elements: Vec::new(), }]; - run_compact_task_inner( - sess, - turn_context, - input, - auto_compact_callsite, - incoming_items, - ) - .await?; + run_compact_task_inner(sess, turn_context, input, compact_callsite, incoming_items).await?; Ok(()) } @@ -142,7 +143,7 @@ async fn run_compact_task_inner( sess: Arc, turn_context: Arc, input: Vec, - auto_compact_callsite: CompactCallsite, + compact_callsite: CompactCallsite, incoming_items: Option>, ) -> CodexResult<()> { let compaction_item = TurnItem::ContextCompaction(ContextCompactionItem::new()); @@ -240,7 +241,7 @@ async fn run_compact_task_inner( // messages intact. error!( turn_id = %turn_context.sub_id, - auto_compact_callsite = ?auto_compact_callsite, + compact_callsite = ?compact_callsite, "Context window exceeded while compacting; removing oldest history item. Error: {e}" ); history.remove_first_item(); @@ -251,7 +252,7 @@ async fn run_compact_task_inner( sess.set_total_tokens_full(turn_context.as_ref()).await; error!( turn_id = %turn_context.sub_id, - auto_compact_callsite = ?auto_compact_callsite, + compact_callsite = ?compact_callsite, compact_error = %e, "compaction failed after history truncation could not proceed" ); @@ -272,7 +273,7 @@ async fn run_compact_task_inner( } error!( turn_id = %turn_context.sub_id, - auto_compact_callsite = ?auto_compact_callsite, + compact_callsite = ?compact_callsite, retries, max_retries, compact_error = %e, @@ -294,7 +295,7 @@ async fn run_compact_task_inner( COMPACT_USER_MESSAGE_MAX_TOKENS, ); let mut new_history = process_compacted_history(compacted_history); - match auto_compact_callsite { + match compact_callsite { CompactCallsite::MidTurnContinuation | CompactCallsite::PreSamplingModelSwitch => { // Mid-turn and pre-sampling model-switch compaction continue the in-flight turn and // therefore must keep canonical context anchored above the latest real user turn. diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 5d60703cef7..92d11b75985 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -32,11 +32,10 @@ use tracing::info; pub(crate) async fn run_inline_remote_auto_compact_task( sess: Arc, turn_context: Arc, - auto_compact_callsite: CompactCallsite, + compact_callsite: CompactCallsite, incoming_items: Option>, ) -> CodexResult<()> { - run_remote_compact_task_inner(&sess, &turn_context, auto_compact_callsite, incoming_items) - .await?; + run_remote_compact_task_inner(&sess, &turn_context, compact_callsite, incoming_items).await?; Ok(()) } @@ -57,20 +56,16 @@ pub(crate) async fn run_remote_compact_task( async fn run_remote_compact_task_inner( sess: &Arc, turn_context: &Arc, - auto_compact_callsite: CompactCallsite, + compact_callsite: CompactCallsite, incoming_items: Option>, ) -> CodexResult<()> { - if let Err(err) = run_remote_compact_task_inner_impl( - sess, - turn_context, - auto_compact_callsite, - incoming_items, - ) - .await + if let Err(err) = + run_remote_compact_task_inner_impl(sess, turn_context, compact_callsite, incoming_items) + .await { error!( turn_id = %turn_context.sub_id, - auto_compact_callsite = ?auto_compact_callsite, + compact_callsite = ?compact_callsite, compact_error = %err, "remote compaction task failed" ); @@ -82,7 +77,7 @@ async fn run_remote_compact_task_inner( async fn run_remote_compact_task_inner_impl( sess: &Arc, turn_context: &Arc, - auto_compact_callsite: CompactCallsite, + compact_callsite: CompactCallsite, incoming_items: Option>, ) -> CodexResult<()> { let compaction_item = TurnItem::ContextCompaction(ContextCompactionItem::new()); @@ -116,7 +111,7 @@ async fn run_remote_compact_task_inner_impl( if deleted_items > 0 { info!( turn_id = %turn_context.sub_id, - auto_compact_callsite = ?auto_compact_callsite, + compact_callsite = ?compact_callsite, deleted_items, "trimmed history items before remote compaction" ); @@ -153,7 +148,7 @@ async fn run_remote_compact_task_inner_impl( build_compact_request_log_data(&prompt.input, &prompt.base_instructions.text); log_remote_compact_failure( turn_context, - auto_compact_callsite, + compact_callsite, &compact_request_log_data, total_usage_breakdown, &err, @@ -162,7 +157,7 @@ async fn run_remote_compact_task_inner_impl( }) .await?; new_history = process_compacted_history(new_history); - match auto_compact_callsite { + match compact_callsite { CompactCallsite::MidTurnContinuation | CompactCallsite::PreSamplingModelSwitch => { // Mid-turn and pre-sampling model-switch compaction continue the in-flight turn and // therefore must keep canonical context anchored above the latest real user turn. @@ -275,14 +270,14 @@ fn remove_incoming_echoes_from_compacted_history( fn log_remote_compact_failure( turn_context: &TurnContext, - auto_compact_callsite: CompactCallsite, + compact_callsite: CompactCallsite, log_data: &CompactRequestLogData, total_usage_breakdown: TotalTokenUsageBreakdown, err: &CodexErr, ) { error!( turn_id = %turn_context.sub_id, - auto_compact_callsite = ?auto_compact_callsite, + compact_callsite = ?compact_callsite, last_api_response_total_tokens = total_usage_breakdown.last_api_response_total_tokens, all_history_items_model_visible_bytes = total_usage_breakdown.all_history_items_model_visible_bytes, estimated_tokens_of_items_added_since_last_successful_api_response = total_usage_breakdown.estimated_tokens_of_items_added_since_last_successful_api_response, From 0fbc4c76a8c179f8f8941dacb94150a437f51197 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Wed, 18 Feb 2026 01:42:23 -0800 Subject: [PATCH 69/80] Clarify compaction reinjection rationale comments --- codex-rs/core/src/compact.rs | 8 ++++---- codex-rs/core/src/compact_remote.rs | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 2c8ca1528ba..9aa872c6df8 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -55,8 +55,8 @@ pub(crate) enum CompactCallsite { // Canonical context reinjection policy for compacted replacement history: // - `MidTurnContinuation` and `PreSamplingModelSwitch`: reinsert canonical initial context above -// the last real user message because compaction is rewriting in-flight history that the model -// will continue sampling from immediately. +// the last real user message because those paths do not persist/reseed canonical context in a +// later post-compaction step. // - `ManualCompact`: do not reinsert during compaction; `/compact` reseeds on the next user turn. // - `PreTurn*`: do not reinsert into replacement history; `run_turn` persists canonical context // directly above the incoming user message after compaction. @@ -297,8 +297,8 @@ async fn run_compact_task_inner( let mut new_history = process_compacted_history(compacted_history); match compact_callsite { CompactCallsite::MidTurnContinuation | CompactCallsite::PreSamplingModelSwitch => { - // Mid-turn and pre-sampling model-switch compaction continue the in-flight turn and - // therefore must keep canonical context anchored above the latest real user turn. + // These callsites do not get a later post-compaction canonical-context write in + // `run_turn`, so replacement history must carry canonical context directly. let initial_context = sess.build_initial_context(turn_context.as_ref()).await; insert_initial_context_before_last_real_user(&mut new_history, initial_context); } diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 92d11b75985..97788bd31f3 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -159,8 +159,8 @@ async fn run_remote_compact_task_inner_impl( new_history = process_compacted_history(new_history); match compact_callsite { CompactCallsite::MidTurnContinuation | CompactCallsite::PreSamplingModelSwitch => { - // Mid-turn and pre-sampling model-switch compaction continue the in-flight turn and - // therefore must keep canonical context anchored above the latest real user turn. + // These callsites do not get a later post-compaction canonical-context write in + // `run_turn`, so replacement history must carry canonical context directly. let initial_context = sess.build_initial_context(turn_context.as_ref()).await; insert_initial_context_before_last_real_user(&mut new_history, initial_context); } From 96b88aee15fcdcfa4b358d9d7ecc3558f9a32caf Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Wed, 18 Feb 2026 01:50:58 -0800 Subject: [PATCH 70/80] Reinject context for summary-only mid-turn compaction --- codex-rs/core/src/compact.rs | 86 ++++++++++++++++++- codex-rs/core/src/compact_remote.rs | 4 +- codex-rs/core/tests/suite/compact_remote.rs | 2 +- ...d_turn_compaction_summary_only_shapes.snap | 10 ++- 4 files changed, 91 insertions(+), 11 deletions(-) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 9aa872c6df8..109f12b6939 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -300,7 +300,7 @@ async fn run_compact_task_inner( // These callsites do not get a later post-compaction canonical-context write in // `run_turn`, so replacement history must carry canonical context directly. let initial_context = sess.build_initial_context(turn_context.as_ref()).await; - insert_initial_context_before_last_real_user(&mut new_history, initial_context); + insert_initial_context_before_last_user_anchor(&mut new_history, initial_context); } CompactCallsite::ManualCompact => { // Manual `/compact` intentionally rewrites transcript history without reseeding turn @@ -389,15 +389,27 @@ pub(crate) fn process_compacted_history( compacted_history } -pub(crate) fn insert_initial_context_before_last_real_user( +pub(crate) fn insert_initial_context_before_last_user_anchor( compacted_history: &mut Vec, initial_context: Vec, ) { if initial_context.is_empty() { return; } - if let Some(last_real_user_index) = compacted_history.iter().rposition(is_real_user_message) { - compacted_history.splice(last_real_user_index..last_real_user_index, initial_context); + let insertion_index = compacted_history + .iter() + .rposition(is_real_user_message) + .or_else(|| { + compacted_history.iter().rposition(|item| { + matches!( + crate::event_mapping::parse_turn_item(item), + Some(TurnItem::UserMessage(user_message)) + if is_summary_message(&user_message.message()) + ) + }) + }); + if let Some(index) = insertion_index { + compacted_history.splice(index..index, initial_context); } } @@ -1268,4 +1280,70 @@ keep me updated }]; assert_eq!(refreshed, expected); } + + #[test] + fn insert_initial_context_before_last_user_anchor_falls_back_to_last_summary() { + let mut compacted_history = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!("{SUMMARY_PREFIX}\nolder summary"), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!("{SUMMARY_PREFIX}\nlatest summary"), + }], + end_turn: None, + phase: None, + }, + ]; + let initial_context = vec![ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "fresh permissions".to_string(), + }], + end_turn: None, + phase: None, + }]; + + insert_initial_context_before_last_user_anchor(&mut compacted_history, initial_context); + + let expected = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!("{SUMMARY_PREFIX}\nolder summary"), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "fresh permissions".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!("{SUMMARY_PREFIX}\nlatest summary"), + }], + end_turn: None, + phase: None, + }, + ]; + assert_eq!(compacted_history, expected); + } } diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 97788bd31f3..10dc4720515 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -6,7 +6,7 @@ use crate::codex::TurnContext; use crate::compact::CompactCallsite; use crate::compact::extract_latest_model_switch_update_from_items; use crate::compact::extract_trailing_model_switch_update_for_compaction_request; -use crate::compact::insert_initial_context_before_last_real_user; +use crate::compact::insert_initial_context_before_last_user_anchor; use crate::compact::process_compacted_history; use crate::compact::should_keep_compacted_history_item; use crate::context_manager::ContextManager; @@ -162,7 +162,7 @@ async fn run_remote_compact_task_inner_impl( // These callsites do not get a later post-compaction canonical-context write in // `run_turn`, so replacement history must carry canonical context directly. let initial_context = sess.build_initial_context(turn_context.as_ref()).await; - insert_initial_context_before_last_real_user(&mut new_history, initial_context); + insert_initial_context_before_last_user_anchor(&mut new_history, initial_context); } CompactCallsite::ManualCompact => { // Manual `/compact` intentionally rewrites transcript history without reseeding turn diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index e4168f92d30..98d5a05488a 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -1790,7 +1790,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_layout() insta::assert_snapshot!( "remote_mid_turn_compaction_summary_only_shapes", format_labeled_requests_snapshot( - "Remote mid-turn compaction where compact output has only summary user content: continuation layout keeps the summary-only compact output without inserting extra context items.", + "Remote mid-turn compaction where compact output has only summary user content: continuation layout reinjects canonical context before the latest summary.", &[ ("Remote Compaction Request", &compact_request), ( diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_summary_only_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_summary_only_shapes.snap index 109f91e46e3..88561b245b9 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_summary_only_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_summary_only_shapes.snap @@ -1,9 +1,8 @@ --- source: core/tests/suite/compact_remote.rs -assertion_line: 1790 -expression: "format_labeled_requests_snapshot(\"Remote mid-turn compaction where compact output has only summary user content: continuation layout keeps the summary-only compact output without inserting extra context items.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &post_compact_turn_request),])" +expression: "format_labeled_requests_snapshot(\"Remote mid-turn compaction where compact output has only summary user content: continuation layout reinjects canonical context before the latest summary.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", &post_compact_turn_request),])" --- -Scenario: Remote mid-turn compaction where compact output has only summary user content: continuation layout keeps the summary-only compact output without inserting extra context items. +Scenario: Remote mid-turn compaction where compact output has only summary user content: continuation layout reinjects canonical context before the latest summary. ## Remote Compaction Request 00:message/developer: @@ -14,4 +13,7 @@ Scenario: Remote mid-turn compaction where compact output has only summary user 05:function_call_output:unsupported call: test_tool ## Remote Post-Compaction History Layout -00:message/user:\nREMOTE_SUMMARY_ONLY +00:message/developer: +01:message/user: +02:message/user:> +03:message/user:\nREMOTE_SUMMARY_ONLY From 5ae08bc00bbc96f2c0da5a0fff3ecffdd70fb418 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Wed, 18 Feb 2026 01:52:29 -0800 Subject: [PATCH 71/80] Update compaction test for canonical context reinjection --- codex-rs/core/tests/suite/compact.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 49d3252647d..5f5a3b130e8 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -818,9 +818,14 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { let body = requests_payloads.clone()[i].body_json(); let input = body.get("input").and_then(|v| v.as_array()).unwrap(); let input = normalize_inputs(input); - assert_eq!(input.len(), 2); - let user_message_received = input[0]["content"][0]["text"].as_str().unwrap(); - let summary_message = input[1]["content"][0]["text"].as_str().unwrap(); + assert_eq!(input.len(), 3); + let environment_context = input[0]["content"][0]["text"].as_str().unwrap(); + let user_message_received = input[1]["content"][0]["text"].as_str().unwrap(); + let summary_message = input[2]["content"][0]["text"].as_str().unwrap(); + assert!( + environment_context.contains(""), + "compaction request at index {i} should retain canonical environment context" + ); assert_eq!(user_message_received, user_message); assert_eq!( summary_message, expected_summary, From 7476184a225b0d97dac80dfdbdd2133ed2529517 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Wed, 18 Feb 2026 01:53:05 -0800 Subject: [PATCH 72/80] Document user-anchor behavior for context reinjection --- codex-rs/core/src/compact.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 109f12b6939..06863cafa52 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -389,6 +389,11 @@ pub(crate) fn process_compacted_history( compacted_history } +/// Inserts canonical initial context immediately before the latest user anchor in compacted +/// replacement history: +/// - prefer the last real (non-summary) user message; +/// - otherwise fall back to the last summary user message. +/// If no user anchor exists, this is a no-op. pub(crate) fn insert_initial_context_before_last_user_anchor( compacted_history: &mut Vec, initial_context: Vec, From b66c6e63fd87ece2516c10c07e5ce636be92123f Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Wed, 18 Feb 2026 01:54:51 -0800 Subject: [PATCH 73/80] Reuse summary-user helper in compaction anchoring --- codex-rs/core/src/compact.rs | 43 +++++++++++++++--------------------- 1 file changed, 18 insertions(+), 25 deletions(-) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 06863cafa52..0446e08f927 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -363,16 +363,8 @@ pub fn content_items_to_text(content: &[ContentItem]) -> Option { pub(crate) fn collect_user_messages(items: &[ResponseItem]) -> Vec { items .iter() - .filter_map(|item| match crate::event_mapping::parse_turn_item(item) { - Some(TurnItem::UserMessage(user)) => { - if is_summary_message(&user.message()) { - None - } else { - Some(user.message()) - } - } - _ => None, - }) + .filter_map(parsed_user_message_text) + .filter(|message| !is_summary_message(message)) .collect() } @@ -405,13 +397,9 @@ pub(crate) fn insert_initial_context_before_last_user_anchor( .iter() .rposition(is_real_user_message) .or_else(|| { - compacted_history.iter().rposition(|item| { - matches!( - crate::event_mapping::parse_turn_item(item), - Some(TurnItem::UserMessage(user_message)) - if is_summary_message(&user_message.message()) - ) - }) + compacted_history + .iter() + .rposition(is_summary_user_message_item) }); if let Some(index) = insertion_index { compacted_history.splice(index..index, initial_context); @@ -419,10 +407,18 @@ pub(crate) fn insert_initial_context_before_last_user_anchor( } fn is_real_user_message(item: &ResponseItem) -> bool { - matches!( - crate::event_mapping::parse_turn_item(item), - Some(TurnItem::UserMessage(user_message)) if !is_summary_message(&user_message.message()) - ) + parsed_user_message_text(item).is_some_and(|message| !is_summary_message(&message)) +} + +fn is_summary_user_message_item(item: &ResponseItem) -> bool { + parsed_user_message_text(item).is_some_and(|message| is_summary_message(&message)) +} + +fn parsed_user_message_text(item: &ResponseItem) -> Option { + match crate::event_mapping::parse_turn_item(item) { + Some(TurnItem::UserMessage(user_message)) => Some(user_message.message()), + _ => None, + } } fn is_user_shell_command_record(item: &ResponseItem) -> bool { @@ -466,10 +462,7 @@ pub(crate) fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { return true; } - matches!( - crate::event_mapping::parse_turn_item(item), - Some(TurnItem::UserMessage(_)) - ) + parsed_user_message_text(item).is_some() } // Keep compaction records for local/remote history continuity and token accounting. ResponseItem::Compaction { .. } => true, From 16285c158ff33ba3502511a87421e18d88d7ca26 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Wed, 18 Feb 2026 02:09:49 -0800 Subject: [PATCH 74/80] Move pre-turn context persistence to run_turn callsite --- codex-rs/core/src/codex.rs | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 81df6149f64..5bdd1c60e20 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4284,17 +4284,25 @@ pub(crate) async fn run_turn( return None; } - let Ok(pre_turn_compaction_outcome) = run_pre_turn_auto_compaction_if_needed( + let pre_turn_compaction_outcome = match run_pre_turn_auto_compaction_if_needed( &sess, &turn_context, auto_compact_limit, &incoming_turn_items, - &pre_turn_context_items, ) .await - else { - // Error messaging is emitted inside run_pre_turn_auto_compaction_if_needed. - return None; + { + Ok(outcome) => outcome, + Err(()) => { + if !pre_turn_context_items.is_empty() { + // Preserve model-visible settings updates even when pre-turn compaction fails + // before turn input persistence can run. + sess.record_conversation_items(&turn_context, &pre_turn_context_items) + .await; + } + // Error messaging is emitted inside run_pre_turn_auto_compaction_if_needed. + return None; + } }; persist_pre_turn_items_for_compaction_outcome( &sess, @@ -4766,7 +4774,6 @@ async fn run_pre_turn_auto_compaction_if_needed( turn_context: &Arc, auto_compact_limit: i64, incoming_turn_items: &[ResponseItem], - pre_turn_context_items: &[ResponseItem], ) -> Result { let total_usage_tokens = sess.get_total_token_usage().await; let incoming_items_tokens_estimate = incoming_turn_items @@ -4790,12 +4797,6 @@ async fn run_pre_turn_auto_compaction_if_needed( .await; if let Err(err) = compact_result { - 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; - } if matches!(err, CodexErr::Interrupted) { return Err(()); } @@ -6245,7 +6246,6 @@ mod tests { &turn_context, 10, &incoming_turn_items, - &[], ) .await .expect("pre-turn compaction no-op should not fail"); From 0c5363772ba90eef0d65fdfd866aeaae6ae0a199 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Wed, 18 Feb 2026 02:11:25 -0800 Subject: [PATCH 75/80] nit --- codex-rs/core/src/codex.rs | 94 ++++++++++++++++++------------------ codex-rs/core/src/compact.rs | 8 --- 2 files changed, 48 insertions(+), 54 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 5bdd1c60e20..af1d9206295 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4788,61 +4788,63 @@ async fn run_pre_turn_auto_compaction_if_needed( return Ok(PreTurnCompactionOutcome::NotNeeded); } - let compact_result = run_auto_compact( + match run_auto_compact( sess, turn_context, CompactCallsite::PreTurnIncludingIncomingUserMessage, Some(incoming_turn_items.to_vec()), ) - .await; + .await + { + Ok(()) => { + // If compaction no-oped because there was no user-turn boundary even after including + // incoming items, do not treat this as "compacted with incoming". The caller must + // persist incoming items explicitly in this case. + let has_user_turn_boundary_after_compaction = sess + .clone_history() + .await + .raw_items() + .iter() + .any(crate::context_manager::is_user_turn_boundary); + if !has_user_turn_boundary_after_compaction { + return Ok(PreTurnCompactionOutcome::NotNeeded); + } - if let Err(err) = compact_result { - if matches!(err, CodexErr::Interrupted) { - return Err(()); + Ok(PreTurnCompactionOutcome::CompactedWithIncomingItems) } - let event = match err { - CodexErr::ContextWindowExceeded => { - error!( - turn_id = %turn_context.sub_id, - compact_callsite = ?CompactCallsite::PreTurnIncludingIncomingUserMessage, - incoming_items_tokens_estimate, - auto_compact_limit, - reason = "pre-turn compaction exceeded context window", - "incoming user/context is too large for pre-turn auto-compaction flow" - ); - 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 => { - let compact_error_prefix = if should_use_remote_compact_task(&turn_context.provider) - { - "Error running remote compact task" - } else { - "Error running local compact task" - }; - EventMsg::Error(other.to_error_event(Some(compact_error_prefix.to_string()))) + Err(err) => { + if matches!(err, CodexErr::Interrupted) { + return Err(()); } - }; - sess.send_event(turn_context, event).await; - return Err(()); - } - - // If compaction no-oped because there was no user-turn boundary even after including incoming - // items, do not treat this as "compacted with incoming". The caller must persist incoming - // items explicitly in this case. - let has_user_turn_boundary_after_compaction = sess - .clone_history() - .await - .raw_items() - .iter() - .any(crate::context_manager::is_user_turn_boundary); - if !has_user_turn_boundary_after_compaction { - return Ok(PreTurnCompactionOutcome::NotNeeded); + let event = match err { + CodexErr::ContextWindowExceeded => { + error!( + turn_id = %turn_context.sub_id, + compact_callsite = ?CompactCallsite::PreTurnIncludingIncomingUserMessage, + incoming_items_tokens_estimate, + auto_compact_limit, + reason = "pre-turn compaction exceeded context window", + "incoming user/context is too large for pre-turn auto-compaction flow" + ); + 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 => { + let compact_error_prefix = + if should_use_remote_compact_task(&turn_context.provider) { + "Error running remote compact task" + } else { + "Error running local compact task" + }; + EventMsg::Error(other.to_error_event(Some(compact_error_prefix.to_string()))) + } + }; + sess.send_event(turn_context, event).await; + Err(()) + } } - - Ok(PreTurnCompactionOutcome::CompactedWithIncomingItems) } fn is_projected_submission_over_auto_compact_limit( diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 0446e08f927..a4742aa7d11 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -53,14 +53,6 @@ pub(crate) enum CompactCallsite { MidTurnContinuation, } -// Canonical context reinjection policy for compacted replacement history: -// - `MidTurnContinuation` and `PreSamplingModelSwitch`: reinsert canonical initial context above -// the last real user message because those paths do not persist/reseed canonical context in a -// later post-compaction step. -// - `ManualCompact`: do not reinsert during compaction; `/compact` reseeds on the next user turn. -// - `PreTurn*`: do not reinsert into replacement history; `run_turn` persists canonical context -// directly above the incoming user message after compaction. - pub(crate) fn should_use_remote_compact_task(provider: &ModelProviderInfo) -> bool { provider.is_openai() } From 7b4e4b5853eac1299c8a46086823a7ea49d6613d Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Wed, 18 Feb 2026 02:14:54 -0800 Subject: [PATCH 76/80] Remove redundant developer-role check in compaction filter --- codex-rs/core/src/compact.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index a4742aa7d11..7e3b79abb43 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -442,9 +442,6 @@ fn is_user_shell_command_record(item: &ResponseItem) -> bool { pub(crate) fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { match item { ResponseItem::Message { role, .. } => { - if role == "developer" { - return false; - } if role != "user" { return false; } From 6901781b757930b1f247a16f88b12d72958ac9ba Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Wed, 18 Feb 2026 02:18:19 -0800 Subject: [PATCH 77/80] Fix clippy doc list formatting for anchor helper --- codex-rs/core/src/compact.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 7e3b79abb43..ba2875cc158 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -377,6 +377,7 @@ pub(crate) fn process_compacted_history( /// replacement history: /// - prefer the last real (non-summary) user message; /// - otherwise fall back to the last summary user message. +/// /// If no user anchor exists, this is a no-op. pub(crate) fn insert_initial_context_before_last_user_anchor( compacted_history: &mut Vec, From ca84df34b8d4106e7ac85e14f3adf4137aeb9b0e Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Wed, 18 Feb 2026 02:22:07 -0800 Subject: [PATCH 78/80] edge case --- codex-rs/core/src/compact.rs | 50 +++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index ba2875cc158..7eef5b53069 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -378,7 +378,7 @@ pub(crate) fn process_compacted_history( /// - prefer the last real (non-summary) user message; /// - otherwise fall back to the last summary user message. /// -/// If no user anchor exists, this is a no-op. +/// If no user anchor exists, append initial context at the end. pub(crate) fn insert_initial_context_before_last_user_anchor( compacted_history: &mut Vec, initial_context: Vec, @@ -396,6 +396,8 @@ pub(crate) fn insert_initial_context_before_last_user_anchor( }); if let Some(index) = insertion_index { compacted_history.splice(index..index, initial_context); + } else { + compacted_history.extend(initial_context); } } @@ -1334,4 +1336,50 @@ keep me updated ]; assert_eq!(compacted_history, expected); } + + #[test] + fn insert_initial_context_before_last_user_anchor_appends_when_no_user_anchor_exists() { + let mut compacted_history = vec![ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "assistant only".to_string(), + }], + end_turn: None, + phase: None, + }]; + let initial_context = vec![ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "fresh permissions".to_string(), + }], + end_turn: None, + phase: None, + }]; + + insert_initial_context_before_last_user_anchor(&mut compacted_history, initial_context); + + let expected = vec![ + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "assistant only".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "fresh permissions".to_string(), + }], + end_turn: None, + phase: None, + }, + ]; + assert_eq!(compacted_history, expected); + } } From c085499bb090cf7f67a0517dc77fece6be8ac5d3 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Wed, 18 Feb 2026 09:13:14 -0800 Subject: [PATCH 79/80] Avoid dropping historical duplicates in remote echo stripping --- codex-rs/core/src/compact_remote.rs | 122 ++++++++++++++++++++++------ 1 file changed, 95 insertions(+), 27 deletions(-) diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 10dc4720515..ac8f6c93005 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -99,6 +99,7 @@ async fn run_remote_compact_task_inner_impl( &base_instructions, incoming_items.as_deref(), ); + let historical_items_before_incoming = history.raw_items().to_vec(); if let Some(incoming_items) = incoming_items.as_ref() { history.record_items(incoming_items.iter(), turn_context.truncation_policy); } @@ -180,7 +181,11 @@ async fn run_remote_compact_task_inner_impl( .filter(|item| should_keep_compacted_history_item(item)) .cloned() .collect(); - remove_incoming_echoes_from_compacted_history(&mut new_history, &incoming_history_items); + remove_incoming_echoes_from_compacted_history( + &mut new_history, + &incoming_history_items, + &historical_items_before_incoming, + ); } // Reattach stripped model-switch updates into replacement history so post-compaction // sampling keeps model-switch guidance regardless of compaction callsite. @@ -234,34 +239,48 @@ fn build_compact_request_log_data( fn remove_incoming_echoes_from_compacted_history( new_history: &mut Vec, incoming_history_items: &[ResponseItem], + historical_items_before_incoming: &[ResponseItem], ) { + let items_match = + |candidate: &ResponseItem, incoming_item: &ResponseItem| match (candidate, incoming_item) { + ( + ResponseItem::Message { + role: candidate_role, + content: candidate_content, + .. + }, + ResponseItem::Message { + role: incoming_role, + content: incoming_content, + .. + }, + ) => candidate_role == incoming_role && candidate_content == incoming_content, + ( + ResponseItem::Compaction { + encrypted_content: candidate_content, + }, + ResponseItem::Compaction { + encrypted_content: incoming_content, + }, + ) => candidate_content == incoming_content, + _ => candidate == incoming_item, + }; + for incoming_item in incoming_history_items { - if let Some(index) = - new_history - .iter() - .rposition(|candidate| match (candidate, incoming_item) { - ( - ResponseItem::Message { - role: candidate_role, - content: candidate_content, - .. - }, - ResponseItem::Message { - role: incoming_role, - content: incoming_content, - .. - }, - ) => candidate_role == incoming_role && candidate_content == incoming_content, - ( - ResponseItem::Compaction { - encrypted_content: candidate_content, - }, - ResponseItem::Compaction { - encrypted_content: incoming_content, - }, - ) => candidate_content == incoming_content, - _ => candidate == incoming_item, - }) + let historical_count = historical_items_before_incoming + .iter() + .filter(|candidate| items_match(candidate, incoming_item)) + .count(); + let compacted_count = new_history + .iter() + .filter(|candidate| items_match(candidate, incoming_item)) + .count(); + if compacted_count <= historical_count { + continue; + } + if let Some(index) = new_history + .iter() + .rposition(|candidate| items_match(candidate, incoming_item)) { new_history.remove(index); } @@ -371,6 +390,10 @@ mod tests { } } + fn summary_user_message(text: &str) -> ResponseItem { + user_message(&format!("{}\n{text}", crate::compact::SUMMARY_PREFIX)) + } + #[test] fn trim_accounts_for_incoming_items_tokens() { let base_instructions = BaseInstructions { @@ -425,4 +448,49 @@ mod tests { ); assert_eq!(history.raw_items(), vec![user_message("USER_ONE")]); } + + #[test] + fn remove_incoming_echoes_preserves_historical_duplicates_when_not_echoed() { + let mut compacted_history = vec![ + user_message("REPEAT_MESSAGE"), + summary_user_message("LATEST_SUMMARY"), + ]; + let incoming_items = vec![user_message("REPEAT_MESSAGE")]; + let historical_items_before_incoming = vec![user_message("REPEAT_MESSAGE")]; + + remove_incoming_echoes_from_compacted_history( + &mut compacted_history, + &incoming_items, + &historical_items_before_incoming, + ); + + let expected = vec![ + user_message("REPEAT_MESSAGE"), + summary_user_message("LATEST_SUMMARY"), + ]; + assert_eq!(compacted_history, expected); + } + + #[test] + fn remove_incoming_echoes_removes_net_new_echoes_only() { + let mut compacted_history = vec![ + user_message("REPEAT_MESSAGE"), + user_message("REPEAT_MESSAGE"), + summary_user_message("LATEST_SUMMARY"), + ]; + let incoming_items = vec![user_message("REPEAT_MESSAGE")]; + let historical_items_before_incoming = vec![user_message("REPEAT_MESSAGE")]; + + remove_incoming_echoes_from_compacted_history( + &mut compacted_history, + &incoming_items, + &historical_items_before_incoming, + ); + + let expected = vec![ + user_message("REPEAT_MESSAGE"), + summary_user_message("LATEST_SUMMARY"), + ]; + assert_eq!(compacted_history, expected); + } } From 8e6b114df018c6302e83ee0ef7b4c322a1015d8a Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Wed, 18 Feb 2026 09:16:00 -0800 Subject: [PATCH 80/80] Rename /compact start test for no-history semantics --- codex-rs/app-server/tests/suite/v2/compaction.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/app-server/tests/suite/v2/compaction.rs b/codex-rs/app-server/tests/suite/v2/compaction.rs index 09d75879f71..d663be130fd 100644 --- a/codex-rs/app-server/tests/suite/v2/compaction.rs +++ b/codex-rs/app-server/tests/suite/v2/compaction.rs @@ -199,7 +199,7 @@ async fn auto_compaction_remote_emits_started_and_completed_items() -> Result<() } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn thread_compact_start_triggers_compaction_and_returns_empty_response() -> Result<()> { +async fn thread_compact_start_without_history_emits_started_and_completed_items() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await;