From 5cc5f12efc859f076af79cc321d884495e41f7f2 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 30 Apr 2026 11:02:13 -0700 Subject: [PATCH] Move item event mapping into app-server-protocol (#20299) ## Why Follow-up to #20291. The v2 item-event-to-notification translation had been embedded in `app-server/src/bespoke_event_handling.rs`, which made it hard to reuse anywhere else. This PR moves that stateless mapping into shared protocol code so other entry points can produce the same `ServerNotification` payloads without copying app-server logic. That also lets `thread-manager-sample` demonstrate the same notification surface that the app server exposes, instead of only printing the final assistant message. ## What changed - move `item_event_to_server_notification` into `codex-app-server-protocol::protocol::event_mapping` - keep the mapper tests next to the shared implementation in `codex-app-server-protocol` - re-export the mapper from `codex-core-api` so lightweight consumers can use it without reaching into `app-server-protocol` directly - simplify `app-server/src/bespoke_event_handling.rs` so it delegates the stateless event-to-notification projection to the shared helper - update `thread-manager-sample` to: - print mapped notifications as newline-delimited JSON - use the shared mapper through `codex-core-api` - enable the default feature set so the sample exposes the normal tool surface - use a `read_only` permission profile so shell commands can run in the sample without widening permissions ## Testing - `cargo test -p codex-app-server-protocol` - `cargo test -p codex-core-api` - `cargo test -p codex-app-server bespoke_event_handling::tests` - `cargo test -p codex-thread-manager-sample` - `cargo run -p codex-thread-manager-sample -- "briefly explore the repo with pwd and ls, then summarize it"` --- codex-rs/Cargo.lock | 2 + codex-rs/app-server-protocol/src/lib.rs | 1 + .../src/protocol/event_mapping.rs | 807 +++++++++++++++ .../app-server-protocol/src/protocol/mod.rs | 1 + .../app-server/src/bespoke_event_handling.rs | 951 ++---------------- codex-rs/core-api/Cargo.toml | 1 + codex-rs/core-api/src/lib.rs | 3 + codex-rs/thread-manager-sample/Cargo.toml | 1 + codex-rs/thread-manager-sample/src/main.rs | 87 +- 9 files changed, 955 insertions(+), 899 deletions(-) create mode 100644 codex-rs/app-server-protocol/src/protocol/event_mapping.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d8744c437..89a868ef5 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2485,6 +2485,7 @@ name = "codex-core-api" version = "0.0.0" dependencies = [ "codex-analytics", + "codex-app-server-protocol", "codex-arg0", "codex-config", "codex-core", @@ -3515,6 +3516,7 @@ dependencies = [ "anyhow", "clap", "codex-core-api", + "serde_json", "tracing", ] diff --git a/codex-rs/app-server-protocol/src/lib.rs b/codex-rs/app-server-protocol/src/lib.rs index 46f0c9ae4..2fcf54f4b 100644 --- a/codex-rs/app-server-protocol/src/lib.rs +++ b/codex-rs/app-server-protocol/src/lib.rs @@ -14,6 +14,7 @@ pub use export::generate_ts_with_options; pub use export::generate_types; pub use jsonrpc_lite::*; pub use protocol::common::*; +pub use protocol::event_mapping::*; pub use protocol::item_builders::*; pub use protocol::thread_history::*; pub use protocol::v1::ApplyPatchApprovalParams; diff --git a/codex-rs/app-server-protocol/src/protocol/event_mapping.rs b/codex-rs/app-server-protocol/src/protocol/event_mapping.rs new file mode 100644 index 000000000..d86d45f16 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/event_mapping.rs @@ -0,0 +1,807 @@ +use crate::protocol::common::ServerNotification; +use crate::protocol::item_builders::build_command_execution_begin_item; +use crate::protocol::item_builders::build_command_execution_end_item; +use crate::protocol::item_builders::build_file_change_begin_item; +use crate::protocol::item_builders::convert_patch_changes; +use crate::protocol::v2::AgentMessageDeltaNotification; +use crate::protocol::v2::CollabAgentState; +use crate::protocol::v2::CollabAgentTool; +use crate::protocol::v2::CollabAgentToolCallStatus; +use crate::protocol::v2::CommandExecutionOutputDeltaNotification; +use crate::protocol::v2::DynamicToolCallOutputContentItem; +use crate::protocol::v2::DynamicToolCallStatus; +use crate::protocol::v2::FileChangeOutputDeltaNotification; +use crate::protocol::v2::FileChangePatchUpdatedNotification; +use crate::protocol::v2::ItemCompletedNotification; +use crate::protocol::v2::ItemStartedNotification; +use crate::protocol::v2::McpToolCallError; +use crate::protocol::v2::McpToolCallResult; +use crate::protocol::v2::McpToolCallStatus; +use crate::protocol::v2::PlanDeltaNotification; +use crate::protocol::v2::ReasoningSummaryPartAddedNotification; +use crate::protocol::v2::ReasoningSummaryTextDeltaNotification; +use crate::protocol::v2::ReasoningTextDeltaNotification; +use crate::protocol::v2::TerminalInteractionNotification; +use crate::protocol::v2::ThreadItem; +use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynamicToolCallOutputContentItem; +use codex_protocol::protocol::EventMsg; +use serde_json::Value as JsonValue; +use std::collections::HashMap; + +/// Build the v2 app-server notification that directly corresponds to a single core event. +/// +/// This only covers the stateless event-to-notification projections that have a one-to-one +/// mapping. Callers remain responsible for any surrounding state checks or side effects before +/// invoking this helper. +pub fn item_event_to_server_notification( + msg: EventMsg, + thread_id: &str, + turn_id: &str, + is_file_change_output: bool, +) -> ServerNotification { + let thread_id = thread_id.to_string(); + let turn_id = turn_id.to_string(); + match msg { + EventMsg::DynamicToolCallResponse(response) => { + let status = if response.success { + DynamicToolCallStatus::Completed + } else { + DynamicToolCallStatus::Failed + }; + let duration_ms = i64::try_from(response.duration.as_millis()).ok(); + let item = ThreadItem::DynamicToolCall { + id: response.call_id, + namespace: response.namespace, + tool: response.tool, + arguments: response.arguments, + status, + content_items: Some( + response + .content_items + .into_iter() + .map(|item| match item { + CoreDynamicToolCallOutputContentItem::InputText { text } => { + DynamicToolCallOutputContentItem::InputText { text } + } + CoreDynamicToolCallOutputContentItem::InputImage { image_url } => { + DynamicToolCallOutputContentItem::InputImage { image_url } + } + }) + .collect(), + ), + success: Some(response.success), + duration_ms, + }; + ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id, + turn_id: response.turn_id, + item, + }) + } + EventMsg::McpToolCallBegin(begin_event) => { + let item = ThreadItem::McpToolCall { + id: begin_event.call_id, + server: begin_event.invocation.server, + tool: begin_event.invocation.tool, + status: McpToolCallStatus::InProgress, + arguments: begin_event.invocation.arguments.unwrap_or(JsonValue::Null), + mcp_app_resource_uri: begin_event.mcp_app_resource_uri, + result: None, + error: None, + duration_ms: None, + }; + ServerNotification::ItemStarted(ItemStartedNotification { + thread_id, + turn_id, + item, + }) + } + EventMsg::McpToolCallEnd(end_event) => { + let status = if end_event.is_success() { + McpToolCallStatus::Completed + } else { + McpToolCallStatus::Failed + }; + let duration_ms = i64::try_from(end_event.duration.as_millis()).ok(); + let (result, error) = match &end_event.result { + Ok(value) => ( + Some(Box::new(McpToolCallResult { + content: value.content.clone(), + structured_content: value.structured_content.clone(), + meta: value.meta.clone(), + })), + None, + ), + Err(message) => ( + None, + Some(McpToolCallError { + message: message.clone(), + }), + ), + }; + let item = ThreadItem::McpToolCall { + id: end_event.call_id, + server: end_event.invocation.server, + tool: end_event.invocation.tool, + status, + arguments: end_event.invocation.arguments.unwrap_or(JsonValue::Null), + mcp_app_resource_uri: end_event.mcp_app_resource_uri, + result, + error, + duration_ms, + }; + ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id, + turn_id, + item, + }) + } + EventMsg::CollabAgentSpawnBegin(begin_event) => { + let item = ThreadItem::CollabAgentToolCall { + id: begin_event.call_id, + tool: CollabAgentTool::SpawnAgent, + status: CollabAgentToolCallStatus::InProgress, + sender_thread_id: begin_event.sender_thread_id.to_string(), + receiver_thread_ids: Vec::new(), + prompt: Some(begin_event.prompt), + model: Some(begin_event.model), + reasoning_effort: Some(begin_event.reasoning_effort), + agents_states: HashMap::new(), + }; + ServerNotification::ItemStarted(ItemStartedNotification { + thread_id, + turn_id, + item, + }) + } + EventMsg::CollabAgentSpawnEnd(end_event) => { + let has_receiver = end_event.new_thread_id.is_some(); + let status = match &end_event.status { + codex_protocol::protocol::AgentStatus::Errored(_) + | codex_protocol::protocol::AgentStatus::NotFound => { + CollabAgentToolCallStatus::Failed + } + _ if has_receiver => CollabAgentToolCallStatus::Completed, + _ => CollabAgentToolCallStatus::Failed, + }; + let (receiver_thread_ids, agents_states) = match end_event.new_thread_id { + Some(id) => { + let receiver_id = id.to_string(); + let received_status = CollabAgentState::from(end_event.status.clone()); + ( + vec![receiver_id.clone()], + [(receiver_id, received_status)].into_iter().collect(), + ) + } + None => (Vec::new(), HashMap::new()), + }; + let item = ThreadItem::CollabAgentToolCall { + id: end_event.call_id, + tool: CollabAgentTool::SpawnAgent, + status, + sender_thread_id: end_event.sender_thread_id.to_string(), + receiver_thread_ids, + prompt: Some(end_event.prompt), + model: Some(end_event.model), + reasoning_effort: Some(end_event.reasoning_effort), + agents_states, + }; + ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id, + turn_id, + item, + }) + } + EventMsg::CollabAgentInteractionBegin(begin_event) => { + let receiver_thread_ids = vec![begin_event.receiver_thread_id.to_string()]; + let item = ThreadItem::CollabAgentToolCall { + id: begin_event.call_id, + tool: CollabAgentTool::SendInput, + status: CollabAgentToolCallStatus::InProgress, + sender_thread_id: begin_event.sender_thread_id.to_string(), + receiver_thread_ids, + prompt: Some(begin_event.prompt), + model: None, + reasoning_effort: None, + agents_states: HashMap::new(), + }; + ServerNotification::ItemStarted(ItemStartedNotification { + thread_id, + turn_id, + item, + }) + } + EventMsg::CollabAgentInteractionEnd(end_event) => { + let status = match &end_event.status { + codex_protocol::protocol::AgentStatus::Errored(_) + | codex_protocol::protocol::AgentStatus::NotFound => { + CollabAgentToolCallStatus::Failed + } + _ => CollabAgentToolCallStatus::Completed, + }; + let receiver_id = end_event.receiver_thread_id.to_string(); + let received_status = CollabAgentState::from(end_event.status); + let item = ThreadItem::CollabAgentToolCall { + id: end_event.call_id, + tool: CollabAgentTool::SendInput, + status, + sender_thread_id: end_event.sender_thread_id.to_string(), + receiver_thread_ids: vec![receiver_id.clone()], + prompt: Some(end_event.prompt), + model: None, + reasoning_effort: None, + agents_states: [(receiver_id, received_status)].into_iter().collect(), + }; + ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id, + turn_id, + item, + }) + } + EventMsg::CollabWaitingBegin(begin_event) => { + let receiver_thread_ids = begin_event + .receiver_thread_ids + .iter() + .map(ToString::to_string) + .collect(); + let item = ThreadItem::CollabAgentToolCall { + id: begin_event.call_id, + tool: CollabAgentTool::Wait, + status: CollabAgentToolCallStatus::InProgress, + sender_thread_id: begin_event.sender_thread_id.to_string(), + receiver_thread_ids, + prompt: None, + model: None, + reasoning_effort: None, + agents_states: HashMap::new(), + }; + ServerNotification::ItemStarted(ItemStartedNotification { + thread_id, + turn_id, + item, + }) + } + EventMsg::CollabWaitingEnd(end_event) => { + let status = if end_event.statuses.values().any(|status| { + matches!( + status, + codex_protocol::protocol::AgentStatus::Errored(_) + | codex_protocol::protocol::AgentStatus::NotFound + ) + }) { + CollabAgentToolCallStatus::Failed + } else { + CollabAgentToolCallStatus::Completed + }; + let receiver_thread_ids = end_event.statuses.keys().map(ToString::to_string).collect(); + let agents_states = end_event + .statuses + .iter() + .map(|(id, status)| (id.to_string(), CollabAgentState::from(status.clone()))) + .collect(); + let item = ThreadItem::CollabAgentToolCall { + id: end_event.call_id, + tool: CollabAgentTool::Wait, + status, + sender_thread_id: end_event.sender_thread_id.to_string(), + receiver_thread_ids, + prompt: None, + model: None, + reasoning_effort: None, + agents_states, + }; + ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id, + turn_id, + item, + }) + } + EventMsg::CollabCloseBegin(begin_event) => { + let item = ThreadItem::CollabAgentToolCall { + id: begin_event.call_id, + tool: CollabAgentTool::CloseAgent, + status: CollabAgentToolCallStatus::InProgress, + sender_thread_id: begin_event.sender_thread_id.to_string(), + receiver_thread_ids: vec![begin_event.receiver_thread_id.to_string()], + prompt: None, + model: None, + reasoning_effort: None, + agents_states: HashMap::new(), + }; + ServerNotification::ItemStarted(ItemStartedNotification { + thread_id, + turn_id, + item, + }) + } + EventMsg::CollabCloseEnd(end_event) => { + let status = match &end_event.status { + codex_protocol::protocol::AgentStatus::Errored(_) + | codex_protocol::protocol::AgentStatus::NotFound => { + CollabAgentToolCallStatus::Failed + } + _ => CollabAgentToolCallStatus::Completed, + }; + let receiver_id = end_event.receiver_thread_id.to_string(); + let agents_states = [( + receiver_id.clone(), + CollabAgentState::from(end_event.status), + )] + .into_iter() + .collect(); + let item = ThreadItem::CollabAgentToolCall { + id: end_event.call_id, + tool: CollabAgentTool::CloseAgent, + status, + sender_thread_id: end_event.sender_thread_id.to_string(), + receiver_thread_ids: vec![receiver_id], + prompt: None, + model: None, + reasoning_effort: None, + agents_states, + }; + ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id, + turn_id, + item, + }) + } + EventMsg::CollabResumeBegin(begin_event) => { + let item = ThreadItem::CollabAgentToolCall { + id: begin_event.call_id, + tool: CollabAgentTool::ResumeAgent, + status: CollabAgentToolCallStatus::InProgress, + sender_thread_id: begin_event.sender_thread_id.to_string(), + receiver_thread_ids: vec![begin_event.receiver_thread_id.to_string()], + prompt: None, + model: None, + reasoning_effort: None, + agents_states: HashMap::new(), + }; + ServerNotification::ItemStarted(ItemStartedNotification { + thread_id, + turn_id, + item, + }) + } + EventMsg::CollabResumeEnd(end_event) => { + let status = match &end_event.status { + codex_protocol::protocol::AgentStatus::Errored(_) + | codex_protocol::protocol::AgentStatus::NotFound => { + CollabAgentToolCallStatus::Failed + } + _ => CollabAgentToolCallStatus::Completed, + }; + let receiver_id = end_event.receiver_thread_id.to_string(); + let agents_states = [( + receiver_id.clone(), + CollabAgentState::from(end_event.status), + )] + .into_iter() + .collect(); + let item = ThreadItem::CollabAgentToolCall { + id: end_event.call_id, + tool: CollabAgentTool::ResumeAgent, + status, + sender_thread_id: end_event.sender_thread_id.to_string(), + receiver_thread_ids: vec![receiver_id], + prompt: None, + model: None, + reasoning_effort: None, + agents_states, + }; + ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id, + turn_id, + item, + }) + } + EventMsg::AgentMessageContentDelta(event) => { + let codex_protocol::protocol::AgentMessageContentDeltaEvent { item_id, delta, .. } = + event; + ServerNotification::AgentMessageDelta(AgentMessageDeltaNotification { + thread_id, + turn_id, + item_id, + delta, + }) + } + EventMsg::PlanDelta(event) => ServerNotification::PlanDelta(PlanDeltaNotification { + thread_id, + turn_id, + item_id: event.item_id, + delta: event.delta, + }), + EventMsg::ReasoningContentDelta(event) => { + ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification { + thread_id, + turn_id, + item_id: event.item_id, + delta: event.delta, + summary_index: event.summary_index, + }) + } + EventMsg::ReasoningRawContentDelta(event) => { + ServerNotification::ReasoningTextDelta(ReasoningTextDeltaNotification { + thread_id, + turn_id, + item_id: event.item_id, + delta: event.delta, + content_index: event.content_index, + }) + } + EventMsg::AgentReasoningSectionBreak(event) => { + ServerNotification::ReasoningSummaryPartAdded(ReasoningSummaryPartAddedNotification { + thread_id, + turn_id, + item_id: event.item_id, + summary_index: event.summary_index, + }) + } + EventMsg::ItemStarted(item_started_event) => { + ServerNotification::ItemStarted(ItemStartedNotification { + thread_id, + turn_id, + item: item_started_event.item.into(), + }) + } + EventMsg::ItemCompleted(item_completed_event) => { + ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id, + turn_id, + item: item_completed_event.item.into(), + }) + } + EventMsg::PatchApplyBegin(patch_begin_event) => { + ServerNotification::ItemStarted(ItemStartedNotification { + thread_id, + turn_id, + item: build_file_change_begin_item(&patch_begin_event), + }) + } + EventMsg::PatchApplyUpdated(event) => { + ServerNotification::FileChangePatchUpdated(FileChangePatchUpdatedNotification { + thread_id, + turn_id, + item_id: event.call_id, + changes: convert_patch_changes(&event.changes), + }) + } + EventMsg::ExecCommandBegin(exec_command_begin_event) => { + ServerNotification::ItemStarted(ItemStartedNotification { + thread_id, + turn_id, + item: build_command_execution_begin_item(&exec_command_begin_event), + }) + } + EventMsg::ExecCommandOutputDelta(exec_command_output_delta_event) => { + let item_id = exec_command_output_delta_event.call_id; + let delta = String::from_utf8_lossy(&exec_command_output_delta_event.chunk).to_string(); + if is_file_change_output { + ServerNotification::FileChangeOutputDelta(FileChangeOutputDeltaNotification { + thread_id, + turn_id, + item_id, + delta, + }) + } else { + ServerNotification::CommandExecutionOutputDelta( + CommandExecutionOutputDeltaNotification { + thread_id, + turn_id, + item_id, + delta, + }, + ) + } + } + EventMsg::TerminalInteraction(terminal_event) => { + ServerNotification::TerminalInteraction(TerminalInteractionNotification { + thread_id, + turn_id, + item_id: terminal_event.call_id, + process_id: terminal_event.process_id, + stdin: terminal_event.stdin, + }) + } + EventMsg::ExecCommandEnd(exec_command_end_event) => { + ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id, + turn_id, + item: build_command_execution_end_item(&exec_command_end_event), + }) + } + _ => unreachable!("unsupported item event"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_protocol::ThreadId; + use codex_protocol::mcp::CallToolResult; + use codex_protocol::protocol::CollabResumeBeginEvent; + use codex_protocol::protocol::CollabResumeEndEvent; + use codex_protocol::protocol::McpInvocation; + use codex_protocol::protocol::McpToolCallBeginEvent; + use codex_protocol::protocol::McpToolCallEndEvent; + use pretty_assertions::assert_eq; + use rmcp::model::Content; + use std::time::Duration; + + fn assert_item_started_server_notification( + notification: ServerNotification, + expected: ItemStartedNotification, + ) { + match notification { + ServerNotification::ItemStarted(payload) => assert_eq!(payload, expected), + other => panic!("expected item started notification, got {other:?}"), + } + } + + fn assert_item_completed_server_notification( + notification: ServerNotification, + expected: ItemCompletedNotification, + ) { + match notification { + ServerNotification::ItemCompleted(payload) => assert_eq!(payload, expected), + other => panic!("expected item completed notification, got {other:?}"), + } + } + + #[test] + fn collab_resume_begin_maps_to_item_started_resume_agent() { + let event = CollabResumeBeginEvent { + call_id: "call-1".to_string(), + sender_thread_id: ThreadId::new(), + receiver_thread_id: ThreadId::new(), + receiver_agent_nickname: None, + receiver_agent_role: None, + }; + + let notification = item_event_to_server_notification( + EventMsg::CollabResumeBegin(event.clone()), + "thread-1", + "turn-1", + /*is_file_change_output*/ false, + ); + assert_item_started_server_notification( + notification, + ItemStartedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item: ThreadItem::CollabAgentToolCall { + id: event.call_id, + tool: CollabAgentTool::ResumeAgent, + status: CollabAgentToolCallStatus::InProgress, + sender_thread_id: event.sender_thread_id.to_string(), + receiver_thread_ids: vec![event.receiver_thread_id.to_string()], + prompt: None, + model: None, + reasoning_effort: None, + agents_states: HashMap::new(), + }, + }, + ); + } + + #[test] + fn collab_resume_end_maps_to_item_completed_resume_agent() { + let event = CollabResumeEndEvent { + call_id: "call-2".to_string(), + sender_thread_id: ThreadId::new(), + receiver_thread_id: ThreadId::new(), + receiver_agent_nickname: None, + receiver_agent_role: None, + status: codex_protocol::protocol::AgentStatus::NotFound, + }; + + let receiver_id = event.receiver_thread_id.to_string(); + let notification = item_event_to_server_notification( + EventMsg::CollabResumeEnd(event.clone()), + "thread-2", + "turn-2", + /*is_file_change_output*/ false, + ); + assert_item_completed_server_notification( + notification, + ItemCompletedNotification { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + item: ThreadItem::CollabAgentToolCall { + id: event.call_id, + tool: CollabAgentTool::ResumeAgent, + status: CollabAgentToolCallStatus::Failed, + sender_thread_id: event.sender_thread_id.to_string(), + receiver_thread_ids: vec![receiver_id.clone()], + prompt: None, + model: None, + reasoning_effort: None, + agents_states: [( + receiver_id, + CollabAgentState::from(codex_protocol::protocol::AgentStatus::NotFound), + )] + .into_iter() + .collect(), + }, + }, + ); + } + + #[test] + fn mcp_tool_call_begin_maps_to_item_started_notification_with_args() { + let begin_event = McpToolCallBeginEvent { + call_id: "call_123".to_string(), + invocation: McpInvocation { + server: "codex".to_string(), + tool: "list_mcp_resources".to_string(), + arguments: Some(serde_json::json!({"server": ""})), + }, + mcp_app_resource_uri: Some("ui://widget/list-resources.html".to_string()), + }; + + let notification = item_event_to_server_notification( + EventMsg::McpToolCallBegin(begin_event.clone()), + "thread-1", + "turn_1", + /*is_file_change_output*/ false, + ); + assert_item_started_server_notification( + notification, + ItemStartedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn_1".to_string(), + item: ThreadItem::McpToolCall { + id: begin_event.call_id, + server: begin_event.invocation.server, + tool: begin_event.invocation.tool, + status: McpToolCallStatus::InProgress, + arguments: serde_json::json!({"server": ""}), + mcp_app_resource_uri: Some("ui://widget/list-resources.html".to_string()), + result: None, + error: None, + duration_ms: None, + }, + }, + ); + } + + #[test] + fn mcp_tool_call_begin_maps_to_item_started_notification_without_args() { + let begin_event = McpToolCallBeginEvent { + call_id: "call_456".to_string(), + invocation: McpInvocation { + server: "codex".to_string(), + tool: "list_mcp_resources".to_string(), + arguments: None, + }, + mcp_app_resource_uri: None, + }; + + let notification = item_event_to_server_notification( + EventMsg::McpToolCallBegin(begin_event.clone()), + "thread-2", + "turn_2", + /*is_file_change_output*/ false, + ); + assert_item_started_server_notification( + notification, + ItemStartedNotification { + thread_id: "thread-2".to_string(), + turn_id: "turn_2".to_string(), + item: ThreadItem::McpToolCall { + id: begin_event.call_id, + server: begin_event.invocation.server, + tool: begin_event.invocation.tool, + status: McpToolCallStatus::InProgress, + arguments: JsonValue::Null, + mcp_app_resource_uri: None, + result: None, + error: None, + duration_ms: None, + }, + }, + ); + } + + #[test] + fn mcp_tool_call_end_maps_to_item_completed_notification_on_success() { + let content = vec![ + serde_json::to_value(Content::text("{\"resources\":[]}")) + .expect("content should serialize"), + ]; + let result = CallToolResult { + content: content.clone(), + is_error: Some(false), + structured_content: None, + meta: Some(serde_json::json!({ + "ui/resourceUri": "ui://widget/list-resources.html" + })), + }; + + let end_event = McpToolCallEndEvent { + call_id: "call_789".to_string(), + invocation: McpInvocation { + server: "codex".to_string(), + tool: "list_mcp_resources".to_string(), + arguments: Some(serde_json::json!({"server": ""})), + }, + mcp_app_resource_uri: Some("ui://widget/list-resources.html".to_string()), + duration: Duration::from_nanos(92708), + result: Ok(result), + }; + + let notification = item_event_to_server_notification( + EventMsg::McpToolCallEnd(end_event.clone()), + "thread-3", + "turn_3", + /*is_file_change_output*/ false, + ); + assert_item_completed_server_notification( + notification, + ItemCompletedNotification { + thread_id: "thread-3".to_string(), + turn_id: "turn_3".to_string(), + item: ThreadItem::McpToolCall { + id: end_event.call_id, + server: end_event.invocation.server, + tool: end_event.invocation.tool, + status: McpToolCallStatus::Completed, + arguments: serde_json::json!({"server": ""}), + mcp_app_resource_uri: Some("ui://widget/list-resources.html".to_string()), + result: Some(Box::new(McpToolCallResult { + content, + structured_content: None, + meta: Some(serde_json::json!({ + "ui/resourceUri": "ui://widget/list-resources.html" + })), + })), + error: None, + duration_ms: Some(0), + }, + }, + ); + } + + #[test] + fn mcp_tool_call_end_maps_to_item_completed_notification_on_error() { + let end_event = McpToolCallEndEvent { + call_id: "call_err".to_string(), + invocation: McpInvocation { + server: "codex".to_string(), + tool: "list_mcp_resources".to_string(), + arguments: None, + }, + mcp_app_resource_uri: None, + duration: Duration::from_millis(1), + result: Err("boom".to_string()), + }; + + let notification = item_event_to_server_notification( + EventMsg::McpToolCallEnd(end_event.clone()), + "thread-4", + "turn_4", + /*is_file_change_output*/ false, + ); + assert_item_completed_server_notification( + notification, + ItemCompletedNotification { + thread_id: "thread-4".to_string(), + turn_id: "turn_4".to_string(), + item: ThreadItem::McpToolCall { + id: end_event.call_id, + server: end_event.invocation.server, + tool: end_event.invocation.tool, + status: McpToolCallStatus::Failed, + arguments: JsonValue::Null, + mcp_app_resource_uri: None, + result: None, + error: Some(McpToolCallError { + message: "boom".to_string(), + }), + duration_ms: Some(1), + }, + }, + ); + } +} diff --git a/codex-rs/app-server-protocol/src/protocol/mod.rs b/codex-rs/app-server-protocol/src/protocol/mod.rs index 4179d361c..592944b35 100644 --- a/codex-rs/app-server-protocol/src/protocol/mod.rs +++ b/codex-rs/app-server-protocol/src/protocol/mod.rs @@ -2,6 +2,7 @@ // Exposes protocol pieces used by `lib.rs` via `pub use protocol::common::*;`. pub mod common; +pub mod event_mapping; pub mod item_builders; mod mappers; mod serde_helpers; diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index a54964f41..00e13245b 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -14,27 +14,19 @@ use crate::thread_status::ThreadWatchManager; use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::AccountRateLimitsUpdatedNotification; use codex_app_server_protocol::AdditionalPermissionProfile as V2AdditionalPermissionProfile; -use codex_app_server_protocol::AgentMessageDeltaNotification; use codex_app_server_protocol::CodexErrorInfo as V2CodexErrorInfo; -use codex_app_server_protocol::CollabAgentState as V2CollabAgentStatus; -use codex_app_server_protocol::CollabAgentTool; -use codex_app_server_protocol::CollabAgentToolCallStatus as V2CollabToolCallStatus; use codex_app_server_protocol::CommandAction as V2ParsedCommand; use codex_app_server_protocol::CommandExecutionApprovalDecision; -use codex_app_server_protocol::CommandExecutionOutputDeltaNotification; use codex_app_server_protocol::CommandExecutionRequestApprovalParams; use codex_app_server_protocol::CommandExecutionRequestApprovalResponse; use codex_app_server_protocol::CommandExecutionSource; use codex_app_server_protocol::CommandExecutionStatus; use codex_app_server_protocol::DeprecationNoticeNotification; -use codex_app_server_protocol::DynamicToolCallOutputContentItem; use codex_app_server_protocol::DynamicToolCallParams; use codex_app_server_protocol::DynamicToolCallStatus; use codex_app_server_protocol::ErrorNotification; use codex_app_server_protocol::ExecPolicyAmendment as V2ExecPolicyAmendment; use codex_app_server_protocol::FileChangeApprovalDecision; -use codex_app_server_protocol::FileChangeOutputDeltaNotification; -use codex_app_server_protocol::FileChangePatchUpdatedNotification; use codex_app_server_protocol::FileChangeRequestApprovalParams; use codex_app_server_protocol::FileChangeRequestApprovalResponse; use codex_app_server_protocol::FileUpdateChange; @@ -49,9 +41,6 @@ use codex_app_server_protocol::McpServerElicitationRequestParams; use codex_app_server_protocol::McpServerElicitationRequestResponse; use codex_app_server_protocol::McpServerStartupState; use codex_app_server_protocol::McpServerStatusUpdatedNotification; -use codex_app_server_protocol::McpToolCallError; -use codex_app_server_protocol::McpToolCallResult; -use codex_app_server_protocol::McpToolCallStatus; use codex_app_server_protocol::ModelReroutedNotification; use codex_app_server_protocol::ModelVerificationNotification; use codex_app_server_protocol::NetworkApprovalContext as V2NetworkApprovalContext; @@ -60,16 +49,11 @@ use codex_app_server_protocol::NetworkPolicyRuleAction as V2NetworkPolicyRuleAct use codex_app_server_protocol::PatchApplyStatus; use codex_app_server_protocol::PermissionsRequestApprovalParams; use codex_app_server_protocol::PermissionsRequestApprovalResponse; -use codex_app_server_protocol::PlanDeltaNotification; use codex_app_server_protocol::RawResponseItemCompletedNotification; -use codex_app_server_protocol::ReasoningSummaryPartAddedNotification; -use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification; -use codex_app_server_protocol::ReasoningTextDeltaNotification; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequestPayload; use codex_app_server_protocol::SkillsChangedNotification; -use codex_app_server_protocol::TerminalInteractionNotification; use codex_app_server_protocol::ThreadGoalUpdatedNotification; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadNameUpdatedNotification; @@ -98,21 +82,19 @@ use codex_app_server_protocol::TurnPlanUpdatedNotification; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::WarningNotification; -use codex_app_server_protocol::build_command_execution_end_item; use codex_app_server_protocol::build_file_change_approval_request_item; -use codex_app_server_protocol::build_file_change_begin_item; use codex_app_server_protocol::build_file_change_end_item; use codex_app_server_protocol::build_item_from_guardian_event; use codex_app_server_protocol::build_turns_from_rollout_items; use codex_app_server_protocol::convert_patch_changes; use codex_app_server_protocol::guardian_auto_approval_review_notification; +use codex_app_server_protocol::item_event_to_server_notification; use codex_core::CodexThread; use codex_core::ThreadManager; use codex_core::find_thread_name_by_id; use codex_core::review_format::format_review_findings_block; use codex_core::review_prompts; use codex_protocol::ThreadId; -use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynamicToolCallOutputContentItem; use codex_protocol::items::parse_hook_prompt_message; use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; use codex_protocol::plan_tool::UpdatePlanArgs; @@ -120,8 +102,6 @@ use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecApprovalRequestEvent; -use codex_protocol::protocol::McpToolCallBeginEvent; -use codex_protocol::protocol::McpToolCallEndEvent; use codex_protocol::protocol::Op; use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::ReviewDecision; @@ -146,8 +126,6 @@ use tokio::sync::oneshot; use tracing::error; use tracing::warn; -type JsonValue = serde_json::Value; - enum CommandExecutionApprovalPresentation { Network(V2NetworkApprovalContext), Command(CommandExecutionCompletionItem), @@ -888,262 +866,30 @@ pub(crate) async fn apply_bespoke_event_handling( crate::dynamic_tools::on_call_response(call_id, rx, conversation).await; }); } - EventMsg::DynamicToolCallResponse(response) => { - let status = if response.success { - DynamicToolCallStatus::Completed - } else { - DynamicToolCallStatus::Failed - }; - let duration_ms = i64::try_from(response.duration.as_millis()).ok(); - let item = ThreadItem::DynamicToolCall { - id: response.call_id, - namespace: response.namespace, - tool: response.tool, - arguments: response.arguments, - status, - content_items: Some( - response - .content_items - .into_iter() - .map(|item| match item { - CoreDynamicToolCallOutputContentItem::InputText { text } => { - DynamicToolCallOutputContentItem::InputText { text } - } - CoreDynamicToolCallOutputContentItem::InputImage { image_url } => { - DynamicToolCallOutputContentItem::InputImage { image_url } - } - }) - .collect(), - ), - success: Some(response.success), - duration_ms, - }; - let notification = ItemCompletedNotification { - thread_id: conversation_id.to_string(), - turn_id: response.turn_id, - item, - }; - outgoing - .send_server_notification(ServerNotification::ItemCompleted(notification)) - .await; - } - // TODO(celia): properly construct McpToolCall TurnItem in core. - EventMsg::McpToolCallBegin(begin_event) => { - let notification = construct_mcp_tool_call_notification( - begin_event, - conversation_id.to_string(), - event_turn_id.clone(), - ) - .await; - outgoing - .send_server_notification(ServerNotification::ItemStarted(notification)) - .await; - } - EventMsg::McpToolCallEnd(end_event) => { - let notification = construct_mcp_tool_call_end_notification( - end_event, - conversation_id.to_string(), - event_turn_id.clone(), - ) - .await; - outgoing - .send_server_notification(ServerNotification::ItemCompleted(notification)) - .await; - } - EventMsg::CollabAgentSpawnBegin(begin_event) => { - let item = ThreadItem::CollabAgentToolCall { - id: begin_event.call_id, - tool: CollabAgentTool::SpawnAgent, - status: V2CollabToolCallStatus::InProgress, - sender_thread_id: begin_event.sender_thread_id.to_string(), - receiver_thread_ids: Vec::new(), - prompt: Some(begin_event.prompt), - model: Some(begin_event.model), - reasoning_effort: Some(begin_event.reasoning_effort), - agents_states: HashMap::new(), - }; - let notification = ItemStartedNotification { - thread_id: conversation_id.to_string(), - turn_id: event_turn_id.clone(), - item, - }; - outgoing - .send_server_notification(ServerNotification::ItemStarted(notification)) - .await; - } - EventMsg::CollabAgentSpawnEnd(end_event) => { - let has_receiver = end_event.new_thread_id.is_some(); - let status = match &end_event.status { - codex_protocol::protocol::AgentStatus::Errored(_) - | codex_protocol::protocol::AgentStatus::NotFound => V2CollabToolCallStatus::Failed, - _ if has_receiver => V2CollabToolCallStatus::Completed, - _ => V2CollabToolCallStatus::Failed, - }; - let (receiver_thread_ids, agents_states) = match end_event.new_thread_id { - Some(id) => { - let receiver_id = id.to_string(); - let received_status = V2CollabAgentStatus::from(end_event.status.clone()); - ( - vec![receiver_id.clone()], - [(receiver_id, received_status)].into_iter().collect(), - ) - } - None => (Vec::new(), HashMap::new()), - }; - let item = ThreadItem::CollabAgentToolCall { - id: end_event.call_id, - tool: CollabAgentTool::SpawnAgent, - status, - sender_thread_id: end_event.sender_thread_id.to_string(), - receiver_thread_ids, - prompt: Some(end_event.prompt), - model: Some(end_event.model), - reasoning_effort: Some(end_event.reasoning_effort), - agents_states, - }; - let notification = ItemCompletedNotification { - thread_id: conversation_id.to_string(), - turn_id: event_turn_id.clone(), - item, - }; - outgoing - .send_server_notification(ServerNotification::ItemCompleted(notification)) - .await; - } - EventMsg::CollabAgentInteractionBegin(begin_event) => { - let receiver_thread_ids = vec![begin_event.receiver_thread_id.to_string()]; - let item = ThreadItem::CollabAgentToolCall { - id: begin_event.call_id, - tool: CollabAgentTool::SendInput, - status: V2CollabToolCallStatus::InProgress, - sender_thread_id: begin_event.sender_thread_id.to_string(), - receiver_thread_ids, - prompt: Some(begin_event.prompt), - model: None, - reasoning_effort: None, - agents_states: HashMap::new(), - }; - let notification = ItemStartedNotification { - thread_id: conversation_id.to_string(), - turn_id: event_turn_id.clone(), - item, - }; - outgoing - .send_server_notification(ServerNotification::ItemStarted(notification)) - .await; - } - EventMsg::CollabAgentInteractionEnd(end_event) => { - let status = match &end_event.status { - codex_protocol::protocol::AgentStatus::Errored(_) - | codex_protocol::protocol::AgentStatus::NotFound => V2CollabToolCallStatus::Failed, - _ => V2CollabToolCallStatus::Completed, - }; - let receiver_id = end_event.receiver_thread_id.to_string(); - let received_status = V2CollabAgentStatus::from(end_event.status); - let item = ThreadItem::CollabAgentToolCall { - id: end_event.call_id, - tool: CollabAgentTool::SendInput, - status, - sender_thread_id: end_event.sender_thread_id.to_string(), - receiver_thread_ids: vec![receiver_id.clone()], - prompt: Some(end_event.prompt), - model: None, - reasoning_effort: None, - agents_states: [(receiver_id, received_status)].into_iter().collect(), - }; - let notification = ItemCompletedNotification { - thread_id: conversation_id.to_string(), - turn_id: event_turn_id.clone(), - item, - }; - outgoing - .send_server_notification(ServerNotification::ItemCompleted(notification)) - .await; - } - EventMsg::CollabWaitingBegin(begin_event) => { - let receiver_thread_ids = begin_event - .receiver_thread_ids - .iter() - .map(ToString::to_string) - .collect(); - let item = ThreadItem::CollabAgentToolCall { - id: begin_event.call_id, - tool: CollabAgentTool::Wait, - status: V2CollabToolCallStatus::InProgress, - sender_thread_id: begin_event.sender_thread_id.to_string(), - receiver_thread_ids, - prompt: None, - model: None, - reasoning_effort: None, - agents_states: HashMap::new(), - }; - let notification = ItemStartedNotification { - thread_id: conversation_id.to_string(), - turn_id: event_turn_id.clone(), - item, - }; - outgoing - .send_server_notification(ServerNotification::ItemStarted(notification)) - .await; - } - EventMsg::CollabWaitingEnd(end_event) => { - let status = if end_event.statuses.values().any(|status| { - matches!( - status, - codex_protocol::protocol::AgentStatus::Errored(_) - | codex_protocol::protocol::AgentStatus::NotFound - ) - }) { - V2CollabToolCallStatus::Failed - } else { - V2CollabToolCallStatus::Completed - }; - let receiver_thread_ids = end_event.statuses.keys().map(ToString::to_string).collect(); - let agents_states = end_event - .statuses - .iter() - .map(|(id, status)| (id.to_string(), V2CollabAgentStatus::from(status.clone()))) - .collect(); - let item = ThreadItem::CollabAgentToolCall { - id: end_event.call_id, - tool: CollabAgentTool::Wait, - status, - sender_thread_id: end_event.sender_thread_id.to_string(), - receiver_thread_ids, - prompt: None, - model: None, - reasoning_effort: None, - agents_states, - }; - let notification = ItemCompletedNotification { - thread_id: conversation_id.to_string(), - turn_id: event_turn_id.clone(), - item, - }; - outgoing - .send_server_notification(ServerNotification::ItemCompleted(notification)) - .await; - } - EventMsg::CollabCloseBegin(begin_event) => { - let item = ThreadItem::CollabAgentToolCall { - id: begin_event.call_id, - tool: CollabAgentTool::CloseAgent, - status: V2CollabToolCallStatus::InProgress, - sender_thread_id: begin_event.sender_thread_id.to_string(), - receiver_thread_ids: vec![begin_event.receiver_thread_id.to_string()], - prompt: None, - model: None, - reasoning_effort: None, - agents_states: HashMap::new(), - }; - let notification = ItemStartedNotification { - thread_id: conversation_id.to_string(), - turn_id: event_turn_id.clone(), - item, - }; - outgoing - .send_server_notification(ServerNotification::ItemStarted(notification)) - .await; + msg @ (EventMsg::DynamicToolCallResponse(_) + | EventMsg::McpToolCallBegin(_) + | EventMsg::McpToolCallEnd(_) + | EventMsg::CollabAgentSpawnBegin(_) + | EventMsg::CollabAgentSpawnEnd(_) + | EventMsg::CollabAgentInteractionBegin(_) + | EventMsg::CollabAgentInteractionEnd(_) + | EventMsg::CollabWaitingBegin(_) + | EventMsg::CollabWaitingEnd(_) + | EventMsg::CollabCloseBegin(_) + | EventMsg::CollabResumeBegin(_) + | EventMsg::CollabResumeEnd(_) + | EventMsg::AgentMessageContentDelta(_) + | EventMsg::PlanDelta(_) + | EventMsg::ReasoningContentDelta(_) + | EventMsg::ReasoningRawContentDelta(_) + | EventMsg::AgentReasoningSectionBreak(_)) => { + let notification = item_event_to_server_notification( + msg, + &conversation_id.to_string(), + &event_turn_id, + /*is_file_change_output*/ false, + ); + outgoing.send_server_notification(notification).await; } EventMsg::CollabCloseEnd(end_event) => { if thread_manager @@ -1155,83 +901,13 @@ pub(crate) async fn apply_bespoke_event_handling( .remove_thread(&end_event.receiver_thread_id.to_string()) .await; } - let status = match &end_event.status { - codex_protocol::protocol::AgentStatus::Errored(_) - | codex_protocol::protocol::AgentStatus::NotFound => V2CollabToolCallStatus::Failed, - _ => V2CollabToolCallStatus::Completed, - }; - let receiver_id = end_event.receiver_thread_id.to_string(); - let agents_states = [( - receiver_id.clone(), - V2CollabAgentStatus::from(end_event.status), - )] - .into_iter() - .collect(); - let item = ThreadItem::CollabAgentToolCall { - id: end_event.call_id, - tool: CollabAgentTool::CloseAgent, - status, - sender_thread_id: end_event.sender_thread_id.to_string(), - receiver_thread_ids: vec![receiver_id], - prompt: None, - model: None, - reasoning_effort: None, - agents_states, - }; - let notification = ItemCompletedNotification { - thread_id: conversation_id.to_string(), - turn_id: event_turn_id.clone(), - item, - }; - outgoing - .send_server_notification(ServerNotification::ItemCompleted(notification)) - .await; - } - EventMsg::CollabResumeBegin(begin_event) => { - let item = collab_resume_begin_item(begin_event); - let notification = ItemStartedNotification { - thread_id: conversation_id.to_string(), - turn_id: event_turn_id.clone(), - item, - }; - outgoing - .send_server_notification(ServerNotification::ItemStarted(notification)) - .await; - } - EventMsg::CollabResumeEnd(end_event) => { - let item = collab_resume_end_item(end_event); - let notification = ItemCompletedNotification { - thread_id: conversation_id.to_string(), - turn_id: event_turn_id.clone(), - item, - }; - outgoing - .send_server_notification(ServerNotification::ItemCompleted(notification)) - .await; - } - EventMsg::AgentMessageContentDelta(event) => { - let codex_protocol::protocol::AgentMessageContentDeltaEvent { item_id, delta, .. } = - event; - let notification = AgentMessageDeltaNotification { - thread_id: conversation_id.to_string(), - turn_id: event_turn_id.clone(), - item_id, - delta, - }; - outgoing - .send_server_notification(ServerNotification::AgentMessageDelta(notification)) - .await; - } - EventMsg::PlanDelta(event) => { - let notification = PlanDeltaNotification { - thread_id: conversation_id.to_string(), - turn_id: event_turn_id.clone(), - item_id: event.item_id, - delta: event.delta, - }; - outgoing - .send_server_notification(ServerNotification::PlanDelta(notification)) - .await; + let notification = item_event_to_server_notification( + EventMsg::CollabCloseEnd(end_event), + &conversation_id.to_string(), + &event_turn_id, + /*is_file_change_output*/ false, + ); + outgoing.send_server_notification(notification).await; } EventMsg::ContextCompacted(..) => { // Core still fans out this deprecated event for legacy clients; @@ -1246,45 +922,6 @@ pub(crate) async fn apply_bespoke_event_handling( .send_server_notification(ServerNotification::DeprecationNotice(notification)) .await; } - EventMsg::ReasoningContentDelta(event) => { - let notification = ReasoningSummaryTextDeltaNotification { - thread_id: conversation_id.to_string(), - turn_id: event_turn_id.clone(), - item_id: event.item_id, - delta: event.delta, - summary_index: event.summary_index, - }; - outgoing - .send_server_notification(ServerNotification::ReasoningSummaryTextDelta( - notification, - )) - .await; - } - EventMsg::ReasoningRawContentDelta(event) => { - let notification = ReasoningTextDeltaNotification { - thread_id: conversation_id.to_string(), - turn_id: event_turn_id.clone(), - item_id: event.item_id, - delta: event.delta, - content_index: event.content_index, - }; - outgoing - .send_server_notification(ServerNotification::ReasoningTextDelta(notification)) - .await; - } - EventMsg::AgentReasoningSectionBreak(event) => { - let notification = ReasoningSummaryPartAddedNotification { - thread_id: conversation_id.to_string(), - turn_id: event_turn_id.clone(), - item_id: event.item_id, - summary_index: event.summary_index, - }; - outgoing - .send_server_notification(ServerNotification::ReasoningSummaryPartAdded( - notification, - )) - .await; - } EventMsg::TokenCount(token_count_event) => { handle_token_count_event(conversation_id, event_turn_id, token_count_event, &outgoing) .await; @@ -1396,27 +1033,17 @@ pub(crate) async fn apply_bespoke_event_handling( .send_server_notification(ServerNotification::ItemCompleted(completed)) .await; } - EventMsg::ItemStarted(item_started_event) => { - let item: ThreadItem = item_started_event.item.clone().into(); - let notification = ItemStartedNotification { - thread_id: conversation_id.to_string(), - turn_id: event_turn_id.clone(), - item, - }; - outgoing - .send_server_notification(ServerNotification::ItemStarted(notification)) - .await; - } - EventMsg::ItemCompleted(item_completed_event) => { - let item: ThreadItem = item_completed_event.item.clone().into(); - let notification = ItemCompletedNotification { - thread_id: conversation_id.to_string(), - turn_id: event_turn_id.clone(), - item, - }; - outgoing - .send_server_notification(ServerNotification::ItemCompleted(notification)) - .await; + msg @ (EventMsg::ItemStarted(_) + | EventMsg::ItemCompleted(_) + | EventMsg::PatchApplyUpdated(_) + | EventMsg::TerminalInteraction(_)) => { + let notification = item_event_to_server_notification( + msg, + &conversation_id.to_string(), + &event_turn_id, + /*is_file_change_output*/ false, + ); + outgoing.send_server_notification(notification).await; } EventMsg::HookStarted(event) => { let notification = HookStartedNotification { @@ -1493,28 +1120,15 @@ pub(crate) async fn apply_bespoke_event_handling( .insert(item_id.clone()) }; if first_start { - let item = build_file_change_begin_item(&patch_begin_event); - let notification = ItemStartedNotification { - thread_id: conversation_id.to_string(), - turn_id: event_turn_id.clone(), - item, - }; - outgoing - .send_server_notification(ServerNotification::ItemStarted(notification)) - .await; + let notification = item_event_to_server_notification( + EventMsg::PatchApplyBegin(patch_begin_event), + &conversation_id.to_string(), + &event_turn_id, + /*is_file_change_output*/ false, + ); + outgoing.send_server_notification(notification).await; } } - EventMsg::PatchApplyUpdated(event) => { - let notification = FileChangePatchUpdatedNotification { - thread_id: conversation_id.to_string(), - turn_id: event_turn_id.clone(), - item_id: event.call_id, - changes: convert_patch_changes(&event.changes), - }; - outgoing - .send_server_notification(ServerNotification::FileChangePatchUpdated(notification)) - .await; - } EventMsg::PatchApplyEnd(patch_end_event) => { // Until we migrate the core to be aware of a first class FileChangeItem // and emit the corresponding EventMsg, we repurpose the call_id as the item_id. @@ -1540,14 +1154,6 @@ pub(crate) async fn apply_bespoke_event_handling( return; } let item_id = exec_command_begin_event.call_id.clone(); - let cwd = exec_command_begin_event.cwd.clone(); - let command_actions = exec_command_begin_event - .parsed_cmd - .into_iter() - .map(|parsed| V2ParsedCommand::from_core_with_cwd(parsed, &cwd)) - .collect::>(); - let command = shlex_join(&exec_command_begin_event.command); - let process_id = exec_command_begin_event.process_id; let first_start = { let mut state = thread_state.lock().await; state @@ -1556,26 +1162,13 @@ pub(crate) async fn apply_bespoke_event_handling( .insert(item_id.clone()) }; if first_start { - let item = ThreadItem::CommandExecution { - id: item_id, - command, - cwd, - process_id, - source: exec_command_begin_event.source.into(), - status: CommandExecutionStatus::InProgress, - command_actions, - aggregated_output: None, - exit_code: None, - duration_ms: None, - }; - let notification = ItemStartedNotification { - thread_id: conversation_id.to_string(), - turn_id: event_turn_id.clone(), - item, - }; - outgoing - .send_server_notification(ServerNotification::ItemStarted(notification)) - .await; + let notification = item_event_to_server_notification( + EventMsg::ExecCommandBegin(exec_command_begin_event), + &conversation_id.to_string(), + &event_turn_id, + /*is_file_change_output*/ false, + ); + outgoing.send_server_notification(notification).await; } } EventMsg::ExecCommandOutputDelta(exec_command_output_delta_event) => { @@ -1590,48 +1183,13 @@ pub(crate) async fn apply_bespoke_event_handling( let state = thread_state.lock().await; state.turn_summary.file_change_started.contains(&item_id) }; - if is_file_change { - let delta = - String::from_utf8_lossy(&exec_command_output_delta_event.chunk).to_string(); - let notification = FileChangeOutputDeltaNotification { - thread_id: conversation_id.to_string(), - turn_id: event_turn_id.clone(), - item_id, - delta, - }; - outgoing - .send_server_notification(ServerNotification::FileChangeOutputDelta( - notification, - )) - .await; - } else { - let notification = CommandExecutionOutputDeltaNotification { - thread_id: conversation_id.to_string(), - turn_id: event_turn_id.clone(), - item_id, - delta: String::from_utf8_lossy(&exec_command_output_delta_event.chunk) - .to_string(), - }; - outgoing - .send_server_notification(ServerNotification::CommandExecutionOutputDelta( - notification, - )) - .await; - } - } - EventMsg::TerminalInteraction(terminal_event) => { - let item_id = terminal_event.call_id.clone(); - - let notification = TerminalInteractionNotification { - thread_id: conversation_id.to_string(), - turn_id: event_turn_id.clone(), - item_id, - process_id: terminal_event.process_id, - stdin: terminal_event.stdin, - }; - outgoing - .send_server_notification(ServerNotification::TerminalInteraction(notification)) - .await; + let notification = item_event_to_server_notification( + EventMsg::ExecCommandOutputDelta(exec_command_output_delta_event), + &conversation_id.to_string(), + &event_turn_id, + is_file_change, + ); + outgoing.send_server_notification(notification).await; } EventMsg::ExecCommandEnd(exec_command_end_event) => { let call_id = exec_command_end_event.call_id.clone(); @@ -1651,17 +1209,13 @@ pub(crate) async fn apply_bespoke_event_handling( // emitted for unified exec interactions. return; } - - let item = build_command_execution_end_item(&exec_command_end_event); - - let notification = ItemCompletedNotification { - thread_id: conversation_id.to_string(), - turn_id: event_turn_id.clone(), - item, - }; - outgoing - .send_server_notification(ServerNotification::ItemCompleted(notification)) - .await; + let notification = item_event_to_server_notification( + EventMsg::ExecCommandEnd(exec_command_end_event), + &conversation_id.to_string(), + &event_turn_id, + /*is_file_change_output*/ false, + ); + outgoing.send_server_notification(notification).await; } // If this is a TurnAborted, reply to any pending interrupt requests. EventMsg::TurnAborted(turn_aborted_event) => { @@ -2676,120 +2230,6 @@ async fn on_command_execution_request_approval_response( } } -fn collab_resume_begin_item( - begin_event: codex_protocol::protocol::CollabResumeBeginEvent, -) -> ThreadItem { - ThreadItem::CollabAgentToolCall { - id: begin_event.call_id, - tool: CollabAgentTool::ResumeAgent, - status: V2CollabToolCallStatus::InProgress, - sender_thread_id: begin_event.sender_thread_id.to_string(), - receiver_thread_ids: vec![begin_event.receiver_thread_id.to_string()], - prompt: None, - model: None, - reasoning_effort: None, - agents_states: HashMap::new(), - } -} - -fn collab_resume_end_item(end_event: codex_protocol::protocol::CollabResumeEndEvent) -> ThreadItem { - let status = match &end_event.status { - codex_protocol::protocol::AgentStatus::Errored(_) - | codex_protocol::protocol::AgentStatus::NotFound => V2CollabToolCallStatus::Failed, - _ => V2CollabToolCallStatus::Completed, - }; - let receiver_id = end_event.receiver_thread_id.to_string(); - let agents_states = [( - receiver_id.clone(), - V2CollabAgentStatus::from(end_event.status), - )] - .into_iter() - .collect(); - ThreadItem::CollabAgentToolCall { - id: end_event.call_id, - tool: CollabAgentTool::ResumeAgent, - status, - sender_thread_id: end_event.sender_thread_id.to_string(), - receiver_thread_ids: vec![receiver_id], - prompt: None, - model: None, - reasoning_effort: None, - agents_states, - } -} - -/// similar to handle_mcp_tool_call_begin in exec -async fn construct_mcp_tool_call_notification( - begin_event: McpToolCallBeginEvent, - thread_id: String, - turn_id: String, -) -> ItemStartedNotification { - let item = ThreadItem::McpToolCall { - id: begin_event.call_id, - server: begin_event.invocation.server, - tool: begin_event.invocation.tool, - status: McpToolCallStatus::InProgress, - arguments: begin_event.invocation.arguments.unwrap_or(JsonValue::Null), - mcp_app_resource_uri: begin_event.mcp_app_resource_uri, - result: None, - error: None, - duration_ms: None, - }; - ItemStartedNotification { - thread_id, - turn_id, - item, - } -} - -/// similar to handle_mcp_tool_call_end in exec -async fn construct_mcp_tool_call_end_notification( - end_event: McpToolCallEndEvent, - thread_id: String, - turn_id: String, -) -> ItemCompletedNotification { - let status = if end_event.is_success() { - McpToolCallStatus::Completed - } else { - McpToolCallStatus::Failed - }; - let duration_ms = i64::try_from(end_event.duration.as_millis()).ok(); - - let (result, error) = match &end_event.result { - Ok(value) => ( - Some(Box::new(McpToolCallResult { - content: value.content.clone(), - structured_content: value.structured_content.clone(), - meta: value.meta.clone(), - })), - None, - ), - Err(message) => ( - None, - Some(McpToolCallError { - message: message.clone(), - }), - ), - }; - - let item = ThreadItem::McpToolCall { - id: end_event.call_id, - server: end_event.invocation.server, - tool: end_event.invocation.tool, - status, - arguments: end_event.invocation.arguments.unwrap_or(JsonValue::Null), - mcp_app_resource_uri: end_event.mcp_app_resource_uri, - result, - error, - duration_ms, - }; - ItemCompletedNotification { - thread_id, - turn_id, - item, - } -} - #[cfg(test)] mod tests { use super::*; @@ -2809,7 +2249,6 @@ mod tests { use codex_login::CodexAuth; use codex_protocol::items::HookPromptFragment; use codex_protocol::items::build_hook_prompt_message; - use codex_protocol::mcp::CallToolResult; use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; use codex_protocol::permissions::FileSystemAccessMode; @@ -2818,12 +2257,9 @@ mod tests { use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; - use codex_protocol::protocol::CollabResumeBeginEvent; - use codex_protocol::protocol::CollabResumeEndEvent; use codex_protocol::protocol::CreditsSnapshot; use codex_protocol::protocol::GuardianAssessmentEvent; use codex_protocol::protocol::GuardianAssessmentStatus; - use codex_protocol::protocol::McpInvocation; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow; use codex_protocol::protocol::TokenUsage; @@ -2833,11 +2269,8 @@ mod tests { use codex_utils_absolute_path::test_support::test_path_buf; use core_test_support::load_default_config_for_test; use pretty_assertions::assert_eq; - use rmcp::model::Content; - use serde_json::Value as JsonValue; use serde_json::json; use std::path::PathBuf; - use std::time::Duration; use tempfile::TempDir; use tokio::sync::Mutex; use tokio::sync::mpsc; @@ -3820,63 +3253,6 @@ mod tests { ); } - #[test] - fn collab_resume_begin_maps_to_item_started_resume_agent() { - let event = CollabResumeBeginEvent { - call_id: "call-1".to_string(), - sender_thread_id: ThreadId::new(), - receiver_thread_id: ThreadId::new(), - receiver_agent_nickname: None, - receiver_agent_role: None, - }; - - let item = collab_resume_begin_item(event.clone()); - let expected = ThreadItem::CollabAgentToolCall { - id: event.call_id, - tool: CollabAgentTool::ResumeAgent, - status: V2CollabToolCallStatus::InProgress, - sender_thread_id: event.sender_thread_id.to_string(), - receiver_thread_ids: vec![event.receiver_thread_id.to_string()], - prompt: None, - model: None, - reasoning_effort: None, - agents_states: HashMap::new(), - }; - assert_eq!(item, expected); - } - - #[test] - fn collab_resume_end_maps_to_item_completed_resume_agent() { - let event = CollabResumeEndEvent { - call_id: "call-2".to_string(), - sender_thread_id: ThreadId::new(), - receiver_thread_id: ThreadId::new(), - receiver_agent_nickname: None, - receiver_agent_role: None, - status: codex_protocol::protocol::AgentStatus::NotFound, - }; - - let item = collab_resume_end_item(event.clone()); - let receiver_id = event.receiver_thread_id.to_string(); - let expected = ThreadItem::CollabAgentToolCall { - id: event.call_id, - tool: CollabAgentTool::ResumeAgent, - status: V2CollabToolCallStatus::Failed, - sender_thread_id: event.sender_thread_id.to_string(), - receiver_thread_ids: vec![receiver_id.clone()], - prompt: None, - model: None, - reasoning_effort: None, - agents_states: [( - receiver_id, - V2CollabAgentStatus::from(codex_protocol::protocol::AgentStatus::NotFound), - )] - .into_iter() - .collect(), - }; - assert_eq!(item, expected); - } - #[tokio::test] async fn test_handle_error_records_message() -> Result<()> { let conversation_id = ThreadId::new(); @@ -4245,46 +3621,6 @@ mod tests { Ok(()) } - #[tokio::test] - async fn test_construct_mcp_tool_call_begin_notification_with_args() { - let begin_event = McpToolCallBeginEvent { - call_id: "call_123".to_string(), - invocation: McpInvocation { - server: "codex".to_string(), - tool: "list_mcp_resources".to_string(), - arguments: Some(serde_json::json!({"server": ""})), - }, - mcp_app_resource_uri: Some("ui://widget/list-resources.html".to_string()), - }; - - let thread_id = ThreadId::new().to_string(); - let turn_id = "turn_1".to_string(); - let notification = construct_mcp_tool_call_notification( - begin_event.clone(), - thread_id.clone(), - turn_id.clone(), - ) - .await; - - let expected = ItemStartedNotification { - thread_id, - turn_id, - item: ThreadItem::McpToolCall { - id: begin_event.call_id, - server: begin_event.invocation.server, - tool: begin_event.invocation.tool, - status: McpToolCallStatus::InProgress, - arguments: serde_json::json!({"server": ""}), - mcp_app_resource_uri: Some("ui://widget/list-resources.html".to_string()), - result: None, - error: None, - duration_ms: None, - }, - }; - - assert_eq!(notification, expected); - } - #[tokio::test] async fn test_handle_turn_complete_emits_error_multiple_turns() -> Result<()> { // Conversation A will have two turns; Conversation B will have one turn. @@ -4410,151 +3746,6 @@ mod tests { Ok(()) } - #[tokio::test] - async fn test_construct_mcp_tool_call_begin_notification_without_args() { - let begin_event = McpToolCallBeginEvent { - call_id: "call_456".to_string(), - invocation: McpInvocation { - server: "codex".to_string(), - tool: "list_mcp_resources".to_string(), - arguments: None, - }, - mcp_app_resource_uri: None, - }; - - let thread_id = ThreadId::new().to_string(); - let turn_id = "turn_2".to_string(); - let notification = construct_mcp_tool_call_notification( - begin_event.clone(), - thread_id.clone(), - turn_id.clone(), - ) - .await; - - let expected = ItemStartedNotification { - thread_id, - turn_id, - item: ThreadItem::McpToolCall { - id: begin_event.call_id, - server: begin_event.invocation.server, - tool: begin_event.invocation.tool, - status: McpToolCallStatus::InProgress, - arguments: JsonValue::Null, - mcp_app_resource_uri: None, - result: None, - error: None, - duration_ms: None, - }, - }; - - assert_eq!(notification, expected); - } - - #[tokio::test] - async fn test_construct_mcp_tool_call_end_notification_success() { - let content = vec![ - serde_json::to_value(Content::text("{\"resources\":[]}")) - .expect("content should serialize"), - ]; - let result = CallToolResult { - content: content.clone(), - is_error: Some(false), - structured_content: None, - meta: Some(serde_json::json!({ - "ui/resourceUri": "ui://widget/list-resources.html" - })), - }; - - let end_event = McpToolCallEndEvent { - call_id: "call_789".to_string(), - invocation: McpInvocation { - server: "codex".to_string(), - tool: "list_mcp_resources".to_string(), - arguments: Some(serde_json::json!({"server": ""})), - }, - mcp_app_resource_uri: Some("ui://widget/list-resources.html".to_string()), - duration: Duration::from_nanos(92708), - result: Ok(result), - }; - - let thread_id = ThreadId::new().to_string(); - let turn_id = "turn_3".to_string(); - let notification = construct_mcp_tool_call_end_notification( - end_event.clone(), - thread_id.clone(), - turn_id.clone(), - ) - .await; - - let expected = ItemCompletedNotification { - thread_id, - turn_id, - item: ThreadItem::McpToolCall { - id: end_event.call_id, - server: end_event.invocation.server, - tool: end_event.invocation.tool, - status: McpToolCallStatus::Completed, - arguments: serde_json::json!({"server": ""}), - mcp_app_resource_uri: Some("ui://widget/list-resources.html".to_string()), - result: Some(Box::new(McpToolCallResult { - content, - structured_content: None, - meta: Some(serde_json::json!({ - "ui/resourceUri": "ui://widget/list-resources.html" - })), - })), - error: None, - duration_ms: Some(0), - }, - }; - - assert_eq!(notification, expected); - } - - #[tokio::test] - async fn test_construct_mcp_tool_call_end_notification_error() { - let end_event = McpToolCallEndEvent { - call_id: "call_err".to_string(), - invocation: McpInvocation { - server: "codex".to_string(), - tool: "list_mcp_resources".to_string(), - arguments: None, - }, - mcp_app_resource_uri: None, - duration: Duration::from_millis(1), - result: Err("boom".to_string()), - }; - - let thread_id = ThreadId::new().to_string(); - let turn_id = "turn_4".to_string(); - let notification = construct_mcp_tool_call_end_notification( - end_event.clone(), - thread_id.clone(), - turn_id.clone(), - ) - .await; - - let expected = ItemCompletedNotification { - thread_id, - turn_id, - item: ThreadItem::McpToolCall { - id: end_event.call_id, - server: end_event.invocation.server, - tool: end_event.invocation.tool, - status: McpToolCallStatus::Failed, - arguments: JsonValue::Null, - mcp_app_resource_uri: None, - result: None, - error: Some(McpToolCallError { - message: "boom".to_string(), - }), - duration_ms: Some(1), - }, - }; - - assert_eq!(notification, expected); - } - #[tokio::test] async fn test_handle_turn_diff_emits_v2_notification() -> Result<()> { let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); diff --git a/codex-rs/core-api/Cargo.toml b/codex-rs/core-api/Cargo.toml index 5f0e24ea7..0cc084650 100644 --- a/codex-rs/core-api/Cargo.toml +++ b/codex-rs/core-api/Cargo.toml @@ -13,6 +13,7 @@ path = "src/lib.rs" workspace = true [dependencies] +codex-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } codex-analytics = { workspace = true } codex-config = { workspace = true } diff --git a/codex-rs/core-api/src/lib.rs b/codex-rs/core-api/src/lib.rs index 00f1abb24..c4eee541c 100644 --- a/codex-rs/core-api/src/lib.rs +++ b/codex-rs/core-api/src/lib.rs @@ -3,6 +3,8 @@ #![deny(private_bounds, private_interfaces, unreachable_pub)] pub use codex_analytics::AnalyticsEventsClient; +pub use codex_app_server_protocol::ServerNotification; +pub use codex_app_server_protocol::item_event_to_server_notification; pub use codex_arg0::Arg0DispatchPaths; pub use codex_arg0::arg0_dispatch_or_else; pub use codex_config::ConfigLayerStack; @@ -42,6 +44,7 @@ pub use codex_exec_server::EnvironmentManager; pub use codex_exec_server::EnvironmentManagerArgs; pub use codex_exec_server::ExecServerRuntimePaths; pub use codex_features::Feature; +pub use codex_features::Features; pub use codex_login::AuthManager; pub use codex_login::default_client::set_default_originator; pub use codex_model_provider_info::OPENAI_PROVIDER_ID; diff --git a/codex-rs/thread-manager-sample/Cargo.toml b/codex-rs/thread-manager-sample/Cargo.toml index b0d6efa39..9e857ad0b 100644 --- a/codex-rs/thread-manager-sample/Cargo.toml +++ b/codex-rs/thread-manager-sample/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [dependencies] anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } # Keep this sample limited to a single Codex workspace dependency. # Add new Codex surface area to `codex-core-api` instead of depending on # additional `codex-*` crates here. diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index c2b3eecfd..cf022fed1 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -23,6 +23,7 @@ use codex_core_api::EnvironmentManager; use codex_core_api::EnvironmentManagerArgs; use codex_core_api::EventMsg; use codex_core_api::ExecServerRuntimePaths; +use codex_core_api::Features; use codex_core_api::GhostSnapshotConfig; use codex_core_api::History; use codex_core_api::MemoriesConfig; @@ -53,13 +54,14 @@ use codex_core_api::WebSearchMode; use codex_core_api::arg0_dispatch_or_else; use codex_core_api::built_in_model_providers; use codex_core_api::find_codex_home; +use codex_core_api::item_event_to_server_notification; use codex_core_api::set_default_originator; use codex_core_api::thread_store_from_config; #[derive(Debug, Parser)] #[command( name = "codex-thread-manager-sample", - about = "Run one Codex turn through ThreadManager and print the final assistant output." + about = "Run one Codex turn through ThreadManager and print mapped notifications as newline-delimited JSON." )] struct Args { /// Override the model for this run. @@ -125,19 +127,14 @@ async fn run_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { .await .context("start Codex thread")?; - let turn_output = run_turn(&thread, prompt).await; + let thread_id_string = thread_id.to_string(); + let turn_output = run_turn(&thread, &thread_id_string, prompt).await; let shutdown_result = thread.shutdown_and_wait().await; let _ = thread_manager.remove_thread(&thread_id).await; - let output = turn_output?; + turn_output?; shutdown_result.context("shut down Codex thread")?; - let mut stdout = std::io::stdout().lock(); - stdout.write_all(output.as_bytes())?; - if !output.ends_with('\n') { - stdout.write_all(b"\n")?; - } - Ok(()) } @@ -151,7 +148,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R .context("OpenAI model provider should be available")? .clone(); - Ok(Config { + let mut config = Config { config_layer_stack: ConfigLayerStack::default(), startup_warnings: Vec::new(), model, @@ -164,7 +161,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R personality: None, permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::Never), - permission_profile: Constrained::allow_any(PermissionProfile::default()), + permission_profile: Constrained::allow_any(PermissionProfile::read_only()), active_permission_profile: None, network: None, allow_login_shell: true, @@ -261,10 +258,15 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R feedback_enabled: false, tool_suggest: ToolSuggestConfig::default(), otel: OtelConfig::default(), - }) + }; + config + .features + .set(Features::with_defaults()) + .context("configure default features")?; + Ok(config) } -async fn run_turn(thread: &CodexThread, prompt: String) -> anyhow::Result { +async fn run_turn(thread: &CodexThread, thread_id: &str, prompt: String) -> anyhow::Result<()> { thread .submit(Op::UserInput { items: vec![UserInput::Text { @@ -278,15 +280,62 @@ async fn run_turn(thread: &CodexThread, prompt: String) -> anyhow::Result = None; + let mut stdout = std::io::stdout().lock(); loop { let event = thread.next_event().await.context("read Codex event")?; - match event.msg { - EventMsg::TurnComplete(event) => { - return Ok(event.last_agent_message.unwrap_or(last_agent_message)); + let notification = match &event.msg { + EventMsg::TurnStarted(event) => { + current_turn_id = Some(event.turn_id.clone()); + None } - EventMsg::AgentMessage(event) => { - last_agent_message = event.message; + EventMsg::DynamicToolCallResponse(_) + | EventMsg::McpToolCallBegin(_) + | EventMsg::McpToolCallEnd(_) + | EventMsg::CollabAgentSpawnBegin(_) + | EventMsg::CollabAgentSpawnEnd(_) + | EventMsg::CollabAgentInteractionBegin(_) + | EventMsg::CollabAgentInteractionEnd(_) + | EventMsg::CollabWaitingBegin(_) + | EventMsg::CollabWaitingEnd(_) + | EventMsg::CollabCloseBegin(_) + | EventMsg::CollabCloseEnd(_) + | EventMsg::CollabResumeBegin(_) + | EventMsg::CollabResumeEnd(_) + | EventMsg::AgentMessageContentDelta(_) + | EventMsg::PlanDelta(_) + | EventMsg::ReasoningContentDelta(_) + | EventMsg::ReasoningRawContentDelta(_) + | EventMsg::AgentReasoningSectionBreak(_) + | EventMsg::ItemStarted(_) + | EventMsg::ItemCompleted(_) + | EventMsg::PatchApplyBegin(_) + | EventMsg::PatchApplyUpdated(_) + | EventMsg::TerminalInteraction(_) + | EventMsg::ExecCommandBegin(_) + | EventMsg::ExecCommandOutputDelta(_) + | EventMsg::ExecCommandEnd(_) => Some(item_event_to_server_notification( + event.msg.clone(), + thread_id, + current_turn_id + .as_deref() + .context("mapped notification arrived before turn started")?, + /*is_file_change_output*/ false, + )), + _ => None, + }; + if let Some(notification) = notification { + serde_json::to_writer(&mut stdout, ¬ification) + .context("serialize mapped notification")?; + stdout + .write_all(b"\n") + .context("write notification newline")?; + stdout.flush().context("flush notification output")?; + } + + match event.msg { + EventMsg::TurnComplete(_) => { + return Ok(()); } EventMsg::Error(event) => { bail!(event.message);