Codex Handbook
core/tests/common/apps_test_server.rs 528 lines
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}")                    }                }))            }        }    }}