mirror of
https://github.com/openai/codex.git
synced 2026-11-10 21:00:47 +00:00
Add experimental app-server support for connection-owned dynamic tool providers over both `stdio` and websocket.
Clients can now:
- register a tool with `toolProvider/register`
- unregister it with `toolProvider/unregister`
Registered tools are stored in an app-server registry, mapped back to the owning connection, and routed through the existing dynamic tool call flow. Tools with `injectIntoContext: true` are exposed to the model; tools with `injectIntoContext: false` remain callable only from `js_repl` via `codex.tool(...)`.
This also extends dynamic tool call events with `thread_id` so provider-owned tools can be invoked from newly created or resumed threads.
```mermaid
sequenceDiagram
participant Client as Provider Client
participant AppServer as App Server
participant ThreadClient as Thread Client
participant Core as Core Thread
Client->>AppServer: toolProvider/register(tool)
AppServer->>AppServer: store tool -> owning connection
ThreadClient->>AppServer: thread/start
AppServer->>Core: load thread + sync registered tools
ThreadClient->>AppServer: turn/start
AppServer->>Core: submit user input
Core-->>AppServer: DynamicToolCallRequest(thread_id, tool, args, provider_owned=true)
AppServer->>Client: item/tool/call
Client-->>AppServer: DynamicToolCallResponse
AppServer->>Core: submit dynamic tool response
Core-->>AppServer: continue turn
818 lines
27 KiB
Rust
818 lines
27 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::ToolProviderRegisterResponse;
|
|
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(),
|
|
inject_into_context: true,
|
|
};
|
|
|
|
// 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(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn tool_provider_registration_routes_stdio_dynamic_tool_calls() -> Result<()> {
|
|
let call_id = "dyn-provider-call-1";
|
|
let tool_name = "provider_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 register_request_id = mcp
|
|
.send_raw_request(
|
|
"toolProvider/register",
|
|
Some(json!({
|
|
"tool": {
|
|
"name": tool_name,
|
|
"description": "Demo provider tool",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"city": { "type": "string" }
|
|
},
|
|
"required": ["city"],
|
|
"additionalProperties": false
|
|
},
|
|
"defaultTimeoutMs": 1_000,
|
|
"injectIntoContext": true
|
|
}
|
|
})),
|
|
)
|
|
.await?;
|
|
let register_response: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(register_request_id)),
|
|
)
|
|
.await??;
|
|
let _: ToolProviderRegisterResponse = to_response(register_response)?;
|
|
|
|
let thread_request_id = mcp
|
|
.send_thread_start_request(ThreadStartParams::default())
|
|
.await?;
|
|
let thread_response: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(thread_request_id)),
|
|
)
|
|
.await??;
|
|
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_response)?;
|
|
let thread_id = thread.id.clone();
|
|
|
|
let turn_request_id = mcp
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: thread_id.clone(),
|
|
input: vec![V2UserInput::Text {
|
|
text: "Run the provider tool".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let turn_response: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(turn_request_id)),
|
|
)
|
|
.await??;
|
|
let TurnStartResponse { turn } = to_response::<TurnStartResponse>(turn_response)?;
|
|
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:?}"),
|
|
};
|
|
assert_eq!(
|
|
params,
|
|
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(),
|
|
}
|
|
);
|
|
|
|
mcp.send_response(
|
|
request_id,
|
|
serde_json::to_value(DynamicToolCallResponse {
|
|
content_items: vec![DynamicToolCallOutputContentItem::InputText {
|
|
text: "dynamic-ok".to_string(),
|
|
}],
|
|
success: true,
|
|
})?,
|
|
)
|
|
.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);
|
|
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
|
|
let bodies = responses_bodies(&server).await?;
|
|
let tool = bodies
|
|
.iter()
|
|
.find_map(|body| find_tool(body, tool_name))
|
|
.context("expected provider tool to be injected into request")?;
|
|
assert_eq!(
|
|
tool.get("description"),
|
|
Some(&Value::String("Demo provider tool".to_string()))
|
|
);
|
|
|
|
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,
|
|
FunctionCallOutputPayload::from_text("dynamic-ok".to_string())
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn tool_provider_registration_with_inject_disabled_stays_out_of_model_context() -> Result<()>
|
|
{
|
|
let server = create_mock_responses_server_sequence_unchecked(vec![
|
|
create_final_assistant_message_sse_response("Done")?,
|
|
])
|
|
.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 register_request_id = mcp
|
|
.send_raw_request(
|
|
"toolProvider/register",
|
|
Some(json!({
|
|
"tool": {
|
|
"name": "hidden_provider_tool",
|
|
"description": "Hidden provider tool",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {},
|
|
"additionalProperties": false
|
|
},
|
|
"defaultTimeoutMs": 1_000,
|
|
"injectIntoContext": false
|
|
}
|
|
})),
|
|
)
|
|
.await?;
|
|
let register_response: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(register_request_id)),
|
|
)
|
|
.await??;
|
|
let _: ToolProviderRegisterResponse = to_response(register_response)?;
|
|
|
|
let thread_request_id = mcp
|
|
.send_thread_start_request(ThreadStartParams::default())
|
|
.await?;
|
|
let thread_response: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(thread_request_id)),
|
|
)
|
|
.await??;
|
|
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_response)?;
|
|
|
|
let turn_request_id = mcp
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: thread.id,
|
|
input: vec![V2UserInput::Text {
|
|
text: "Hello".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let turn_response: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(turn_request_id)),
|
|
)
|
|
.await??;
|
|
let _: TurnStartResponse = to_response::<TurnStartResponse>(turn_response)?;
|
|
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
|
|
let bodies = responses_bodies(&server).await?;
|
|
assert!(
|
|
bodies
|
|
.iter()
|
|
.all(|body| find_tool(body, "hidden_provider_tool").is_none())
|
|
);
|
|
|
|
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,
|
|
}),
|
|
inject_into_context: true,
|
|
};
|
|
|
|
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_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,
|
|
}),
|
|
inject_into_context: true,
|
|
};
|
|
|
|
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,
|
|
detail: None,
|
|
}
|
|
}
|
|
})
|
|
.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
|
|
"#
|
|
),
|
|
)
|
|
}
|