diff --git a/codex-rs/core/tests/fixtures/rollout_copy_paste_local_image.json b/codex-rs/core/tests/fixtures/rollout_copy_paste_local_image.json new file mode 100644 index 0000000000..2bf48cfacf --- /dev/null +++ b/codex-rs/core/tests/fixtures/rollout_copy_paste_local_image.json @@ -0,0 +1,22 @@ +{ + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "" + }, + { + "type": "input_image", + "image_url": "__IMAGE_URL__" + }, + { + "type": "input_text", + "text": "" + }, + { + "type": "input_text", + "text": "pasted image" + } + ] +} diff --git a/codex-rs/core/tests/fixtures/rollout_drag_drop_image.json b/codex-rs/core/tests/fixtures/rollout_drag_drop_image.json new file mode 100644 index 0000000000..99e193d165 --- /dev/null +++ b/codex-rs/core/tests/fixtures/rollout_drag_drop_image.json @@ -0,0 +1,22 @@ +{ + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "" + }, + { + "type": "input_image", + "image_url": "__IMAGE_URL__" + }, + { + "type": "input_text", + "text": "" + }, + { + "type": "input_text", + "text": "dropped image" + } + ] +} diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs new file mode 100644 index 0000000000..f3996805f1 --- /dev/null +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -0,0 +1,203 @@ +use anyhow::Context; +use codex_core::protocol::AskForApproval; +use codex_core::protocol::EventMsg; +use codex_core::protocol::Op; +use codex_core::protocol::RolloutItem; +use codex_core::protocol::RolloutLine; +use codex_core::protocol::SandboxPolicy; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; +use codex_protocol::user_input::UserInput; +use codex_utils_cargo_bin::find_resource; +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_response_created; +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::test_codex::TestCodex; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use image::ImageBuffer; +use image::Rgba; +use pretty_assertions::assert_eq; +use std::path::Path; + +fn load_expected_response_item(path: &str, image_url: &str) -> anyhow::Result { + let full_path = find_resource!(path).context("fixture path should resolve")?; + let raw = std::fs::read_to_string(&full_path) + .with_context(|| format!("read fixture {}", full_path.display()))?; + let replaced = raw.replace("__IMAGE_URL__", image_url); + let response = serde_json::from_str(&replaced) + .with_context(|| format!("parse response item fixture {}", full_path.display()))?; + Ok(response) +} + +fn find_user_message_with_image(text: &str) -> Option { + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let rollout: RolloutLine = match serde_json::from_str(trimmed) { + Ok(rollout) => rollout, + Err(_) => continue, + }; + if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) = + &rollout.item + && role == "user" + && content + .iter() + .any(|span| matches!(span, ContentItem::InputImage { .. })) + && let RolloutItem::ResponseItem(item) = rollout.item.clone() + { + return Some(item); + } + } + None +} + +fn write_test_png(path: &Path, color: [u8; 4]) -> anyhow::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let image = ImageBuffer::from_pixel(2, 2, Rgba(color)); + image.save(path)?; + Ok(()) +} + +fn extract_image_url(item: &ResponseItem) -> Option { + match item { + ResponseItem::Message { content, .. } => content.iter().find_map(|span| match span { + ContentItem::InputImage { image_url } => Some(image_url.clone()), + _ => None, + }), + _ => None, + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + + let TestCodex { + codex, + cwd, + session_configured, + .. + } = test_codex().build(&server).await?; + + let rel_path = "images/paste.png"; + let abs_path = cwd.path().join(rel_path); + write_test_png(&abs_path, [12, 34, 56, 255])?; + + let response = sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-1"), + ]); + responses::mount_sse_once(&server, response).await; + + let session_model = session_configured.model.clone(); + + codex + .submit(Op::UserTurn { + items: vec![ + UserInput::LocalImage { + path: abs_path.clone(), + }, + UserInput::Text { + text: "pasted image".to_string(), + }, + ], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; + + let rollout_text = std::fs::read_to_string(codex.rollout_path())?; + let actual = find_user_message_with_image(&rollout_text) + .expect("expected user message with input image in rollout"); + + let image_url = extract_image_url(&actual).expect("expected image url in rollout"); + let expected = load_expected_response_item( + "tests/fixtures/rollout_copy_paste_local_image.json", + &image_url, + )?; + + assert_eq!(actual, expected); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + + let TestCodex { + codex, + cwd, + session_configured, + .. + } = test_codex().build(&server).await?; + + let image_url = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=" + .to_string(); + + let response = sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-1"), + ]); + responses::mount_sse_once(&server, response).await; + + let session_model = session_configured.model.clone(); + + codex + .submit(Op::UserTurn { + items: vec![ + UserInput::Image { + image_url: image_url.clone(), + }, + UserInput::Text { + text: "dropped image".to_string(), + }, + ], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; + + let rollout_text = std::fs::read_to_string(codex.rollout_path())?; + let actual = find_user_message_with_image(&rollout_text) + .expect("expected user message with input image in rollout"); + + let image_url = extract_image_url(&actual).expect("expected image url in rollout"); + let expected = + load_expected_response_item("tests/fixtures/rollout_drag_drop_image.json", &image_url)?; + + assert_eq!(actual, expected); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 44093778d3..83b0891872 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -31,6 +31,7 @@ mod exec_policy; mod fork_thread; mod grep_files; mod hierarchical_agents; +mod image_rollout; mod items; mod json_result; mod list_dir; diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 1bed23ac37..1038606831 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -2763,6 +2763,18 @@ mod tests { } } + #[test] + fn image_placeholder_snapshots() { + snapshot_composer_state("image_placeholder_single", false, |composer| { + composer.attach_image(PathBuf::from("/tmp/image1.png")); + }); + + snapshot_composer_state("image_placeholder_multiple", false, |composer| { + composer.attach_image(PathBuf::from("/tmp/image1.png")); + composer.attach_image(PathBuf::from("/tmp/image2.png")); + }); + } + #[test] fn slash_popup_model_first_for_mo_ui() { use ratatui::Terminal; diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__image_placeholder_multiple.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__image_placeholder_multiple.snap new file mode 100644 index 0000000000..44db174435 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__image_placeholder_multiple.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 2099 +expression: terminal.backend() +--- +" " +"› [Image #1][Image #2] " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__image_placeholder_single.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__image_placeholder_single.snap new file mode 100644 index 0000000000..80dcae6c2c --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__image_placeholder_single.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 2099 +expression: terminal.backend() +--- +" " +"› [Image #1] " +" " +" " +" " +" " +" " +" " +" 100% context left "