Forward history note images to the model (#41292)

## What changed

- Convert history backend `images` into `input_image` function-call output items alongside encrypted or plaintext history content.
- Keep image data out of logged output and post-tool-use hook responses.
- Reject malformed image attachments instead of silently dropping them.

## Testing

- Cover encrypted and plaintext outputs, supported image detail values, malformed attachments, and propagation into the next model request.

GitOrigin-RevId: a8eee10eb09c637bcfb06a759dd6313caf707e51
This commit is contained in:
Brandon Zhang authored and copyberry committed 2026-08-28 07:18:54 +00:00
1 parent 7d6f808b97
commit 94311d4475
6 files changed
+339 -25

No files matched your search

@@ -230,14 +230,27 @@ async fn installed_extension_exposes_and_invokes_history_notes_tools() -> TestRe
),
] {
server.reset().await;
let mut response = json!({"encrypted_output": "enc_history"});
let mut expected_output =
vec![json!({"type": "encrypted_content", "encrypted_content": "enc_history"})];
if namespace == "history" && name == "read_item" {
// Images are not part of the text budget sent to the backend.
let data = "cG5n".repeat(1_000);
response["images"] = json!([
{"data": data, "mime_type": "image/png", "detail": "original"},
{"data": "anBlZw==", "mime_type": "image/jpeg", "detail": "high"}
]);
expected_output.extend([
json!({"type": "input_image", "image_url": format!("data:image/png;base64,{data}"), "detail": "original"}),
json!({"type": "input_image", "image_url": "data:image/jpeg;base64,anBlZw==", "detail": "high"})
]);
}
Mock::given(method("POST"))
.and(path(format!(
"/backend-api/codex/alpha/{namespace}/v2/{name}"
)))
.and(header("x-openai-actor-authorization", "actor-biscuit"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"encrypted_output": "enc_history"
})))
.respond_with(ResponseTemplate::new(200).set_body_json(response))
.mount(&server)
.await;
let tool_name = ToolName::namespaced(namespace, name);
@@ -245,12 +258,29 @@ async fn installed_extension_exposes_and_invokes_history_notes_tools() -> TestRe
.iter()
.find(|tool| tool.tool_name() == tool_name)
.expect("exposed tool");
tool.handle(tool_call(tool_name, arguments.clone())).await?;
let call = tool_call(tool_name, arguments.clone());
let output = tool.handle(call.clone()).await?;
assert_eq!(
serde_json::to_value(output.to_response_item(&call.call_id, &call.payload))?,
json!({"type": "function_call_output", "call_id": call.call_id, "output": expected_output})
);
arguments["context"] = json!({
"session_id": "session-123",
"current_agent_name": "/root/worker",
});
let requests = server.received_requests().await.expect("recorded requests");
for request in &requests {
assert_eq!(
serde_json::from_str::<serde_json::Value>(
request
.headers
.get("x-openai-tool-output-truncation-policy")
.expect("truncation policy header")
.to_str()?
)?,
json!({"mode": "bytes", "limit": 1024})
);
}
assert_eq!(
requests
.iter()
@@ -260,6 +290,39 @@ async fn installed_extension_exposes_and_invokes_history_notes_tools() -> TestRe
);
}
// Plaintext backend responses must keep attachments out of text and logs too.
server.reset().await;
let text_result = json!({"content": "look: [image 1]", "n_chars": 15, "next_offset_chars": 15});
let mut response = text_result.clone();
response["images"] = json!([
{"data": "cG5n", "mime_type": "image/png", "detail": "original"}
]);
Mock::given(method("POST"))
.and(path("/backend-api/codex/alpha/history/v2/read_item"))
.and(header("x-openai-actor-authorization", "actor-biscuit"))
.respond_with(ResponseTemplate::new(200).set_body_json(response))
.mount(&server)
.await;
let tool_name = ToolName::namespaced("history", "read_item");
let read_item = tools
.iter()
.find(|tool| tool.tool_name() == tool_name)
.expect("history.read_item");
let call = tool_call(tool_name, json!({"window_id": "window", "item_id": "item"}));
let output = read_item.handle(call.clone()).await?;
assert_eq!(output.log_output(), text_result.to_string());
assert_eq!(
serde_json::to_value(output.to_response_item(&call.call_id, &call.payload))?,
json!({
"type": "function_call_output",
"call_id": call.call_id,
"output": [
{"type": "input_text", "text": text_result.to_string()},
{"type": "input_image", "image_url": "data:image/png;base64,cG5n", "detail": "original"}
]
})
);
for result in [
json!({"text": ""}),
json!({"text": "x".repeat(4_001)}),
@@ -0,0 +1,91 @@
//! Verify history attachments survive native preparation into the next model request.
use std::sync::Arc;
use codex_core::config::Config;
use codex_core::config::TokenBudgetConfig;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_history_notes_extension::install;
use codex_login::AuthHeaders;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use core_test_support::responses;
use core_test_support::test_codex::test_codex;
use http::HeaderMap;
use pretty_assertions::assert_eq;
use serde_json::json;
use wiremock::Mock;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
const PNG: &str =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII=";
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn history_images_reach_the_next_model_request() -> Result<(), Box<dyn std::error::Error>> {
let server = responses::start_mock_server().await;
Mock::given(method("POST"))
.and(path("/v1/alpha/history/v2/read_item"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"encrypted_output": "opaque-history-text",
"images": [
{"data": PNG, "mime_type": "image/png", "detail": "original"},
{"data": PNG, "mime_type": "image/png", "detail": "high"},
{"data": PNG, "mime_type": "image/png", "detail": "auto"}
]
})))
.mount(&server)
.await;
let requests = responses::mount_sse_sequence(
&server,
vec![
responses::sse(vec![
responses::ev_function_call_with_namespace(
"read-history",
"history",
"read_item",
&json!({"window_id": "window", "item_id": "item"}).to_string(),
),
responses::ev_completed("first-response"),
]),
responses::sse(vec![
responses::ev_assistant_message("assistant", "done"),
responses::ev_completed("second-response"),
]),
],
)
.await;
let auth = CodexAuth::Headers(AuthHeaders::new(HeaderMap::new()));
let mut extensions = ExtensionRegistryBuilder::<Config>::new();
install(
&mut extensions,
AuthManager::from_auth_for_testing(auth.clone()),
);
let test = test_codex()
.with_auth(auth)
.with_extensions(Arc::new(extensions.build()))
.with_config(|config| {
config.model_provider.name = "OpenAI".to_string();
config.token_budget = Some(TokenBudgetConfig {
use_history_notes_extension: true,
..TokenBudgetConfig::default()
});
})
.build_with_auto_env(&server)
.await?;
test.submit_text_turn("Read the image from history.")
.await?;
let requests = requests.requests();
assert_eq!(requests.len(), 2);
assert_eq!(
requests[1].function_call_output("read-history")["output"],
json!([
{"type": "encrypted_content", "encrypted_content": "opaque-history-text"},
{"type": "input_image", "image_url": format!("data:image/png;base64,{PNG}"), "detail": "original"},
{"type": "input_image", "image_url": format!("data:image/png;base64,{PNG}"), "detail": "high"},
{"type": "input_image", "image_url": format!("data:image/png;base64,{PNG}"), "detail": "auto"}
])
);
Ok(())
}