Fix js_repl view_image attachments in nested tool calls (#12725)

## Summary

- Fix `js_repl` so `await codex.tool("view_image", { path })` actually
attaches the image to the active turn when called from inside the JS
REPL.
- Restore the behavior expected by the existing `js_repl`
image-attachment test.
- This is a follow-up to
[#12553](https://github.com/openai/codex/pull/12553), which changed
`view_image` to return structured image content.

## Root Cause

- [#12553](https://github.com/openai/codex/pull/12553) changed
`view_image` from directly injecting a pending user image message to
returning structured `function_call_output` content items.
- The nested tool-call bridge inside `js_repl` serialized that tool
response back to the JS runtime, but it did not mirror returned image
content into the active turn.
- As a result, `view_image` appeared to succeed inside `js_repl`, but no
`input_image` was actually attached for the outer turn.

## What Changed

- Updated the nested tool-call path in `js_repl` to inspect function
tool responses for structured content items.
- When a nested tool response includes `input_image` content, `js_repl`
now injects a corresponding user `Message` into the active turn before
returning the raw tool result back to the JS runtime.
- Kept the normal JSON result flow intact, so `codex.tool(...)` still
returns the original tool output object to JavaScript.

## Why

- `js_repl` documentation and tests already assume that `view_image` can
be used from inside the REPL to attach generated images to the model.
- Without this fix, the nested call path silently dropped that
attachment behavior.
This commit is contained in:
Curtis 'Fjord' Hawthorne
2026-02-24 18:23:53 -08:00
committed by GitHub
parent 74e112ea09
commit 125fbec317
7 changed files with 223 additions and 76 deletions

View File

@@ -36,28 +36,37 @@ use image::GenericImageView;
use image::ImageBuffer;
use image::Rgba;
use image::load_from_memory;
use pretty_assertions::assert_eq;
use serde_json::Value;
use tokio::time::Duration;
use wiremock::BodyPrintLimit;
use wiremock::MockServer;
fn find_image_message(body: &Value) -> Option<&Value> {
fn image_messages(body: &Value) -> Vec<&Value> {
body.get("input")
.and_then(Value::as_array)
.and_then(|items| {
items.iter().find(|item| {
item.get("type").and_then(Value::as_str) == Some("message")
&& item
.get("content")
.and_then(Value::as_array)
.map(|content| {
content.iter().any(|span| {
span.get("type").and_then(Value::as_str) == Some("input_image")
.map(|items| {
items
.iter()
.filter(|item| {
item.get("type").and_then(Value::as_str) == Some("message")
&& item
.get("content")
.and_then(Value::as_array)
.map(|content| {
content.iter().any(|span| {
span.get("type").and_then(Value::as_str) == Some("input_image")
})
})
})
.unwrap_or(false)
})
.unwrap_or(false)
})
.collect()
})
.unwrap_or_default()
}
fn find_image_message(body: &Value) -> Option<&Value> {
image_messages(body).into_iter().next()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -366,8 +375,16 @@ console.log(out.output?.body?.text ?? "");
);
let body = req.body_json();
let image_message =
find_image_message(&body).expect("pending input image message not included in request");
let image_messages = image_messages(&body);
assert_eq!(
image_messages.len(),
1,
"js_repl view_image should inject exactly one pending input image message"
);
let image_message = image_messages
.into_iter()
.next()
.expect("pending input image message not included in request");
let image_url = image_message
.get("content")
.and_then(Value::as_array)