mirror of
https://github.com/openai/codex.git
synced 2026-04-24 22:54:54 +00:00
Previously, clients would call `thread/start` with dynamic_tools set, and when a model invokes a dynamic tool, it would just make the server->client `item/tool/call` request and wait for the client's response to complete the tool call. This works, but it doesn't have an `item/started` or `item/completed` event. Now we are doing this: - [new] emit `item/started` with `DynamicToolCall` populated with the call arguments - send an `item/tool/call` server request - [new] once the client responds, emit `item/completed` with `DynamicToolCall` populated with the response. Also, with `persistExtendedHistory: true`, dynamic tool calls are now reconstructable in `thread/read` and `thread/resume` as `ThreadItem::DynamicToolCall`.
587 lines
19 KiB
Rust
587 lines
19 KiB
Rust
use anyhow::Context;
|
|
use anyhow::Result;
|
|
use app_test_support::McpProcess;
|
|
use app_test_support::create_final_assistant_message_sse_response;
|
|
use app_test_support::create_mock_responses_server_sequence_unchecked;
|
|
use app_test_support::to_response;
|
|
use codex_app_server_protocol::DynamicToolCallOutputContentItem;
|
|
use codex_app_server_protocol::DynamicToolCallParams;
|
|
use codex_app_server_protocol::DynamicToolCallResponse;
|
|
use codex_app_server_protocol::DynamicToolCallStatus;
|
|
use codex_app_server_protocol::DynamicToolSpec;
|
|
use codex_app_server_protocol::ItemCompletedNotification;
|
|
use codex_app_server_protocol::ItemStartedNotification;
|
|
use codex_app_server_protocol::JSONRPCNotification;
|
|
use codex_app_server_protocol::JSONRPCResponse;
|
|
use codex_app_server_protocol::RequestId;
|
|
use codex_app_server_protocol::ServerRequest;
|
|
use codex_app_server_protocol::ThreadItem;
|
|
use codex_app_server_protocol::ThreadStartParams;
|
|
use codex_app_server_protocol::ThreadStartResponse;
|
|
use codex_app_server_protocol::TurnStartParams;
|
|
use codex_app_server_protocol::TurnStartResponse;
|
|
use codex_app_server_protocol::UserInput as V2UserInput;
|
|
use codex_protocol::models::FunctionCallOutputBody;
|
|
use codex_protocol::models::FunctionCallOutputContentItem;
|
|
use codex_protocol::models::FunctionCallOutputPayload;
|
|
use core_test_support::responses;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::Value;
|
|
use serde_json::json;
|
|
use std::path::Path;
|
|
use std::time::Duration;
|
|
use tempfile::TempDir;
|
|
use tokio::time::timeout;
|
|
use wiremock::MockServer;
|
|
|
|
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
|
|
|
/// Ensures dynamic tool specs are serialized into the model request payload.
|
|
#[tokio::test]
|
|
async fn thread_start_injects_dynamic_tools_into_model_requests() -> Result<()> {
|
|
let responses = vec![create_final_assistant_message_sse_response("Done")?];
|
|
let server = create_mock_responses_server_sequence_unchecked(responses).await;
|
|
|
|
let codex_home = TempDir::new()?;
|
|
create_config_toml(codex_home.path(), &server.uri())?;
|
|
|
|
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
// Use a minimal JSON schema so we can assert the tool payload round-trips.
|
|
let input_schema = json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"city": { "type": "string" }
|
|
},
|
|
"required": ["city"],
|
|
"additionalProperties": false,
|
|
});
|
|
let dynamic_tool = DynamicToolSpec {
|
|
name: "demo_tool".to_string(),
|
|
description: "Demo dynamic tool".to_string(),
|
|
input_schema: input_schema.clone(),
|
|
};
|
|
|
|
// Thread start injects dynamic tools into the thread's tool registry.
|
|
let thread_req = mcp
|
|
.send_thread_start_request(ThreadStartParams {
|
|
dynamic_tools: Some(vec![dynamic_tool.clone()]),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let thread_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
|
|
)
|
|
.await??;
|
|
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
|
|
|
|
// Start a turn so a model request is issued.
|
|
let turn_req = mcp
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
input: vec![V2UserInput::Text {
|
|
text: "Hello".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let turn_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
|
|
)
|
|
.await??;
|
|
let _turn: TurnStartResponse = to_response::<TurnStartResponse>(turn_resp)?;
|
|
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
|
|
// Inspect the captured model request to assert the tool spec made it through.
|
|
let bodies = responses_bodies(&server).await?;
|
|
let body = bodies
|
|
.first()
|
|
.context("expected at least one responses request")?;
|
|
let tool = find_tool(body, &dynamic_tool.name)
|
|
.context("expected dynamic tool to be injected into request")?;
|
|
|
|
assert_eq!(
|
|
tool.get("description"),
|
|
Some(&Value::String(dynamic_tool.description.clone()))
|
|
);
|
|
assert_eq!(tool.get("parameters"), Some(&input_schema));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Exercises the full dynamic tool call path (server request, client response, model output).
|
|
#[tokio::test]
|
|
async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Result<()> {
|
|
let call_id = "dyn-call-1";
|
|
let tool_name = "demo_tool";
|
|
let tool_args = json!({ "city": "Paris" });
|
|
let tool_call_arguments = serde_json::to_string(&tool_args)?;
|
|
|
|
// First response triggers a dynamic tool call, second closes the turn.
|
|
let responses = vec![
|
|
responses::sse(vec![
|
|
responses::ev_response_created("resp-1"),
|
|
responses::ev_function_call(call_id, tool_name, &tool_call_arguments),
|
|
responses::ev_completed("resp-1"),
|
|
]),
|
|
create_final_assistant_message_sse_response("Done")?,
|
|
];
|
|
let server = create_mock_responses_server_sequence_unchecked(responses).await;
|
|
|
|
let codex_home = TempDir::new()?;
|
|
create_config_toml(codex_home.path(), &server.uri())?;
|
|
|
|
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let dynamic_tool = DynamicToolSpec {
|
|
name: tool_name.to_string(),
|
|
description: "Demo dynamic tool".to_string(),
|
|
input_schema: json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"city": { "type": "string" }
|
|
},
|
|
"required": ["city"],
|
|
"additionalProperties": false,
|
|
}),
|
|
};
|
|
|
|
let thread_req = mcp
|
|
.send_thread_start_request(ThreadStartParams {
|
|
dynamic_tools: Some(vec![dynamic_tool]),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let thread_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
|
|
)
|
|
.await??;
|
|
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
|
|
let thread_id = thread.id.clone();
|
|
|
|
// Start a turn so the tool call is emitted.
|
|
let turn_req = mcp
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: thread_id.clone(),
|
|
input: vec![V2UserInput::Text {
|
|
text: "Run the tool".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let turn_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
|
|
)
|
|
.await??;
|
|
let TurnStartResponse { turn } = to_response::<TurnStartResponse>(turn_resp)?;
|
|
let turn_id = turn.id.clone();
|
|
|
|
let started = wait_for_dynamic_tool_started(&mut mcp, call_id).await?;
|
|
assert_eq!(started.thread_id, thread_id);
|
|
assert_eq!(started.turn_id, turn_id.clone());
|
|
let ThreadItem::DynamicToolCall {
|
|
id,
|
|
tool,
|
|
arguments,
|
|
status,
|
|
content_items,
|
|
success,
|
|
duration_ms,
|
|
} = started.item
|
|
else {
|
|
panic!("expected dynamic tool call item");
|
|
};
|
|
assert_eq!(id, call_id);
|
|
assert_eq!(tool, tool_name);
|
|
assert_eq!(arguments, tool_args);
|
|
assert_eq!(status, DynamicToolCallStatus::InProgress);
|
|
assert_eq!(content_items, None);
|
|
assert_eq!(success, None);
|
|
assert_eq!(duration_ms, None);
|
|
|
|
// Read the tool call request from the app server.
|
|
let request = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_request_message(),
|
|
)
|
|
.await??;
|
|
let (request_id, params) = match request {
|
|
ServerRequest::DynamicToolCall { request_id, params } => (request_id, params),
|
|
other => panic!("expected DynamicToolCall request, got {other:?}"),
|
|
};
|
|
|
|
let expected = DynamicToolCallParams {
|
|
thread_id: thread_id.clone(),
|
|
turn_id: turn_id.clone(),
|
|
call_id: call_id.to_string(),
|
|
tool: tool_name.to_string(),
|
|
arguments: tool_args.clone(),
|
|
};
|
|
assert_eq!(params, expected);
|
|
|
|
// Respond to the tool call so the model receives a function_call_output.
|
|
let response = DynamicToolCallResponse {
|
|
content_items: vec![DynamicToolCallOutputContentItem::InputText {
|
|
text: "dynamic-ok".to_string(),
|
|
}],
|
|
success: true,
|
|
};
|
|
mcp.send_response(request_id, serde_json::to_value(response)?)
|
|
.await?;
|
|
|
|
let completed = wait_for_dynamic_tool_completed(&mut mcp, call_id).await?;
|
|
assert_eq!(completed.thread_id, thread_id);
|
|
assert_eq!(completed.turn_id, turn_id);
|
|
let ThreadItem::DynamicToolCall {
|
|
id,
|
|
tool,
|
|
arguments,
|
|
status,
|
|
content_items,
|
|
success,
|
|
duration_ms,
|
|
} = completed.item
|
|
else {
|
|
panic!("expected dynamic tool call item");
|
|
};
|
|
assert_eq!(id, call_id);
|
|
assert_eq!(tool, tool_name);
|
|
assert_eq!(arguments, tool_args);
|
|
assert_eq!(status, DynamicToolCallStatus::Completed);
|
|
assert_eq!(
|
|
content_items,
|
|
Some(vec![DynamicToolCallOutputContentItem::InputText {
|
|
text: "dynamic-ok".to_string(),
|
|
}])
|
|
);
|
|
assert_eq!(success, Some(true));
|
|
assert!(duration_ms.is_some());
|
|
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
|
|
let bodies = responses_bodies(&server).await?;
|
|
let payload = bodies
|
|
.iter()
|
|
.find_map(|body| function_call_output_payload(body, call_id))
|
|
.context("expected function_call_output in follow-up request")?;
|
|
let expected_payload = FunctionCallOutputPayload::from_content_items(vec![
|
|
FunctionCallOutputContentItem::InputText {
|
|
text: "dynamic-ok".to_string(),
|
|
},
|
|
]);
|
|
assert_eq!(payload, expected_payload);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Ensures dynamic tool call responses can include structured content items.
|
|
#[tokio::test]
|
|
async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<()> {
|
|
let call_id = "dyn-call-items-1";
|
|
let tool_name = "demo_tool";
|
|
let tool_args = json!({ "city": "Paris" });
|
|
let tool_call_arguments = serde_json::to_string(&tool_args)?;
|
|
|
|
let responses = vec![
|
|
responses::sse(vec![
|
|
responses::ev_response_created("resp-1"),
|
|
responses::ev_function_call(call_id, tool_name, &tool_call_arguments),
|
|
responses::ev_completed("resp-1"),
|
|
]),
|
|
create_final_assistant_message_sse_response("Done")?,
|
|
];
|
|
let server = create_mock_responses_server_sequence_unchecked(responses).await;
|
|
|
|
let codex_home = TempDir::new()?;
|
|
create_config_toml(codex_home.path(), &server.uri())?;
|
|
|
|
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let dynamic_tool = DynamicToolSpec {
|
|
name: tool_name.to_string(),
|
|
description: "Demo dynamic tool".to_string(),
|
|
input_schema: json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"city": { "type": "string" }
|
|
},
|
|
"required": ["city"],
|
|
"additionalProperties": false,
|
|
}),
|
|
};
|
|
|
|
let thread_req = mcp
|
|
.send_thread_start_request(ThreadStartParams {
|
|
dynamic_tools: Some(vec![dynamic_tool]),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let thread_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
|
|
)
|
|
.await??;
|
|
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
|
|
let thread_id = thread.id.clone();
|
|
|
|
let turn_req = mcp
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: thread_id.clone(),
|
|
input: vec![V2UserInput::Text {
|
|
text: "Run the tool".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let turn_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
|
|
)
|
|
.await??;
|
|
let TurnStartResponse { turn } = to_response::<TurnStartResponse>(turn_resp)?;
|
|
let turn_id = turn.id.clone();
|
|
|
|
let started = wait_for_dynamic_tool_started(&mut mcp, call_id).await?;
|
|
assert_eq!(started.thread_id, thread_id.clone());
|
|
assert_eq!(started.turn_id, turn_id.clone());
|
|
|
|
let request = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_request_message(),
|
|
)
|
|
.await??;
|
|
let (request_id, params) = match request {
|
|
ServerRequest::DynamicToolCall { request_id, params } => (request_id, params),
|
|
other => panic!("expected DynamicToolCall request, got {other:?}"),
|
|
};
|
|
|
|
let expected = DynamicToolCallParams {
|
|
thread_id,
|
|
turn_id: turn_id.clone(),
|
|
call_id: call_id.to_string(),
|
|
tool: tool_name.to_string(),
|
|
arguments: tool_args,
|
|
};
|
|
assert_eq!(params, expected);
|
|
|
|
let response_content_items = vec![
|
|
DynamicToolCallOutputContentItem::InputText {
|
|
text: "dynamic-ok".to_string(),
|
|
},
|
|
DynamicToolCallOutputContentItem::InputImage {
|
|
image_url: "data:image/png;base64,AAA".to_string(),
|
|
},
|
|
];
|
|
let content_items = response_content_items
|
|
.clone()
|
|
.into_iter()
|
|
.map(|item| match item {
|
|
DynamicToolCallOutputContentItem::InputText { text } => {
|
|
FunctionCallOutputContentItem::InputText { text }
|
|
}
|
|
DynamicToolCallOutputContentItem::InputImage { image_url } => {
|
|
FunctionCallOutputContentItem::InputImage { image_url }
|
|
}
|
|
})
|
|
.collect::<Vec<FunctionCallOutputContentItem>>();
|
|
let response = DynamicToolCallResponse {
|
|
content_items: response_content_items,
|
|
success: true,
|
|
};
|
|
mcp.send_response(request_id, serde_json::to_value(response)?)
|
|
.await?;
|
|
|
|
let completed = wait_for_dynamic_tool_completed(&mut mcp, call_id).await?;
|
|
assert_eq!(completed.thread_id, expected.thread_id.clone());
|
|
assert_eq!(completed.turn_id, turn_id);
|
|
let ThreadItem::DynamicToolCall {
|
|
status,
|
|
content_items: completed_content_items,
|
|
success,
|
|
..
|
|
} = completed.item
|
|
else {
|
|
panic!("expected dynamic tool call item");
|
|
};
|
|
assert_eq!(status, DynamicToolCallStatus::Completed);
|
|
assert_eq!(
|
|
completed_content_items,
|
|
Some(vec![
|
|
DynamicToolCallOutputContentItem::InputText {
|
|
text: "dynamic-ok".to_string(),
|
|
},
|
|
DynamicToolCallOutputContentItem::InputImage {
|
|
image_url: "data:image/png;base64,AAA".to_string(),
|
|
},
|
|
])
|
|
);
|
|
assert_eq!(success, Some(true));
|
|
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
|
|
let bodies = responses_bodies(&server).await?;
|
|
let output_value = bodies
|
|
.iter()
|
|
.find_map(|body| function_call_output_raw_output(body, call_id))
|
|
.context("expected function_call_output output in follow-up request")?;
|
|
assert_eq!(
|
|
output_value,
|
|
json!([
|
|
{
|
|
"type": "input_text",
|
|
"text": "dynamic-ok"
|
|
},
|
|
{
|
|
"type": "input_image",
|
|
"image_url": "data:image/png;base64,AAA"
|
|
}
|
|
])
|
|
);
|
|
|
|
let payload = bodies
|
|
.iter()
|
|
.find_map(|body| function_call_output_payload(body, call_id))
|
|
.context("expected function_call_output in follow-up request")?;
|
|
assert_eq!(
|
|
payload.body,
|
|
FunctionCallOutputBody::ContentItems(content_items.clone())
|
|
);
|
|
assert_eq!(payload.success, None);
|
|
assert_eq!(
|
|
serde_json::to_string(&payload)?,
|
|
serde_json::to_string(&content_items)?
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn responses_bodies(server: &MockServer) -> Result<Vec<Value>> {
|
|
let requests = server
|
|
.received_requests()
|
|
.await
|
|
.context("failed to fetch received requests")?;
|
|
|
|
requests
|
|
.into_iter()
|
|
.filter(|req| req.url.path().ends_with("/responses"))
|
|
.map(|req| {
|
|
req.body_json::<Value>()
|
|
.context("request body should be JSON")
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn find_tool<'a>(body: &'a Value, name: &str) -> Option<&'a Value> {
|
|
body.get("tools")
|
|
.and_then(Value::as_array)
|
|
.and_then(|tools| {
|
|
tools
|
|
.iter()
|
|
.find(|tool| tool.get("name").and_then(Value::as_str) == Some(name))
|
|
})
|
|
}
|
|
|
|
fn function_call_output_payload(body: &Value, call_id: &str) -> Option<FunctionCallOutputPayload> {
|
|
function_call_output_raw_output(body, call_id)
|
|
.and_then(|output| serde_json::from_value(output).ok())
|
|
}
|
|
|
|
fn function_call_output_raw_output(body: &Value, call_id: &str) -> Option<Value> {
|
|
body.get("input")
|
|
.and_then(Value::as_array)
|
|
.and_then(|items| {
|
|
items.iter().find(|item| {
|
|
item.get("type").and_then(Value::as_str) == Some("function_call_output")
|
|
&& item.get("call_id").and_then(Value::as_str) == Some(call_id)
|
|
})
|
|
})
|
|
.and_then(|item| item.get("output"))
|
|
.cloned()
|
|
}
|
|
|
|
async fn wait_for_dynamic_tool_started(
|
|
mcp: &mut McpProcess,
|
|
call_id: &str,
|
|
) -> Result<ItemStartedNotification> {
|
|
loop {
|
|
let notification: JSONRPCNotification = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("item/started"),
|
|
)
|
|
.await??;
|
|
let Some(params) = notification.params else {
|
|
continue;
|
|
};
|
|
let started: ItemStartedNotification = serde_json::from_value(params)?;
|
|
if matches!(&started.item, ThreadItem::DynamicToolCall { id, .. } if id == call_id) {
|
|
return Ok(started);
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn wait_for_dynamic_tool_completed(
|
|
mcp: &mut McpProcess,
|
|
call_id: &str,
|
|
) -> Result<ItemCompletedNotification> {
|
|
loop {
|
|
let notification: JSONRPCNotification = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("item/completed"),
|
|
)
|
|
.await??;
|
|
let Some(params) = notification.params else {
|
|
continue;
|
|
};
|
|
let completed: ItemCompletedNotification = serde_json::from_value(params)?;
|
|
if matches!(&completed.item, ThreadItem::DynamicToolCall { id, .. } if id == call_id) {
|
|
return Ok(completed);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> {
|
|
let config_toml = codex_home.join("config.toml");
|
|
std::fs::write(
|
|
config_toml,
|
|
format!(
|
|
r#"
|
|
model = "mock-model"
|
|
approval_policy = "never"
|
|
sandbox_mode = "read-only"
|
|
|
|
model_provider = "mock_provider"
|
|
|
|
[model_providers.mock_provider]
|
|
name = "Mock provider for test"
|
|
base_url = "{server_uri}/v1"
|
|
wire_api = "responses"
|
|
request_max_retries = 0
|
|
stream_max_retries = 0
|
|
"#
|
|
),
|
|
)
|
|
}
|