[codex] Emit MCP tool calls as turn items (#20677)

## Why

`McpToolCall` was still an app-server item synthesized from deprecated
legacy begin/end events. Recent item migrations moved this ownership
into core `TurnItem`s, so MCP tool calls now follow the same canonical
lifecycle and leave legacy events as compatibility fanout.

Keeping the core item close to the v2 `ThreadItem::McpToolCall` shape
also avoids spreading MCP result semantics across app-server conversion
code. Core now owns whether a completed call is `completed` or `failed`,
and whether the payload is a tool result or an error.

## What changed

- Added core `TurnItem::McpToolCall` with flattened `server`, `tool`,
`arguments`, `status`, `result`, and `error` fields.
- Updated MCP tool call emitters, including MCP resource tools, to emit
`ItemStarted`/`ItemCompleted` around directly constructed core MCP
items.
- Updated app-server v2 conversion to project the core MCP item into
`ThreadItem::McpToolCall` without deriving status or splitting `Result`
locally.
- Ignored live deprecated MCP legacy fanout in app-server v2 to avoid
duplicate item notifications, while keeping thread history replay on the
legacy event path.

## Verification

- `cargo test -p codex-protocol`
- `cargo test -p codex-app-server-protocol`
- `cargo test -p codex-core --lib mcp_tool_call`
- `cargo check -p codex-app-server`
- `cargo test -p codex-app-server
mcp_tool_call_completion_notification_contains_truncated_large_result`
This commit is contained in:
pakrym-oai
2026-05-03 22:50:13 -07:00
committed by GitHub
parent 9ddfda9db7
commit c8c30d9d75
8 changed files with 431 additions and 299 deletions
+79
View File
@@ -1843,6 +1843,7 @@ impl HasLegacyEvent for ItemStartedEvent {
})]
}
TurnItem::FileChange(item) => vec![item.as_legacy_begin_event(self.turn_id.clone())],
TurnItem::McpToolCall(item) => vec![item.as_legacy_begin_event()],
_ => Vec::new(),
}
}
@@ -3938,8 +3939,11 @@ mod tests {
use super::*;
use crate::items::FileChangeItem;
use crate::items::ImageGenerationItem;
use crate::items::McpToolCallItem;
use crate::items::McpToolCallStatus;
use crate::items::UserMessageItem;
use crate::items::WebSearchItem;
use crate::mcp::CallToolResult;
use crate::permissions::FileSystemAccessMode;
use crate::permissions::FileSystemPath;
use crate::permissions::FileSystemSandboxEntry;
@@ -4674,6 +4678,40 @@ mod tests {
}
}
#[test]
fn item_started_event_from_mcp_tool_call_emits_begin_event() {
let event = ItemStartedEvent {
thread_id: ThreadId::new(),
turn_id: "turn-1".into(),
item: TurnItem::McpToolCall(McpToolCallItem {
id: "mcp-1".into(),
server: "server".into(),
tool: "tool".into(),
arguments: json!({"arg": "value"}),
mcp_app_resource_uri: Some("app://connector".into()),
status: McpToolCallStatus::InProgress,
result: None,
error: None,
duration: None,
}),
};
let legacy_events = event.as_legacy_events(/*show_raw_agent_reasoning*/ false);
assert_eq!(legacy_events.len(), 1);
match &legacy_events[0] {
EventMsg::McpToolCallBegin(event) => {
assert_eq!(event.call_id, "mcp-1");
assert_eq!(event.invocation.server, "server");
assert_eq!(event.invocation.tool, "tool");
assert_eq!(
event.mcp_app_resource_uri.as_deref(),
Some("app://connector")
);
}
_ => panic!("expected McpToolCallBegin event"),
}
}
#[test]
fn item_completed_event_from_image_generation_emits_end_event() {
let event = ItemCompletedEvent {
@@ -4742,6 +4780,47 @@ mod tests {
}
}
#[test]
fn item_completed_event_from_mcp_tool_call_emits_end_event() {
let event = ItemCompletedEvent {
thread_id: ThreadId::new(),
turn_id: "turn-1".into(),
item: TurnItem::McpToolCall(McpToolCallItem {
id: "mcp-1".into(),
server: "server".into(),
tool: "tool".into(),
arguments: json!({"arg": "value"}),
mcp_app_resource_uri: Some("app://connector".into()),
status: McpToolCallStatus::Completed,
result: Some(CallToolResult {
content: vec![json!({"type": "text", "text": "ok"})],
structured_content: None,
is_error: Some(false),
meta: None,
}),
error: None,
duration: Some(Duration::from_millis(42)),
}),
};
let legacy_events = event.as_legacy_events(/*show_raw_agent_reasoning*/ false);
assert_eq!(legacy_events.len(), 1);
match &legacy_events[0] {
EventMsg::McpToolCallEnd(event) => {
assert_eq!(event.call_id, "mcp-1");
assert_eq!(event.invocation.server, "server");
assert_eq!(event.invocation.tool, "tool");
assert_eq!(
event.mcp_app_resource_uri.as_deref(),
Some("app://connector")
);
assert_eq!(event.duration, Duration::from_millis(42));
assert!(event.is_success());
}
_ => panic!("expected McpToolCallEnd event"),
}
}
#[test]
fn rollback_failed_error_does_not_affect_turn_status() {
let event = ErrorEvent {