From 0c9c7338de84215a60d763608bf8925eb1912ed6 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Wed, 4 Mar 2026 11:58:08 -0800 Subject: [PATCH] Retain reasoning in remote compaction history Keep reasoning items in remote compaction replacement history so assistant hidden messages do not lose their required predecessor. Add unit coverage for the filter and a remote compaction regression test. Co-authored-by: Codex --- codex-rs/core/src/compact_remote.rs | 43 ++++++++- codex-rs/core/tests/suite/compact.rs | 127 +++++++++++++++++++++++++++ 2 files changed, 168 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 40c520f659..a643d3c4ff 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -184,6 +184,8 @@ pub(crate) async fn process_compacted_history( /// keeping only real user messages as parsed by `parse_turn_item`. /// /// This intentionally keeps: +/// - `reasoning` items because assistant hidden messages can require the +/// immediately preceding reasoning item on the next request. /// - `assistant` messages (future remote compaction models may emit them) /// - `user`-role warnings and compaction-generated summary messages because /// they parse as `TurnItem::UserMessage`. @@ -197,10 +199,10 @@ fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { ) } ResponseItem::Message { role, .. } if role == "assistant" => true, + ResponseItem::Reasoning { .. } => true, ResponseItem::Message { .. } => false, ResponseItem::Compaction { .. } => true, - ResponseItem::Reasoning { .. } - | ResponseItem::LocalShellCall { .. } + ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::FunctionCallOutput { .. } | ResponseItem::CustomToolCall { .. } @@ -281,3 +283,40 @@ fn trim_function_call_history_to_fit_context_window( deleted_items } + +#[cfg(test)] +mod tests { + use super::should_keep_compacted_history_item; + use codex_protocol::models::ContentItem; + use codex_protocol::models::ReasoningItemReasoningSummary; + use codex_protocol::models::ResponseItem; + + #[test] + fn preserves_reasoning_items_from_remote_compaction_output() { + let reasoning = ResponseItem::Reasoning { + id: "reasoning-1".to_string(), + summary: vec![ReasoningItemReasoningSummary::SummaryText { + text: "summary".to_string(), + }], + content: None, + encrypted_content: Some("encrypted".to_string()), + }; + + assert!(should_keep_compacted_history_item(&reasoning)); + } + + #[test] + fn preserves_assistant_messages_from_remote_compaction_output() { + let assistant = ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "assistant summary".to_string(), + }], + end_turn: None, + phase: None, + }; + + assert!(should_keep_compacted_history_item(&assistant)); + } +} diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index bb34789499..d0ee478579 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1688,6 +1688,133 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_compaction_replacement_history_preserves_reasoning_items() { + skip_if_no_network!(); + + let server = start_mock_server().await; + + let first_user = "REMOTE_REASONING_USER_ONE"; + let second_user = "REMOTE_REASONING_USER_TWO"; + let third_user = "REMOTE_REASONING_USER_THREE"; + let remote_reasoning_summary = "REMOTE_COMPACT_REASONING"; + let remote_summary = "REMOTE_COMPACT_SUMMARY"; + let pre_last_reasoning_content = "a".repeat(2_400); + let post_last_reasoning_content = "b".repeat(4_000); + + let first_turn = sse(vec![ + ev_reasoning_item("pre-reasoning", &["pre"], &[&pre_last_reasoning_content]), + ev_completed_with_tokens("r1", 10), + ]); + let second_turn = sse(vec![ + ev_reasoning_item("post-reasoning", &["post"], &[&post_last_reasoning_content]), + ev_completed_with_tokens("r2", 80), + ]); + let third_turn = sse(vec![ + ev_assistant_message("m3", FINAL_REPLY), + ev_completed_with_tokens("r3", 1), + ]); + + let request_log = mount_sse_sequence(&server, vec![first_turn, second_turn, third_turn]).await; + + let compacted_history = vec![ + codex_protocol::models::ResponseItem::Reasoning { + id: "remote-reasoning".to_string(), + summary: vec![ + codex_protocol::models::ReasoningItemReasoningSummary::SummaryText { + text: remote_reasoning_summary.to_string(), + }, + ], + content: None, + encrypted_content: Some("ENCRYPTED_REMOTE_REASONING".to_string()), + }, + codex_protocol::models::ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![codex_protocol::models::ContentItem::OutputText { + text: remote_summary.to_string(), + }], + end_turn: None, + phase: None, + }, + codex_protocol::models::ResponseItem::Compaction { + encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), + }, + ]; + let compact_mock = + mount_compact_json_once(&server, serde_json::json!({ "output": compacted_history })).await; + + let codex = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(|config| { + set_test_compact_prompt(config); + config.model_auto_compact_token_limit = Some(300); + }) + .build(&server) + .await + .expect("build codex") + .codex; + + for (idx, user) in [first_user, second_user, third_user] + .into_iter() + .enumerate() + { + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: user.to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await + .expect("submit user"); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + if idx < 2 { + assert!( + compact_mock.requests().is_empty(), + "remote compaction should not run before the third user turn" + ); + } + } + + let compact_requests = compact_mock.requests(); + assert_eq!( + compact_requests.len(), + 1, + "expected a single remote compaction request" + ); + assert_eq!( + compact_requests[0].path(), + "/v1/responses/compact", + "remote compaction should hit the compact endpoint" + ); + + let requests = request_log.requests(); + assert_eq!(requests.len(), 3, "expected three user turns"); + + let second_request_body = requests[1].body_json().to_string(); + assert!( + !body_contains_text(&second_request_body, remote_reasoning_summary), + "second turn should not include compacted remote reasoning yet" + ); + assert!( + !body_contains_text(&second_request_body, remote_summary), + "second turn should not include compacted remote assistant history yet" + ); + + let third_request_body = requests[2].body_json().to_string(); + assert!( + body_contains_text(&third_request_body, remote_reasoning_summary), + "third turn should preserve remote reasoning items in replacement history" + ); + assert!( + body_contains_text(&third_request_body, remote_summary), + "third turn should preserve remote assistant summaries in replacement history" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { skip_if_no_network!();