Keep real user messages in inline compaction

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Charles Cunningham
2026-03-03 16:20:16 -08:00
parent 1f50631422
commit 857fb71ea1
2 changed files with 114 additions and 5 deletions

View File

@@ -93,6 +93,13 @@ async fn run_compact_task_inner(
input: Vec<UserInput>,
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);

View File

@@ -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<String> = 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))]