use codex_core::config_types::SandboxMode; use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use serde::Deserialize; use serde::Serialize; use strum_macros::Display; use uuid::Uuid; use mcp_types::CallToolResult; use mcp_types::ContentBlock; use mcp_types::RequestId; use mcp_types::TextContent; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(transparent)] pub struct ConversationId(pub Uuid); #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(transparent)] pub struct MessageId(pub Uuid); // Requests #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolCallRequest { #[serde(rename = "jsonrpc")] pub jsonrpc: &'static str, pub id: RequestId, pub method: &'static str, pub params: ToolCallRequestParams, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "name", content = "arguments", rename_all = "camelCase")] pub enum ToolCallRequestParams { ConversationCreate(ConversationCreateArgs), ConversationStream(ConversationStreamArgs), ConversationSendMessage(ConversationSendMessageArgs), ConversationsList(ConversationsListArgs), } impl ToolCallRequestParams { /// Wrap this request in a JSON-RPC request. #[allow(dead_code)] pub fn into_request(self, id: RequestId) -> ToolCallRequest { ToolCallRequest { jsonrpc: "2.0", id, method: "tools/call", params: self, } } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ConversationCreateArgs { pub prompt: String, pub model: String, pub cwd: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub approval_policy: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub sandbox: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub config: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub profile: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub base_instructions: Option, } /// Optional overrides for an existing conversation's execution context when sending a message. /// Fields left as `None` inherit the current conversation/session settings. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ConversationOverrides { #[serde(default, skip_serializing_if = "Option::is_none")] pub model: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub cwd: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub approval_policy: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub sandbox: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub config: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub profile: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub base_instructions: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ConversationStreamArgs { pub conversation_id: ConversationId, } /// If omitted, the message continues from the latest turn. /// Set to resume/edit from an earlier parent message in the thread. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ConversationSendMessageArgs { pub conversation_id: ConversationId, pub content: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub parent_message_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[serde(flatten)] pub conversation_overrides: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ConversationsListArgs { #[serde(default, skip_serializing_if = "Option::is_none")] pub limit: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub cursor: Option, } // Responses #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ToolCallResponse { pub request_id: RequestId, #[serde(default, skip_serializing_if = "Option::is_none")] pub is_error: Option, #[serde(default, skip_serializing_if = "Option::is_none", flatten)] pub result: Option, } impl From for CallToolResult { fn from(val: ToolCallResponse) -> Self { let ToolCallResponse { request_id: _request_id, is_error, result, } = val; match result { Some(res) => match serde_json::to_value(&res) { Ok(v) => CallToolResult { content: vec![ContentBlock::TextContent(TextContent { r#type: "text".to_string(), text: v.to_string(), annotations: None, })], is_error, structured_content: Some(v), }, Err(e) => CallToolResult { content: vec![ContentBlock::TextContent(TextContent { r#type: "text".to_string(), text: format!("Failed to serialize tool result: {e}"), annotations: None, })], is_error: Some(true), structured_content: None, }, }, None => CallToolResult { content: vec![], is_error, structured_content: None, }, } } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(untagged)] pub enum ToolCallResponseResult { ConversationCreate(ConversationCreateResult), ConversationStream(ConversationStreamResult), ConversationSendMessage(ConversationSendMessageResult), ConversationsList(ConversationsListResult), } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(untagged)] pub enum ConversationCreateResult { Ok { conversation_id: ConversationId, model: String, }, Error { message: String, }, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ConversationStreamResult {} #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] // TODO: remove this status because we have is_error field in the response. #[serde(tag = "status", rename_all = "camelCase")] pub enum ConversationSendMessageResult { Ok, Error { message: String }, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ConversationsListResult { pub conversations: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub next_cursor: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ConversationSummary { pub conversation_id: ConversationId, pub title: String, } // Notifications #[derive(Debug, Clone, Deserialize, Display)] pub enum ServerNotification { InitialState(InitialStateNotificationParams), StreamDisconnected(StreamDisconnectedNotificationParams), CodexEvent(Box), } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NotificationMeta { #[serde(skip_serializing_if = "Option::is_none")] pub conversation_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub request_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct InitialStateNotificationParams { #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] pub meta: Option, pub initial_state: InitialStatePayload, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct InitialStatePayload { #[serde(default)] pub events: Vec, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct StreamDisconnectedNotificationParams { #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] pub meta: Option, pub reason: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CodexEventNotificationParams { #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] pub meta: Option, pub msg: EventMsg, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CancelNotificationParams { pub request_id: RequestId, #[serde(default, skip_serializing_if = "Option::is_none")] pub reason: Option, } impl Serialize for ServerNotification { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, { use serde::ser::SerializeMap; let mut map = serializer.serialize_map(Some(2))?; match self { ServerNotification::CodexEvent(p) => { map.serialize_entry("method", &format!("notifications/{}", p.msg))?; map.serialize_entry("params", p)?; } ServerNotification::InitialState(p) => { map.serialize_entry("method", "notifications/initial_state")?; map.serialize_entry("params", p)?; } ServerNotification::StreamDisconnected(p) => { map.serialize_entry("method", "notifications/stream_disconnected")?; map.serialize_entry("params", p)?; } } map.end() } } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(tag = "method", content = "params", rename_all = "camelCase")] pub enum ClientNotification { #[serde(rename = "notifications/cancelled")] Cancelled(CancelNotificationParams), } #[cfg(test)] #[allow(clippy::expect_used)] #[allow(clippy::unwrap_used)] mod tests { use std::path::PathBuf; use super::*; use codex_core::protocol::McpInvocation; use codex_core::protocol::McpToolCallBeginEvent; use pretty_assertions::assert_eq; use serde::Serialize; use serde_json::Value; use serde_json::json; use uuid::uuid; fn to_val(v: &T) -> Value { serde_json::to_value(v).expect("serialize to Value") } // ----- Requests ----- #[test] fn serialize_tool_call_request_params_conversation_create_minimal() { let req = ToolCallRequestParams::ConversationCreate(ConversationCreateArgs { prompt: "".into(), model: "o3".into(), cwd: "/repo".into(), approval_policy: None, sandbox: None, config: None, profile: None, base_instructions: None, }); let observed = to_val(&req.into_request(mcp_types::RequestId::Integer(2))); let expected = json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "conversationCreate", "arguments": { "prompt": "", "model": "o3", "cwd": "/repo" } } }); assert_eq!(observed, expected); } #[test] fn serialize_tool_call_request_params_conversation_send_message_with_overrides_and_parent_message_id() { let req = ToolCallRequestParams::ConversationSendMessage(ConversationSendMessageArgs { conversation_id: ConversationId(uuid!("d0f6ecbe-84a2-41c1-b23d-b20473b25eab")), content: vec![ InputItem::Text { text: "Hi".into() }, InputItem::Image { image_url: "https://example.com/cat.jpg".into(), }, InputItem::LocalImage { path: "notes.txt".into(), }, ], parent_message_id: Some(MessageId(uuid!("67e55044-10b1-426f-9247-bb680e5fe0c8"))), conversation_overrides: Some(ConversationOverrides { model: Some("o4-mini".into()), cwd: Some("/workdir".into()), approval_policy: None, sandbox: Some(SandboxMode::DangerFullAccess), config: Some(json!({"temp": 0.2})), profile: Some("eng".into()), base_instructions: Some("Be terse".into()), }), }); let observed = to_val(&req.into_request(mcp_types::RequestId::Integer(2))); let expected = json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "conversationSendMessage", "arguments": { "conversation_id": "d0f6ecbe-84a2-41c1-b23d-b20473b25eab", "content": [ { "type": "text", "text": "Hi" }, { "type": "image", "image_url": "https://example.com/cat.jpg" }, { "type": "local_image", "path": "notes.txt" } ], "parent_message_id": "67e55044-10b1-426f-9247-bb680e5fe0c8", "model": "o4-mini", "cwd": "/workdir", "sandbox": "danger-full-access", "config": { "temp": 0.2 }, "profile": "eng", "base_instructions": "Be terse" } } }); assert_eq!(observed, expected); } #[test] fn serialize_tool_call_request_params_conversations_list_with_opts() { let req = ToolCallRequestParams::ConversationsList(ConversationsListArgs { limit: Some(50), cursor: Some("abc".into()), }); let observed = to_val(&req.into_request(RequestId::Integer(2))); let expected = json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "conversationsList", "arguments": { "limit": 50, "cursor": "abc" } } }); assert_eq!(observed, expected); } #[test] fn serialize_tool_call_request_params_conversation_stream() { let req = ToolCallRequestParams::ConversationStream(ConversationStreamArgs { conversation_id: ConversationId(uuid!("67e55044-10b1-426f-9247-bb680e5fe0c8")), }); let observed = to_val(&req.into_request(mcp_types::RequestId::Integer(2))); let expected = json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "conversationStream", "arguments": { "conversation_id": "67e55044-10b1-426f-9247-bb680e5fe0c8" } } }); assert_eq!(observed, expected); } // ----- Message inputs / sources ----- #[test] fn serialize_message_input_image_url() { let item = InputItem::Image { image_url: "https://example.com/x.png".into(), }; let observed = to_val(&item); let expected = json!({ "type": "image", "image_url": "https://example.com/x.png" }); assert_eq!(observed, expected); } #[test] fn serialize_message_input_local_image_path() { let url = InputItem::LocalImage { path: PathBuf::from("https://example.com/a.pdf"), }; let id = InputItem::LocalImage { path: PathBuf::from("file_456"), }; let observed_url = to_val(&url); let expected_url = json!({"type":"local_image","path":"https://example.com/a.pdf"}); assert_eq!( observed_url, expected_url, "LocalImage with URL path should serialize as image_url" ); let observed_id = to_val(&id); let expected_id = json!({"type":"local_image","path":"file_456"}); assert_eq!( observed_id, expected_id, "LocalImage with file id should serialize as image_url" ); } #[test] fn serialize_message_input_image_url_without_detail() { let item = InputItem::Image { image_url: "https://example.com/x.png".into(), }; let observed = to_val(&item); let expected = json!({ "type": "image", "image_url": "https://example.com/x.png" }); assert_eq!(observed, expected); } // ----- Responses ----- #[test] fn response_success_conversation_create_full_schema() { let env = ToolCallResponse { request_id: RequestId::Integer(1), is_error: None, result: Some(ToolCallResponseResult::ConversationCreate( ConversationCreateResult::Ok { conversation_id: ConversationId(uuid!("d0f6ecbe-84a2-41c1-b23d-b20473b25eab")), model: "o3".into(), }, )), }; let req_id = env.request_id.clone(); let observed = to_val(&CallToolResult::from(env)); let expected = json!({ "content": [ { "type": "text", "text": "{\"conversation_id\":\"d0f6ecbe-84a2-41c1-b23d-b20473b25eab\",\"model\":\"o3\"}" } ], "structuredContent": { "conversation_id": "d0f6ecbe-84a2-41c1-b23d-b20473b25eab", "model": "o3" } }); assert_eq!( observed, expected, "response (ConversationCreate) must match" ); assert_eq!(req_id, RequestId::Integer(1)); } #[test] fn response_error_conversation_create_full_schema() { let env = ToolCallResponse { request_id: RequestId::Integer(2), is_error: Some(true), result: Some(ToolCallResponseResult::ConversationCreate( ConversationCreateResult::Error { message: "Failed to initialize session".into(), }, )), }; let req_id = env.request_id.clone(); let observed = to_val(&CallToolResult::from(env)); let expected = json!({ "content": [ { "type": "text", "text": "{\"message\":\"Failed to initialize session\"}" } ], "isError": true, "structuredContent": { "message": "Failed to initialize session" } }); assert_eq!( observed, expected, "error response (ConversationCreate) must match" ); assert_eq!(req_id, RequestId::Integer(2)); } #[test] fn response_success_conversation_stream_empty_result_object() { let env = ToolCallResponse { request_id: RequestId::Integer(2), is_error: None, result: Some(ToolCallResponseResult::ConversationStream( ConversationStreamResult {}, )), }; let req_id = env.request_id.clone(); let observed = to_val(&CallToolResult::from(env)); let expected = json!({ "content": [ { "type": "text", "text": "{}" } ], "structuredContent": {} }); assert_eq!( observed, expected, "response (ConversationStream) must have empty object result" ); assert_eq!(req_id, RequestId::Integer(2)); } #[test] fn response_success_send_message_accepted_full_schema() { let env = ToolCallResponse { request_id: RequestId::Integer(3), is_error: None, result: Some(ToolCallResponseResult::ConversationSendMessage( ConversationSendMessageResult::Ok, )), }; let req_id = env.request_id.clone(); let observed = to_val(&CallToolResult::from(env)); let expected = json!({ "content": [ { "type": "text", "text": "{\"status\":\"ok\"}" } ], "structuredContent": { "status": "ok" } }); assert_eq!( observed, expected, "response (ConversationSendMessageAccepted) must match" ); assert_eq!(req_id, RequestId::Integer(3)); } #[test] fn response_success_conversations_list_with_next_cursor_full_schema() { let env = ToolCallResponse { request_id: RequestId::Integer(4), is_error: None, result: Some(ToolCallResponseResult::ConversationsList( ConversationsListResult { conversations: vec![ConversationSummary { conversation_id: ConversationId(uuid!( "67e55044-10b1-426f-9247-bb680e5fe0c8" )), title: "Refactor config loader".into(), }], next_cursor: Some("next123".into()), }, )), }; let req_id = env.request_id.clone(); let observed = to_val(&CallToolResult::from(env)); let expected = json!({ "content": [ { "type": "text", "text": "{\"conversations\":[{\"conversation_id\":\"67e55044-10b1-426f-9247-bb680e5fe0c8\",\"title\":\"Refactor config loader\"}],\"next_cursor\":\"next123\"}" } ], "structuredContent": { "conversations": [ { "conversation_id": "67e55044-10b1-426f-9247-bb680e5fe0c8", "title": "Refactor config loader" } ], "next_cursor": "next123" } }); assert_eq!( observed, expected, "response (ConversationsList with cursor) must match" ); assert_eq!(req_id, RequestId::Integer(4)); } #[test] fn response_error_only_is_error_and_request_id_string() { let env = ToolCallResponse { request_id: RequestId::Integer(4), is_error: Some(true), result: None, }; let req_id = env.request_id.clone(); let observed = to_val(&CallToolResult::from(env)); let expected = json!({ "content": [], "isError": true }); assert_eq!( observed, expected, "error response must omit `result` and include `isError`" ); assert_eq!(req_id, RequestId::Integer(4)); } // ----- Notifications ----- #[test] fn serialize_notification_initial_state_minimal() { let params = InitialStateNotificationParams { meta: Some(NotificationMeta { conversation_id: Some(ConversationId(uuid!( "67e55044-10b1-426f-9247-bb680e5fe0c8" ))), request_id: Some(RequestId::Integer(44)), }), initial_state: InitialStatePayload { events: vec![ CodexEventNotificationParams { meta: None, msg: EventMsg::TaskStarted, }, CodexEventNotificationParams { meta: None, msg: EventMsg::AgentMessageDelta( codex_core::protocol::AgentMessageDeltaEvent { delta: "Loading...".into(), }, ), }, ], }, }; let observed = to_val(&ServerNotification::InitialState(params.clone())); let expected = json!({ "method": "notifications/initial_state", "params": { "_meta": { "conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8", "requestId": 44 }, "initial_state": { "events": [ { "msg": { "type": "task_started" } }, { "msg": { "type": "agent_message_delta", "delta": "Loading..." } } ] } } }); assert_eq!(observed, expected); } #[test] fn serialize_notification_initial_state_omits_empty_events_full_json() { let params = InitialStateNotificationParams { meta: None, initial_state: InitialStatePayload { events: vec![] }, }; let observed = to_val(&ServerNotification::InitialState(params)); let expected = json!({ "method": "notifications/initial_state", "params": { "initial_state": { "events": [] } } }); assert_eq!(observed, expected); } #[test] fn serialize_notification_stream_disconnected() { let params = StreamDisconnectedNotificationParams { meta: Some(NotificationMeta { conversation_id: Some(ConversationId(uuid!( "67e55044-10b1-426f-9247-bb680e5fe0c8" ))), request_id: None, }), reason: "New stream() took over".into(), }; let observed = to_val(&ServerNotification::StreamDisconnected(params)); let expected = json!({ "method": "notifications/stream_disconnected", "params": { "_meta": { "conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8" }, "reason": "New stream() took over" } }); assert_eq!(observed, expected); } #[test] fn serialize_notification_codex_event_uses_eventmsg_type_in_method() { let params = CodexEventNotificationParams { meta: Some(NotificationMeta { conversation_id: Some(ConversationId(uuid!( "67e55044-10b1-426f-9247-bb680e5fe0c8" ))), request_id: Some(RequestId::Integer(44)), }), msg: EventMsg::AgentMessage(codex_core::protocol::AgentMessageEvent { message: "hi".into(), }), }; let observed = to_val(&ServerNotification::CodexEvent(Box::new(params))); let expected = json!({ "method": "notifications/agent_message", "params": { "_meta": { "conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8", "requestId": 44 }, "msg": { "type": "agent_message", "message": "hi" } } }); assert_eq!(observed, expected); } #[test] fn serialize_notification_codex_event_task_started_full_json() { let params = CodexEventNotificationParams { meta: Some(NotificationMeta { conversation_id: Some(ConversationId(uuid!( "67e55044-10b1-426f-9247-bb680e5fe0c8" ))), request_id: Some(RequestId::Integer(7)), }), msg: EventMsg::TaskStarted, }; let observed = to_val(&ServerNotification::CodexEvent(Box::new(params))); let expected = json!({ "method": "notifications/task_started", "params": { "_meta": { "conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8", "requestId": 7 }, "msg": { "type": "task_started" } } }); assert_eq!(observed, expected); } #[test] fn serialize_notification_codex_event_agent_message_delta_full_json() { let params = CodexEventNotificationParams { meta: None, msg: EventMsg::AgentMessageDelta(codex_core::protocol::AgentMessageDeltaEvent { delta: "stream...".into(), }), }; let observed = to_val(&ServerNotification::CodexEvent(Box::new(params))); let expected = json!({ "method": "notifications/agent_message_delta", "params": { "msg": { "type": "agent_message_delta", "delta": "stream..." } } }); assert_eq!(observed, expected); } #[test] fn serialize_notification_codex_event_agent_message_full_json() { let params = CodexEventNotificationParams { meta: Some(NotificationMeta { conversation_id: Some(ConversationId(uuid!( "67e55044-10b1-426f-9247-bb680e5fe0c8" ))), request_id: Some(RequestId::Integer(44)), }), msg: EventMsg::AgentMessage(codex_core::protocol::AgentMessageEvent { message: "hi".into(), }), }; let observed = to_val(&ServerNotification::CodexEvent(Box::new(params))); let expected = json!({ "method": "notifications/agent_message", "params": { "_meta": { "conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8", "requestId": 44 }, "msg": { "type": "agent_message", "message": "hi" } } }); assert_eq!(observed, expected); } #[test] fn serialize_notification_codex_event_agent_reasoning_full_json() { let params = CodexEventNotificationParams { meta: None, msg: EventMsg::AgentReasoning(codex_core::protocol::AgentReasoningEvent { text: "thinking…".into(), }), }; let observed = to_val(&ServerNotification::CodexEvent(Box::new(params))); let expected = json!({ "method": "notifications/agent_reasoning", "params": { "msg": { "type": "agent_reasoning", "text": "thinking…" } } }); assert_eq!(observed, expected); } #[test] fn serialize_notification_codex_event_token_count_full_json() { let usage = codex_core::protocol::TokenUsage { input_tokens: 10, cached_input_tokens: Some(2), output_tokens: 5, reasoning_output_tokens: Some(1), total_tokens: 16, }; let params = CodexEventNotificationParams { meta: None, msg: EventMsg::TokenCount(usage), }; let observed = to_val(&ServerNotification::CodexEvent(Box::new(params))); let expected = json!({ "method": "notifications/token_count", "params": { "msg": { "type": "token_count", "input_tokens": 10, "cached_input_tokens": 2, "output_tokens": 5, "reasoning_output_tokens": 1, "total_tokens": 16 } } }); assert_eq!(observed, expected); } #[test] fn serialize_notification_codex_event_session_configured_full_json() { let params = CodexEventNotificationParams { meta: Some(NotificationMeta { conversation_id: Some(ConversationId(uuid!( "67e55044-10b1-426f-9247-bb680e5fe0c8" ))), request_id: None, }), msg: EventMsg::SessionConfigured(codex_core::protocol::SessionConfiguredEvent { session_id: uuid!("67e55044-10b1-426f-9247-bb680e5fe0c8"), model: "codex-mini-latest".into(), history_log_id: 42, history_entry_count: 3, }), }; let observed = to_val(&ServerNotification::CodexEvent(Box::new(params))); let expected = json!({ "method": "notifications/session_configured", "params": { "_meta": { "conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8" }, "msg": { "type": "session_configured", "session_id": "67e55044-10b1-426f-9247-bb680e5fe0c8", "model": "codex-mini-latest", "history_log_id": 42, "history_entry_count": 3 } } }); assert_eq!(observed, expected); } #[test] fn serialize_notification_codex_event_exec_command_begin_full_json() { let params = CodexEventNotificationParams { meta: None, msg: EventMsg::ExecCommandBegin(codex_core::protocol::ExecCommandBeginEvent { call_id: "c1".into(), command: vec!["bash".into(), "-lc".into(), "echo hi".into()], cwd: std::path::PathBuf::from("/work"), }), }; let observed = to_val(&ServerNotification::CodexEvent(Box::new(params))); let expected = json!({ "method": "notifications/exec_command_begin", "params": { "msg": { "type": "exec_command_begin", "call_id": "c1", "command": ["bash", "-lc", "echo hi"], "cwd": "/work" } } }); assert_eq!(observed, expected); } #[test] fn serialize_notification_codex_event_mcp_tool_call_begin_full_json() { let params = CodexEventNotificationParams { meta: None, msg: EventMsg::McpToolCallBegin(McpToolCallBeginEvent { call_id: "m1".into(), invocation: McpInvocation { server: "calc".into(), tool: "add".into(), arguments: Some(json!({"a":1,"b":2})), }, }), }; let observed = to_val(&ServerNotification::CodexEvent(Box::new(params))); let expected = json!({ "method": "notifications/mcp_tool_call_begin", "params": { "msg": { "type": "mcp_tool_call_begin", "call_id": "m1", "invocation": { "server": "calc", "tool": "add", "arguments": { "a": 1, "b": 2 } } } } }); assert_eq!(observed, expected); } #[test] fn serialize_notification_codex_event_patch_apply_end_full_json() { let params = CodexEventNotificationParams { meta: None, msg: EventMsg::PatchApplyEnd(codex_core::protocol::PatchApplyEndEvent { call_id: "p1".into(), stdout: "ok".into(), stderr: "".into(), success: true, }), }; let observed = to_val(&ServerNotification::CodexEvent(Box::new(params))); let expected = json!({ "method": "notifications/patch_apply_end", "params": { "msg": { "type": "patch_apply_end", "call_id": "p1", "stdout": "ok", "stderr": "", "success": true } } }); assert_eq!(observed, expected); } // ----- Cancelled notifications ----- #[test] fn serialize_notification_cancelled_with_reason_full_json() { let params = CancelNotificationParams { request_id: RequestId::String("r-123".into()), reason: Some("user_cancelled".into()), }; let observed = to_val(&ClientNotification::Cancelled(params)); let expected = json!({ "method": "notifications/cancelled", "params": { "requestId": "r-123", "reason": "user_cancelled" } }); assert_eq!(observed, expected); } #[test] fn serialize_notification_cancelled_without_reason_full_json() { let params = CancelNotificationParams { request_id: RequestId::Integer(77), reason: None, }; let observed = to_val(&ClientNotification::Cancelled(params)); // Check exact structure: reason must be omitted. assert_eq!(observed["method"], "notifications/cancelled"); assert_eq!(observed["params"]["requestId"], 77); assert!( observed["params"].get("reason").is_none(), "reason must be omitted when None" ); } }