Preserve annotations when omitting unsupported media (#40277)

## What changed

- Render omitted image and audio inputs as contextual fragments tagged with
  `images.unsupported` and `audio.unsupported` content kinds.
- Normalize message media through annotated content so metadata for surrounding
  content remains intact when switching to a model without media support.

## Testing

- Extend history and model-switching tests to verify content-kind annotations
  before and after unsupported media is replaced.

GitOrigin-RevId: 4ee6577f8b78bd5a850ee01ea9d4590c20272850
This commit is contained in:
pakrym-oai
2026-08-23 20:14:26 +00:00
committed by copyberry
parent 287587c32c
commit aec653daa9
6 changed files with 115 additions and 70 deletions
+2
View File
@@ -38,6 +38,7 @@ mod rollout_budget;
mod subagent_notification;
mod token_budget_context;
mod turn_aborted;
mod unsupported_media;
mod user_instructions;
mod user_shell_command;
pub(crate) mod world_state;
@@ -98,6 +99,7 @@ pub(crate) use token_budget_context::TokenBudgetContext;
pub(crate) use token_budget_context::TokenBudgetRemainingContext;
pub(crate) use token_budget_context::TokenBudgetReminder;
pub(crate) use turn_aborted::TurnAborted;
pub(crate) use unsupported_media::UnsupportedMedia;
pub(crate) use user_instructions::UserInstructions;
pub(crate) use user_shell_command::UserShellCommand;
pub(crate) use world_state::ManagedDeveloperInstructions;
@@ -0,0 +1,42 @@
use super::ContextualUserFragment;
use codex_protocol::models::ContentItemKind;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct UnsupportedMedia {
text: &'static str,
content_kind: &'static str,
}
impl UnsupportedMedia {
pub(crate) const IMAGE: Self = Self {
text: "image content omitted because you do not support image input",
content_kind: "images.unsupported",
};
pub(crate) const AUDIO: Self = Self {
text: "audio content omitted because you do not support audio input",
content_kind: "audio.unsupported",
};
}
impl ContextualUserFragment for UnsupportedMedia {
fn content_kind(&self) -> ContentItemKind {
ContentItemKind(self.content_kind.to_string())
}
fn role(&self) -> &'static str {
"user"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("", "")
}
fn body(&self) -> String {
self.text.to_string()
}
}
@@ -46,6 +46,13 @@ const EXEC_FORMAT_MAX_BYTES: usize = 10_000;
const EXEC_FORMAT_MAX_TOKENS: usize = 2_500;
const TEST_WAV_SAMPLE_RATE: u32 = 8_000;
fn unknown_content_metadata() -> InternalChatMessageMetadataPassthrough {
InternalChatMessageMetadataPassthrough {
content_item_kinds: Some(vec![ContentItemKind("unknown".to_string())]),
..Default::default()
}
}
fn pcm_wav_data_url(sample_count: u32) -> (String, usize) {
let padding = sample_count % 2;
let mut bytes = Vec::new();
@@ -78,7 +85,7 @@ fn assistant_msg(text: &str) -> ResponseItem {
text: text.to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
internal_chat_message_metadata_passthrough: Some(unknown_content_metadata()),
}
}
@@ -97,7 +104,7 @@ fn inter_agent_assistant_msg(text: &str) -> ResponseItem {
text: serde_json::to_string(&communication).unwrap(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
internal_chat_message_metadata_passthrough: Some(unknown_content_metadata()),
}
}
@@ -257,7 +264,7 @@ fn user_msg(text: &str) -> ResponseItem {
text: text.to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
internal_chat_message_metadata_passthrough: Some(unknown_content_metadata()),
}
}
@@ -269,7 +276,7 @@ fn user_input_text_msg(text: &str) -> ResponseItem {
text: text.to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
internal_chat_message_metadata_passthrough: Some(unknown_content_metadata()),
}
}
@@ -281,7 +288,7 @@ fn developer_msg(text: &str) -> ResponseItem {
text: text.to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
internal_chat_message_metadata_passthrough: Some(unknown_content_metadata()),
}
}
@@ -296,7 +303,10 @@ fn developer_msg_with_fragments(texts: &[&str]) -> ResponseItem {
})
.collect(),
phase: None,
internal_chat_message_metadata_passthrough: None,
internal_chat_message_metadata_passthrough: Some(InternalChatMessageMetadataPassthrough {
content_item_kinds: Some(vec![ContentItemKind("unknown".to_string()); texts.len()]),
..Default::default()
}),
}
}
@@ -397,41 +407,7 @@ fn filters_non_api_messages() {
let a = assistant_msg("hello");
h.record_items([&u, &a], policy);
let items = raw_items(&h);
assert_eq!(
items,
vec![
ResponseItem::Reasoning {
id: None,
summary: vec![ReasoningItemReasoningSummary::SummaryText {
text: "summary".to_string(),
}],
content: Some(vec![ReasoningItemContent::ReasoningText {
text: "thinking...".to_string(),
}]),
encrypted_content: None,
internal_chat_message_metadata_passthrough: None,
},
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::OutputText {
text: "hi".to_string()
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
},
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "hello".to_string()
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
}
]
);
assert_eq!(raw_items(&h), vec![reasoning, u, a]);
}
#[test]
@@ -680,7 +656,17 @@ fn for_prompt_strips_media_when_model_does_not_support_it() {
},
],
phase: None,
internal_chat_message_metadata_passthrough: None,
internal_chat_message_metadata_passthrough: Some(
InternalChatMessageMetadataPassthrough {
content_item_kinds: Some(vec![
ContentItemKind("user.text".to_string()),
ContentItemKind("user.image".to_string()),
ContentItemKind("user.audio".to_string()),
ContentItemKind("user.text".to_string()),
]),
..Default::default()
},
),
},
ResponseItem::FunctionCall {
id: None,
@@ -764,7 +750,17 @@ fn for_prompt_strips_media_when_model_does_not_support_it() {
},
],
phase: None,
internal_chat_message_metadata_passthrough: None,
internal_chat_message_metadata_passthrough: Some(
InternalChatMessageMetadataPassthrough {
content_item_kinds: Some(vec![
ContentItemKind("user.text".to_string()),
ContentItemKind("images.unsupported".to_string()),
ContentItemKind("audio.unsupported".to_string()),
ContentItemKind("user.text".to_string()),
]),
..Default::default()
},
),
},
ResponseItem::FunctionCall {
id: None,
@@ -867,7 +863,7 @@ fn for_prompt_strips_media_when_model_does_not_support_it() {
audio_url: "data:audio/wav;base64,YXVkaW8=".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
internal_chat_message_metadata_passthrough: Some(unknown_content_metadata()),
};
let with_audio = create_history_with_items(vec![audio_message.clone()]);
assert_eq!(
@@ -914,7 +910,7 @@ fn for_prompt_preserves_image_generation_calls_when_images_are_supported() {
text: "hi".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
internal_chat_message_metadata_passthrough: Some(unknown_content_metadata()),
}
]
);
@@ -951,7 +947,7 @@ fn for_prompt_clears_image_generation_result_when_images_are_unsupported() {
text: "generate a lobster".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
internal_chat_message_metadata_passthrough: Some(unknown_content_metadata()),
},
ResponseItem::ImageGenerationCall {
id: Some(ResponseItemId::with_suffix("ig", "123")),
+22 -23
View File
@@ -1,3 +1,5 @@
use codex_context_fragments::set_annotated_content;
use codex_context_fragments::to_annotated_content;
use codex_history::ResponseItemEnvelope;
use codex_protocol::ResponseItemId;
use codex_protocol::models::ContentItem;
@@ -8,13 +10,11 @@ use codex_protocol::openai_models::InputModality;
use std::collections::HashSet;
use uuid::Uuid;
use crate::context::ContextualUserFragment;
use crate::context::UnsupportedMedia;
use crate::util::error_or_panic;
use tracing::info;
const IMAGE_CONTENT_OMITTED_PLACEHOLDER: &str =
"image content omitted because you do not support image input";
const AUDIO_CONTENT_OMITTED_PLACEHOLDER: &str =
"audio content omitted because you do not support audio input";
// Changing this value would change model-visible IDs and invalidate prompt caches.
const SYNTHETIC_OUTPUT_ID_NAMESPACE: Uuid = Uuid::from_u128(0x90d38d3e_6a5b_4d52_bfe2_2f1e634bfac4);
@@ -338,19 +338,16 @@ pub(crate) fn strip_images_when_unsupported(
for envelope in items.iter_mut() {
match &mut envelope.item {
ResponseItem::Message { content, .. } => {
let mut normalized_content = Vec::with_capacity(content.len());
for content_item in content.iter() {
match content_item {
ContentItem::InputImage { .. } => {
normalized_content.push(ContentItem::InputText {
text: IMAGE_CONTENT_OMITTED_PLACEHOLDER.to_string(),
});
}
_ => normalized_content.push(content_item.clone()),
ResponseItem::Message { .. } => {
let Some(mut content) = to_annotated_content(&mut envelope.item) else {
continue;
};
for content_item in &mut content {
if matches!(content_item.content(), ContentItem::InputImage { .. }) {
*content_item = UnsupportedMedia::IMAGE.render_fragment().into_parts().1;
}
}
*content = normalized_content;
let _ = set_annotated_content(&mut envelope.item, content);
}
ResponseItem::FunctionCallOutput { output, .. }
| ResponseItem::CustomToolCallOutput { output, .. } => {
@@ -361,7 +358,7 @@ pub(crate) fn strip_images_when_unsupported(
FunctionCallOutputContentItem::InputImage { .. } => {
normalized_content_items.push(
FunctionCallOutputContentItem::InputText {
text: IMAGE_CONTENT_OMITTED_PLACEHOLDER.to_string(),
text: UnsupportedMedia::IMAGE.render(),
},
);
}
@@ -391,14 +388,16 @@ pub(crate) fn strip_audio_when_unsupported(
for envelope in items.iter_mut() {
match &mut envelope.item {
ResponseItem::Message { content, .. } => {
for content_item in content.iter_mut() {
if matches!(content_item, ContentItem::InputAudio { .. }) {
*content_item = ContentItem::InputText {
text: AUDIO_CONTENT_OMITTED_PLACEHOLDER.to_string(),
};
ResponseItem::Message { .. } => {
let Some(mut content) = to_annotated_content(&mut envelope.item) else {
continue;
};
for content_item in &mut content {
if matches!(content_item.content(), ContentItem::InputAudio { .. }) {
*content_item = UnsupportedMedia::AUDIO.render_fragment().into_parts().1;
}
}
let _ = set_annotated_content(&mut envelope.item, content);
}
ResponseItem::FunctionCallOutput { output, .. }
| ResponseItem::CustomToolCallOutput { output, .. } => {
@@ -409,7 +408,7 @@ pub(crate) fn strip_audio_when_unsupported(
FunctionCallOutputContentItem::InputAudio { .. }
) {
*content_item = FunctionCallOutputContentItem::InputText {
text: AUDIO_CONTENT_OMITTED_PLACEHOLDER.to_string(),
text: UnsupportedMedia::AUDIO.render(),
};
}
}
+1 -1
View File
@@ -11312,7 +11312,7 @@ async fn sample_rollout(
(
rollout_items,
live_history.for_prompt(&reconstruction_turn.model_info.input_modalities),
strip_metadata_from_items(&raw_history_items(&live_history)),
)
}
@@ -992,6 +992,7 @@ async fn model_change_from_multimodal_to_text_strips_prior_media_content() -> Re
assert_eq!(requests.len(), 2, "expected two model requests");
let first_request = requests.first().expect("expected first request");
assert!(first_request.has_content_kinds(&["user.image", "user.audio", "user.text"]));
assert!(
!first_request.message_input_image_urls("user").is_empty(),
"first request should include the uploaded image"
@@ -1002,6 +1003,11 @@ async fn model_change_from_multimodal_to_text_strips_prior_media_content() -> Re
);
let second_request = requests.last().expect("expected second request");
assert!(second_request.has_content_kinds(&[
"images.unsupported",
"audio.unsupported",
"user.text",
]));
assert!(
second_request.message_input_image_urls("user").is_empty(),
"second request should strip unsupported image content"