Support typed content in contextual prompt fragments (#54776)

## Why

Contextual fragments and extension-owned world-state sections currently return strings, limiting them to text content.

## What changed

- Return `ContentItem` from fragment bodies and rendering, preserving classification and attribution. Apply text markers only to `InputText`; pass other content through unchanged.
- Allow extension prompt and world-state contributions to carry typed content, and preserve media in context-window hints.
- Hash non-text world-state fragments by their serialized content and role while retaining the existing text hashing behavior.
- Add shared content-to-tool-output conversion that preserves image references, image detail, and audio.

## Testing

Add coverage for typed world-state hashing and preservation of image content and attribution. Expand code-mode output tests to cover input text, output text, file-backed and inline images, and audio.

GitOrigin-RevId: 2ed00d466a3f169b151202f3eef71e667ea64be1
This commit is contained in:
pakrym-oai
2026-10-17 04:02:21 +00:00
committed by copyberry
parent 3b8f43a06b
commit ce0323f2f2
162 changed files with 1109 additions and 570 deletions
@@ -27,6 +27,7 @@ use codex_protocol::models::LocalShellExecAction;
use codex_protocol::models::LocalShellStatus;
use codex_protocol::models::ReasoningItemContent;
use codex_protocol::models::ReasoningItemReasoningSummary;
use codex_protocol::models::input_text;
use codex_protocol::openai_models::InputModality;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::openai_models::default_input_modalities;
@@ -37,6 +38,7 @@ use codex_protocol::protocol::InterAgentCommunication;
use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_OPEN_TAG;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::TurnContextItem;
use codex_protocol_test_support::ContentItemTestExt;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_output_truncation::TruncationPolicy;
use codex_utils_output_truncation::truncate_text;
@@ -601,7 +603,7 @@ fn world_state_transitions_persist_changed_state_and_skip_unchanged_state() {
return (None, None);
}
(Some(snapshot), Some(RenderedWorldStateFragment::new(
"developer", ("<test>", "</test>"), value,
"developer", ("<test>", "</test>"), input_text(value.to_string()),
)))
}));
state
@@ -615,7 +617,7 @@ fn world_state_transitions_persist_changed_state_and_skip_unchanged_state() {
let WorldStateUpdateContent::Fragment(fragment) = &fragments[0].content else {
panic!("expected a text fragment");
};
assert_eq!(fragment.body(), "after");
assert_eq!(fragment.body().expect_text(), "after");
let patch = patch.expect("changed snapshot must be persisted");
assert!(!patch.full);
let mut replayed = WorldStateSnapshot::from(&full.state);
@@ -646,7 +648,7 @@ fn world_state_reconciles_matching_legacy_history_once() {
fragments
.into_iter()
.map(|update| match update.content {
WorldStateUpdateContent::Fragment(fragment) => fragment.body(),
WorldStateUpdateContent::Fragment(fragment) => fragment.body().expect_text(),
WorldStateUpdateContent::Item(item) =>
panic!("expected a text fragment, got {item:?}"),
})
@@ -1943,7 +1945,9 @@ 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 model_switch = ModelSwitchInstructions::new("switched model instructions")
.render()
.expect_text();
let developer_message = ResponseItem::Message {
status: None,
encrypted_content: None,
@@ -372,11 +372,8 @@ pub(crate) fn strip_images_when_unsupported(
for content_item in content_items.iter() {
match content_item {
FunctionCallOutputContentItem::InputImage { .. } => {
normalized_content_items.push(
FunctionCallOutputContentItem::InputText {
text: UnsupportedMedia::IMAGE.render(),
},
);
normalized_content_items
.push(UnsupportedMedia::IMAGE.render().into());
}
_ => normalized_content_items.push(content_item.clone()),
}
@@ -423,9 +420,7 @@ pub(crate) fn strip_audio_when_unsupported(
content_item,
FunctionCallOutputContentItem::InputAudio { .. }
) {
*content_item = FunctionCallOutputContentItem::InputText {
text: UnsupportedMedia::AUDIO.render(),
};
*content_item = UnsupportedMedia::AUDIO.render().into();
}
}
}
@@ -5,6 +5,7 @@ use crate::context::ContextualUserFragment;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ContentItemKind;
use codex_protocol::models::InternalChatMessageMetadataPassthrough;
use codex_protocol::models::input_text;
use pretty_assertions::assert_eq;
use serde_json::json;
@@ -31,8 +32,8 @@ impl ContextualUserFragment for TestFragment {
("", "")
}
fn body(&self) -> String {
self.text.to_string()
fn body(&self) -> ContentItem {
input_text(self.text.to_string())
}
}