mirror of
https://github.com/openai/codex.git
synced 2026-05-23 20:44:50 +00:00
## Why The MCP tool path had accumulated a few core-owned special cases: a dedicated payload variant, resolver plumbing, a legacy `AfterToolUse` translation path, and a side channel for parallel-call metadata. That made `ToolRegistry` and the spec builder know more about MCP than they needed to. This change moves MCP-specific execution details back onto `ToolInfo` and `McpHandler` so `codex-core` can treat MCP calls like normal function calls while still preserving MCP-specific dispatch and telemetry behavior where it belongs. ## What changed - removed `resolve_mcp_tool_info`, `ToolPayload::Mcp`, `ToolKind`, and the remaining registry-side MCP resolver path - stored MCP routing metadata directly on `McpHandler` and `ToolInfo`, including `supports_parallel_tool_calls` - deleted the legacy `AfterToolUse` consumer in `core`, which removes the need for handler-specific `after_tool_use_payload` implementations - switched tool-result telemetry to handler-provided tags and kept MCP-specific dispatch payload construction inside the handler - simplified tool spec planning/building by passing `ToolInfo` directly and dropping the direct/deferred MCP wrapper structs and the parallel-server side table ## Testing - `cargo check -p codex-core -p codex-mcp -p codex-otel` - `cargo test -p codex-core mcp_parallel_support_uses_exact_payload_server` - `cargo test -p codex-core direct_mcp_tools_register_namespaced_handlers` - `cargo test -p codex-core search_tool_description_lists_each_mcp_source_once` - `cargo test -p codex-mcp list_all_tools_uses_startup_snapshot_while_client_is_pending` - `just fix -p codex-core -p codex-mcp -p codex-otel`
462 lines
15 KiB
Rust
462 lines
15 KiB
Rust
use super::*;
|
|
use codex_protocol::models::DEFAULT_IMAGE_DETAIL;
|
|
use core_test_support::assert_regex_match;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::json;
|
|
|
|
#[test]
|
|
fn custom_tool_calls_should_roundtrip_as_custom_outputs() {
|
|
let payload = ToolPayload::Custom {
|
|
input: "patch".to_string(),
|
|
};
|
|
let response = FunctionToolOutput::from_text("patched".to_string(), Some(true))
|
|
.to_response_item("call-42", &payload);
|
|
|
|
match response {
|
|
ResponseInputItem::CustomToolCallOutput {
|
|
call_id, output, ..
|
|
} => {
|
|
assert_eq!(call_id, "call-42");
|
|
assert_eq!(output.content_items(), None);
|
|
assert_eq!(output.body.to_text().as_deref(), Some("patched"));
|
|
assert_eq!(output.success, Some(true));
|
|
}
|
|
other => panic!("expected CustomToolCallOutput, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn function_payloads_remain_function_outputs() {
|
|
let payload = ToolPayload::Function {
|
|
arguments: "{}".to_string(),
|
|
};
|
|
let response = FunctionToolOutput::from_text("ok".to_string(), Some(true))
|
|
.to_response_item("fn-1", &payload);
|
|
|
|
match response {
|
|
ResponseInputItem::FunctionCallOutput { call_id, output } => {
|
|
assert_eq!(call_id, "fn-1");
|
|
assert_eq!(output.content_items(), None);
|
|
assert_eq!(output.body.to_text().as_deref(), Some("ok"));
|
|
assert_eq!(output.success, Some(true));
|
|
}
|
|
other => panic!("expected FunctionCallOutput, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_code_mode_result_serializes_full_call_tool_result() {
|
|
let output = CallToolResult {
|
|
content: vec![serde_json::json!({
|
|
"type": "text",
|
|
"text": "ignored",
|
|
})],
|
|
structured_content: Some(serde_json::json!({
|
|
"threadId": "thread_123",
|
|
"content": "done",
|
|
})),
|
|
is_error: Some(false),
|
|
meta: Some(serde_json::json!({
|
|
"source": "mcp",
|
|
})),
|
|
};
|
|
|
|
let result = output.code_mode_result(&ToolPayload::Function {
|
|
arguments: "{}".to_string(),
|
|
});
|
|
|
|
assert_eq!(
|
|
result,
|
|
serde_json::json!({
|
|
"content": [{
|
|
"type": "text",
|
|
"text": "ignored",
|
|
}],
|
|
"structuredContent": {
|
|
"threadId": "thread_123",
|
|
"content": "done",
|
|
},
|
|
"isError": false,
|
|
"_meta": {
|
|
"source": "mcp",
|
|
},
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_tool_output_response_item_includes_wall_time() {
|
|
let output = McpToolOutput {
|
|
result: CallToolResult {
|
|
content: vec![serde_json::json!({
|
|
"type": "text",
|
|
"text": "done",
|
|
})],
|
|
structured_content: None,
|
|
is_error: Some(false),
|
|
meta: None,
|
|
},
|
|
tool_input: json!({}),
|
|
wall_time: std::time::Duration::from_millis(1250),
|
|
original_image_detail_supported: false,
|
|
truncation_policy: TruncationPolicy::Bytes(1024),
|
|
};
|
|
|
|
let response = output.to_response_item(
|
|
"mcp-call-1",
|
|
&ToolPayload::Function {
|
|
arguments: "{}".to_string(),
|
|
},
|
|
);
|
|
|
|
match response {
|
|
ResponseInputItem::FunctionCallOutput { call_id, output } => {
|
|
assert_eq!(call_id, "mcp-call-1");
|
|
assert_eq!(output.success, Some(true));
|
|
let Some(text) = output.body.to_text() else {
|
|
panic!("MCP output should serialize as text");
|
|
};
|
|
let Some(payload) = text.strip_prefix("Wall time: 1.2500 seconds\nOutput:\n") else {
|
|
panic!("MCP output should include wall-time header: {text}");
|
|
};
|
|
let parsed: serde_json::Value = serde_json::from_str(payload).unwrap_or_else(|err| {
|
|
panic!("MCP output should serialize JSON content: {err}");
|
|
});
|
|
assert_eq!(
|
|
parsed,
|
|
json!([{
|
|
"type": "text",
|
|
"text": "done",
|
|
}])
|
|
);
|
|
}
|
|
other => panic!("expected FunctionCallOutput, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_tool_output_response_item_truncates_large_structured_content() {
|
|
let output = McpToolOutput {
|
|
result: CallToolResult {
|
|
content: vec![serde_json::json!({
|
|
"type": "text",
|
|
"text": "ignored when structured content is present",
|
|
})],
|
|
structured_content: Some(serde_json::json!({
|
|
"items": "large structured value ".repeat(1_000),
|
|
})),
|
|
is_error: Some(false),
|
|
meta: None,
|
|
},
|
|
tool_input: json!({}),
|
|
wall_time: std::time::Duration::from_millis(1250),
|
|
original_image_detail_supported: false,
|
|
truncation_policy: TruncationPolicy::Bytes(128),
|
|
};
|
|
|
|
let response = output.to_response_item(
|
|
"mcp-call-large",
|
|
&ToolPayload::Function {
|
|
arguments: "{}".to_string(),
|
|
},
|
|
);
|
|
|
|
match response {
|
|
ResponseInputItem::FunctionCallOutput { call_id, output } => {
|
|
assert_eq!(call_id, "mcp-call-large");
|
|
assert_eq!(output.success, Some(true));
|
|
let text = output
|
|
.body
|
|
.to_text()
|
|
.expect("MCP output should serialize as text");
|
|
assert!(text.starts_with("Wall time: 1.2500 seconds\nOutput:\n"));
|
|
assert!(text.contains("chars truncated"));
|
|
assert!(!text.contains("ignored when structured content is present"));
|
|
}
|
|
other => panic!("expected FunctionCallOutput, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_tool_output_response_item_preserves_content_items() {
|
|
let image_url = "data:image/png;base64,AAA";
|
|
let output = McpToolOutput {
|
|
result: CallToolResult {
|
|
content: vec![serde_json::json!({
|
|
"type": "image",
|
|
"mimeType": "image/png",
|
|
"data": "AAA",
|
|
})],
|
|
structured_content: None,
|
|
is_error: Some(false),
|
|
meta: None,
|
|
},
|
|
tool_input: json!({}),
|
|
wall_time: std::time::Duration::from_millis(500),
|
|
original_image_detail_supported: false,
|
|
truncation_policy: TruncationPolicy::Bytes(1024),
|
|
};
|
|
|
|
let response = output.to_response_item(
|
|
"mcp-call-2",
|
|
&ToolPayload::Function {
|
|
arguments: "{}".to_string(),
|
|
},
|
|
);
|
|
|
|
match response {
|
|
ResponseInputItem::FunctionCallOutput { output, .. } => {
|
|
assert_eq!(
|
|
output.content_items(),
|
|
Some(
|
|
vec![
|
|
FunctionCallOutputContentItem::InputText {
|
|
text: "Wall time: 0.5000 seconds\nOutput:".to_string(),
|
|
},
|
|
FunctionCallOutputContentItem::InputImage {
|
|
image_url: image_url.to_string(),
|
|
detail: Some(DEFAULT_IMAGE_DETAIL),
|
|
},
|
|
]
|
|
.as_slice()
|
|
)
|
|
);
|
|
assert_eq!(
|
|
output.body.to_text().as_deref(),
|
|
Some("Wall time: 0.5000 seconds\nOutput:")
|
|
);
|
|
}
|
|
other => panic!("expected FunctionCallOutput, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_tool_output_code_mode_result_stays_raw_call_tool_result() {
|
|
let large_content = "large structured value ".repeat(1_000);
|
|
let output = McpToolOutput {
|
|
result: CallToolResult {
|
|
content: vec![serde_json::json!({
|
|
"type": "text",
|
|
"text": "ignored",
|
|
})],
|
|
structured_content: Some(serde_json::json!({
|
|
"content": large_content,
|
|
})),
|
|
is_error: Some(false),
|
|
meta: None,
|
|
},
|
|
tool_input: json!({}),
|
|
wall_time: std::time::Duration::from_millis(1250),
|
|
original_image_detail_supported: false,
|
|
truncation_policy: TruncationPolicy::Bytes(64),
|
|
};
|
|
|
|
let result = output.code_mode_result(&ToolPayload::Function {
|
|
arguments: "{}".to_string(),
|
|
});
|
|
|
|
assert_eq!(
|
|
result,
|
|
serde_json::json!({
|
|
"content": [{
|
|
"type": "text",
|
|
"text": "ignored",
|
|
}],
|
|
"structuredContent": {
|
|
"content": "large structured value ".repeat(1_000),
|
|
},
|
|
"isError": false,
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn custom_tool_calls_can_derive_text_from_content_items() {
|
|
let payload = ToolPayload::Custom {
|
|
input: "patch".to_string(),
|
|
};
|
|
let response = FunctionToolOutput::from_content(
|
|
vec![
|
|
FunctionCallOutputContentItem::InputText {
|
|
text: "line 1".to_string(),
|
|
},
|
|
FunctionCallOutputContentItem::InputImage {
|
|
image_url: "data:image/png;base64,AAA".to_string(),
|
|
detail: Some(DEFAULT_IMAGE_DETAIL),
|
|
},
|
|
FunctionCallOutputContentItem::InputText {
|
|
text: "line 2".to_string(),
|
|
},
|
|
],
|
|
Some(true),
|
|
)
|
|
.to_response_item("call-99", &payload);
|
|
|
|
match response {
|
|
ResponseInputItem::CustomToolCallOutput {
|
|
call_id, output, ..
|
|
} => {
|
|
let expected = vec![
|
|
FunctionCallOutputContentItem::InputText {
|
|
text: "line 1".to_string(),
|
|
},
|
|
FunctionCallOutputContentItem::InputImage {
|
|
image_url: "data:image/png;base64,AAA".to_string(),
|
|
detail: Some(DEFAULT_IMAGE_DETAIL),
|
|
},
|
|
FunctionCallOutputContentItem::InputText {
|
|
text: "line 2".to_string(),
|
|
},
|
|
];
|
|
assert_eq!(call_id, "call-99");
|
|
assert_eq!(output.content_items(), Some(expected.as_slice()));
|
|
assert_eq!(output.body.to_text().as_deref(), Some("line 1\nline 2"));
|
|
assert_eq!(output.success, Some(true));
|
|
}
|
|
other => panic!("expected CustomToolCallOutput, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn tool_search_payloads_roundtrip_as_tool_search_outputs() {
|
|
let payload = ToolPayload::ToolSearch {
|
|
arguments: SearchToolCallParams {
|
|
query: "calendar".to_string(),
|
|
limit: None,
|
|
},
|
|
};
|
|
let response = ToolSearchOutput {
|
|
tools: vec![LoadableToolSpec::Function(codex_tools::ResponsesApiTool {
|
|
name: "create_event".to_string(),
|
|
description: String::new(),
|
|
strict: false,
|
|
defer_loading: Some(true),
|
|
parameters: codex_tools::JsonSchema::object(
|
|
/*properties*/ Default::default(),
|
|
/*required*/ None,
|
|
/*additional_properties*/ None,
|
|
),
|
|
output_schema: None,
|
|
})],
|
|
}
|
|
.to_response_item("search-1", &payload);
|
|
|
|
match response {
|
|
ResponseInputItem::ToolSearchOutput {
|
|
call_id,
|
|
status,
|
|
execution,
|
|
tools,
|
|
} => {
|
|
assert_eq!(call_id, "search-1");
|
|
assert_eq!(status, "completed");
|
|
assert_eq!(execution, "client");
|
|
assert_eq!(
|
|
tools,
|
|
vec![json!({
|
|
"type": "function",
|
|
"name": "create_event",
|
|
"description": "",
|
|
"strict": false,
|
|
"defer_loading": true,
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {}
|
|
}
|
|
})]
|
|
);
|
|
}
|
|
other => panic!("expected ToolSearchOutput, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn log_preview_uses_content_items_when_plain_text_is_missing() {
|
|
let output = FunctionToolOutput::from_content(
|
|
vec![FunctionCallOutputContentItem::InputText {
|
|
text: "preview".to_string(),
|
|
}],
|
|
Some(true),
|
|
);
|
|
|
|
assert_eq!(output.log_preview(), "preview");
|
|
assert_eq!(
|
|
function_call_output_content_items_to_text(&output.body),
|
|
Some("preview".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn telemetry_preview_returns_original_within_limits() {
|
|
let content = "short output";
|
|
assert_eq!(telemetry_preview(content), content);
|
|
}
|
|
|
|
#[test]
|
|
fn telemetry_preview_truncates_by_bytes() {
|
|
let content = "x".repeat(TELEMETRY_PREVIEW_MAX_BYTES + 8);
|
|
let preview = telemetry_preview(&content);
|
|
|
|
assert!(preview.contains(TELEMETRY_PREVIEW_TRUNCATION_NOTICE));
|
|
assert!(
|
|
preview.len()
|
|
<= TELEMETRY_PREVIEW_MAX_BYTES + TELEMETRY_PREVIEW_TRUNCATION_NOTICE.len() + 1
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn telemetry_preview_truncates_by_lines() {
|
|
let content = (0..(TELEMETRY_PREVIEW_MAX_LINES + 5))
|
|
.map(|idx| format!("line {idx}"))
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
|
|
let preview = telemetry_preview(&content);
|
|
let lines: Vec<&str> = preview.lines().collect();
|
|
|
|
assert!(lines.len() <= TELEMETRY_PREVIEW_MAX_LINES + 1);
|
|
assert_eq!(lines.last(), Some(&TELEMETRY_PREVIEW_TRUNCATION_NOTICE));
|
|
}
|
|
|
|
#[test]
|
|
fn exec_command_tool_output_formats_truncated_response() {
|
|
let payload = ToolPayload::Function {
|
|
arguments: "{}".to_string(),
|
|
};
|
|
let response = ExecCommandToolOutput {
|
|
event_call_id: "call-42".to_string(),
|
|
chunk_id: "abc123".to_string(),
|
|
wall_time: std::time::Duration::from_millis(1250),
|
|
raw_output: b"token one token two token three token four token five".to_vec(),
|
|
max_output_tokens: Some(4),
|
|
process_id: None,
|
|
exit_code: Some(0),
|
|
original_token_count: Some(10),
|
|
hook_command: None,
|
|
}
|
|
.to_response_item("call-42", &payload);
|
|
|
|
match response {
|
|
ResponseInputItem::FunctionCallOutput { call_id, output } => {
|
|
assert_eq!(call_id, "call-42");
|
|
assert_eq!(output.success, Some(true));
|
|
let text = output
|
|
.body
|
|
.to_text()
|
|
.expect("exec output should serialize as text");
|
|
assert_regex_match(
|
|
r#"(?sx)
|
|
^Chunk\ ID:\ abc123
|
|
\nWall\ time:\ \d+\.\d{4}\ seconds
|
|
\nProcess\ exited\ with\ code\ 0
|
|
\nOriginal\ token\ count:\ 10
|
|
\nOutput:
|
|
\n.*tokens\ truncated.*
|
|
$"#,
|
|
&text,
|
|
);
|
|
}
|
|
other => panic!("expected FunctionCallOutput, got {other:?}"),
|
|
}
|
|
}
|