diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index d7462cddb2..6902b36821 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -14,7 +14,6 @@ use codex_protocol::config_types::Verbosity; use codex_protocol::config_types::WebSearchMode; use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent; use codex_protocol::items::TurnItem as CoreTurnItem; -use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::parse_command::ParsedCommand as CoreParsedCommand; @@ -2692,9 +2691,10 @@ pub enum DynamicToolCallResult { /// App-server-facing dynamic tool output items. /// -/// This mirrors `FunctionCallOutputContentItem` today, but is intentionally -/// defined separately so the app-server API can evolve without forcing matching -/// protocol changes at the same time. +/// This is intentionally defined separately from +/// `codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem` and +/// `codex_protocol::models::FunctionCallOutputContentItem` so the app-server API +/// can evolve independently. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "camelCase")] #[ts(tag = "type")] @@ -2710,28 +2710,6 @@ pub enum DynamicToolCallOutputContentItem { }, } -impl From for FunctionCallOutputContentItem { - fn from(item: DynamicToolCallOutputContentItem) -> Self { - match item { - DynamicToolCallOutputContentItem::InputText { text } => Self::InputText { text }, - DynamicToolCallOutputContentItem::InputImage { image_url } => { - Self::InputImage { image_url } - } - } - } -} - -impl From for DynamicToolCallOutputContentItem { - fn from(item: FunctionCallOutputContentItem) -> Self { - match item { - FunctionCallOutputContentItem::InputText { text } => Self::InputText { text }, - FunctionCallOutputContentItem::InputImage { image_url } => { - Self::InputImage { image_url } - } - } - } -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -2917,7 +2895,6 @@ mod tests { use codex_protocol::items::TurnItem; use codex_protocol::items::UserMessageItem; use codex_protocol::items::WebSearchItem; - use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::WebSearchAction as CoreWebSearchAction; use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; use codex_protocol::user_input::UserInput as CoreUserInput; @@ -3119,21 +3096,6 @@ mod tests { ); } - #[test] - fn dynamic_tool_content_item_maps_to_function_call_output_content_item() { - let item = DynamicToolCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,AAA".to_string(), - }; - - let converted: FunctionCallOutputContentItem = item.into(); - assert_eq!( - converted, - FunctionCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,AAA".to_string(), - } - ); - } - #[test] fn dynamic_tool_content_item_accepts_legacy_snake_case_payloads() { let item = serde_json::from_value::(json!({ diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 4f6fd15dc6..b92ae6940c 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -89,6 +89,7 @@ use codex_core::review_format::format_review_findings_block; use codex_core::review_prompts; use codex_protocol::ThreadId; use codex_protocol::dynamic_tools::DynamicToolResponse as CoreDynamicToolResponse; +use codex_protocol::dynamic_tools::DynamicToolResult as CoreDynamicToolResult; use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::ReviewOutputEvent; use codex_protocol::request_user_input::RequestUserInputAnswer as CoreRequestUserInputAnswer; @@ -352,9 +353,10 @@ pub(crate) async fn apply_bespoke_event_handling( id: call_id.clone(), response: CoreDynamicToolResponse { call_id, - output: Some("dynamic tool calls require api v2".to_string()), + result: CoreDynamicToolResult::Output { + output: "dynamic tool calls require api v2".to_string(), + }, success: false, - content_items: None, }, }) .await; diff --git a/codex-rs/app-server/src/dynamic_tools.rs b/codex-rs/app-server/src/dynamic_tools.rs index c63ec45d34..542dab8b49 100644 --- a/codex-rs/app-server/src/dynamic_tools.rs +++ b/codex-rs/app-server/src/dynamic_tools.rs @@ -1,7 +1,9 @@ use codex_app_server_protocol::DynamicToolCallResponse; use codex_app_server_protocol::DynamicToolCallResult; use codex_core::CodexThread; +use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynamicToolCallOutputContentItem; use codex_protocol::dynamic_tools::DynamicToolResponse as CoreDynamicToolResponse; +use codex_protocol::dynamic_tools::DynamicToolResult as CoreDynamicToolResult; use codex_protocol::protocol::Op; use std::sync::Arc; use tokio::sync::oneshot; @@ -19,9 +21,10 @@ pub(crate) async fn on_call_response( error!("request failed: {err:?}"); let fallback = CoreDynamicToolResponse { call_id: call_id.clone(), - output: Some("dynamic tool request failed".to_string()), + result: CoreDynamicToolResult::Output { + output: "dynamic tool request failed".to_string(), + }, success: false, - content_items: None, }; if let Err(err) = conversation .submit(Op::DynamicToolResponse { @@ -46,18 +49,18 @@ pub(crate) async fn on_call_response( } }); - let (output, content_items) = match response.result { - DynamicToolCallResult::ContentItems { content_items } => ( - None, - Some(content_items.into_iter().map(Into::into).collect()), - ), - DynamicToolCallResult::Output { output } => (Some(output), None), + let result = match response.result { + DynamicToolCallResult::ContentItems { content_items } => { + CoreDynamicToolResult::ContentItems { + content_items: content_items.into_iter().map(map_content_item).collect(), + } + } + DynamicToolCallResult::Output { output } => CoreDynamicToolResult::Output { output }, }; let response = CoreDynamicToolResponse { call_id: call_id.clone(), - output, + result, success: response.success, - content_items, }; if let Err(err) = conversation .submit(Op::DynamicToolResponse { @@ -69,3 +72,16 @@ pub(crate) async fn on_call_response( error!("failed to submit DynamicToolResponse: {err}"); } } + +fn map_content_item( + item: codex_app_server_protocol::DynamicToolCallOutputContentItem, +) -> CoreDynamicToolCallOutputContentItem { + match item { + codex_app_server_protocol::DynamicToolCallOutputContentItem::InputText { text } => { + CoreDynamicToolCallOutputContentItem::InputText { text } + } + codex_app_server_protocol::DynamicToolCallOutputContentItem::InputImage { image_url } => { + CoreDynamicToolCallOutputContentItem::InputImage { image_url } + } + } +} diff --git a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs index d670c096cc..e3528c81fd 100644 --- a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs +++ b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs @@ -330,7 +330,14 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<( let content_items = response_content_items .clone() .into_iter() - .map(Into::into) + .map(|item| match item { + DynamicToolCallOutputContentItem::InputText { text } => { + FunctionCallOutputContentItem::InputText { text } + } + DynamicToolCallOutputContentItem::InputImage { image_url } => { + FunctionCallOutputContentItem::InputImage { image_url } + } + }) .collect::>(); let response = DynamicToolCallResponse { result: DynamicToolCallResult::ContentItems { diff --git a/codex-rs/core/src/tools/handlers/dynamic.rs b/codex-rs/core/src/tools/handlers/dynamic.rs index dd4d95b059..2188ff6ebb 100644 --- a/codex-rs/core/src/tools/handlers/dynamic.rs +++ b/codex-rs/core/src/tools/handlers/dynamic.rs @@ -8,8 +8,10 @@ use crate::tools::handlers::parse_arguments; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; use async_trait::async_trait; +use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem; use codex_protocol::dynamic_tools::DynamicToolCallRequest; use codex_protocol::dynamic_tools::DynamicToolResponse; +use codex_protocol::dynamic_tools::DynamicToolResult; use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::protocol::EventMsg; use serde_json::Value; @@ -57,15 +59,18 @@ impl ToolHandler for DynamicToolHandler { })?; let DynamicToolResponse { - output, - content_items, - success, - .. + result, success, .. } = response; - let output = normalize_output(output, content_items.as_deref()); + let (content, content_items) = match result { + DynamicToolResult::Output { output } => (output, None), + DynamicToolResult::ContentItems { content_items } => ( + content_items_to_text(Some(&content_items)).unwrap_or_default(), + Some(content_items.into_iter().map(map_content_item).collect()), + ), + }; Ok(ToolOutput::Function { - content: output, + content, content_items, success: Some(success), }) @@ -106,24 +111,13 @@ async fn request_dynamic_tool( rx_response.await.ok() } -fn normalize_output( - output: Option, - content_items: Option<&[FunctionCallOutputContentItem]>, -) -> String { - if let Some(output) = output { - return output; - } - - content_items_to_text(content_items).unwrap_or_default() -} - fn content_items_to_text( - content_items: Option<&[FunctionCallOutputContentItem]>, + content_items: Option<&[DynamicToolCallOutputContentItem]>, ) -> Option { let mut text = Vec::new(); for item in content_items.unwrap_or_default() { - if let FunctionCallOutputContentItem::InputText { text: segment } = item + if let DynamicToolCallOutputContentItem::InputText { text: segment } = item && !segment.trim().is_empty() { text.push(segment.as_str()); @@ -137,51 +131,52 @@ fn content_items_to_text( } } +fn map_content_item(item: DynamicToolCallOutputContentItem) -> FunctionCallOutputContentItem { + match item { + DynamicToolCallOutputContentItem::InputText { text } => { + FunctionCallOutputContentItem::InputText { text } + } + DynamicToolCallOutputContentItem::InputImage { image_url } => { + FunctionCallOutputContentItem::InputImage { image_url } + } + } +} + #[cfg(test)] mod tests { use super::*; use pretty_assertions::assert_eq; #[test] - fn normalize_output_prefers_explicit_output() { - let content_items = vec![FunctionCallOutputContentItem::InputText { - text: "fallback".to_string(), - }]; - - let output = normalize_output(Some("explicit".to_string()), Some(&content_items)); - assert_eq!(output, "explicit"); - } - - #[test] - fn normalize_output_uses_text_content_items() { + fn content_items_to_text_uses_text_content_items() { let content_items = vec![ - FunctionCallOutputContentItem::InputText { + DynamicToolCallOutputContentItem::InputText { text: "line 1".to_string(), }, - FunctionCallOutputContentItem::InputImage { + DynamicToolCallOutputContentItem::InputImage { image_url: "data:image/png;base64,AAA".to_string(), }, - FunctionCallOutputContentItem::InputText { + DynamicToolCallOutputContentItem::InputText { text: "line 2".to_string(), }, ]; - let output = normalize_output(None, Some(&content_items)); + let output = content_items_to_text(Some(&content_items)).unwrap_or_default(); assert_eq!(output, "line 1\nline 2"); } #[test] - fn normalize_output_ignores_empty_and_image_only_content_items() { + fn content_items_to_text_ignores_empty_and_image_only_content_items() { let content_items = vec![ - FunctionCallOutputContentItem::InputText { + DynamicToolCallOutputContentItem::InputText { text: " ".to_string(), }, - FunctionCallOutputContentItem::InputImage { + DynamicToolCallOutputContentItem::InputImage { image_url: "data:image/png;base64,AAA".to_string(), }, ]; - let output = normalize_output(None, Some(&content_items)); - assert_eq!(output, ""); + let output = content_items_to_text(Some(&content_items)); + assert_eq!(output, None); } } diff --git a/codex-rs/protocol/src/dynamic_tools.rs b/codex-rs/protocol/src/dynamic_tools.rs index 4a2430ce70..66b2379569 100644 --- a/codex-rs/protocol/src/dynamic_tools.rs +++ b/codex-rs/protocol/src/dynamic_tools.rs @@ -4,8 +4,6 @@ use serde::Serialize; use serde_json::Value as JsonValue; use ts_rs::TS; -use crate::models::FunctionCallOutputContentItem; - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct DynamicToolSpec { @@ -27,9 +25,33 @@ pub struct DynamicToolCallRequest { #[serde(rename_all = "camelCase")] pub struct DynamicToolResponse { pub call_id: String, - pub output: Option, + #[serde(flatten)] + pub result: DynamicToolResult, pub success: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub content_items: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] +#[serde(untagged, rename_all = "camelCase")] +pub enum DynamicToolResult { + ContentItems { + #[serde(rename = "contentItems")] + content_items: Vec, + }, + Output { + output: String, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +pub enum DynamicToolCallOutputContentItem { + #[serde(alias = "input_text")] + InputText { text: String }, + #[serde(alias = "input_image", rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + InputImage { + #[serde(alias = "image_url")] + image_url: String, + }, }