From 857fb71ea1d299ea42f25389f1042650a4a280ea Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Tue, 3 Mar 2026 16:20:16 -0800 Subject: [PATCH] Keep real user messages in inline compaction Co-authored-by: Codex --- codex-rs/core/src/compact.rs | 22 +++++-- codex-rs/core/tests/suite/compact.rs | 97 ++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 5 deletions(-) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 6e9e6ea978..57363eca59 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -93,6 +93,13 @@ async fn run_compact_task_inner( input: Vec, initial_context_injection: InitialContextInjection, ) -> CodexResult<()> { + let has_synthetic_compact_prompt = matches!( + input.as_slice(), + [UserInput::Text { + text, + text_elements, + }] if text == turn_context.compact_prompt() && text_elements.is_empty() + ); let compaction_item = TurnItem::ContextCompaction(ContextCompactionItem::new()); sess.emit_turn_item_started(&turn_context, &compaction_item) .await; @@ -195,11 +202,16 @@ async fn run_compact_task_inner( }; let summary_text = format!("{SUMMARY_PREFIX}\n{summary_suffix}"); let mut user_messages = collect_user_messages(compaction_history_items); - // Local compaction appends one synthetic user prompt for the compaction model call. Rebuild - // from the local prompt history so we can drop only that trailing synthetic input and preserve - // any earlier real user message even if its text happens to equal the configured compact - // prompt. - user_messages.pop(); + if has_synthetic_compact_prompt + && user_messages + .last() + .is_some_and(|message| message == turn_context.compact_prompt()) + { + // Local inline compaction appends one synthetic user prompt for the compaction model call. + // Rebuild from the local prompt history so we can drop only that trailing synthetic input + // and preserve earlier real user messages, including ones whose text matches the prompt. + user_messages.pop(); + } let mut new_history = build_compacted_history(Vec::new(), &user_messages, &summary_text); diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index a62e4f86ad..b7d18388bf 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1476,6 +1476,103 @@ async fn auto_compact_runs_after_token_limit_hit() { ); } +// Windows CI only: bump to 4 workers to prevent SSE/event starvation and test timeouts. +#[cfg_attr(windows, tokio::test(flavor = "multi_thread", worker_threads = 4))] +#[cfg_attr(not(windows), tokio::test(flavor = "multi_thread", worker_threads = 2))] +async fn auto_compact_keeps_last_real_user_message_when_prompt_is_summary_shaped() { + skip_if_no_network!(); + + let server = start_mock_server().await; + + let sse1 = sse(vec![ + ev_assistant_message("m1", FIRST_REPLY), + ev_completed_with_tokens("r1", 70_000), + ]); + let sse2 = sse(vec![ + ev_assistant_message("m2", "SECOND_REPLY"), + ev_completed_with_tokens("r2", 330_000), + ]); + let sse3 = sse(vec![ + ev_assistant_message("m3", AUTO_SUMMARY_TEXT), + ev_completed_with_tokens("r3", 200), + ]); + let sse4 = sse(vec![ + ev_assistant_message("m4", FINAL_REPLY), + ev_completed_with_tokens("r4", 120), + ]); + + let request_log = mount_sse_sequence(&server, vec![sse1, sse2, sse3, sse4]).await; + + let model_provider = non_openai_model_provider(&server); + let summary_shaped_prompt = format!("{SUMMARY_PREFIX}\ncustom compact prompt"); + let compact_prompt = summary_shaped_prompt.clone(); + let mut builder = test_codex().with_config(move |config| { + config.model_provider = model_provider; + config.compact_prompt = Some(summary_shaped_prompt); + config.model_auto_compact_token_limit = Some(200_000); + }); + let codex = builder.build(&server).await.unwrap().codex; + + for user in [FIRST_AUTO_MSG, SECOND_AUTO_MSG, POST_AUTO_USER_MSG] { + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: user.into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await + .unwrap(); + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + } + + let requests = request_log.requests(); + let follow_up = requests + .iter() + .rev() + .find(|request| { + let body = request.body_json().to_string(); + body.contains(POST_AUTO_USER_MSG) && !body_contains_text(&body, &compact_prompt) + }) + .expect("follow-up request missing"); + + let follow_up_body = follow_up.body_json(); + let input_follow_up = follow_up_body + .get("input") + .and_then(|v| v.as_array()) + .expect("follow-up input"); + let user_texts: Vec = input_follow_up + .iter() + .filter(|item| item.get("type").and_then(|v| v.as_str()) == Some("message")) + .filter(|item| item.get("role").and_then(|v| v.as_str()) == Some("user")) + .filter_map(|item| { + item.get("content") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|entry| entry.get("text")) + .and_then(|v| v.as_str()) + .map(std::string::ToString::to_string) + }) + .collect(); + + assert!( + user_texts.iter().any(|text| text == SECOND_AUTO_MSG), + "summary-shaped compact prompts must not drop the last real user message" + ); + assert!( + user_texts.iter().any(|text| text == POST_AUTO_USER_MSG), + "follow-up request should include the new user message" + ); + assert!( + user_texts + .iter() + .any(|text| text.contains(AUTO_SUMMARY_TEXT)), + "follow-up request should include the compacted summary" + ); +} + // Windows CI only: bump to 4 workers to prevent SSE/event starvation and test timeouts. #[cfg_attr(windows, tokio::test(flavor = "multi_thread", worker_threads = 4))] #[cfg_attr(not(windows), tokio::test(flavor = "multi_thread", worker_threads = 2))]