Preserve content annotations when rolling back model switches (#40271)

## What changed

- Filter rolled-back model-switch instructions through annotated content so the
  surviving developer fragments retain their matching content-kind metadata.
- Add regression coverage for annotation preservation and request-level metadata
  alignment after rolling back a first-turn model change.

GitOrigin-RevId: 42a3c0cf4c5727bc6fe40990254467f40f4686f9
This commit is contained in:
pakrym-oai
2026-08-23 19:33:54 +00:00
committed by copyberry
parent 184ff338ad
commit bd0d4a23e3
3 changed files with 84 additions and 4 deletions
+8 -4
View File
@@ -9,6 +9,8 @@ use crate::event_mapping::is_contextual_user_message_content;
use crate::session::turn_context::TurnContext;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use codex_context_fragments::set_annotated_content;
use codex_context_fragments::to_annotated_content;
use codex_extension_api::ConversationHistorySnapshot;
use codex_history::CodexHarnessMetadata;
use codex_history::ResponseItemEnvelope;
@@ -345,17 +347,19 @@ impl ContextManager {
{
retained_items.retain_mut(|item| {
if item.turn_id() == Some(first_turn_id)
&& let ResponseItem::Message { role, content, .. } = &mut item.item
&& role == "developer"
&& matches!(&item.item, ResponseItem::Message { role, .. } if role == "developer")
{
let Some(mut content) = to_annotated_content(&mut item.item) else {
return false;
};
content.retain(|content| {
!matches!(
content,
content.content(),
ContentItem::InputText { text }
if ModelSwitchInstructions::matches_text(text)
)
});
!content.is_empty()
!content.is_empty() && set_annotated_content(&mut item.item, content).is_some()
} else {
true
}
@@ -11,6 +11,7 @@ use codex_protocol::AgentPath;
use codex_protocol::ResponseItemId;
use codex_protocol::models::BaseInstructions;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ContentItemKind;
use codex_protocol::models::DEFAULT_IMAGE_DETAIL;
use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::FunctionCallOutputContentItem;
@@ -1255,6 +1256,67 @@ fn drop_last_n_user_turns_trims_context_updates_above_rolled_back_turn() {
);
}
#[test]
fn drop_last_n_user_turns_preserves_annotations_for_surviving_developer_fragments() {
let turn_id = "rolled-back-turn";
let model_switch = ModelSwitchInstructions::new("switched model instructions").render();
let developer_message = ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![
ContentItem::InputText {
text: "persistent developer instructions".to_string(),
},
ContentItem::InputText { text: model_switch },
ContentItem::InputText {
text: "persistent environment instructions".to_string(),
},
],
phase: None,
internal_chat_message_metadata_passthrough: Some(InternalChatMessageMetadataPassthrough {
turn_id: Some(turn_id.to_string()),
content_item_kinds: Some(vec![
ContentItemKind("generic.developer_instructions".to_string()),
ContentItemKind("model_switch.instructions".to_string()),
ContentItemKind("environments.instructions".to_string()),
]),
..Default::default()
}),
};
let mut user_message = user_input_text_msg("turn to roll back");
user_message.set_turn_id_if_missing(turn_id);
let mut history = create_history_with_items(vec![developer_message, user_message]);
history.drop_last_n_user_turns(/*num_turns*/ 1);
assert_eq!(
raw_items(&history),
vec![ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![
ContentItem::InputText {
text: "persistent developer instructions".to_string(),
},
ContentItem::InputText {
text: "persistent environment instructions".to_string(),
},
],
phase: None,
internal_chat_message_metadata_passthrough: Some(
InternalChatMessageMetadataPassthrough {
turn_id: Some(turn_id.to_string()),
content_item_kinds: Some(vec![
ContentItemKind("generic.developer_instructions".to_string()),
ContentItemKind("environments.instructions".to_string()),
]),
..Default::default()
},
),
}],
);
}
#[test]
fn drop_last_n_user_turns_trims_saved_prefix_update_above_rolled_back_turn() {
let items = vec![
@@ -345,6 +345,20 @@ async fn rollback_first_turn_model_change_removes_its_instructions(
let request = &response_mock.requests()[1];
assert_eq!(request.body_json()["model"], followup_model);
let misaligned_messages = request
.inputs_of_type("message")
.into_iter()
.filter(|message| {
message["internal_chat_message_metadata_passthrough"]["content_item_kinds"]
.as_array()
.is_some_and(|kinds| {
message["content"]
.as_array()
.is_none_or(|content| content.len() != kinds.len())
})
})
.collect::<Vec<_>>();
assert_eq!(misaligned_messages, Vec::<serde_json::Value>::new());
let model_switch_count = request
.message_input_texts("developer")
.iter()