diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index f990a80dce..17e7e33c7d 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -333,7 +333,8 @@ impl ContextManager { /// This function enforces a couple of invariants on the in-memory history: /// 1. every call (function/custom) has a corresponding output entry /// 2. every output has a corresponding call entry - /// 3. when images are unsupported, image content is stripped from messages and tool outputs + /// 3. trailing reasoning items are dropped before the next user message and at prompt end + /// 4. when images are unsupported, image content is stripped from messages and tool outputs fn normalize_history(&mut self, input_modalities: &[InputModality]) { // all function/tool calls must have a corresponding output normalize::ensure_call_outputs_present(&mut self.items); @@ -341,6 +342,9 @@ impl ContextManager { // all outputs must have a corresponding function/tool call normalize::remove_orphan_outputs(&mut self.items); + // interrupted turns can leave replay-unsafe trailing reasoning before the next user item + normalize::remove_trailing_reasoning_before_user_messages(&mut self.items); + // strip images when model does not support them normalize::strip_images_when_unsupported(input_modalities, &mut self.items); } diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 71b3aded0c..b6bb257bbe 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -442,6 +442,59 @@ fn for_prompt_preserves_image_generation_calls_when_images_are_supported() { ); } +#[test] +fn for_prompt_drops_trailing_reasoning_before_next_user_message() { + let history = create_history_with_items(vec![ + user_input_text_msg("first"), + assistant_msg("done"), + reasoning_msg("orphaned"), + user_input_text_msg("second"), + ]); + + assert_eq!( + history.for_prompt(&default_input_modalities()), + vec![ + user_input_text_msg("first"), + assistant_msg("done"), + user_input_text_msg("second"), + ] + ); +} + +#[test] +fn for_prompt_drops_trailing_reasoning_at_prompt_end() { + let history = create_history_with_items(vec![ + user_input_text_msg("first"), + assistant_msg("done"), + reasoning_msg("orphaned"), + ]); + + assert_eq!( + history.for_prompt(&default_input_modalities()), + vec![user_input_text_msg("first"), assistant_msg("done"),] + ); +} + +#[test] +fn for_prompt_keeps_reasoning_when_followed_by_assistant_message() { + let history = create_history_with_items(vec![ + user_input_text_msg("first"), + reasoning_msg("kept"), + assistant_msg("done"), + user_input_text_msg("second"), + ]); + + assert_eq!( + history.for_prompt(&default_input_modalities()), + vec![ + user_input_text_msg("first"), + reasoning_msg("kept"), + assistant_msg("done"), + user_input_text_msg("second"), + ] + ); +} + #[test] fn for_prompt_clears_image_generation_result_when_images_are_unsupported() { let history = create_history_with_items(vec![ diff --git a/codex-rs/core/src/context_manager/normalize.rs b/codex-rs/core/src/context_manager/normalize.rs index 839bae331e..5bb67f5b5d 100644 --- a/codex-rs/core/src/context_manager/normalize.rs +++ b/codex-rs/core/src/context_manager/normalize.rs @@ -194,6 +194,33 @@ pub(crate) fn remove_orphan_outputs(items: &mut Vec) { }); } +pub(crate) fn remove_trailing_reasoning_before_user_messages(items: &mut Vec) { + let mut normalized = Vec::with_capacity(items.len()); + let mut removed_count = 0usize; + + for item in items.drain(..) { + if matches!(&item, ResponseItem::Message { role, .. } if role == "user") { + while matches!(normalized.last(), Some(ResponseItem::Reasoning { .. })) { + normalized.pop(); + removed_count += 1; + } + } + + normalized.push(item); + } + + while matches!(normalized.last(), Some(ResponseItem::Reasoning { .. })) { + normalized.pop(); + removed_count += 1; + } + + if removed_count > 0 { + info!("Removed {removed_count} trailing reasoning item(s) before user input or prompt end"); + } + + *items = normalized; +} + pub(crate) fn remove_corresponding_for(items: &mut Vec, item: &ResponseItem) { match item { ResponseItem::FunctionCall { call_id, .. } => {