use crate::test_codex::TestCodexBuilder;use crate::test_codex::test_codex;use anyhow::Result;use codex_core::config::Config;use codex_features::Feature;use codex_login::CodexAuth;use codex_models_manager::bundled_models_response;use serde_json::Value;use serde_json::json;use wiremock::Mock;use wiremock::MockServer;use wiremock::Request;use wiremock::Respond;use wiremock::ResponseTemplate;use wiremock::matchers::method;use wiremock::matchers::path;use wiremock::matchers::path_regex;const CONNECTOR_ID: &str = "calendar";const CONNECTOR_NAME: &str = "Calendar";const DISCOVERABLE_CALENDAR_ID: &str = "connector_2128aebfecb84f64a069897515042a44";const DISCOVERABLE_GMAIL_ID: &str = "connector_68df038e0ba48191908c8434991bbac2";const CONNECTOR_DESCRIPTION: &str = "Plan events and manage your calendar.";const CODEX_APPS_META_KEY: &str = "_codex_apps";const PROTOCOL_VERSION: &str = "2025-11-25";const SERVER_NAME: &str = "codex-apps-test";const SERVER_VERSION: &str = "1.0.0";const SEARCHABLE_TOOL_COUNT: usize = 100;const CALENDAR_CREATE_EVENT_TOOL_NAME: &str = "calendar_create_event";const CALENDAR_APP_ONLY_TOOL_NAME: &str = "calendar_app_only_action";pub const CALENDAR_EXTRACT_TEXT_TOOL_NAME: &str = "calendar_extract_text";const CALENDAR_LIST_EVENTS_TOOL_NAME: &str = "calendar_list_events";pub const DIRECT_CALENDAR_CREATE_EVENT_TOOL: &str = "mcp__codex_apps__calendar__create_event";pub const DIRECT_CALENDAR_APP_ONLY_TOOL: &str = "mcp__codex_apps__calendar__app_only_action";pub const DIRECT_CALENDAR_LIST_EVENTS_TOOL: &str = "mcp__codex_apps__calendar__list_events";pub const DIRECT_CALENDAR_EXTRACT_TEXT_TOOL: &str = "mcp__codex_apps__calendar__extract_text";pub const SEARCH_CALENDAR_NAMESPACE: &str = "mcp__codex_apps__calendar";pub const SEARCH_CALENDAR_APP_ONLY_TOOL: &str = "_app_only_action";pub const SEARCH_CALENDAR_CREATE_TOOL: &str = "_create_event";pub const SEARCH_CALENDAR_EXTRACT_TEXT_TOOL: &str = "_extract_text";pub const SEARCH_CALENDAR_LIST_TOOL: &str = "_list_events";pub const CALENDAR_CREATE_EVENT_RESOURCE_URI: &str = "connector://calendar/tools/calendar_create_event";pub const CALENDAR_CREATE_EVENT_MCP_APP_RESOURCE_URI: &str = "ui://widget/calendar-create-event.html";const CALENDAR_LIST_EVENTS_RESOURCE_URI: &str = "connector://calendar/tools/calendar_list_events";pub const DOCUMENT_EXTRACT_TEXT_RESOURCE_URI: &str = "connector://calendar/tools/calendar_extract_text";#[derive(Clone)]pub struct AppsTestServer { pub chatgpt_base_url: String,}#[derive(Clone, Copy)]pub enum AppsTestToolLoading { Direct, Searchable,}impl AppsTestServer { pub async fn mount(server: &MockServer) -> Result<Self> { Self::mount_with_connector_name(server, CONNECTOR_NAME).await } pub async fn mount_searchable(server: &MockServer) -> Result<Self> { mount_oauth_metadata(server).await; mount_connectors_directory(server).await; mount_streamable_http_json_rpc( server, CONNECTOR_NAME.to_string(), CONNECTOR_DESCRIPTION.to_string(), /*searchable*/ true, /*include_app_only_tool*/ false, ) .await; Ok(Self { chatgpt_base_url: server.uri(), }) } pub async fn mount_with_connector_name( server: &MockServer, connector_name: &str, ) -> Result<Self> { mount_oauth_metadata(server).await; mount_connectors_directory(server).await; mount_streamable_http_json_rpc( server, connector_name.to_string(), CONNECTOR_DESCRIPTION.to_string(), /*searchable*/ false, /*include_app_only_tool*/ false, ) .await; Ok(Self { chatgpt_base_url: server.uri(), }) } pub async fn mount_with_app_only_tool( server: &MockServer, tool_loading: AppsTestToolLoading, ) -> Result<Self> { mount_oauth_metadata(server).await; mount_connectors_directory(server).await; mount_streamable_http_json_rpc( server, CONNECTOR_NAME.to_string(), CONNECTOR_DESCRIPTION.to_string(), matches!(tool_loading, AppsTestToolLoading::Searchable), /*include_app_only_tool*/ true, ) .await; Ok(Self { chatgpt_base_url: server.uri(), }) }}pub fn configure_search_capable_model(config: &mut Config) { let mut model_catalog = bundled_models_response().expect("bundled models.json should parse"); let model = model_catalog .models .iter_mut() .find(|model| model.slug == "gpt-5.4") .expect("gpt-5.4 exists in bundled models.json"); config.model = Some("gpt-5.4".to_string()); model.supports_search_tool = true; config.model_catalog = Some(model_catalog);}fn configure_apps(config: &mut Config, apps_base_url: &str) { config .features .enable(Feature::Apps) .expect("test config should allow feature update"); config.chatgpt_base_url = apps_base_url.to_string();}pub fn configure_search_capable_apps(config: &mut Config, apps_base_url: &str) { configure_apps(config, apps_base_url); configure_search_capable_model(config);}pub fn apps_enabled_builder(apps_base_url: impl Into<String>) -> TestCodexBuilder { let apps_base_url = apps_base_url.into(); test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_config(move |config| configure_apps(config, apps_base_url.as_str()))}pub fn search_capable_apps_builder(apps_base_url: impl Into<String>) -> TestCodexBuilder { let apps_base_url = apps_base_url.into(); test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_config(move |config| configure_search_capable_apps(config, apps_base_url.as_str()))}fn apps_tool_call_id(body: &Value) -> Option<&str> { body.get("params")? .get("_meta")? .get(CODEX_APPS_META_KEY)? .get("call_id")? .as_str()}pub async fn recorded_apps_tool_calls(server: &MockServer) -> Vec<Value> { server .received_requests() .await .expect("mock server should capture requests") .into_iter() .filter_map(|request| { let body: Value = serde_json::from_slice(&request.body).ok()?; (request.url.path() == "/api/codex/apps" && body.get("method").and_then(Value::as_str) == Some("tools/call")) .then_some(body) }) .collect()}pub async fn recorded_apps_tool_call_by_call_id(server: &MockServer, call_id: &str) -> Value { let matches = recorded_apps_tool_calls(server) .await .into_iter() .filter(|body| apps_tool_call_id(body) == Some(call_id)) .collect::<Vec<_>>(); assert_eq!( matches.len(), 1, "expected exactly one apps tools/call request for call_id {call_id}" ); matches .into_iter() .next() .expect("matching apps tools/call request should be recorded")}pub async fn recorded_apps_tool_call_by_name(server: &MockServer, tool_name: &str) -> Value { let matches = recorded_apps_tool_calls(server) .await .into_iter() .filter(|body| body.pointer("/params/name").and_then(Value::as_str) == Some(tool_name)) .collect::<Vec<_>>(); assert_eq!( matches.len(), 1, "expected exactly one apps tools/call request for tool {tool_name}" ); matches .into_iter() .next() .expect("matching apps tools/call request should be recorded")}async fn mount_oauth_metadata(server: &MockServer) { Mock::given(method("GET")) .and(path("/.well-known/oauth-authorization-server/mcp")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "authorization_endpoint": format!("{}/oauth/authorize", server.uri()), "token_endpoint": format!("{}/oauth/token", server.uri()), "scopes_supported": [""], }))) .mount(server) .await;}async fn mount_connectors_directory(server: &MockServer) { Mock::given(method("GET")) .and(path("/connectors/directory/list")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "apps": [ { "id": DISCOVERABLE_CALENDAR_ID, "name": "Google Calendar", "description": "Plan events and schedules.", }, { "id": DISCOVERABLE_GMAIL_ID, "name": "Gmail", "description": "Find and summarize email threads.", } ], "nextToken": null }))) .mount(server) .await; Mock::given(method("GET")) .and(path("/connectors/directory/list_workspace")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "apps": [], "nextToken": null }))) .mount(server) .await;}async fn mount_streamable_http_json_rpc( server: &MockServer, connector_name: String, connector_description: String, searchable: bool, include_app_only_tool: bool,) { Mock::given(method("POST")) .and(path_regex("^/api/codex/apps/?$")) .respond_with(CodexAppsJsonRpcResponder { connector_name, connector_description, searchable, include_app_only_tool, }) .mount(server) .await;}struct CodexAppsJsonRpcResponder { connector_name: String, connector_description: String, searchable: bool, include_app_only_tool: bool,}impl Respond for CodexAppsJsonRpcResponder { fn respond(&self, request: &Request) -> ResponseTemplate { let body: Value = match serde_json::from_slice(&request.body) { Ok(body) => body, Err(error) => { return ResponseTemplate::new(400).set_body_json(json!({ "error": format!("invalid JSON-RPC body: {error}"), })); } }; let Some(method) = body.get("method").and_then(Value::as_str) else { return ResponseTemplate::new(400).set_body_json(json!({ "error": "missing method in JSON-RPC request", })); }; match method { "initialize" => { let id = body.get("id").cloned().unwrap_or(Value::Null); let protocol_version = body .pointer("/params/protocolVersion") .and_then(Value::as_str) .unwrap_or(PROTOCOL_VERSION); ResponseTemplate::new(200).set_body_json(json!({ "jsonrpc": "2.0", "id": id, "result": { "protocolVersion": protocol_version, "capabilities": { "tools": { "listChanged": true } }, "serverInfo": { "name": SERVER_NAME, "version": SERVER_VERSION } } })) } "notifications/initialized" => ResponseTemplate::new(202), "tools/list" => { let id = body.get("id").cloned().unwrap_or(Value::Null); let mut response = json!({ "jsonrpc": "2.0", "id": id, "result": { "tools": [ { "name": CALENDAR_CREATE_EVENT_TOOL_NAME, "description": "Create a calendar event.", "annotations": { "readOnlyHint": false, "destructiveHint": false, "openWorldHint": false }, "inputSchema": { "type": "object", "properties": { "title": { "type": "string" }, "starts_at": { "type": "string" }, "timezone": { "type": "string" } }, "required": ["title", "starts_at"], "additionalProperties": false }, "_meta": { "connector_id": CONNECTOR_ID, "connector_name": self.connector_name.clone(), "connector_description": self.connector_description.clone(), "openai/outputTemplate": CALENDAR_CREATE_EVENT_MCP_APP_RESOURCE_URI, "_codex_apps": { "resource_uri": CALENDAR_CREATE_EVENT_RESOURCE_URI, "contains_mcp_source": true, "connector_id": CONNECTOR_ID } } }, { "name": CALENDAR_LIST_EVENTS_TOOL_NAME, "description": "List calendar events.", "annotations": { "readOnlyHint": true }, "inputSchema": { "type": "object", "properties": { "query": { "type": "string" }, "limit": { "type": "integer" } }, "additionalProperties": false }, "_meta": { "connector_id": CONNECTOR_ID, "connector_name": self.connector_name.clone(), "connector_description": self.connector_description.clone(), "_codex_apps": { "resource_uri": CALENDAR_LIST_EVENTS_RESOURCE_URI, "contains_mcp_source": true, "connector_id": CONNECTOR_ID } } }, { "name": CALENDAR_EXTRACT_TEXT_TOOL_NAME, "description": "Extract text from an uploaded document.", "annotations": { "readOnlyHint": false }, "inputSchema": { "type": "object", "properties": { "file": { "type": "object", "description": "Document file payload.", "properties": { "file_id": { "type": "string" } }, "required": ["file_id"] } }, "required": ["file"], "additionalProperties": false }, "_meta": { "connector_id": CONNECTOR_ID, "connector_name": self.connector_name.clone(), "connector_description": self.connector_description.clone(), "openai/fileParams": ["file"], "_codex_apps": { "resource_uri": DOCUMENT_EXTRACT_TEXT_RESOURCE_URI, "contains_mcp_source": true, "connector_id": CONNECTOR_ID } } } ], "nextCursor": null } }); if self.searchable && let Some(tools) = response .pointer_mut("/result/tools") .and_then(Value::as_array_mut) { for index in 3..SEARCHABLE_TOOL_COUNT { tools.push(json!({ "name": format!("calendar_timezone_option_{index}"), "description": format!("Read timezone option {index}."), "annotations": { "readOnlyHint": true }, "inputSchema": { "type": "object", "properties": { "timezone": { "type": "string" } }, "additionalProperties": false }, "_meta": { "connector_id": CONNECTOR_ID, "connector_name": self.connector_name.clone(), "connector_description": self.connector_description.clone() } })); } } if self.include_app_only_tool && let Some(tools) = response .pointer_mut("/result/tools") .and_then(Value::as_array_mut) { tools.push(json!({ "name": CALENDAR_APP_ONLY_TOOL_NAME, "description": "Open a calendar app-only action.", "inputSchema": { "type": "object", "properties": {}, "additionalProperties": false }, "_meta": { "connector_id": CONNECTOR_ID, "connector_name": self.connector_name.clone(), "connector_description": self.connector_description.clone(), "ui": { "visibility": ["app"] } } })); } ResponseTemplate::new(200).set_body_json(response) } "tools/call" => { let id = body.get("id").cloned().unwrap_or(Value::Null); let tool_name = body .pointer("/params/name") .and_then(Value::as_str) .unwrap_or_default(); let title = body .pointer("/params/arguments/title") .and_then(Value::as_str) .unwrap_or_default(); let starts_at = body .pointer("/params/arguments/starts_at") .and_then(Value::as_str) .unwrap_or_default(); let file_id = body .pointer("/params/arguments/file/file_id") .and_then(Value::as_str) .unwrap_or_default(); let codex_apps_meta = body.pointer("/params/_meta/_codex_apps").cloned(); ResponseTemplate::new(200).set_body_json(json!({ "jsonrpc": "2.0", "id": id, "result": { "content": [{ "type": "text", "text": format!("called {tool_name} for {title} at {starts_at} with {file_id}") }], "structuredContent": { "_codex_apps": codex_apps_meta, }, "isError": false } })) } method if method.starts_with("notifications/") => ResponseTemplate::new(202), _ => { let id = body.get("id").cloned().unwrap_or(Value::Null); ResponseTemplate::new(200).set_body_json(json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32601, "message": format!("method not found: {method}") } })) } } }}