This commit is contained in:
Aaron Levine
2026-02-02 16:30:18 -08:00
parent 86dc7d0ccb
commit 95cd861224
6 changed files with 104 additions and 100 deletions

View File

@@ -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<DynamicToolCallOutputContentItem> 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<FunctionCallOutputContentItem> 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::<DynamicToolCallOutputContentItem>(json!({

View File

@@ -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;

View File

@@ -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 }
}
}
}

View File

@@ -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::<Vec<FunctionCallOutputContentItem>>();
let response = DynamicToolCallResponse {
result: DynamicToolCallResult::ContentItems {

View File

@@ -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<String>,
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<String> {
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);
}
}

View File

@@ -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<String>,
#[serde(flatten)]
pub result: DynamicToolResult,
pub success: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub content_items: Option<Vec<FunctionCallOutputContentItem>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
#[serde(untagged, rename_all = "camelCase")]
pub enum DynamicToolResult {
ContentItems {
#[serde(rename = "contentItems")]
content_items: Vec<DynamicToolCallOutputContentItem>,
},
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,
},
}