use codex_features::Feature;use codex_protocol::items::ImageViewItem;use codex_protocol::items::TurnItem;use codex_protocol::models::DEFAULT_IMAGE_DETAIL;use codex_protocol::models::FunctionCallOutputBody;use codex_protocol::models::FunctionCallOutputContentItem;use codex_protocol::models::FunctionCallOutputPayload;use codex_protocol::models::ImageDetail;use codex_protocol::models::ResponseInputItem;use codex_protocol::openai_models::InputModality;use codex_utils_image::PromptImageMode;use codex_utils_image::data_url_from_bytes;use codex_utils_image::load_for_prompt_bytes;use serde::Deserialize;use crate::function_tool::FunctionCallError;use crate::original_image_detail::can_request_original_image_detail;use crate::tools::context::ToolInvocation;use crate::tools::context::ToolOutput;use crate::tools::context::ToolPayload;use crate::tools::context::boxed_tool_output;use crate::tools::handlers::parse_arguments;use crate::tools::handlers::resolve_tool_environment;use crate::tools::handlers::view_image_spec::ViewImageToolOptions;use crate::tools::handlers::view_image_spec::create_view_image_tool;use crate::tools::registry::CoreToolRuntime;use crate::tools::registry::ToolExecutor;use codex_tools::ToolName;use codex_tools::ToolSpec;use codex_utils_path_uri::PathUri;pub struct ViewImageHandler { options: ViewImageToolOptions,}impl Default for ViewImageHandler { fn default() -> Self { Self { options: ViewImageToolOptions { can_request_original_image_detail: false, include_environment_id: false, }, } }}impl ViewImageHandler { pub(crate) fn new(options: ViewImageToolOptions) -> Self { Self { options } }}const VIEW_IMAGE_UNSUPPORTED_MESSAGE: &str = "view_image is not allowed because you do not support image inputs";#[derive(Deserialize)]struct ViewImageArgs { path: String, #[serde(default)] environment_id: Option<String>, detail: Option<String>,}#[derive(Clone, Copy, Eq, PartialEq)]enum ViewImageDetail { High, Original,}impl ToolExecutor<ToolInvocation> for ViewImageHandler { fn tool_name(&self) -> ToolName { ToolName::plain("view_image") } fn spec(&self) -> ToolSpec { create_view_image_tool(self.options) } fn supports_parallel_tool_calls(&self) -> bool { true } fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> { Box::pin(self.handle_call(invocation)) }}impl ViewImageHandler { async fn handle_call( &self, invocation: ToolInvocation, ) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> { if !invocation .turn .model_info .input_modalities .contains(&InputModality::Image) { return Err(FunctionCallError::RespondToModel( VIEW_IMAGE_UNSUPPORTED_MESSAGE.to_string(), )); } let ToolInvocation { session, turn, payload, call_id, .. } = invocation; let arguments = match payload { ToolPayload::Function { arguments } => arguments, _ => { return Err(FunctionCallError::RespondToModel( "view_image handler received unsupported payload".to_string(), )); } }; let ViewImageArgs { path, environment_id, detail, } = parse_arguments(&arguments)?; // `high` is the explicit spelling of the default resized path. // Other string values remain invalid rather than being silently reinterpreted. let detail = match detail.as_deref() { None => None, Some("high") => Some(ViewImageDetail::High), Some("original") => Some(ViewImageDetail::Original), Some(detail) => { return Err(FunctionCallError::RespondToModel(format!( "view_image.detail only supports `high` or `original`; omit `detail` for default high resized behavior, got `{detail}`" ))); } }; let Some(turn_environment) = resolve_tool_environment(turn.as_ref(), environment_id.as_deref())? else { return Err(FunctionCallError::RespondToModel( "view_image is unavailable in this session".to_string(), )); }; // TODO(anp): Resolve tool paths using the selected environment's native path convention // so view_image can support relative paths in foreign environments. let cwd = turn_environment.cwd().to_abs_path().map_err(|err| { FunctionCallError::RespondToModel(format!( "environment cwd `{}` is not native to the Codex host: {err}", turn_environment.cwd() )) })?; let abs_path = cwd.join(path); let sandbox = turn.file_system_sandbox_context( /*additional_permissions*/ None, turn_environment.cwd(), ); let fs = turn_environment.environment.get_filesystem(); let path_uri = PathUri::from_abs_path(&abs_path); let metadata = fs .get_metadata(&path_uri, Some(&sandbox)) .await .map_err(|error| { FunctionCallError::RespondToModel(format!( "unable to locate image at `{}`: {error}", abs_path.display() )) })?; if !metadata.is_file { return Err(FunctionCallError::RespondToModel(format!( "image path `{}` is not a file", abs_path.display() ))); } let file_bytes = fs .read_file(&path_uri, Some(&sandbox)) .await .map_err(|error| { FunctionCallError::RespondToModel(format!( "unable to read image at `{}`: {error}", abs_path.display() )) })?; let event_path = abs_path.clone(); let can_request_original_detail = can_request_original_image_detail(&turn.model_info); let use_original_detail = can_request_original_detail && matches!(detail, Some(ViewImageDetail::Original)); let image_detail = if use_original_detail { ImageDetail::Original } else { DEFAULT_IMAGE_DETAIL }; let image_url = if turn.features.enabled(Feature::ResizeAllImages) { // The history insertion path owns image decoding and resizing when this is enabled. data_url_from_bytes("application/octet-stream", &file_bytes) } else { let image_mode = if use_original_detail { PromptImageMode::Original } else { PromptImageMode::ResizeToFit }; load_for_prompt_bytes(abs_path.as_path(), file_bytes, image_mode) .map_err(|error| { FunctionCallError::RespondToModel(format!( "unable to process image at `{}`: {error}", abs_path.display() )) })? .into_data_url() }; let item = TurnItem::ImageView(ImageViewItem { id: call_id, path: event_path, }); session.emit_turn_item_started(turn.as_ref(), &item).await; session.emit_turn_item_completed(turn.as_ref(), item).await; Ok(boxed_tool_output(ViewImageOutput { image_url, image_detail, })) }}impl CoreToolRuntime for ViewImageHandler {}pub struct ViewImageOutput { image_url: String, image_detail: ImageDetail,}impl ToolOutput for ViewImageOutput { fn log_preview(&self) -> String { format!("<image data URL omitted: {} bytes>", self.image_url.len()) } fn success_for_logging(&self) -> bool { true } fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem { let body = FunctionCallOutputBody::ContentItems(vec![FunctionCallOutputContentItem::InputImage { image_url: self.image_url.clone(), detail: Some(self.image_detail), }]); let output = FunctionCallOutputPayload { body, success: Some(true), }; ResponseInputItem::FunctionCallOutput { call_id: call_id.to_string(), output, } } fn code_mode_result(&self, _payload: &ToolPayload) -> serde_json::Value { serde_json::json!({ "image_url": self.image_url, "detail": self.image_detail }) }}#[cfg(test)]mod tests { use super::*; use crate::session::tests::make_session_and_context; use crate::session::turn_context::TurnEnvironment; use crate::tools::context::ToolCallSource; use crate::tools::context::ToolInvocation; use crate::turn_diff_tracker::TurnDiffTracker; use codex_protocol::models::PermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; use core_test_support::TempDirExt; use pretty_assertions::assert_eq; use serde_json::json; use std::sync::Arc; use tokio::sync::Mutex; fn replace_primary_environment_cwd(turn: &mut crate::TurnContext, cwd: AbsolutePathBuf) { let current = turn .environments .turn_environments .first() .cloned() .expect("default local turn environment"); turn.environments.turn_environments[0] = TurnEnvironment::new( current.environment_id, current.environment, PathUri::from_abs_path(&cwd), current.shell, ); } #[test] fn log_preview_omits_image_data() { let output = ViewImageOutput { image_url: "data:image/png;base64,AAA".to_string(), image_detail: DEFAULT_IMAGE_DETAIL, }; assert_eq!(output.log_preview(), "<image data URL omitted: 25 bytes>"); } #[test] fn code_mode_result_returns_image_url_object() { let output = ViewImageOutput { image_url: "data:image/png;base64,AAA".to_string(), image_detail: DEFAULT_IMAGE_DETAIL, }; let result = output.code_mode_result(&ToolPayload::Function { arguments: "{}".to_string(), }); assert_eq!( result, json!({ "image_url": "data:image/png;base64,AAA", "detail": "high", }) ); } #[tokio::test] async fn handle_passes_sandbox_context_for_local_filesystem_reads() { let (session, mut turn) = make_session_and_context().await; let image_dir = tempfile::tempdir().expect("create image temp dir"); let image_cwd = image_dir.abs(); replace_primary_environment_cwd(&mut turn, image_cwd.clone()); let image_path = image_cwd.join("image.png"); std::fs::write(image_path.as_path(), b"not a real image").expect("write test image"); turn.permission_profile = PermissionProfile::read_only(); let result = ViewImageHandler::default() .handle(ToolInvocation { session: Arc::new(session), turn: Arc::new(turn), cancellation_token: tokio_util::sync::CancellationToken::new(), tracker: Arc::new(Mutex::new(TurnDiffTracker::new())), call_id: "call-view-image".to_string(), tool_name: codex_tools::ToolName::plain("view_image"), source: ToolCallSource::Direct, payload: ToolPayload::Function { arguments: json!({ "path": "image.png" }).to_string(), }, }) .await; let Err(FunctionCallError::RespondToModel(message)) = result else { panic!("expected sandboxed filesystem error"); }; assert!( message.contains("sandboxed filesystem operations require configured runtime paths"), "{message}" ); } #[tokio::test] async fn handle_rejects_unsupported_detail() { let (session, turn) = make_session_and_context().await; let result = ViewImageHandler::default() .handle(ToolInvocation { session: Arc::new(session), turn: Arc::new(turn), cancellation_token: tokio_util::sync::CancellationToken::new(), tracker: Arc::new(Mutex::new(TurnDiffTracker::new())), call_id: "call-view-image".to_string(), tool_name: codex_tools::ToolName::plain("view_image"), source: ToolCallSource::Direct, payload: ToolPayload::Function { arguments: json!({ "path": "image.png", "detail": "low" }).to_string(), }, }) .await; let Err(FunctionCallError::RespondToModel(message)) = result else { panic!("expected unsupported detail error"); }; assert_eq!( message, "view_image.detail only supports `high` or `original`; omit `detail` for default high resized behavior, got `low`" ); } #[tokio::test(flavor = "multi_thread")] async fn handle_accepts_explicit_high_detail() { let (session, mut turn) = make_session_and_context().await; let image_dir = tempfile::tempdir().expect("create image temp dir"); let image_cwd = image_dir.abs(); replace_primary_environment_cwd(&mut turn, image_cwd.clone()); let image_path = image_cwd.join("image.png"); std::fs::write(image_path.as_path(), b"not a real image").expect("write test image"); turn.permission_profile = PermissionProfile::Disabled; let result = ViewImageHandler::default() .handle(ToolInvocation { session: Arc::new(session), turn: Arc::new(turn), cancellation_token: tokio_util::sync::CancellationToken::new(), tracker: Arc::new(Mutex::new(TurnDiffTracker::new())), call_id: "call-view-image".to_string(), tool_name: codex_tools::ToolName::plain("view_image"), source: ToolCallSource::Direct, payload: ToolPayload::Function { arguments: json!({ "path": "image.png", "detail": "high" }).to_string(), }, }) .await; let Err(FunctionCallError::RespondToModel(message)) = result else { panic!("expected image processing error"); }; assert!(message.contains("unable to process image"), "{message}"); }}