Files
llmx/codex-rs/mcp-server/src/mcp_protocol.rs

1055 lines
36 KiB
Rust
Raw Normal View History

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<AskForApproval>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox: Option<SandboxMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_instructions: Option<String>,
}
/// 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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approval_policy: Option<AskForApproval>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox: Option<SandboxMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_instructions: Option<String>,
}
#[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<InputItem>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_message_id: Option<MessageId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(flatten)]
pub conversation_overrides: Option<ConversationOverrides>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ConversationsListArgs {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
}
// 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<bool>,
#[serde(default, skip_serializing_if = "Option::is_none", flatten)]
pub result: Option<ToolCallResponseResult>,
}
impl From<ToolCallResponse> 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<ConversationSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
}
#[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<CodexEventNotificationParams>),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NotificationMeta {
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<ConversationId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_id: Option<RequestId>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InitialStateNotificationParams {
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
pub meta: Option<NotificationMeta>,
pub initial_state: InitialStatePayload,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InitialStatePayload {
#[serde(default)]
pub events: Vec<CodexEventNotificationParams>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StreamDisconnectedNotificationParams {
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
pub meta: Option<NotificationMeta>,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CodexEventNotificationParams {
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
pub meta: Option<NotificationMeta>,
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<String>,
}
impl Serialize for ServerNotification {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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<T: Serialize>(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"
);
}
}