Files
codex/codex-rs/core/src/image_preparation_tests.rs
T
tongzhou wang 1b6e0d8145 Track content provenance through prompt assembly and compaction (#53831)
## Why

Message roles and content classifications do not identify who supplied each context part. Source attribution needs to survive prompt assembly and history truncation, including messages that combine text from different producers.

## What changed

- Add per-part `ContentItemMetadata` for user input, developer instructions, `AGENTS.md`, skills, harness context, command hooks, and tool context with optional callable namespaces.
- Carry attribution alongside content and classifications through contextual fragments, extension prompts, hook output, and agent messages. Keep tool-provided context-window hints separate from harness text.
- Preserve the source of retained text slices during compaction and attribute generated truncation notices to the harness. Default missing historical attribution to unknown.
- Attribute host MCP calls using cached tool namespaces only when catalog revisions remain stable, without starting servers or refreshing catalogs for attribution.
- Send the new metadata to Responses Lite and strip it from requests to endpoints that do not support it.

## Testing

Extend compaction tests to cover mixed sources, missing or extra metadata entries, and discarded media. Update Responses Lite request assertions to verify endpoint-specific attribution handling.

GitOrigin-RevId: b0ebc929b7c60f3ec49d1affbf897312d63edcc8
2026-10-14 02:01:49 +00:00

671 lines
23 KiB
Rust

use std::io::Cursor;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use codex_attachment_store::AttachmentStoreError;
use codex_attachment_store::AttachmentStoreErrorKind;
use codex_attachment_store::InlineAttachmentStore;
use codex_attachment_store::ResolveFuture;
use codex_attachment_store::ResolveRequest;
use codex_attachment_store::UploadFuture;
use codex_protocol::models::ContentItemKind;
use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ImageReference;
use codex_protocol::models::InternalChatMessageMetadataPassthrough;
use codex_utils_image::data_url_from_bytes;
use image::DynamicImage;
use image::GenericImageView;
use image::ImageBuffer;
use image::ImageFormat;
use image::Rgba;
use pretty_assertions::assert_eq;
use serde_json::from_value;
use serde_json::json;
use super::*;
struct FailingAttachmentStore;
impl AttachmentStore for FailingAttachmentStore {
fn upload(&self, _request: UploadRequest) -> UploadFuture<'_> {
Box::pin(async {
Err(AttachmentStoreError::new(
AttachmentStoreErrorKind::Backend,
"upload failed",
))
})
}
fn resolve<'a>(&'a self, request: ResolveRequest<'a>) -> ResolveFuture<'a> {
let file_id = request.file_id;
Box::pin(async move {
Err(AttachmentStoreError::new(
AttachmentStoreErrorKind::NotFound,
format!("attachment `{file_id}` was not found"),
))
})
}
}
fn png_data_url(width: u32, height: u32) -> (String, Vec<u8>) {
let image = ImageBuffer::from_pixel(width, height, Rgba([10u8, 20, 30, 255]));
let mut encoded = Cursor::new(Vec::new());
DynamicImage::ImageRgba8(image)
.write_to(&mut encoded, ImageFormat::Png)
.expect("encode PNG");
let bytes = encoded.into_inner();
(data_url_from_bytes("image/png", &bytes), bytes)
}
fn decoded_image(image_url: &str) -> (Vec<u8>, DynamicImage) {
let (_, payload) = image_url.split_once(',').expect("data URL payload");
let bytes = BASE64_STANDARD.decode(payload).expect("decode image URL");
let image = image::load_from_memory(&bytes).expect("decode processed image");
(bytes, image)
}
#[tokio::test(flavor = "multi_thread")]
async fn preparation_preserves_small_image_bytes_and_replaces_remote_urls() {
let (data_url, original_bytes) = png_data_url(/*width*/ 64, /*height*/ 32);
let mut items = vec![ResponseItem::Message {
status: None,
encrypted_content: None,
id: None,
role: "user".to_string(),
content: vec![
ContentItem::InputImage {
image: ImageReference::Inline {
image_url: data_url,
},
detail: Some(ImageDetail::High),
},
ContentItem::InputImage {
image: ImageReference::Inline {
image_url: "https://example.com/image.png".to_string(),
},
detail: Some(ImageDetail::Low),
},
],
phase: None,
internal_chat_message_metadata_passthrough: None,
}];
items.push(ResponseItem::Message {
status: None,
encrypted_content: None,
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputImage {
image: ImageReference::Inline {
image_url: "https://example.com/developer-image.png".to_string(),
},
detail: Some(ImageDetail::High),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
});
prepare_response_items(
"image-preparation-thread",
/*ephemeral*/ false,
&mut items,
ImagePreparationMode::DetailBased,
ImageResizeNoticeMode::Disabled,
&InlineAttachmentStore,
)
.await;
let ResponseItem::Message { content, .. } = &items[0] else {
panic!("expected message");
};
let [
ContentItem::InputImage {
image: ImageReference::Inline { image_url },
..
},
ContentItem::InputText { text },
] = content.as_slice()
else {
panic!("expected two images");
};
assert_eq!(decoded_image(image_url).0, original_bytes);
assert_eq!(text, REMOTE_IMAGE_URL_PLACEHOLDER);
assert_eq!(
&items[1],
&ResponseItem::Message {
status: None,
encrypted_content: None,
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: REMOTE_IMAGE_URL_PLACEHOLDER.to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: Some(
InternalChatMessageMetadataPassthrough {
content_item_kinds: Some(vec![ContentItemKind(
"images.preparation_error".to_string()
)]),
content_item_metadata: from_value(json!([{"harness_injected": true}])).unwrap(),
..Default::default()
}
),
}
);
}
#[tokio::test(flavor = "multi_thread")]
async fn detail_policies_apply_the_expected_budgets() {
for (detail, effective_detail, input_dimensions, expected_dimensions) in [
(
Some(ImageDetail::High),
ImageDetailSetting::High,
(2048, 2048),
(1600, 1600),
),
(
Some(ImageDetail::Original),
ImageDetailSetting::Original,
(6401, 100),
(6000, 94),
),
(
Some(ImageDetail::Original),
ImageDetailSetting::Original,
(3201, 3201),
(3200, 3200),
),
(
Some(ImageDetail::Auto),
ImageDetailSetting::High,
(2048, 2048),
(1600, 1600),
),
(None, ImageDetailSetting::High, (2048, 2048), (1600, 1600)),
] {
let (image_url, _) = png_data_url(input_dimensions.0, input_dimensions.1);
let mut items = vec![ResponseItem::Message {
status: None,
encrypted_content: None,
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputImage {
image: ImageReference::Inline { image_url },
detail,
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
}];
let metadata = prepare_response_items(
"image-preparation-thread",
/*ephemeral*/ false,
&mut items,
ImagePreparationMode::DetailBased,
ImageResizeNoticeMode::Disabled,
&InlineAttachmentStore,
)
.await;
let ResponseItem::Message { content, .. } = &items[0] else {
panic!("expected message");
};
let [
ContentItem::InputImage {
image: ImageReference::Inline { image_url },
..
},
] = content.as_slice()
else {
panic!("expected image");
};
assert_eq!(decoded_image(image_url).1.dimensions(), expected_dimensions);
assert_eq!(
metadata,
vec![ImagePreparationMetadata {
message_role: Some("user".to_string()),
item_id: None,
effective_detail,
source_width: input_dimensions.0,
source_height: input_dimensions.1,
prepared_width: expected_dimensions.0,
prepared_height: expected_dimensions.1,
}]
);
}
}
#[tokio::test(flavor = "multi_thread")]
async fn preparation_reports_tool_output_item_id() {
let call_id = "call-image";
let (image_url, _) = png_data_url(/*width*/ 64, /*height*/ 32);
let mut items = vec![ResponseItem::FunctionCallOutput {
id: None,
call_id: Some(call_id.to_string()),
name: None,
namespace: None,
output: FunctionCallOutputPayload::from_content_items(vec![
FunctionCallOutputContentItem::InputImage {
image: ImageReference::Inline { image_url },
detail: Some(ImageDetail::High),
},
]),
internal_chat_message_metadata_passthrough: None,
}];
let metadata = prepare_response_items(
"image-preparation-thread",
/*ephemeral*/ false,
&mut items,
ImagePreparationMode::DetailBased,
ImageResizeNoticeMode::Disabled,
&InlineAttachmentStore,
)
.await;
assert_eq!(
metadata,
vec![ImagePreparationMetadata {
message_role: None,
item_id: Some(call_id.to_string()),
effective_detail: ImageDetailSetting::High,
source_width: 64,
source_height: 32,
prepared_width: 64,
prepared_height: 32,
}]
);
}
#[tokio::test(flavor = "multi_thread")]
async fn upload_failure_keeps_resized_image_inline() {
let (image_url, _) = png_data_url(/*width*/ 2048, /*height*/ 2048);
let mut items = vec![ResponseItem::Message {
status: None,
encrypted_content: None,
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputImage {
image: ImageReference::Inline { image_url },
detail: Some(ImageDetail::High),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
}];
prepare_response_items(
"image-preparation-thread",
/*ephemeral*/ false,
&mut items,
ImagePreparationMode::DetailBased,
ImageResizeNoticeMode::Disabled,
&FailingAttachmentStore,
)
.await;
let ResponseItem::Message { content, .. } = &items[0] else {
panic!("expected message");
};
let [
ContentItem::InputImage {
image: ImageReference::Inline { image_url },
..
},
] = content.as_slice()
else {
panic!("expected inline image");
};
assert_eq!(decoded_image(image_url).1.dimensions(), (1600, 1600));
}
/// File-backed images retain their sequence positions even though preparation skips them.
#[tokio::test(flavor = "multi_thread")]
async fn resize_notices_count_file_backed_images_and_skip_failed_images() {
let (large_image_url, _) = png_data_url(/*width*/ 2048, /*height*/ 2048);
let (small_image_url, _) = png_data_url(/*width*/ 64, /*height*/ 32);
let mut items = vec![
ResponseItem::Message {
status: None,
encrypted_content: None,
id: None,
role: "user".to_string(),
content: vec![
ContentItem::InputImage {
image: ImageReference::File {
file_id: "file_message".to_string(),
},
detail: Some(ImageDetail::High),
},
ContentItem::InputImage {
image: ImageReference::Inline {
image_url: small_image_url,
},
detail: Some(ImageDetail::High),
},
ContentItem::InputImage {
image: ImageReference::Inline {
image_url: "data:image/png;base64,%%%".to_string(),
},
detail: Some(ImageDetail::High),
},
ContentItem::InputImage {
image: ImageReference::Inline {
image_url: large_image_url.clone(),
},
detail: Some(ImageDetail::High),
},
],
phase: None,
internal_chat_message_metadata_passthrough: Some(
InternalChatMessageMetadataPassthrough {
content_item_kinds: Some(vec![
ContentItemKind("user.image".to_string()),
ContentItemKind("user.image".to_string()),
ContentItemKind("user.image".to_string()),
ContentItemKind("user.image".to_string()),
]),
..Default::default()
},
),
},
ResponseItem::FunctionCallOutput {
id: None,
call_id: Some("call-image".to_string()),
name: None,
namespace: None,
output: FunctionCallOutputPayload::from_content_items(vec![
FunctionCallOutputContentItem::InputImage {
image: ImageReference::File {
file_id: "file_tool".to_string(),
},
detail: Some(ImageDetail::High),
},
FunctionCallOutputContentItem::InputImage {
image: ImageReference::Inline {
image_url: "data:image/png;base64,%%%".to_string(),
},
detail: Some(ImageDetail::High),
},
FunctionCallOutputContentItem::InputImage {
image: ImageReference::Inline {
image_url: large_image_url,
},
detail: Some(ImageDetail::High),
},
]),
internal_chat_message_metadata_passthrough: None,
},
];
prepare_response_items(
"image-preparation-thread",
/*ephemeral*/ false,
&mut items,
ImagePreparationMode::DetailBased,
ImageResizeNoticeMode::Enabled,
&InlineAttachmentStore,
)
.await;
let expected_user_notice = concat!(
"<image_resize_notice>\n",
"Image 4 of 4 in the preceding user message was resized from 2048x2048 to 1600x1600 pixels.\n",
"</image_resize_notice>"
);
let ResponseItem::Message {
content,
internal_chat_message_metadata_passthrough,
..
} = &items[0]
else {
panic!("expected message");
};
assert_eq!(
internal_chat_message_metadata_passthrough,
&Some(InternalChatMessageMetadataPassthrough {
content_item_kinds: Some(vec![
ContentItemKind("user.image".to_string()),
ContentItemKind("user.image".to_string()),
ContentItemKind("images.preparation_error".to_string()),
ContentItemKind("user.image".to_string()),
]),
content_item_metadata: from_value(json!([{}, {}, {"harness_injected": true}, {}]))
.unwrap(),
..Default::default()
}),
);
let [
ContentItem::InputImage {
image: ImageReference::File { file_id },
..
},
ContentItem::InputImage {
image:
ImageReference::Inline {
image_url: small_message_image_url,
},
..
},
ContentItem::InputText {
text: failed_message_image,
},
ContentItem::InputImage {
image:
ImageReference::Inline {
image_url: resized_message_image_url,
},
..
},
] = content.as_slice()
else {
panic!("expected file image, unchanged image, failed image placeholder, and resized image");
};
assert_eq!(file_id, "file_message");
assert_eq!(
decoded_image(small_message_image_url).1.dimensions(),
(64, 32)
);
assert_eq!(failed_message_image, IMAGE_PROCESSING_ERROR_PLACEHOLDER);
assert_eq!(
decoded_image(resized_message_image_url).1.dimensions(),
(1600, 1600)
);
assert_eq!(
&items[1],
&ResponseItem::Message {
status: None,
encrypted_content: None,
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: expected_user_notice.to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: Some(
InternalChatMessageMetadataPassthrough {
content_item_kinds: Some(vec![ContentItemKind(
"images.resize_notice".to_string()
)]),
content_item_metadata: from_value(json!([{"harness_injected": true}])).unwrap(),
..Default::default()
},
),
}
);
let ResponseItem::FunctionCallOutput { output, .. } = &items[2] else {
panic!("expected function call output");
};
let [
FunctionCallOutputContentItem::InputImage {
image: ImageReference::File { file_id },
..
},
FunctionCallOutputContentItem::InputText {
text: failed_tool_image,
},
FunctionCallOutputContentItem::InputImage {
image:
ImageReference::Inline {
image_url: resized_tool_image_url,
},
..
},
] = output.content_items().expect("tool output content items")
else {
panic!("expected file image, failed image placeholder, and resized image in tool output");
};
assert_eq!(file_id, "file_tool");
assert_eq!(failed_tool_image, IMAGE_PROCESSING_ERROR_PLACEHOLDER);
assert_eq!(
decoded_image(resized_tool_image_url).1.dimensions(),
(1600, 1600)
);
assert_eq!(
&items[3],
&ResponseItem::Message {
status: None,
encrypted_content: None,
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: concat!(
"<image_resize_notice>\n",
"Image 3 of 3 in the preceding tool output was resized from 2048x2048 to 1600x1600 pixels.\n",
"</image_resize_notice>"
)
.to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: Some(
InternalChatMessageMetadataPassthrough {
content_item_kinds: Some(vec![ContentItemKind(
"images.resize_notice".to_string()
)]),
content_item_metadata: from_value(json!([{"harness_injected": true}])).unwrap(),
..Default::default()
},
),
}
);
}
#[tokio::test(flavor = "multi_thread")]
async fn preparation_replaces_only_failed_tool_images_and_preserves_metadata() {
let (valid_image_url, _) = png_data_url(/*width*/ 64, /*height*/ 32);
let expected_valid_image_url = valid_image_url.clone();
let mut items = vec![ResponseItem::CustomToolCallOutput {
id: None,
call_id: "call-1".to_string(),
name: None,
output: FunctionCallOutputPayload {
body: FunctionCallOutputBody::ContentItems(vec![
FunctionCallOutputContentItem::InputText {
text: "before".to_string(),
},
FunctionCallOutputContentItem::InputImage {
image: ImageReference::Inline {
image_url: "data:image/png;base64,%%%".to_string(),
},
detail: Some(ImageDetail::High),
},
FunctionCallOutputContentItem::InputImage {
image: ImageReference::Inline {
image_url: data_url_from_bytes("image/png", b"not an image"),
},
detail: Some(ImageDetail::High),
},
FunctionCallOutputContentItem::InputImage {
image: ImageReference::Inline {
image_url: valid_image_url.clone(),
},
detail: Some(ImageDetail::Low),
},
FunctionCallOutputContentItem::InputImage {
image: ImageReference::Inline {
image_url: valid_image_url,
},
detail: Some(ImageDetail::High),
},
]),
success: Some(true),
},
internal_chat_message_metadata_passthrough: None,
}];
prepare_response_items(
"image-preparation-thread",
/*ephemeral*/ false,
&mut items,
ImagePreparationMode::DetailBased,
ImageResizeNoticeMode::Disabled,
&InlineAttachmentStore,
)
.await;
assert_eq!(
items,
vec![ResponseItem::CustomToolCallOutput {
id: None,
call_id: "call-1".to_string(),
name: None,
output: FunctionCallOutputPayload {
body: FunctionCallOutputBody::ContentItems(vec![
FunctionCallOutputContentItem::InputText {
text: "before".to_string(),
},
FunctionCallOutputContentItem::InputText {
text: IMAGE_PROCESSING_ERROR_PLACEHOLDER.to_string(),
},
FunctionCallOutputContentItem::InputText {
text: IMAGE_PROCESSING_ERROR_PLACEHOLDER.to_string(),
},
FunctionCallOutputContentItem::InputText {
text: UNSUPPORTED_LOW_DETAIL_PLACEHOLDER.to_string(),
},
FunctionCallOutputContentItem::InputImage {
image: ImageReference::Inline {
image_url: expected_valid_image_url
},
detail: Some(ImageDetail::High),
},
]),
success: Some(true),
},
internal_chat_message_metadata_passthrough: None,
}]
);
}
#[test]
fn preparation_errors_use_bounded_actionable_placeholders() {
let cases = [
(
ImagePreparationError::RemoteUrlUnsupported,
REMOTE_IMAGE_URL_PLACEHOLDER,
),
(
ImagePreparationError::UnsupportedLowDetail,
UNSUPPORTED_LOW_DETAIL_PLACEHOLDER,
),
(
ImagePreparationError::Processing(ImageProcessingError::ImageTooLarge {
representation: "decoded input",
size: 2,
max: 1,
}),
IMAGE_TOO_LARGE_PLACEHOLDER,
),
(
ImagePreparationError::Processing(ImageProcessingError::InvalidDataUrl {
reason: "details remain in logs".to_string(),
}),
IMAGE_PROCESSING_ERROR_PLACEHOLDER,
),
];
for (error, expected) in cases {
assert_eq!(error.placeholder(), expected);
}
}