From 706b03d9fe6bcb5ba99215cf2d5273bc9815723a Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 3 Feb 2026 16:36:06 -0800 Subject: [PATCH 01/11] core: refresh developer instructions after compaction replacement history --- codex-rs/core/src/codex.rs | 74 +++++++++++++++++- codex-rs/core/src/compact.rs | 114 ++++++++++++++++++++++++++++ codex-rs/core/src/compact_remote.rs | 3 + 3 files changed, 190 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index ba34af5ed8c..c762958f26a 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1768,7 +1768,11 @@ impl Session { } RolloutItem::Compacted(compacted) => { if let Some(replacement) = &compacted.replacement_history { - history.replace(replacement.clone()); + let initial_context = self.build_initial_context(turn_context).await; + history.replace(compact::refresh_compacted_developer_instructions( + replacement.clone(), + &initial_context, + )); } else { let user_messages = collect_user_messages(history.raw_items()); let rebuilt = compact::build_compacted_history( @@ -4664,6 +4668,74 @@ mod tests { assert_eq!(expected, reconstructed); } + #[tokio::test] + async fn reconstruct_history_refreshes_developer_instructions_for_replacement_history() { + let (session, turn_context) = make_session_and_context().await; + let rollout_items = vec![RolloutItem::Compacted(CompactedItem { + message: String::new(), + replacement_history: Some(vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "summary".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "stale developer instructions".to_string(), + }], + end_turn: None, + phase: None, + }, + ]), + })]; + + let reconstructed = session + .reconstruct_history_from_rollout(&turn_context, &rollout_items) + .await; + + let has_stale_message = reconstructed.iter().any(|item| { + matches!( + item, + ResponseItem::Message { role, content, .. } + if role == "developer" + && content.iter().any(|part| matches!( + part, + ContentItem::InputText { text } + if text == "stale developer instructions" + )) + ) + }); + assert!(!has_stale_message); + + let summary_position = reconstructed.iter().position(|item| { + matches!( + item, + ResponseItem::Message { role, content, .. } + if role == "user" + && content.iter().any(|part| matches!( + part, + ContentItem::InputText { text } if text == "summary" + )) + ) + }); + let summary_position = summary_position.expect("summary should be present"); + let initial_context = session.build_initial_context(&turn_context).await; + let expected_developer_messages: Vec = initial_context + .into_iter() + .filter( + |item| matches!(item, ResponseItem::Message { role, .. } if role == "developer"), + ) + .collect(); + let actual_after_summary = reconstructed[summary_position + 1..].to_vec(); + assert_eq!(actual_after_summary, expected_developer_messages); + } + #[tokio::test] async fn record_initial_history_reconstructs_resumed_transcript() { let (session, turn_context) = make_session_and_context().await; diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index ee94e4994b8..72d26e1012b 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -264,6 +264,25 @@ pub(crate) fn is_summary_message(message: &str) -> bool { message.starts_with(format!("{SUMMARY_PREFIX}\n").as_str()) } +pub(crate) fn refresh_compacted_developer_instructions( + mut compacted_history: Vec, + initial_context: &[ResponseItem], +) -> Vec { + compacted_history + .retain(|item| !matches!(item, ResponseItem::Message { role, .. } if role == "developer")); + + compacted_history.extend( + initial_context + .iter() + .filter( + |item| matches!(item, ResponseItem::Message { role, .. } if role == "developer"), + ) + .cloned(), + ); + + compacted_history +} + pub(crate) fn build_compacted_history( initial_context: Vec, user_messages: &[String], @@ -579,4 +598,99 @@ mod tests { "expected compacted history to retain marker" ); } + + #[test] + fn refresh_compacted_developer_instructions_replaces_developer_messages() { + let compacted_history = vec![ + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "stale permissions".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, + }, + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "stale personality".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, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "cwd=/tmp".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 = + refresh_compacted_developer_instructions(compacted_history, &initial_context); + let expected = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "summary".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, + }, + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "fresh personality".to_string(), + }], + 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 12bc769ce2f..e9825034512 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use crate::Prompt; use crate::codex::Session; use crate::codex::TurnContext; +use crate::compact::refresh_compacted_developer_instructions; use crate::context_manager::ContextManager; use crate::context_manager::is_codex_generated_item; use crate::error::Result as CodexResult; @@ -80,6 +81,8 @@ async fn run_remote_compact_task_inner_impl( .client .compact_conversation_history(&prompt) .await?; + let initial_context = sess.build_initial_context(turn_context).await; + new_history = refresh_compacted_developer_instructions(new_history, &initial_context); if !ghost_snapshots.is_empty() { new_history.extend(ghost_snapshots); From 72bf70c061111cd15fa925d8bb2ae1ccccb69d7a Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 3 Feb 2026 17:09:50 -0800 Subject: [PATCH 02/11] Use shared process_compacted_history path and add e2e coverage --- codex-rs/core/src/codex.rs | 18 +- codex-rs/core/src/compact.rs | 7 +- codex-rs/core/src/compact_remote.rs | 6 +- codex-rs/core/tests/suite/compact_remote.rs | 212 +++++++++++++++++++- 4 files changed, 228 insertions(+), 15 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index c762958f26a..bcefcc41eb4 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1768,11 +1768,10 @@ impl Session { } RolloutItem::Compacted(compacted) => { if let Some(replacement) = &compacted.replacement_history { - let initial_context = self.build_initial_context(turn_context).await; - history.replace(compact::refresh_compacted_developer_instructions( - replacement.clone(), - &initial_context, - )); + history.replace( + self.process_compacted_history(turn_context, replacement.clone()) + .await, + ); } else { let user_messages = collect_user_messages(history.raw_items()); let rebuilt = compact::build_compacted_history( @@ -1792,6 +1791,15 @@ impl Session { history.raw_items().to_vec() } + pub(crate) async fn process_compacted_history( + &self, + turn_context: &TurnContext, + compacted_history: Vec, + ) -> Vec { + let initial_context = self.build_initial_context(turn_context).await; + compact::process_compacted_history(compacted_history, &initial_context) + } + /// 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.rs b/codex-rs/core/src/compact.rs index 72d26e1012b..9cbe2394db3 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -264,7 +264,7 @@ pub(crate) fn is_summary_message(message: &str) -> bool { message.starts_with(format!("{SUMMARY_PREFIX}\n").as_str()) } -pub(crate) fn refresh_compacted_developer_instructions( +pub(crate) fn process_compacted_history( mut compacted_history: Vec, initial_context: &[ResponseItem], ) -> Vec { @@ -600,7 +600,7 @@ mod tests { } #[test] - fn refresh_compacted_developer_instructions_replaces_developer_messages() { + fn process_compacted_history_replaces_developer_messages() { let compacted_history = vec![ ResponseItem::Message { id: None, @@ -660,8 +660,7 @@ mod tests { }, ]; - let refreshed = - refresh_compacted_developer_instructions(compacted_history, &initial_context); + let refreshed = process_compacted_history(compacted_history, &initial_context); let expected = vec![ ResponseItem::Message { id: None, diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index e9825034512..6a6ad031741 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -3,7 +3,6 @@ use std::sync::Arc; use crate::Prompt; use crate::codex::Session; use crate::codex::TurnContext; -use crate::compact::refresh_compacted_developer_instructions; use crate::context_manager::ContextManager; use crate::context_manager::is_codex_generated_item; use crate::error::Result as CodexResult; @@ -81,8 +80,9 @@ async fn run_remote_compact_task_inner_impl( .client .compact_conversation_history(&prompt) .await?; - let initial_context = sess.build_initial_context(turn_context).await; - new_history = refresh_compacted_developer_instructions(new_history, &initial_context); + new_history = sess + .process_compacted_history(turn_context, new_history) + .await; if !ghost_snapshots.is_empty() { new_history.extend(ghost_snapshots); diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index b0aff9efaa9..a24db3d3711 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -537,10 +537,58 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> }; if let RolloutItem::Compacted(compacted) = entry.item && compacted.message.is_empty() - && compacted.replacement_history.as_ref() == Some(&compacted_history) + && let Some(replacement_history) = compacted.replacement_history.as_ref() { - saw_compacted_history = true; - break; + let has_compacted_user_summary = replacement_history.iter().any(|item| { + matches!( + item, + ResponseItem::Message { role, content, .. } + if role == "user" + && content.iter().any(|part| matches!( + part, + ContentItem::InputText { text } if text == "COMPACTED_USER_SUMMARY" + )) + ) + }); + let has_compaction_item = replacement_history.iter().any(|item| { + matches!( + item, + ResponseItem::Compaction { encrypted_content } + if encrypted_content == "ENCRYPTED_COMPACTION_SUMMARY" + ) + }); + let has_compacted_assistant_note = replacement_history.iter().any(|item| { + matches!( + item, + ResponseItem::Message { role, content, .. } + if role == "assistant" + && content.iter().any(|part| matches!( + part, + ContentItem::OutputText { text } if text == "COMPACTED_ASSISTANT_NOTE" + )) + ) + }); + let has_permissions_developer_message = replacement_history.iter().any(|item| { + matches!( + item, + ResponseItem::Message { role, content, .. } + if role == "developer" + && content.iter().any(|part| matches!( + part, + ContentItem::InputText { text } + if text.contains("") + )) + ) + }); + + if has_compacted_user_summary + && has_compaction_item + && has_compacted_assistant_note + && has_permissions_developer_message + { + saw_compacted_history = true; + break; + } } } @@ -551,3 +599,161 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = wiremock::MockServer::start().await; + let stale_developer_message = "STALE_DEVELOPER_INSTRUCTIONS_SHOULD_BE_REMOVED"; + + let mut start_builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(|config| { + config.features.enable(Feature::RemoteCompaction); + }); + let initial = start_builder.build(&server).await?; + let home = initial.home.clone(); + let rollout_path = initial + .session_configured + .rollout_path + .clone() + .expect("rollout path"); + + let responses_mock = responses::mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + responses::ev_assistant_message("m1", "BASELINE_REPLY"), + responses::ev_completed("resp-1"), + ]), + responses::sse(vec![ + responses::ev_assistant_message("m2", "AFTER_COMPACT_REPLY"), + responses::ev_completed("resp-2"), + ]), + responses::sse(vec![ + responses::ev_assistant_message("m3", "AFTER_RESUME_REPLY"), + responses::ev_completed("resp-3"), + ]), + ], + ) + .await; + + let compacted_history = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "REMOTE_COMPACTED_SUMMARY".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: stale_developer_message.to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Compaction { + encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), + }, + ]; + let compact_mock = responses::mount_compact_json_once( + &server, + serde_json::json!({ "output": compacted_history }), + ) + .await; + + initial + .codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "start remote compact flow".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + initial.codex.submit(Op::Compact).await?; + wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + initial + .codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "after compact in same session".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + initial.codex.submit(Op::Shutdown).await?; + wait_for_event(&initial.codex, |ev| { + matches!(ev, EventMsg::ShutdownComplete) + }) + .await; + + let mut resume_builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(|config| { + config.features.enable(Feature::RemoteCompaction); + }); + let resumed = resume_builder.resume(&server, home, rollout_path).await?; + + resumed + .codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "after resume".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + assert_eq!(compact_mock.requests().len(), 1); + let requests = responses_mock.requests(); + assert_eq!(requests.len(), 3, "expected three model requests"); + + let after_compact_request = &requests[1]; + let after_resume_request = &requests[2]; + + let after_compact_body = after_compact_request.body_json().to_string(); + assert!( + !after_compact_body.contains(stale_developer_message), + "stale developer instructions should be removed immediately after compaction" + ); + assert!( + after_compact_body.contains(""), + "fresh developer instructions should be present after compaction" + ); + assert!( + after_compact_body.contains("REMOTE_COMPACTED_SUMMARY"), + "compacted summary should be present after compaction" + ); + + let after_resume_body = after_resume_request.body_json().to_string(); + assert!( + !after_resume_body.contains(stale_developer_message), + "stale developer instructions should be removed after resume" + ); + assert!( + after_resume_body.contains(""), + "fresh developer instructions should be present after resume" + ); + assert!( + after_resume_body.contains("REMOTE_COMPACTED_SUMMARY"), + "compacted summary should persist after resume" + ); + + Ok(()) +} From ff8082911eeac7fed11d2fe195e2877d45b32183 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Wed, 4 Feb 2026 14:49:47 -0800 Subject: [PATCH 03/11] Strip turn_aborted markers during compaction --- codex-rs/core/src/compact.rs | 158 +++++++++++++++++++++-------------- 1 file changed, 94 insertions(+), 64 deletions(-) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 9cbe2394db3..1af6b109318 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -14,7 +14,6 @@ use crate::protocol::EventMsg; use crate::protocol::TurnContextItem; use crate::protocol::TurnStartedEvent; use crate::protocol::WarningEvent; -use crate::session_prefix::TURN_ABORTED_OPEN_TAG; use crate::truncate::TruncationPolicy; use crate::truncate::approx_token_count; use crate::truncate::truncate_text; @@ -235,31 +234,11 @@ pub(crate) fn collect_user_messages(items: &[ResponseItem]) -> Vec { Some(user.message()) } } - _ => collect_turn_aborted_marker(item), + _ => None, }) .collect() } -fn collect_turn_aborted_marker(item: &ResponseItem) -> Option { - let ResponseItem::Message { role, content, .. } = item else { - return None; - }; - if role != "user" { - return None; - } - - let text = content_items_to_text(content)?; - if text - .trim_start() - .to_ascii_lowercase() - .starts_with(TURN_ABORTED_OPEN_TAG) - { - Some(text) - } else { - None - } -} - pub(crate) fn is_summary_message(message: &str) -> bool { message.starts_with(format!("{SUMMARY_PREFIX}\n").as_str()) } @@ -268,8 +247,7 @@ pub(crate) fn process_compacted_history( mut compacted_history: Vec, initial_context: &[ResponseItem], ) -> Vec { - compacted_history - .retain(|item| !matches!(item, ResponseItem::Message { role, .. } if role == "developer")); + compacted_history.retain(should_keep_compacted_history_item); compacted_history.extend( initial_context @@ -283,6 +261,17 @@ pub(crate) fn process_compacted_history( compacted_history } +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, + } +} + pub(crate) fn build_compacted_history( initial_context: Vec, user_messages: &[String], @@ -394,7 +383,6 @@ async fn drain_to_completed( mod tests { use super::*; - use crate::session_prefix::TURN_ABORTED_OPEN_TAG; use pretty_assertions::assert_eq; #[test] @@ -559,16 +547,13 @@ mod tests { } #[test] - fn build_compacted_history_preserves_turn_aborted_markers() { - let marker = format!( - "{TURN_ABORTED_OPEN_TAG}\n turn-1\n interrupted\n" - ); - let items = vec![ + fn process_compacted_history_replaces_developer_messages() { + let compacted_history = vec![ ResponseItem::Message { id: None, - role: "user".to_string(), + role: "developer".to_string(), content: vec![ContentItem::InputText { - text: marker.clone(), + text: "stale permissions".to_string(), }], end_turn: None, phase: None, @@ -577,36 +562,27 @@ mod tests { id: None, role: "user".to_string(), content: vec![ContentItem::InputText { - text: "real user message".to_string(), + text: "summary".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "stale personality".to_string(), }], end_turn: None, phase: None, }, ]; - - let user_messages = collect_user_messages(&items); - let history = build_compacted_history(Vec::new(), &user_messages, "SUMMARY"); - - let found_marker = history.iter().any(|item| match item { - ResponseItem::Message { role, content, .. } if role == "user" => { - content_items_to_text(content).is_some_and(|text| text == marker) - } - _ => false, - }); - assert!( - found_marker, - "expected compacted history to retain marker" - ); - } - - #[test] - fn process_compacted_history_replaces_developer_messages() { - let compacted_history = vec![ + let initial_context = vec![ ResponseItem::Message { id: None, role: "developer".to_string(), content: vec![ContentItem::InputText { - text: "stale permissions".to_string(), + text: "fresh permissions".to_string(), }], end_turn: None, phase: None, @@ -615,7 +591,7 @@ mod tests { id: None, role: "user".to_string(), content: vec![ContentItem::InputText { - text: "summary".to_string(), + text: "cwd=/tmp".to_string(), }], end_turn: None, phase: None, @@ -624,27 +600,29 @@ mod tests { id: None, role: "developer".to_string(), content: vec![ContentItem::InputText { - text: "stale personality".to_string(), + text: "fresh personality".to_string(), }], end_turn: None, phase: None, }, ]; - let initial_context = vec![ + + let refreshed = process_compacted_history(compacted_history, &initial_context); + 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, }, ResponseItem::Message { id: None, - role: "user".to_string(), + role: "developer".to_string(), content: vec![ContentItem::InputText { - text: "cwd=/tmp".to_string(), + text: "fresh permissions".to_string(), }], end_turn: None, phase: None, @@ -659,9 +637,39 @@ mod tests { phase: None, }, ]; + assert_eq!(refreshed, expected); + } - let refreshed = process_compacted_history(compacted_history, &initial_context); - let expected = vec![ + #[test] + fn process_compacted_history_drops_non_user_content_messages() { + let compacted_history = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "# AGENTS.md instructions for /repo\n\n\nkeep me updated\n".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "\n /repo\n zsh\n".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "\n turn-1\n interrupted\n".to_string(), + }], + end_turn: None, + phase: None, + }, ResponseItem::Message { id: None, role: "user".to_string(), @@ -675,7 +683,29 @@ mod tests { id: None, role: "developer".to_string(), content: vec![ContentItem::InputText { - text: "fresh permissions".to_string(), + text: "stale developer instructions".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 developer instructions".to_string(), + }], + end_turn: None, + phase: None, + }]; + + let refreshed = process_compacted_history(compacted_history, &initial_context); + let expected = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "summary".to_string(), }], end_turn: None, phase: None, @@ -684,7 +714,7 @@ mod tests { id: None, role: "developer".to_string(), content: vec![ContentItem::InputText { - text: "fresh personality".to_string(), + text: "fresh developer instructions".to_string(), }], end_turn: None, phase: None, From d6449e3cbb74a46393a0a10cb87ebe9b88a12b40 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Wed, 4 Feb 2026 17:04:36 -0800 Subject: [PATCH 04/11] Bound reinjected turn context in compacted history --- codex-rs/core/src/compact.rs | 317 ++++++++++++++++++++++++++++++++++- 1 file changed, 309 insertions(+), 8 deletions(-) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 1af6b109318..f5fabdc06fc 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -9,6 +9,7 @@ use crate::codex::get_last_assistant_message_from_turn; use crate::error::CodexErr; use crate::error::Result as CodexResult; use crate::features::Feature; +use crate::instructions::UserInstructions; use crate::protocol::CompactedItem; use crate::protocol::EventMsg; use crate::protocol::TurnContextItem; @@ -31,6 +32,10 @@ use tracing::error; pub const SUMMARIZATION_PROMPT: &str = include_str!("../templates/compact/prompt.md"); pub const SUMMARY_PREFIX: &str = include_str!("../templates/compact/summary_prefix.md"); const COMPACT_USER_MESSAGE_MAX_TOKENS: usize = 20_000; +// Keep reinjected turn context bounded so large instruction blocks do not +// dominate post-compaction history. +const REINJECTED_INITIAL_CONTEXT_MAX_TOKENS: usize = COMPACT_USER_MESSAGE_MAX_TOKENS / 2; +const PERMISSIONS_INSTRUCTIONS_OPEN_TAG: &str = ""; pub(crate) fn should_use_remote_compact_task( session: &Session, @@ -249,18 +254,27 @@ pub(crate) fn process_compacted_history( ) -> Vec { compacted_history.retain(should_keep_compacted_history_item); - compacted_history.extend( - initial_context - .iter() - .filter( - |item| matches!(item, ResponseItem::Message { role, .. } if role == "developer"), - ) - .cloned(), - ); + let initial_context = initial_context_for_reinjection(initial_context); + + // Re-inject canonical context from the current session since we stripped from the pre-compaction history. + compacted_history.extend(initial_context); compacted_history } +/// Returns whether an item from remote compaction output should be preserved. +/// +/// Called while processing the model-provided compacted transcript, before we +/// append fresh canonical context from the current session. +/// +/// We drop: +/// - `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`. +/// +/// This intentionally keeps `user`-role warnings and compaction-generated +/// summary messages because they parse as `TurnItem::UserMessage`. fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { match item { ResponseItem::Message { role, .. } if role == "developer" => false, @@ -272,6 +286,65 @@ fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { } } +fn initial_context_for_reinjection(initial_context: &[ResponseItem]) -> Vec { + let mut selected: Vec> = + initial_context.iter().cloned().map(Some).collect(); + let mut total_tokens: usize = initial_context + .iter() + .map(estimate_response_item_tokens) + .sum(); + if total_tokens <= REINJECTED_INITIAL_CONTEXT_MAX_TOKENS { + return initial_context.to_vec(); + } + + let mut droppable_items: Vec<(usize, usize)> = initial_context + .iter() + .enumerate() + .filter(|(_, item)| is_droppable_initial_context_item(item)) + .map(|(idx, item)| (idx, estimate_response_item_tokens(item))) + .collect(); + // Prefer dropping the largest droppable context chunks first. + droppable_items.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + + for (idx, item_tokens) in droppable_items { + if total_tokens <= REINJECTED_INITIAL_CONTEXT_MAX_TOKENS { + break; + } + selected[idx] = None; + total_tokens = total_tokens.saturating_sub(item_tokens); + } + + selected.into_iter().flatten().collect() +} + +fn is_droppable_initial_context_item(item: &ResponseItem) -> bool { + let ResponseItem::Message { role, content, .. } = item else { + return false; + }; + // We keep permissions and environment context stable, and allow large + // instruction wrappers to be omitted since compaction can summarize them. + if role == "user" { + return UserInstructions::is_user_instructions(content); + } + if role == "developer" { + return !is_permissions_developer_message(content); + } + false +} + +fn is_permissions_developer_message(content: &[ContentItem]) -> bool { + let [ContentItem::InputText { text }] = content else { + return false; + }; + text.starts_with(PERMISSIONS_INSTRUCTIONS_OPEN_TAG) +} + +fn estimate_response_item_tokens(item: &ResponseItem) -> usize { + serde_json::to_string(item) + .map(|s| approx_token_count(&s)) + .unwrap_or_default() +} + pub(crate) fn build_compacted_history( initial_context: Vec, user_messages: &[String], @@ -627,6 +700,15 @@ mod tests { end_turn: None, phase: None, }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "cwd=/tmp".to_string(), + }], + end_turn: None, + phase: None, + }, ResponseItem::Message { id: None, role: "developer".to_string(), @@ -640,6 +722,225 @@ mod tests { assert_eq!(refreshed, expected); } + #[test] + fn process_compacted_history_reinjects_full_initial_context() { + let compacted_history = vec![ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "summary".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, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "# AGENTS.md instructions for /repo\n\n\nkeep me updated\n".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "\n /repo\n zsh\n".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "\n turn-1\n interrupted\n".to_string(), + }], + end_turn: None, + phase: None, + }, + ]; + + let refreshed = process_compacted_history(compacted_history, &initial_context); + let expected = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "summary".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, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "# AGENTS.md instructions for /repo\n\n\nkeep me updated\n".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "\n /repo\n zsh\n".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "\n turn-1\n interrupted\n".to_string(), + }], + end_turn: None, + phase: None, + }, + ]; + assert_eq!(refreshed, expected); + } + + #[test] + fn process_compacted_history_drops_largest_uncapped_context_items_to_fit_budget() { + let compacted_history = vec![ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "summary".to_string(), + }], + end_turn: None, + phase: None, + }]; + let oversized_user_instructions = format!( + "# AGENTS.md instructions for /repo\n\n\n{}\n", + "u".repeat(48_000) + ); + let medium_developer_instructions = "d".repeat(8_000); + let initial_context = vec![ + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "\nallowed\n" + .to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: oversized_user_instructions.clone(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: medium_developer_instructions.clone(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "\n /repo\n zsh\n".to_string(), + }], + end_turn: None, + phase: None, + }, + ]; + let initial_context_tokens: usize = initial_context + .iter() + .map(estimate_response_item_tokens) + .sum(); + assert!( + initial_context_tokens > REINJECTED_INITIAL_CONTEXT_MAX_TOKENS, + "test setup should exceed reinjected initial context budget" + ); + + let refreshed = process_compacted_history(compacted_history, &initial_context); + let reinjected_context_tokens: usize = refreshed + .iter() + .skip(1) + .map(estimate_response_item_tokens) + .sum(); + assert!( + reinjected_context_tokens <= REINJECTED_INITIAL_CONTEXT_MAX_TOKENS, + "re-injected context should respect budget" + ); + assert!( + refreshed.iter().any(|item| matches!( + item, + ResponseItem::Message { role, content, .. } + if role == "developer" + && content_items_to_text(content).as_deref() == Some( + "\nallowed\n" + ) + )), + "permissions instructions should always be re-injected" + ); + assert!( + refreshed.iter().any(|item| matches!( + item, + ResponseItem::Message { role, content, .. } + if role == "user" + && content_items_to_text(content).as_deref() == Some( + "\n /repo\n zsh\n" + ) + )), + "environment context should always be re-injected" + ); + assert!( + refreshed.iter().all(|item| !matches!( + item, + ResponseItem::Message { role, content, .. } + if role == "user" + && content_items_to_text(content).as_deref() + == Some(oversized_user_instructions.as_str()) + )), + "largest droppable context item should be removed first" + ); + assert!( + refreshed.iter().any(|item| matches!( + item, + ResponseItem::Message { role, content, .. } + if role == "developer" + && content_items_to_text(content).as_deref() + == Some(medium_developer_instructions.as_str()) + )), + "smaller droppable context items should remain when budget allows" + ); + } + #[test] fn process_compacted_history_drops_non_user_content_messages() { let compacted_history = vec![ From 7db7fbebc0ea9454d8769b60c102735d6fdb9523 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Wed, 4 Feb 2026 18:26:03 -0800 Subject: [PATCH 05/11] Document reinjected turn-context budget rationale --- codex-rs/core/src/compact.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index f5fabdc06fc..ba97d403892 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -32,8 +32,18 @@ use tracing::error; pub const SUMMARIZATION_PROMPT: &str = include_str!("../templates/compact/prompt.md"); pub const SUMMARY_PREFIX: &str = include_str!("../templates/compact/summary_prefix.md"); const COMPACT_USER_MESSAGE_MAX_TOKENS: usize = 20_000; -// Keep reinjected turn context bounded so large instruction blocks do not -// dominate post-compaction history. +// Turn context messages are re-injected after compaction so the next turn sees +// canonical session state (permissions, environment context, instructions). +// +// Some of those entries can be very large (for example AGENTS.md or custom +// developer instructions), and their size is otherwise unbounded here. Without +// a cap, re-injection can dominate post-compaction history and quickly push +// token usage back toward auto-compaction thresholds. +// +// We therefore budget the reinjected context separately and drop only +// droppable context items (largest first) when needed. Using half of the +// existing compact user-message budget keeps this heuristic simple and local to +// compaction behavior. const REINJECTED_INITIAL_CONTEXT_MAX_TOKENS: usize = COMPACT_USER_MESSAGE_MAX_TOKENS / 2; const PERMISSIONS_INSTRUCTIONS_OPEN_TAG: &str = ""; From 88eb6865dc168e84a4cadaf5a05535b5b57207e2 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Wed, 4 Feb 2026 19:50:26 -0800 Subject: [PATCH 06/11] Insert reinjected turn context before final user message --- codex-rs/core/src/compact.rs | 120 +++++++++++++++++++++++++++++------ 1 file changed, 101 insertions(+), 19 deletions(-) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index ba97d403892..4b1eca3027b 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -266,8 +266,17 @@ pub(crate) fn process_compacted_history( let initial_context = initial_context_for_reinjection(initial_context); - // Re-inject canonical context from the current session since we stripped from the pre-compaction history. - compacted_history.extend(initial_context); + // 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!(item, ResponseItem::Message { role, .. } if role == "user")) + { + compacted_history.splice(last_user_index..last_user_index, initial_context); + } else { + compacted_history.extend(initial_context); + } compacted_history } @@ -694,36 +703,36 @@ mod tests { let expected = vec![ ResponseItem::Message { id: None, - role: "user".to_string(), + role: "developer".to_string(), content: vec![ContentItem::InputText { - text: "summary".to_string(), + text: "fresh permissions".to_string(), }], end_turn: None, phase: None, }, ResponseItem::Message { id: None, - role: "developer".to_string(), + role: "user".to_string(), content: vec![ContentItem::InputText { - text: "fresh permissions".to_string(), + text: "cwd=/tmp".to_string(), }], end_turn: None, phase: None, }, ResponseItem::Message { id: None, - role: "user".to_string(), + role: "developer".to_string(), content: vec![ContentItem::InputText { - text: "cwd=/tmp".to_string(), + text: "fresh personality".to_string(), }], end_turn: None, phase: None, }, ResponseItem::Message { id: None, - role: "developer".to_string(), + role: "user".to_string(), content: vec![ContentItem::InputText { - text: "fresh personality".to_string(), + text: "summary".to_string(), }], end_turn: None, phase: None, @@ -786,18 +795,18 @@ mod tests { let expected = vec![ ResponseItem::Message { id: None, - role: "user".to_string(), + role: "developer".to_string(), content: vec![ContentItem::InputText { - text: "summary".to_string(), + text: "fresh permissions".to_string(), }], end_turn: None, phase: None, }, ResponseItem::Message { id: None, - role: "developer".to_string(), + role: "user".to_string(), content: vec![ContentItem::InputText { - text: "fresh permissions".to_string(), + text: "# AGENTS.md instructions for /repo\n\n\nkeep me updated\n".to_string(), }], end_turn: None, phase: None, @@ -806,7 +815,7 @@ mod tests { id: None, role: "user".to_string(), content: vec![ContentItem::InputText { - text: "# AGENTS.md instructions for /repo\n\n\nkeep me updated\n".to_string(), + text: "\n /repo\n zsh\n".to_string(), }], end_turn: None, phase: None, @@ -815,7 +824,8 @@ mod tests { id: None, role: "user".to_string(), content: vec![ContentItem::InputText { - text: "\n /repo\n zsh\n".to_string(), + text: "\n turn-1\n interrupted\n" + .to_string(), }], end_turn: None, phase: None, @@ -824,7 +834,7 @@ mod tests { id: None, role: "user".to_string(), content: vec![ContentItem::InputText { - text: "\n turn-1\n interrupted\n".to_string(), + text: "summary".to_string(), }], end_turn: None, phase: None, @@ -900,7 +910,14 @@ mod tests { let refreshed = process_compacted_history(compacted_history, &initial_context); let reinjected_context_tokens: usize = refreshed .iter() - .skip(1) + .filter(|item| { + !matches!( + item, + ResponseItem::Message { role, content, .. } + if role == "user" + && content_items_to_text(content).as_deref() == Some("summary") + ) + }) .map(estimate_response_item_tokens) .sum(); assert!( @@ -1012,6 +1029,15 @@ mod tests { let refreshed = process_compacted_history(compacted_history, &initial_context); 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(), @@ -1021,11 +1047,67 @@ mod tests { end_turn: None, phase: None, }, + ]; + assert_eq!(refreshed, expected); + } + + #[test] + fn process_compacted_history_inserts_context_before_last_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: "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); + 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: "developer".to_string(), content: vec![ContentItem::InputText { - text: "fresh developer instructions".to_string(), + 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, From 26273adc37c687b5c664343ecd57deb085bc522e Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Wed, 4 Feb 2026 19:52:25 -0800 Subject: [PATCH 07/11] Update reconstruct-history expectation for context reinjection order --- codex-rs/core/src/codex.rs | 43 ++++++++++++-------------------------- 1 file changed, 13 insertions(+), 30 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index bcefcc41eb4..54e999b905f 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4679,18 +4679,19 @@ mod tests { #[tokio::test] async fn reconstruct_history_refreshes_developer_instructions_for_replacement_history() { let (session, turn_context) = make_session_and_context().await; + let summary_item = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "summary".to_string(), + }], + end_turn: None, + phase: None, + }; let rollout_items = vec![RolloutItem::Compacted(CompactedItem { message: String::new(), replacement_history: Some(vec![ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "summary".to_string(), - }], - end_turn: None, - phase: None, - }, + summary_item.clone(), ResponseItem::Message { id: None, role: "developer".to_string(), @@ -4721,27 +4722,9 @@ mod tests { }); assert!(!has_stale_message); - let summary_position = reconstructed.iter().position(|item| { - matches!( - item, - ResponseItem::Message { role, content, .. } - if role == "user" - && content.iter().any(|part| matches!( - part, - ContentItem::InputText { text } if text == "summary" - )) - ) - }); - let summary_position = summary_position.expect("summary should be present"); - let initial_context = session.build_initial_context(&turn_context).await; - let expected_developer_messages: Vec = initial_context - .into_iter() - .filter( - |item| matches!(item, ResponseItem::Message { role, .. } if role == "developer"), - ) - .collect(); - let actual_after_summary = reconstructed[summary_position + 1..].to_vec(); - assert_eq!(actual_after_summary, expected_developer_messages); + let mut expected = session.build_initial_context(&turn_context).await; + expected.push(summary_item); + assert_eq!(reconstructed, expected); } #[tokio::test] From 592feb01214d545a9e70f8e6a7e72dfd5395b7e8 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Wed, 4 Feb 2026 20:04:45 -0800 Subject: [PATCH 08/11] Add non-resume remote compact stale-instructions regression test --- codex-rs/core/tests/suite/compact_remote.rs | 104 ++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index a24db3d3711..10c68ce31fc 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -757,3 +757,107 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_compact_refreshes_stale_developer_instructions_without_resume() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = wiremock::MockServer::start().await; + let stale_developer_message = "STALE_DEVELOPER_INSTRUCTIONS_SHOULD_BE_REMOVED"; + + let mut builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(|config| { + config.features.enable(Feature::RemoteCompaction); + }); + let test = builder.build(&server).await?; + + let responses_mock = responses::mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + responses::ev_assistant_message("m1", "BASELINE_REPLY"), + responses::ev_completed("resp-1"), + ]), + responses::sse(vec![ + responses::ev_assistant_message("m2", "AFTER_COMPACT_REPLY"), + responses::ev_completed("resp-2"), + ]), + ], + ) + .await; + + let compacted_history = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "REMOTE_COMPACTED_SUMMARY".to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: stale_developer_message.to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Compaction { + encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), + }, + ]; + let compact_mock = responses::mount_compact_json_once( + &server, + serde_json::json!({ "output": compacted_history }), + ) + .await; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "start remote compact flow".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + test.codex.submit(Op::Compact).await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "after compact in same session".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + assert_eq!(compact_mock.requests().len(), 1); + let requests = responses_mock.requests(); + assert_eq!(requests.len(), 2, "expected two model requests"); + + let after_compact_body = requests[1].body_json().to_string(); + assert!( + !after_compact_body.contains(stale_developer_message), + "stale developer instructions should be removed immediately after compaction" + ); + assert!( + after_compact_body.contains(""), + "fresh developer instructions should be present after compaction" + ); + assert!( + after_compact_body.contains("REMOTE_COMPACTED_SUMMARY"), + "compacted summary should be present after compaction" + ); + + Ok(()) +} From 4868ee7f8a09b154b45fcb3979c1480d6bf14dc6 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Thu, 5 Feb 2026 16:01:56 -0800 Subject: [PATCH 09/11] nit --- codex-rs/core/src/codex.rs | 50 ++++++++++++-------------------------- 1 file changed, 16 insertions(+), 34 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 54e999b905f..9ca43a82e0d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1768,10 +1768,7 @@ impl Session { } RolloutItem::Compacted(compacted) => { if let Some(replacement) = &compacted.replacement_history { - history.replace( - self.process_compacted_history(turn_context, replacement.clone()) - .await, - ); + history.replace(replacement.clone()); } else { let user_messages = collect_user_messages(history.raw_items()); let rebuilt = compact::build_compacted_history( @@ -4677,7 +4674,7 @@ mod tests { } #[tokio::test] - async fn reconstruct_history_refreshes_developer_instructions_for_replacement_history() { + async fn reconstruct_history_uses_replacement_history_verbatim() { let (session, turn_context) = make_session_and_context().await; let summary_item = ResponseItem::Message { id: None, @@ -4688,43 +4685,28 @@ mod tests { end_turn: None, phase: None, }; + let replacement_history = vec![ + summary_item.clone(), + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "stale developer instructions".to_string(), + }], + end_turn: None, + phase: None, + }, + ]; let rollout_items = vec![RolloutItem::Compacted(CompactedItem { message: String::new(), - replacement_history: Some(vec![ - summary_item.clone(), - ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "stale developer instructions".to_string(), - }], - end_turn: None, - phase: None, - }, - ]), + replacement_history: Some(replacement_history.clone()), })]; let reconstructed = session .reconstruct_history_from_rollout(&turn_context, &rollout_items) .await; - let has_stale_message = reconstructed.iter().any(|item| { - matches!( - item, - ResponseItem::Message { role, content, .. } - if role == "developer" - && content.iter().any(|part| matches!( - part, - ContentItem::InputText { text } - if text == "stale developer instructions" - )) - ) - }); - assert!(!has_stale_message); - - let mut expected = session.build_initial_context(&turn_context).await; - expected.push(summary_item); - assert_eq!(reconstructed, expected); + assert_eq!(reconstructed, replacement_history); } #[tokio::test] From 5c50dd2f24bb3cb4ce93414bbdb444c4032a7b89 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Thu, 5 Feb 2026 16:22:04 -0800 Subject: [PATCH 10/11] Revert initial context trimming during compaction --- codex-rs/core/src/compact.rs | 201 +---------------------------------- 1 file changed, 1 insertion(+), 200 deletions(-) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 4b1eca3027b..ffc7beed1ea 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -9,7 +9,6 @@ use crate::codex::get_last_assistant_message_from_turn; use crate::error::CodexErr; use crate::error::Result as CodexResult; use crate::features::Feature; -use crate::instructions::UserInstructions; use crate::protocol::CompactedItem; use crate::protocol::EventMsg; use crate::protocol::TurnContextItem; @@ -32,20 +31,6 @@ use tracing::error; pub const SUMMARIZATION_PROMPT: &str = include_str!("../templates/compact/prompt.md"); pub const SUMMARY_PREFIX: &str = include_str!("../templates/compact/summary_prefix.md"); const COMPACT_USER_MESSAGE_MAX_TOKENS: usize = 20_000; -// Turn context messages are re-injected after compaction so the next turn sees -// canonical session state (permissions, environment context, instructions). -// -// Some of those entries can be very large (for example AGENTS.md or custom -// developer instructions), and their size is otherwise unbounded here. Without -// a cap, re-injection can dominate post-compaction history and quickly push -// token usage back toward auto-compaction thresholds. -// -// We therefore budget the reinjected context separately and drop only -// droppable context items (largest first) when needed. Using half of the -// existing compact user-message budget keeps this heuristic simple and local to -// compaction behavior. -const REINJECTED_INITIAL_CONTEXT_MAX_TOKENS: usize = COMPACT_USER_MESSAGE_MAX_TOKENS / 2; -const PERMISSIONS_INSTRUCTIONS_OPEN_TAG: &str = ""; pub(crate) fn should_use_remote_compact_task( session: &Session, @@ -264,7 +249,7 @@ pub(crate) fn process_compacted_history( ) -> Vec { compacted_history.retain(should_keep_compacted_history_item); - let initial_context = initial_context_for_reinjection(initial_context); + 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 @@ -305,65 +290,6 @@ fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { } } -fn initial_context_for_reinjection(initial_context: &[ResponseItem]) -> Vec { - let mut selected: Vec> = - initial_context.iter().cloned().map(Some).collect(); - let mut total_tokens: usize = initial_context - .iter() - .map(estimate_response_item_tokens) - .sum(); - if total_tokens <= REINJECTED_INITIAL_CONTEXT_MAX_TOKENS { - return initial_context.to_vec(); - } - - let mut droppable_items: Vec<(usize, usize)> = initial_context - .iter() - .enumerate() - .filter(|(_, item)| is_droppable_initial_context_item(item)) - .map(|(idx, item)| (idx, estimate_response_item_tokens(item))) - .collect(); - // Prefer dropping the largest droppable context chunks first. - droppable_items.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); - - for (idx, item_tokens) in droppable_items { - if total_tokens <= REINJECTED_INITIAL_CONTEXT_MAX_TOKENS { - break; - } - selected[idx] = None; - total_tokens = total_tokens.saturating_sub(item_tokens); - } - - selected.into_iter().flatten().collect() -} - -fn is_droppable_initial_context_item(item: &ResponseItem) -> bool { - let ResponseItem::Message { role, content, .. } = item else { - return false; - }; - // We keep permissions and environment context stable, and allow large - // instruction wrappers to be omitted since compaction can summarize them. - if role == "user" { - return UserInstructions::is_user_instructions(content); - } - if role == "developer" { - return !is_permissions_developer_message(content); - } - false -} - -fn is_permissions_developer_message(content: &[ContentItem]) -> bool { - let [ContentItem::InputText { text }] = content else { - return false; - }; - text.starts_with(PERMISSIONS_INSTRUCTIONS_OPEN_TAG) -} - -fn estimate_response_item_tokens(item: &ResponseItem) -> usize { - serde_json::to_string(item) - .map(|s| approx_token_count(&s)) - .unwrap_or_default() -} - pub(crate) fn build_compacted_history( initial_context: Vec, user_messages: &[String], @@ -843,131 +769,6 @@ mod tests { assert_eq!(refreshed, expected); } - #[test] - fn process_compacted_history_drops_largest_uncapped_context_items_to_fit_budget() { - let compacted_history = vec![ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "summary".to_string(), - }], - end_turn: None, - phase: None, - }]; - let oversized_user_instructions = format!( - "# AGENTS.md instructions for /repo\n\n\n{}\n", - "u".repeat(48_000) - ); - let medium_developer_instructions = "d".repeat(8_000); - let initial_context = vec![ - ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: "\nallowed\n" - .to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: oversized_user_instructions.clone(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: medium_developer_instructions.clone(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "\n /repo\n zsh\n".to_string(), - }], - end_turn: None, - phase: None, - }, - ]; - let initial_context_tokens: usize = initial_context - .iter() - .map(estimate_response_item_tokens) - .sum(); - assert!( - initial_context_tokens > REINJECTED_INITIAL_CONTEXT_MAX_TOKENS, - "test setup should exceed reinjected initial context budget" - ); - - let refreshed = process_compacted_history(compacted_history, &initial_context); - let reinjected_context_tokens: usize = refreshed - .iter() - .filter(|item| { - !matches!( - item, - ResponseItem::Message { role, content, .. } - if role == "user" - && content_items_to_text(content).as_deref() == Some("summary") - ) - }) - .map(estimate_response_item_tokens) - .sum(); - assert!( - reinjected_context_tokens <= REINJECTED_INITIAL_CONTEXT_MAX_TOKENS, - "re-injected context should respect budget" - ); - assert!( - refreshed.iter().any(|item| matches!( - item, - ResponseItem::Message { role, content, .. } - if role == "developer" - && content_items_to_text(content).as_deref() == Some( - "\nallowed\n" - ) - )), - "permissions instructions should always be re-injected" - ); - assert!( - refreshed.iter().any(|item| matches!( - item, - ResponseItem::Message { role, content, .. } - if role == "user" - && content_items_to_text(content).as_deref() == Some( - "\n /repo\n zsh\n" - ) - )), - "environment context should always be re-injected" - ); - assert!( - refreshed.iter().all(|item| !matches!( - item, - ResponseItem::Message { role, content, .. } - if role == "user" - && content_items_to_text(content).as_deref() - == Some(oversized_user_instructions.as_str()) - )), - "largest droppable context item should be removed first" - ); - assert!( - refreshed.iter().any(|item| matches!( - item, - ResponseItem::Message { role, content, .. } - if role == "developer" - && content_items_to_text(content).as_deref() - == Some(medium_developer_instructions.as_str()) - )), - "smaller droppable context items should remain when budget allows" - ); - } - #[test] fn process_compacted_history_drops_non_user_content_messages() { let compacted_history = vec![ From c155f9cc306d4fd994b32c3e360e9c986a5e0a86 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Thu, 5 Feb 2026 17:33:39 -0800 Subject: [PATCH 11/11] Insert turn context before last real user message --- codex-rs/core/src/compact.rs | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index ffc7beed1ea..8d5787a9b42 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -254,10 +254,12 @@ pub(crate) fn process_compacted_history( // 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!(item, ResponseItem::Message { role, .. } if role == "user")) - { + 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); @@ -853,7 +855,7 @@ mod tests { } #[test] - fn process_compacted_history_inserts_context_before_last_user_message_only() { + fn process_compacted_history_inserts_context_before_last_real_user_message_only() { let compacted_history = vec![ ResponseItem::Message { id: None, @@ -864,6 +866,15 @@ mod tests { 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(), @@ -895,6 +906,15 @@ mod tests { 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(),