From 55b9ad149030769f911e6494a84d304bd90fd8c5 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Fri, 13 Mar 2026 13:43:16 -0700 Subject: [PATCH] Preserve empty user turns during contextual stripping Co-authored-by: Codex --- codex-rs/core/src/compact_tests.rs | 29 ++++++++++++++++++++++++ codex-rs/core/src/event_mapping.rs | 5 ++-- codex-rs/core/src/event_mapping_tests.rs | 17 ++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/compact_tests.rs b/codex-rs/core/src/compact_tests.rs index 6f1380420b..5acb2080df 100644 --- a/codex-rs/core/src/compact_tests.rs +++ b/codex-rs/core/src/compact_tests.rs @@ -150,6 +150,35 @@ fn collect_user_messages_filters_ephemeral_context_entries() { assert_eq!(vec!["real user message".to_string()], collected); } +#[test] +fn collect_user_messages_preserves_empty_user_turns() { + let items = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: Vec::new(), + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "real user message".to_string(), + }], + end_turn: None, + phase: None, + }, + ]; + + let collected = collect_user_messages(&items); + + assert_eq!( + vec![String::new(), "real user message".to_string()], + collected + ); +} + #[test] fn build_token_limited_compacted_history_truncates_overlong_user_messages() { // Use a small truncation limit so the test remains fast while still validating diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 672d296d83..c30801d5ba 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -32,14 +32,15 @@ fn strip_ephemeral_context_prefix(message: &[ContentItem]) -> &[ContentItem] { pub(crate) fn is_contextual_user_message_content(message: &[ContentItem]) -> bool { let stripped = strip_ephemeral_context_prefix(message); - stripped.is_empty() || stripped.iter().any(is_contextual_user_fragment) + (stripped.len() != message.len() && stripped.is_empty()) + || stripped.iter().any(is_contextual_user_fragment) } fn parse_user_message(message: &[ContentItem]) -> Option { - let message = strip_ephemeral_context_prefix(message); if is_contextual_user_message_content(message) { return None; } + let message = strip_ephemeral_context_prefix(message); let mut content: Vec = Vec::new(); diff --git a/codex-rs/core/src/event_mapping_tests.rs b/codex-rs/core/src/event_mapping_tests.rs index 4e82c1be55..daf6ca7e21 100644 --- a/codex-rs/core/src/event_mapping_tests.rs +++ b/codex-rs/core/src/event_mapping_tests.rs @@ -263,6 +263,23 @@ fn drops_user_message_when_only_ephemeral_context_prefix_is_present() { assert!(parse_turn_item(&item).is_none()); } +#[test] +fn parses_empty_user_message_as_user_turn() { + let item = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: Vec::new(), + end_turn: None, + phase: None, + }; + + let turn_item = parse_turn_item(&item).expect("expected empty user message turn item"); + let TurnItem::UserMessage(user_message) = turn_item else { + panic!("expected TurnItem::UserMessage"); + }; + assert_eq!(user_message.content, Vec::::new()); +} + #[test] fn parses_agent_message() { let item = ResponseItem::Message {