diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 9aa872c6df..109f12b693 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 97788bd31f..10dc472051 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 e4168f92d3..98d5a05488 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 109f91e46e..88561b245b 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