From 143daadb318853b10c81d40b43e2b976fe14a4d1 Mon Sep 17 00:00:00 2001 From: Charley Cunningham Date: Fri, 6 Feb 2026 12:25:08 -0800 Subject: [PATCH] core: refresh developer instructions after compaction replacement history (#10574) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary When replaying compacted history (especially `replacement_history` from remote compaction), we should not keep stale developer messages from older session state. This PR trims developer- role messages from compacted replacement history and reinjects fresh developer instructions derived from current turn/session state. This aligns compaction replay behavior with the intended "fresh instructions after summary" model. ## Problem Compaction replay had two paths: - `Compacted { replacement_history: None }`: rebuilt with fresh initial context - `Compacted { replacement_history: Some(...) }`: previously used raw replacement history as-is The second path could carry stale developer instructions (permissions/personality/collab-mode guidance) across session changes. ## What Changed ### 1) Added helper to refresh compacted developer instructions - **File:** `codex-rs/core/src/compact.rs` - **Function:** `refresh_compacted_developer_instructions(...)` Behavior: - remove all `ResponseItem::Message { role: "developer", .. }` from compacted history - append fresh developer messages from current `build_initial_context(...)` ### 2) Applied helper in remote compaction flow - **File:** `codex-rs/core/src/compact_remote.rs` - After receiving compact endpoint output, refresh developer instructions before replacing history and persisting `replacement_history`. ### 3) Applied helper while reconstructing history from rollout - **File:** `codex-rs/core/src/codex.rs` - In `reconstruct_history_from_rollout(...)`, when processing `Compacted` entries with `replacement_history`, refresh developer instructions instead of directly replacing with raw history. ## Non-Goals / Follow-up This PR does **not** address the existing first-turn-after-resume double-injection behavior. A follow-up PR will handle resume-time dedup/idempotence separately. If you want, I can also give you a shorter “squash-merge friendly” version of the description. ## Codex author `codex fork 019c25e6-706e-75d1-9198-688ec00a8256` --- codex-rs/core/src/codex.rs | 45 ++ codex-rs/core/src/compact.rs | 443 ++++++++++++++++++-- codex-rs/core/src/compact_remote.rs | 3 + codex-rs/core/tests/suite/compact_remote.rs | 316 +++++++++++++- 4 files changed, 761 insertions(+), 46 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 2dd6df9a5..9bfe3469f 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -2025,6 +2025,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, @@ -5074,6 +5083,42 @@ mod tests { assert_eq!(expected, reconstructed); } + #[tokio::test] + async fn reconstruct_history_uses_replacement_history_verbatim() { + 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 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(replacement_history.clone()), + })]; + + let reconstructed = session + .reconstruct_history_from_rollout(&turn_context, &rollout_items) + .await; + + assert_eq!(reconstructed, replacement_history); + } + #[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 72e002d45..bbf5700b9 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; @@ -243,35 +242,64 @@ 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()) } +pub(crate) fn process_compacted_history( + mut compacted_history: Vec, + initial_context: &[ResponseItem], +) -> Vec { + 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); + } + + 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, + 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], @@ -391,7 +419,6 @@ async fn drain_to_completed( mod tests { use super::*; - use crate::session_prefix::TURN_ABORTED_OPEN_TAG; use pretty_assertions::assert_eq; #[test] @@ -556,16 +583,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, @@ -574,25 +598,358 @@ 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 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 user_messages = collect_user_messages(&items); - let history = build_compacted_history(Vec::new(), &user_messages, "SUMMARY"); + 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 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, + }, + 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); + } - 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_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: "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, + }, + 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_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(), + 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 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: "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); + 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); } } diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index f627bcb47..8aeeb4016 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -90,6 +90,9 @@ async fn run_remote_compact_task_inner_impl( &turn_context.otel_manager, ) .await?; + 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 a2ae58cf2..75319e42e 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -736,10 +736,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; + } } } @@ -750,3 +798,265 @@ 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(()) +} + +#[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(()) +}