Codex Handbook
core/tests/suite/view_image.rs 1630 lines
#![cfg(not(target_os = "windows"))]use anyhow::Context;use base64::Engine;use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;use codex_exec_server::CreateDirectoryOptions;use codex_exec_server::LOCAL_ENVIRONMENT_ID;use codex_exec_server::REMOTE_ENVIRONMENT_ID;use codex_exec_server::RemoveOptions;use codex_features::Feature;use codex_login::CodexAuth;use codex_protocol::config_types::ReasoningSummary;use codex_protocol::models::PermissionProfile;use codex_protocol::openai_models::ConfigShellToolType;use codex_protocol::openai_models::InputModality;use codex_protocol::openai_models::ModelInfo;use codex_protocol::openai_models::ModelVisibility;use codex_protocol::openai_models::ModelsResponse;use codex_protocol::openai_models::ReasoningEffort;use codex_protocol::openai_models::ReasoningEffortPreset;use codex_protocol::openai_models::TruncationPolicyConfig;use codex_protocol::permissions::FileSystemAccessMode;use codex_protocol::permissions::FileSystemPath;use codex_protocol::permissions::FileSystemSandboxEntry;use codex_protocol::permissions::FileSystemSandboxPolicy;use codex_protocol::permissions::NetworkSandboxPolicy;use codex_protocol::protocol::AskForApproval;use codex_protocol::protocol::EventMsg;use codex_protocol::protocol::Op;use codex_protocol::protocol::TurnEnvironmentSelection;use codex_protocol::user_input::UserInput;use codex_utils_path_uri::PathUri;use core_test_support::PathBufExt;use core_test_support::PathExt;use core_test_support::get_remote_test_env;use core_test_support::responses;use core_test_support::responses::ev_assistant_message;use core_test_support::responses::ev_completed;use core_test_support::responses::ev_function_call;use core_test_support::responses::ev_response_created;use core_test_support::responses::mount_models_once;use core_test_support::responses::mount_sse_sequence;use core_test_support::responses::sse;use core_test_support::responses::start_mock_server;use core_test_support::skip_if_no_network;use core_test_support::skip_if_wine_exec;use core_test_support::test_codex::TestCodex;use core_test_support::test_codex::local;use core_test_support::test_codex::test_codex;use core_test_support::test_codex::turn_permission_fields;use core_test_support::wait_for_event_with_timeout;use image::DynamicImage;use image::GenericImageView;use image::ImageBuffer;use image::Rgba;use image::load_from_memory;use pretty_assertions::assert_eq;use serde_json::Value;use serde_json::json;use std::fs;use std::io::Cursor;use std::path::PathBuf;use std::time::SystemTime;use std::time::UNIX_EPOCH;use tempfile::TempDir;use tokio::time::Duration;use wiremock::BodyPrintLimit;use wiremock::MockServer;#[cfg(not(debug_assertions))]use wiremock::ResponseTemplate;#[cfg(not(debug_assertions))]use wiremock::matchers::body_string_contains;const VIEW_IMAGE_TURN_COMPLETE_TIMEOUT: Duration = Duration::from_secs(30);fn disabled_user_turn(test: &TestCodex, items: Vec<UserInput>, model: String) -> Op {    let (sandbox_policy, permission_profile) =        turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path());    Op::UserInput {        items,        final_output_json_schema: None,        responsesapi_client_metadata: None,        additional_context: Default::default(),        thread_settings: codex_protocol::protocol::ThreadSettingsOverrides {            approval_policy: Some(AskForApproval::Never),            sandbox_policy: Some(sandbox_policy),            permission_profile,            collaboration_mode: Some(codex_protocol::config_types::CollaborationMode {                mode: codex_protocol::config_types::ModeKind::Default,                settings: codex_protocol::config_types::Settings {                    model,                    reasoning_effort: None,                    developer_instructions: None,                },            }),            ..Default::default()        },    }}fn image_messages(body: &Value) -> Vec<&Value> {    body.get("input")        .and_then(Value::as_array)        .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)                })                .collect()        })        .unwrap_or_default()}fn find_image_message(body: &Value) -> Option<&Value> {    image_messages(body).into_iter().next()}fn png_bytes(width: u32, height: u32, rgba: [u8; 4]) -> anyhow::Result<Vec<u8>> {    let image = ImageBuffer::from_pixel(width, height, Rgba(rgba));    let mut cursor = Cursor::new(Vec::new());    DynamicImage::ImageRgba8(image).write_to(&mut cursor, image::ImageFormat::Png)?;    Ok(cursor.into_inner())}async fn create_workspace_directory(test: &TestCodex, rel_path: &str) -> anyhow::Result<PathBuf> {    let abs_path = test.config.cwd.join(rel_path);    let abs_path_uri = PathUri::from_path(&abs_path)?;    test.fs()        .create_directory(            &abs_path_uri,            CreateDirectoryOptions { recursive: true },            /*sandbox*/ None,        )        .await?;    Ok(abs_path.into_path_buf())}async fn write_workspace_file(    test: &TestCodex,    rel_path: &str,    contents: Vec<u8>,) -> anyhow::Result<PathBuf> {    let abs_path = test.config.cwd.join(rel_path);    if let Some(parent) = abs_path.parent() {        let parent_uri = PathUri::from_path(&parent)?;        test.fs()            .create_directory(                &parent_uri,                CreateDirectoryOptions { recursive: true },                /*sandbox*/ None,            )            .await?;    }    let abs_path_uri = PathUri::from_path(&abs_path)?;    test.fs()        .write_file(&abs_path_uri, contents, /*sandbox*/ None)        .await?;    Ok(abs_path.into_path_buf())}async fn write_workspace_png(    test: &TestCodex,    rel_path: &str,    width: u32,    height: u32,    rgba: [u8; 4],) -> anyhow::Result<PathBuf> {    write_workspace_file(test, rel_path, png_bytes(width, height, rgba)?).await}async fn assert_user_turn_local_image_resizes_to(    original_dimensions: (u32, u32),    expected_dimensions: (u32, u32),    resize_policy: TestImageResizePolicy,) -> anyhow::Result<()> {    let server = start_mock_server().await;    let mut builder = test_codex().with_config(move |config| {        if resize_policy == TestImageResizePolicy::AllImages {            let _ = config.features.enable(Feature::ResizeAllImages);        }    });    let test = builder.build_with_remote_env(&server).await?;    let TestCodex {        codex,        session_configured,        ..    } = &test;    let (original_width, original_height) = original_dimensions;    let local_image_dir = tempfile::tempdir()?;    let abs_path = local_image_dir.path().join("example.png");    let image = ImageBuffer::from_pixel(original_width, original_height, Rgba([20u8, 40, 60, 255]));    image.save(&abs_path)?;    let response = sse(vec![        ev_response_created("resp-1"),        ev_assistant_message("msg-1", "done"),        ev_completed("resp-1"),    ]);    let mock = responses::mount_sse_once(&server, response).await;    let session_model = session_configured.model.clone();    codex        .submit(disabled_user_turn(            &test,            vec![UserInput::LocalImage {                path: abs_path.clone(),                detail: None,            }],            session_model,        ))        .await?;    wait_for_event_with_timeout(        codex,        |event| matches!(event, EventMsg::TurnComplete(_)),        // Empirically, image attachment can be slow under Bazel/RBE.        VIEW_IMAGE_TURN_COMPLETE_TIMEOUT,    )    .await;    let body = mock.single_request().body_json();    let image_message =        find_image_message(&body).context("pending input image message not included in request")?;    let image_url = image_message        .get("content")        .and_then(Value::as_array)        .and_then(|content| {            content.iter().find_map(|span| {                if span.get("type").and_then(Value::as_str) == Some("input_image") {                    span.get("image_url").and_then(Value::as_str)                } else {                    None                }            })        })        .context("image_url present")?;    let (prefix, encoded) = image_url        .split_once(',')        .context("image url contains data prefix")?;    assert_eq!(prefix, "data:image/png;base64");    let decoded = BASE64_STANDARD        .decode(encoded)        .context("image data decodes from base64 for request")?;    let resized = load_from_memory(&decoded).context("load resized image")?;    let (width, height) = resized.dimensions();    assert_eq!((width, height), expected_dimensions);    Ok(())}#[derive(Clone, Copy, Eq, PartialEq)]enum TestImageResizePolicy {    Legacy,    AllImages,}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> {    skip_if_no_network!(Ok(()));    assert_user_turn_local_image_resizes_to((2304, 864), (2048, 768), TestImageResizePolicy::Legacy)        .await}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn user_turn_with_vertical_local_image_resizes_to_square_bounds() -> anyhow::Result<()> {    skip_if_no_network!(Ok(()));    assert_user_turn_local_image_resizes_to(        (1024, 4096),        (512, 2048),        TestImageResizePolicy::Legacy,    )    .await}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn resize_all_images_applies_patch_budget_to_local_user_image() -> anyhow::Result<()> {    skip_if_no_network!(Ok(()));    assert_user_turn_local_image_resizes_to(        (2048, 2048),        (1600, 1600),        TestImageResizePolicy::AllImages,    )    .await}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> {    skip_if_no_network!(Ok(()));    let server = start_mock_server().await;    let mut builder = test_codex();    let test = builder.build_with_remote_env(&server).await?;    let TestCodex {        codex,        session_configured,        config,        ..    } = &test;    let cwd = config.cwd.clone();    let rel_path = "assets/example.png";    let abs_path = cwd.join(rel_path);    let original_width = 2304;    let original_height = 864;    write_workspace_png(        &test,        rel_path,        original_width,        original_height,        [255u8, 0, 0, 255],    )    .await?;    let call_id = "view-image-call";    let arguments = serde_json::json!({ "path": rel_path }).to_string();    let first_response = sse(vec![        ev_response_created("resp-1"),        ev_function_call(call_id, "view_image", &arguments),        ev_completed("resp-1"),    ]);    responses::mount_sse_once(&server, first_response).await;    let second_response = sse(vec![        ev_assistant_message("msg-1", "done"),        ev_completed("resp-2"),    ]);    let mock = responses::mount_sse_once(&server, second_response).await;    let session_model = session_configured.model.clone();    codex        .submit(disabled_user_turn(            &test,            vec![UserInput::Text {                text: "please add the screenshot".into(),                text_elements: Vec::new(),            }],            session_model,        ))        .await?;    let mut item_started = None;    let mut item_completed = None;    let mut legacy_event = None;    wait_for_event_with_timeout(        codex,        |event| match event {            EventMsg::ItemStarted(event) => {                if matches!(&event.item, codex_protocol::items::TurnItem::ImageView(_)) {                    item_started = Some(event.item.clone());                }                false            }            EventMsg::ItemCompleted(event) => {                if matches!(&event.item, codex_protocol::items::TurnItem::ImageView(_)) {                    item_completed = Some(event.item.clone());                }                false            }            EventMsg::ViewImageToolCall(event) => {                legacy_event = Some(event.clone());                false            }            EventMsg::TurnComplete(_) => true,            _ => false,        },        // Empirically, we have seen this run slow when run under        // Bazel on arm Linux.        VIEW_IMAGE_TURN_COMPLETE_TIMEOUT,    )    .await;    match item_started.expect("view image item started event emitted") {        codex_protocol::items::TurnItem::ImageView(item) => {            assert_eq!(item.id, call_id);            assert_eq!(item.path, abs_path);        }        other => panic!("expected ImageView item, got {other:?}"),    }    match item_completed.expect("view image item completed event emitted") {        codex_protocol::items::TurnItem::ImageView(item) => {            assert_eq!(item.id, call_id);            assert_eq!(item.path, abs_path);        }        other => panic!("expected ImageView item, got {other:?}"),    }    let legacy_event = legacy_event.expect("legacy view image event emitted");    assert_eq!(legacy_event.call_id, call_id);    assert_eq!(legacy_event.path, abs_path);    let req = mock.single_request();    let body = req.body_json();    assert!(        find_image_message(&body).is_none(),        "view_image tool should not inject a separate image message"    );    let function_output = req.function_call_output(call_id);    let output_items = function_output        .get("output")        .and_then(Value::as_array)        .expect("function_call_output should be a content item array");    assert_eq!(        output_items.len(),        1,        "view_image should return only the image content item (no tag/label text)"    );    assert_eq!(        output_items[0].get("type").and_then(Value::as_str),        Some("input_image"),        "view_image should return only an input_image content item"    );    let image_url = output_items[0]        .get("image_url")        .and_then(Value::as_str)        .expect("image_url present");    let (prefix, encoded) = image_url        .split_once(',')        .expect("image url contains data prefix");    assert_eq!(prefix, "data:image/png;base64");    let decoded = BASE64_STANDARD        .decode(encoded)        .expect("image data decodes from base64 for request");    let resized = load_from_memory(&decoded).expect("load resized image");    let (resized_width, resized_height) = resized.dimensions();    assert_eq!((resized_width, resized_height), (2048, 768));    Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn view_image_routes_to_selected_local_environment() -> anyhow::Result<()> {    skip_if_no_network!(Ok(()));    let server = start_mock_server().await;    let mut builder = test_codex();    let test = builder.build(&server).await?;    write_workspace_file(        &test,        "local.png",        png_bytes(/*width*/ 1, /*height*/ 1, [0, 255, 0, 255])?,    )    .await?;    let call_id = "call-view-image-local-env";    let response_mock = mount_sse_sequence(        &server,        vec![            sse(vec![                ev_response_created("resp-1"),                ev_function_call(                    call_id,                    "view_image",                    &json!({                        "path": "local.png",                        "environment_id": LOCAL_ENVIRONMENT_ID,                    })                    .to_string(),                ),                ev_completed("resp-1"),            ]),            sse(vec![                ev_response_created("resp-2"),                ev_assistant_message("msg-1", "done"),                ev_completed("resp-2"),            ]),        ],    )    .await;    test.submit_turn_with_environments(        "route local view image",        Some(vec![local(test.config.cwd.clone())]),    )    .await?;    let output = response_mock        .last_request()        .context("missing request containing local view_image output")?        .function_call_output(call_id);    let output_items = output        .get("output")        .and_then(Value::as_array)        .context("view_image output should be content items")?;    assert_eq!(output_items.len(), 1);    let image_url = output_items[0]        .get("image_url")        .and_then(Value::as_str)        .context("view_image output should include image_url")?;    assert!(        image_url.starts_with("data:image/png;base64,"),        "unexpected image_url: {image_url}",    );    Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn view_image_tool_applies_local_sandbox_read_denies() -> anyhow::Result<()> {    skip_if_no_network!(Ok(()));    let server = start_mock_server().await;    let mut builder = test_codex();    let test = builder.build(&server).await?;    let rel_path = "denied.png";    let denied_path = test.config.cwd.join(rel_path);    write_workspace_file(        &test,        rel_path,        png_bytes(/*width*/ 1, /*height*/ 1, [0, 255, 0, 255])?,    )    .await?;    let call_id = "call-view-image-outside-cwd";    let response_mock = mount_sse_sequence(        &server,        vec![            sse(vec![                ev_response_created("resp-1"),                ev_function_call(                    call_id,                    "view_image",                    &json!({ "path": rel_path }).to_string(),                ),                ev_completed("resp-1"),            ]),            sse(vec![                ev_response_created("resp-2"),                ev_assistant_message("msg-1", "done"),                ev_completed("resp-2"),            ]),        ],    )    .await;    let mut file_system_sandbox_policy = FileSystemSandboxPolicy::default();    file_system_sandbox_policy        .entries        .push(FileSystemSandboxEntry {            path: FileSystemPath::Path {                path: denied_path.clone(),            },            access: FileSystemAccessMode::Deny,        });    let permission_profile = PermissionProfile::from_runtime_permissions(        &file_system_sandbox_policy,        NetworkSandboxPolicy::Restricted,    );    test.submit_turn_with_permission_profile("attach the denied image", permission_profile)        .await?;    let request = response_mock        .last_request()        .context("missing request containing sandboxed view_image output")?;    assert!(        request.inputs_of_type("input_image").is_empty(),        "sandboxed local view_image should not attach denied images"    );    let output_text = request        .function_call_output_content_and_success(call_id)        .and_then(|(content, _)| content)        .context("sandboxed view_image error text present")?;    let expected_locate_prefix = format!("unable to locate image at `{}`:", denied_path.display());    let expected_read_prefix = format!("unable to read image at `{}`:", denied_path.display());    assert!(        output_text.starts_with(&expected_locate_prefix)            || output_text.starts_with(&expected_read_prefix),        "expected error to start with `{expected_locate_prefix}` or `{expected_read_prefix}` but got `{output_text}`"    );    Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn view_image_routes_to_selected_remote_environment() -> anyhow::Result<()> {    // TODO(anp): Remove after remote-cwd fixtures use target-native paths.    skip_if_wine_exec!(Ok(()), "hardcodes a POSIX remote cwd");    skip_if_no_network!(Ok(()));    let Some(_remote_env) = get_remote_test_env() else {        return Ok(());    };    let server = start_mock_server().await;    let mut builder = test_codex();    let test = builder.build_with_remote_and_local_env(&server).await?;    let local_cwd = TempDir::new()?;    fs::write(local_cwd.path().join("remote.png"), b"not a remote image")?;    let local_selection = local(local_cwd.path().abs());    let remote_cwd = PathBuf::from(format!(        "/tmp/codex-view-image-routing-{}",        SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis()    ))    .abs();    let image_path = remote_cwd.join("remote.png");    let remote_cwd_uri = PathUri::from_path(&remote_cwd)?;    test.fs()        .create_directory(            &remote_cwd_uri,            CreateDirectoryOptions { recursive: true },            /*sandbox*/ None,        )        .await?;    let png = png_bytes(/*width*/ 1, /*height*/ 1, [0, 255, 0, 255])?;    let image_path_uri = PathUri::from_path(&image_path)?;    test.fs()        .write_file(&image_path_uri, png, /*sandbox*/ None)        .await?;    let remote_selection = TurnEnvironmentSelection {        environment_id: REMOTE_ENVIRONMENT_ID.to_string(),        cwd: PathUri::from_abs_path(&remote_cwd),    };    let call_id = "call-view-image-multi-env";    let response_mock = mount_sse_sequence(        &server,        vec![            sse(vec![                ev_response_created("resp-1"),                ev_function_call(                    call_id,                    "view_image",                    &json!({                        "path": "remote.png",                        "environment_id": REMOTE_ENVIRONMENT_ID,                    })                    .to_string(),                ),                ev_completed("resp-1"),            ]),            sse(vec![                ev_response_created("resp-2"),                ev_assistant_message("msg-1", "done"),                ev_completed("resp-2"),            ]),        ],    )    .await;    test.submit_turn_with_environments(        "route view image",        Some(vec![local_selection, remote_selection]),    )    .await?;    let output = response_mock        .last_request()        .context("missing request containing view_image output")?        .function_call_output(call_id)        .clone();    let output_items = output        .get("output")        .and_then(Value::as_array)        .context("view_image output should be content items")?;    assert_eq!(output_items.len(), 1);    let image_url = output_items[0]        .get("image_url")        .and_then(Value::as_str)        .context("view_image output should include image_url")?;    assert!(        image_url.starts_with("data:image/png;base64,"),        "unexpected image_url: {image_url}",    );    test.fs()        .remove(            &remote_cwd_uri,            RemoveOptions {                recursive: true,                force: true,            },            /*sandbox*/ None,        )        .await?;    Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5_3_codex()-> anyhow::Result<()> {    skip_if_no_network!(Ok(()));    let server = start_mock_server().await;    let mut builder = test_codex().with_model("gpt-5.3-codex");    let test = builder.build_with_remote_env(&server).await?;    let TestCodex {        codex,        session_configured,        ..    } = &test;    let rel_path = "assets/original-example.png";    let original_width = 2304;    let original_height = 864;    write_workspace_png(        &test,        rel_path,        original_width,        original_height,        [0u8, 80, 255, 255],    )    .await?;    let call_id = "view-image-original";    let arguments = serde_json::json!({ "path": rel_path, "detail": "original" }).to_string();    let first_response = sse(vec![        ev_response_created("resp-1"),        ev_function_call(call_id, "view_image", &arguments),        ev_completed("resp-1"),    ]);    responses::mount_sse_once(&server, first_response).await;    let second_response = sse(vec![        ev_assistant_message("msg-1", "done"),        ev_completed("resp-2"),    ]);    let mock = responses::mount_sse_once(&server, second_response).await;    let session_model = session_configured.model.clone();    codex        .submit(disabled_user_turn(            &test,            vec![UserInput::Text {                text: "please add the original screenshot".into(),                text_elements: Vec::new(),            }],            session_model,        ))        .await?;    wait_for_event_with_timeout(        codex,        |event| matches!(event, EventMsg::TurnComplete(_)),        VIEW_IMAGE_TURN_COMPLETE_TIMEOUT,    )    .await;    let req = mock.single_request();    let function_output = req.function_call_output(call_id);    let output_items = function_output        .get("output")        .and_then(Value::as_array)        .expect("function_call_output should be a content item array");    assert_eq!(output_items.len(), 1);    assert_eq!(        output_items[0].get("detail").and_then(Value::as_str),        Some("original")    );    let image_url = output_items[0]        .get("image_url")        .and_then(Value::as_str)        .expect("image_url present");    let (_, encoded) = image_url        .split_once(',')        .expect("image url contains data prefix");    let decoded = BASE64_STANDARD        .decode(encoded)        .expect("image data decodes from base64 for request");    let preserved = load_from_memory(&decoded).expect("load preserved image");    let (width, height) = preserved.dimensions();    assert_eq!(width, original_width);    assert_eq!(height, original_height);    Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyhow::Result<()> {    skip_if_no_network!(Ok(()));    let server = start_mock_server().await;    let mut builder = test_codex().with_model("gpt-5.3-codex");    let test = builder.build_with_remote_env(&server).await?;    let TestCodex {        codex,        session_configured,        ..    } = &test;    let rel_path = "assets/unsupported-detail.png";    write_workspace_png(        &test,        rel_path,        /*width*/ 256,        /*height*/ 128,        [0u8, 80, 255, 255],    )    .await?;    let call_id = "view-image-unsupported-detail";    let arguments = serde_json::json!({ "path": rel_path, "detail": "low" }).to_string();    let first_response = sse(vec![        ev_response_created("resp-1"),        ev_function_call(call_id, "view_image", &arguments),        ev_completed("resp-1"),    ]);    responses::mount_sse_once(&server, first_response).await;    let second_response = sse(vec![        ev_assistant_message("msg-1", "done"),        ev_completed("resp-2"),    ]);    let mock = responses::mount_sse_once(&server, second_response).await;    let session_model = session_configured.model.clone();    codex        .submit(disabled_user_turn(            &test,            vec![UserInput::Text {                text: "please attach the image at low detail".into(),                text_elements: Vec::new(),            }],            session_model,        ))        .await?;    wait_for_event_with_timeout(        codex,        |event| matches!(event, EventMsg::TurnComplete(_)),        VIEW_IMAGE_TURN_COMPLETE_TIMEOUT,    )    .await;    let req = mock.single_request();    let body_with_tool_output = req.body_json();    let output_text = req        .function_call_output_content_and_success(call_id)        .and_then(|(content, _)| content)        .expect("output text present");    assert_eq!(        output_text,        "view_image.detail only supports `high` or `original`; omit `detail` for default high resized behavior, got `low`"    );    assert!(        find_image_message(&body_with_tool_output).is_none(),        "unsupported detail values should not produce an input_image message"    );    Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> {    skip_if_no_network!(Ok(()));    let server = start_mock_server().await;    let mut builder = test_codex().with_model("gpt-5.3-codex");    let test = builder.build_with_remote_env(&server).await?;    let TestCodex {        codex,        session_configured,        ..    } = &test;    let rel_path = "assets/null-detail.png";    let original_width = 2304;    let original_height = 864;    write_workspace_png(        &test,        rel_path,        original_width,        original_height,        [0u8, 80, 255, 255],    )    .await?;    let call_id = "view-image-null-detail";    let arguments = serde_json::json!({ "path": rel_path, "detail": null }).to_string();    let first_response = sse(vec![        ev_response_created("resp-1"),        ev_function_call(call_id, "view_image", &arguments),        ev_completed("resp-1"),    ]);    responses::mount_sse_once(&server, first_response).await;    let second_response = sse(vec![        ev_assistant_message("msg-1", "done"),        ev_completed("resp-2"),    ]);    let mock = responses::mount_sse_once(&server, second_response).await;    let session_model = session_configured.model.clone();    codex        .submit(disabled_user_turn(            &test,            vec![UserInput::Text {                text: "please attach the image with a null detail".into(),                text_elements: Vec::new(),            }],            session_model,        ))        .await?;    wait_for_event_with_timeout(        codex,        |event| matches!(event, EventMsg::TurnComplete(_)),        VIEW_IMAGE_TURN_COMPLETE_TIMEOUT,    )    .await;    let req = mock.single_request();    let function_output = req.function_call_output(call_id);    let output_items = function_output        .get("output")        .and_then(Value::as_array)        .expect("function_call_output should be a content item array");    assert_eq!(output_items.len(), 1);    assert_eq!(        output_items[0].get("detail").and_then(Value::as_str),        Some("high")    );    let image_url = output_items[0]        .get("image_url")        .and_then(Value::as_str)        .expect("image_url present");    let (_, encoded) = image_url        .split_once(',')        .expect("image url contains data prefix");    let decoded = BASE64_STANDARD        .decode(encoded)        .expect("image data decodes from base64 for request");    let resized = load_from_memory(&decoded).expect("load resized image");    let (width, height) = resized.dimensions();    assert_eq!((width, height), (2048, 768));    Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> anyhow::Result<()> {    skip_if_no_network!(Ok(()));    let server = start_mock_server().await;    let mut builder = test_codex().with_model("gpt-5.2");    let test = builder.build_with_remote_env(&server).await?;    let TestCodex {        codex,        session_configured,        ..    } = &test;    let rel_path = "assets/original-example-lower-model.png";    let original_width = 2304;    let original_height = 864;    write_workspace_png(        &test,        rel_path,        original_width,        original_height,        [0u8, 80, 255, 255],    )    .await?;    let call_id = "view-image-original-lower-model";    let arguments = serde_json::json!({ "path": rel_path }).to_string();    let first_response = sse(vec![        ev_response_created("resp-1"),        ev_function_call(call_id, "view_image", &arguments),        ev_completed("resp-1"),    ]);    responses::mount_sse_once(&server, first_response).await;    let second_response = sse(vec![        ev_assistant_message("msg-1", "done"),        ev_completed("resp-2"),    ]);    let mock = responses::mount_sse_once(&server, second_response).await;    let session_model = session_configured.model.clone();    codex        .submit(disabled_user_turn(            &test,            vec![UserInput::Text {                text: "please add the screenshot".into(),                text_elements: Vec::new(),            }],            session_model,        ))        .await?;    wait_for_event_with_timeout(        codex,        |event| matches!(event, EventMsg::TurnComplete(_)),        VIEW_IMAGE_TURN_COMPLETE_TIMEOUT,    )    .await;    let req = mock.single_request();    let function_output = req.function_call_output(call_id);    let output_items = function_output        .get("output")        .and_then(Value::as_array)        .expect("function_call_output should be a content item array");    assert_eq!(output_items.len(), 1);    assert_eq!(        output_items[0].get("detail").and_then(Value::as_str),        Some("high")    );    let image_url = output_items[0]        .get("image_url")        .and_then(Value::as_str)        .expect("image_url present");    let (prefix, encoded) = image_url        .split_once(',')        .expect("image url contains data prefix");    assert_eq!(prefix, "data:image/png;base64");    let decoded = BASE64_STANDARD        .decode(encoded)        .expect("image data decodes from base64 for request");    let resized = load_from_memory(&decoded).expect("load resized image");    let (resized_width, resized_height) = resized.dimensions();    assert_eq!((resized_width, resized_height), (2048, 768));    Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn view_image_tool_does_not_force_original_resolution_with_capability_only()-> anyhow::Result<()> {    skip_if_no_network!(Ok(()));    let server = start_mock_server().await;    let mut builder = test_codex().with_model("gpt-5.3-codex");    let test = builder.build_with_remote_env(&server).await?;    let TestCodex {        codex,        session_configured,        ..    } = &test;    let rel_path = "assets/original-example-capability-only.png";    let original_width = 2304;    let original_height = 864;    write_workspace_png(        &test,        rel_path,        original_width,        original_height,        [0u8, 80, 255, 255],    )    .await?;    let call_id = "view-image-capability-only";    let arguments = serde_json::json!({ "path": rel_path }).to_string();    let first_response = sse(vec![        ev_response_created("resp-1"),        ev_function_call(call_id, "view_image", &arguments),        ev_completed("resp-1"),    ]);    responses::mount_sse_once(&server, first_response).await;    let second_response = sse(vec![        ev_assistant_message("msg-1", "done"),        ev_completed("resp-2"),    ]);    let mock = responses::mount_sse_once(&server, second_response).await;    let session_model = session_configured.model.clone();    codex        .submit(disabled_user_turn(            &test,            vec![UserInput::Text {                text: "please add the screenshot".into(),                text_elements: Vec::new(),            }],            session_model,        ))        .await?;    wait_for_event_with_timeout(        codex,        |event| matches!(event, EventMsg::TurnComplete(_)),        VIEW_IMAGE_TURN_COMPLETE_TIMEOUT,    )    .await;    let req = mock.single_request();    let function_output = req.function_call_output(call_id);    let output_items = function_output        .get("output")        .and_then(Value::as_array)        .expect("function_call_output should be a content item array");    assert_eq!(output_items.len(), 1);    assert_eq!(        output_items[0].get("detail").and_then(Value::as_str),        Some("high")    );    let image_url = output_items[0]        .get("image_url")        .and_then(Value::as_str)        .expect("image_url present");    let (_, encoded) = image_url        .split_once(',')        .expect("image url contains data prefix");    let decoded = BASE64_STANDARD        .decode(encoded)        .expect("image data decodes from base64 for request");    let resized = load_from_memory(&decoded).expect("load resized image");    let (resized_width, resized_height) = resized.dimensions();    assert_eq!((resized_width, resized_height), (2048, 768));    Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> {    skip_if_no_network!(Ok(()));    let server = start_mock_server().await;    let mut builder = test_codex();    let test = builder.build_with_remote_env(&server).await?;    let TestCodex {        codex,        session_configured,        ..    } = &test;    let rel_path = "assets";    let abs_path = create_workspace_directory(&test, rel_path).await?;    let call_id = "view-image-directory";    let arguments = serde_json::json!({ "path": rel_path }).to_string();    let first_response = sse(vec![        ev_response_created("resp-1"),        ev_function_call(call_id, "view_image", &arguments),        ev_completed("resp-1"),    ]);    responses::mount_sse_once(&server, first_response).await;    let second_response = sse(vec![        ev_assistant_message("msg-1", "done"),        ev_completed("resp-2"),    ]);    let mock = responses::mount_sse_once(&server, second_response).await;    let session_model = session_configured.model.clone();    codex        .submit(disabled_user_turn(            &test,            vec![UserInput::Text {                text: "please attach the folder".into(),                text_elements: Vec::new(),            }],            session_model,        ))        .await?;    wait_for_event_with_timeout(        codex,        |event| matches!(event, EventMsg::TurnComplete(_)),        VIEW_IMAGE_TURN_COMPLETE_TIMEOUT,    )    .await;    let req = mock.single_request();    let body_with_tool_output = req.body_json();    let output_text = req        .function_call_output_content_and_success(call_id)        .and_then(|(content, _)| content)        .expect("output text present");    let expected_message = format!("image path `{}` is not a file", abs_path.display());    assert_eq!(output_text, expected_message);    assert!(        find_image_message(&body_with_tool_output).is_none(),        "directory path should not produce an input_image message"    );    Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> {    skip_if_no_network!(Ok(()));    let server = start_mock_server().await;    let mut builder = test_codex();    let test = builder.build_with_remote_env(&server).await?;    let TestCodex {        codex,        session_configured,        ..    } = &test;    let rel_path = "assets/example.json";    let abs_path =        write_workspace_file(&test, rel_path, br#"{ "message": "hello" }"#.to_vec()).await?;    let call_id = "view-image-non-image";    let arguments = serde_json::json!({ "path": rel_path }).to_string();    let first_response = sse(vec![        ev_response_created("resp-1"),        ev_function_call(call_id, "view_image", &arguments),        ev_completed("resp-1"),    ]);    responses::mount_sse_once(&server, first_response).await;    let second_response = sse(vec![        ev_assistant_message("msg-1", "done"),        ev_completed("resp-2"),    ]);    let mock = responses::mount_sse_once(&server, second_response).await;    let session_model = session_configured.model.clone();    codex        .submit(disabled_user_turn(            &test,            vec![UserInput::Text {                text: "please use the view_image tool to read the json file".into(),                text_elements: Vec::new(),            }],            session_model,        ))        .await?;    wait_for_event_with_timeout(        codex,        |event| matches!(event, EventMsg::TurnComplete(_)),        VIEW_IMAGE_TURN_COMPLETE_TIMEOUT,    )    .await;    let request = mock.single_request();    assert!(        request.inputs_of_type("input_image").is_empty(),        "non-image file should not produce an input_image message"    );    let (error_text, success) = request        .function_call_output_content_and_success(call_id)        .expect("function_call_output should be present");    assert_eq!(success, None);    let error_text = error_text.expect("error text present");    let expected_error = format!(        "unable to process image at `{}`: unsupported image `application/json`",        abs_path.display()    );    assert!(        error_text.contains(&expected_error),        "error should describe unsupported file type: {error_text}"    );    Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn resize_all_images_turns_invalid_view_image_into_placeholder() -> anyhow::Result<()> {    skip_if_no_network!(Ok(()));    let server = start_mock_server().await;    let mut builder = test_codex().with_config(|config| {        let _ = config.features.enable(Feature::ResizeAllImages);    });    let test = builder.build_with_remote_env(&server).await?;    let TestCodex {        codex,        session_configured,        ..    } = &test;    let rel_path = "assets/invalid-image.json";    write_workspace_file(&test, rel_path, br#"{ "message": "hello" }"#.to_vec()).await?;    let call_id = "view-image-invalid-placeholder";    let arguments = serde_json::json!({ "path": rel_path }).to_string();    responses::mount_sse_once(        &server,        sse(vec![            ev_response_created("resp-1"),            ev_function_call(call_id, "view_image", &arguments),            ev_completed("resp-1"),        ]),    )    .await;    let second_mock = responses::mount_sse_once(        &server,        sse(vec![            ev_assistant_message("msg-1", "done"),            ev_completed("resp-2"),        ]),    )    .await;    codex        .submit(disabled_user_turn(            &test,            vec![UserInput::Text {                text: "please inspect the image".into(),                text_elements: Vec::new(),            }],            session_configured.model.clone(),        ))        .await?;    wait_for_event_with_timeout(        codex,        |event| matches!(event, EventMsg::TurnComplete(_)),        VIEW_IMAGE_TURN_COMPLETE_TIMEOUT,    )    .await;    let request = second_mock.single_request();    assert_eq!(        request.function_call_output(call_id).get("output"),        Some(&serde_json::json!([{            "type": "input_text",            "text": "image content omitted because it could not be processed"        }]))    );    Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> {    skip_if_no_network!(Ok(()));    let server = start_mock_server().await;    let mut builder = test_codex();    let test = builder.build_with_remote_env(&server).await?;    let TestCodex {        codex,        config,        session_configured,        ..    } = &test;    let rel_path = "missing/example.png";    let abs_path = config.cwd.join(rel_path);    let call_id = "view-image-missing";    let arguments = serde_json::json!({ "path": rel_path }).to_string();    let first_response = sse(vec![        ev_response_created("resp-1"),        ev_function_call(call_id, "view_image", &arguments),        ev_completed("resp-1"),    ]);    responses::mount_sse_once(&server, first_response).await;    let second_response = sse(vec![        ev_assistant_message("msg-1", "done"),        ev_completed("resp-2"),    ]);    let mock = responses::mount_sse_once(&server, second_response).await;    let session_model = session_configured.model.clone();    codex        .submit(disabled_user_turn(            &test,            vec![UserInput::Text {                text: "please attach the missing image".into(),                text_elements: Vec::new(),            }],            session_model,        ))        .await?;    wait_for_event_with_timeout(        codex,        |event| matches!(event, EventMsg::TurnComplete(_)),        VIEW_IMAGE_TURN_COMPLETE_TIMEOUT,    )    .await;    let req = mock.single_request();    let body_with_tool_output = req.body_json();    let output_text = req        .function_call_output_content_and_success(call_id)        .and_then(|(content, _)| content)        .expect("output text present");    let expected_prefix = format!("unable to locate image at `{}`:", abs_path.display());    assert!(        output_text.starts_with(&expected_prefix),        "expected error to start with `{expected_prefix}` but got `{output_text}`"    );    assert!(        find_image_message(&body_with_tool_output).is_none(),        "missing file should not produce an input_image message"    );    Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> anyhow::Result<()> {    skip_if_no_network!(Ok(()));    // Use MockServer directly (not start_mock_server) so the first /models request returns our    // text-only model. start_mock_server mounts empty models first, causing get_model_info to    // fall back to model_info_from_slug with default_input_modalities (Text+Image), which would    // incorrectly allow view_image.    let server = MockServer::builder()        .body_print_limit(BodyPrintLimit::Limited(80_000))        .start()        .await;    let model_slug = "text-only-view-image-test-model";    let text_only_model = ModelInfo {        slug: model_slug.to_string(),        display_name: "Text-only view_image test model".to_string(),        description: Some("Remote model for view_image unsupported-path coverage".to_string()),        default_reasoning_level: Some(ReasoningEffort::Medium),        supported_reasoning_levels: vec![ReasoningEffortPreset {            effort: ReasoningEffort::Medium,            description: ReasoningEffort::Medium.to_string(),        }],        shell_type: ConfigShellToolType::ShellCommand,        visibility: ModelVisibility::List,        supported_in_api: true,        input_modalities: vec![InputModality::Text],        used_fallback_model_metadata: false,        supports_search_tool: false,        use_responses_lite: false,        auto_review_model_override: None,        tool_mode: None,        multi_agent_version: None,        priority: 1,        additional_speed_tiers: Vec::new(),        service_tiers: Vec::new(),        default_service_tier: None,        upgrade: None,        base_instructions: "base instructions".to_string(),        model_messages: None,        supports_reasoning_summaries: false,        default_reasoning_summary: ReasoningSummary::Auto,        support_verbosity: false,        default_verbosity: None,        availability_nux: None,        apply_patch_tool_type: None,        web_search_tool_type: Default::default(),        truncation_policy: TruncationPolicyConfig::bytes(/*limit*/ 10_000),        supports_parallel_tool_calls: false,        supports_image_detail_original: false,        context_window: Some(272_000),        max_context_window: None,        auto_compact_token_limit: None,        comp_hash: None,        effective_context_window_percent: 95,        experimental_supported_tools: Vec::new(),    };    mount_models_once(        &server,        ModelsResponse {            models: vec![text_only_model],        },    )    .await;    let mut builder = test_codex()        .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())        .with_config(|config| {            config.model = Some(model_slug.to_string());        });    let test = builder.build_with_remote_env(&server).await?;    let TestCodex { codex, .. } = &test;    let rel_path = "assets/example.png";    write_workspace_png(        &test,        rel_path,        /*width*/ 20,        /*height*/ 20,        [255u8, 0, 0, 255],    )    .await?;    let call_id = "view-image-unsupported-model";    let arguments = serde_json::json!({ "path": rel_path }).to_string();    let first_response = sse(vec![        ev_response_created("resp-1"),        ev_function_call(call_id, "view_image", &arguments),        ev_completed("resp-1"),    ]);    responses::mount_sse_once(&server, first_response).await;    let second_response = sse(vec![        ev_assistant_message("msg-1", "done"),        ev_completed("resp-2"),    ]);    let mock = responses::mount_sse_once(&server, second_response).await;    codex        .submit(disabled_user_turn(            &test,            vec![UserInput::Text {                text: "please attach the image".into(),                text_elements: Vec::new(),            }],            model_slug.to_string(),        ))        .await?;    wait_for_event_with_timeout(        codex,        |event| matches!(event, EventMsg::TurnComplete(_)),        VIEW_IMAGE_TURN_COMPLETE_TIMEOUT,    )    .await;    let output_text = mock        .single_request()        .function_call_output_content_and_success(call_id)        .and_then(|(content, _)| content)        .expect("output text present");    assert_eq!(        output_text,        "view_image is not allowed because you do not support image inputs"    );    Ok(())}#[cfg(not(debug_assertions))]#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> {    skip_if_no_network!(Ok(()));    let server = start_mock_server().await;    const INVALID_IMAGE_ERROR: &str =        "The image data you provided does not represent a valid image";    let invalid_image_mock = responses::mount_response_once_match(        &server,        body_string_contains("\"input_image\""),        ResponseTemplate::new(400)            .insert_header("content-type", "text/plain")            .set_body_string(INVALID_IMAGE_ERROR),    )    .await;    let success_response = sse(vec![        ev_response_created("resp-2"),        ev_assistant_message("msg-1", "done"),        ev_completed("resp-2"),    ]);    let completion_mock = responses::mount_sse_once(&server, success_response).await;    let mut builder = test_codex();    let test = builder.build_with_remote_env(&server).await?;    let TestCodex {        codex,        session_configured,        ..    } = &test;    let rel_path = "assets/poisoned.png";    let abs_path = write_workspace_png(&test, rel_path, 1024, 512, [10u8, 20, 30, 255]).await?;    let session_model = session_configured.model.clone();    codex        .submit(disabled_user_turn(            &test,            vec![UserInput::LocalImage {                path: abs_path.clone(),                detail: None,            }],            session_model,        ))        .await?;    wait_for_event_with_timeout(        &codex,        |event| matches!(event, EventMsg::TurnComplete(_)),        VIEW_IMAGE_TURN_COMPLETE_TIMEOUT,    )    .await;    let first_body = invalid_image_mock.single_request().body_json();    assert!(        find_image_message(&first_body).is_some(),        "initial request should include the uploaded image"    );    let second_request = completion_mock.single_request();    let second_body = second_request.body_json();    assert!(        find_image_message(&second_body).is_none(),        "second request should replace the invalid image"    );    let user_texts = second_request.message_input_texts("user");    assert!(user_texts.iter().any(|text| text == "Invalid image"));    Ok(())}