diff --git a/codex-rs/core/tests/common/apps_test_server.rs b/codex-rs/core/tests/common/apps_test_server.rs index 7f7873e30b..ad73dea403 100644 --- a/codex-rs/core/tests/common/apps_test_server.rs +++ b/codex-rs/core/tests/common/apps_test_server.rs @@ -18,9 +18,12 @@ use wiremock::matchers::path_regex; const CONNECTOR_ID: &str = "calendar"; const CONNECTOR_NAME: &str = "Calendar"; +const GMAIL_CONNECTOR_ID: &str = "gmail"; +const GMAIL_CONNECTOR_NAME: &str = "Gmail"; 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 GMAIL_CONNECTOR_DESCRIPTION: &str = "Find and summarize email threads."; const CODEX_APPS_META_KEY: &str = "_codex_apps"; const PROTOCOL_VERSION: &str = "2025-11-25"; const SERVER_NAME: &str = "codex-apps-test"; @@ -28,6 +31,7 @@ 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"; +const GMAIL_SEARCH_EMAIL_TOOL_NAME: &str = "gmail_search_email"; 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"; @@ -39,6 +43,8 @@ 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 SEARCH_GMAIL_NAMESPACE: &str = "mcp__codex_apps__gmail"; +pub const SEARCH_GMAIL_SEARCH_EMAIL_TOOL: &str = "_search_email"; pub const CALENDAR_CREATE_EVENT_RESOURCE_URI: &str = "connector://calendar/tools/calendar_create_event"; pub const CALENDAR_CREATE_EVENT_MCP_APP_RESOURCE_URI: &str = @@ -46,6 +52,7 @@ pub const CALENDAR_CREATE_EVENT_MCP_APP_RESOURCE_URI: &str = 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"; +pub const GMAIL_SEARCH_EMAIL_RESOURCE_URI: &str = "connector://gmail/tools/gmail_search_email"; #[derive(Clone)] pub struct AppsTestServer { @@ -72,6 +79,7 @@ impl AppsTestServer { CONNECTOR_DESCRIPTION.to_string(), /*searchable*/ true, /*include_app_only_tool*/ false, + /*include_gmail_tool*/ false, ) .await; Ok(Self { @@ -91,6 +99,24 @@ impl AppsTestServer { CONNECTOR_DESCRIPTION.to_string(), /*searchable*/ false, /*include_app_only_tool*/ false, + /*include_gmail_tool*/ false, + ) + .await; + Ok(Self { + chatgpt_base_url: server.uri(), + }) + } + + pub async fn mount_with_gmail(server: &MockServer) -> Result { + 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, + /*include_gmail_tool*/ true, ) .await; Ok(Self { @@ -110,6 +136,7 @@ impl AppsTestServer { CONNECTOR_DESCRIPTION.to_string(), matches!(tool_loading, AppsTestToolLoading::Searchable), /*include_app_only_tool*/ true, + /*include_gmail_tool*/ false, ) .await; Ok(Self { @@ -264,6 +291,7 @@ async fn mount_streamable_http_json_rpc( connector_description: String, searchable: bool, include_app_only_tool: bool, + include_gmail_tool: bool, ) { Mock::given(method("POST")) .and(path_regex("^/api/codex/apps/?$")) @@ -272,6 +300,7 @@ async fn mount_streamable_http_json_rpc( connector_description, searchable, include_app_only_tool, + include_gmail_tool, }) .mount(server) .await; @@ -282,6 +311,7 @@ struct CodexAppsJsonRpcResponder { connector_description: String, searchable: bool, include_app_only_tool: bool, + include_gmail_tool: bool, } impl Respond for CodexAppsJsonRpcResponder { @@ -475,6 +505,38 @@ impl Respond for CodexAppsJsonRpcResponder { } })); } + if self.include_gmail_tool + && let Some(tools) = response + .pointer_mut("/result/tools") + .and_then(Value::as_array_mut) + { + tools.push(json!({ + "name": GMAIL_SEARCH_EMAIL_TOOL_NAME, + "description": "Search Gmail messages.", + "annotations": { + "readOnlyHint": true + }, + "inputSchema": { + "type": "object", + "properties": { + "query": { "type": "string" }, + "limit": { "type": "integer" } + }, + "required": ["query"], + "additionalProperties": false + }, + "_meta": { + "connector_id": GMAIL_CONNECTOR_ID, + "connector_name": GMAIL_CONNECTOR_NAME, + "connector_description": GMAIL_CONNECTOR_DESCRIPTION, + "_codex_apps": { + "resource_uri": GMAIL_SEARCH_EMAIL_RESOURCE_URI, + "contains_mcp_source": true, + "connector_id": GMAIL_CONNECTOR_ID + } + } + })); + } ResponseTemplate::new(200).set_body_json(response) } "tools/call" => { diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index d4d06cdc55..bc8b458611 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -22,10 +22,13 @@ use core_test_support::apps_test_server::CALENDAR_CREATE_EVENT_MCP_APP_RESOURCE_ use core_test_support::apps_test_server::CALENDAR_CREATE_EVENT_RESOURCE_URI; use core_test_support::apps_test_server::DIRECT_CALENDAR_CREATE_EVENT_TOOL as CALENDAR_CREATE_TOOL; use core_test_support::apps_test_server::DIRECT_CALENDAR_LIST_EVENTS_TOOL as CALENDAR_LIST_TOOL; +use core_test_support::apps_test_server::GMAIL_SEARCH_EMAIL_RESOURCE_URI; use core_test_support::apps_test_server::SEARCH_CALENDAR_APP_ONLY_TOOL; use core_test_support::apps_test_server::SEARCH_CALENDAR_CREATE_TOOL; use core_test_support::apps_test_server::SEARCH_CALENDAR_LIST_TOOL; use core_test_support::apps_test_server::SEARCH_CALENDAR_NAMESPACE; +use core_test_support::apps_test_server::SEARCH_GMAIL_NAMESPACE; +use core_test_support::apps_test_server::SEARCH_GMAIL_SEARCH_EMAIL_TOOL; use core_test_support::apps_test_server::apps_enabled_builder; use core_test_support::apps_test_server::configure_search_capable_apps; use core_test_support::apps_test_server::configure_search_capable_model; @@ -221,6 +224,135 @@ async fn always_defer_feature_hides_small_app_tool_sets() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn app_tool_calls_track_multiple_used_connectors_e2e() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount_with_gmail(&server).await?; + let calendar_args = serde_json::to_string(&json!({ + "title": "Lunch", + "starts_at": "2026-03-10T12:00:00Z", + }))?; + let gmail_args = serde_json::to_string(&json!({ + "query": "lunch", + "limit": 5, + }))?; + let mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call_with_namespace( + "calendar-call-1", + SEARCH_CALENDAR_NAMESPACE, + SEARCH_CALENDAR_CREATE_TOOL, + &calendar_args, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_function_call_with_namespace( + "gmail-call-1", + SEARCH_GMAIL_NAMESPACE, + SEARCH_GMAIL_SEARCH_EMAIL_TOOL, + &gmail_args, + ), + ev_completed("resp-2"), + ]), + sse(vec![ + ev_response_created("resp-3"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-3"), + ]), + ], + ) + .await; + + let mut builder = + apps_enabled_builder(apps_server.chatgpt_base_url.clone()).with_config(|config| { + config + .features + .enable(Feature::Sqlite) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + test.submit_turn_with_approval_and_permission_profile( + "Use Calendar, then search Gmail.", + AskForApproval::Never, + PermissionProfile::Disabled, + ) + .await?; + + let requests = mock.requests(); + assert_eq!(requests.len(), 3); + assert!( + namespace_child_tool( + &requests[0].body_json(), + SEARCH_CALENDAR_NAMESPACE, + SEARCH_CALENDAR_CREATE_TOOL + ) + .is_some(), + "first request should expose the Calendar tool" + ); + assert!( + namespace_child_tool( + &requests[0].body_json(), + SEARCH_GMAIL_NAMESPACE, + SEARCH_GMAIL_SEARCH_EMAIL_TOOL + ) + .is_some(), + "first request should expose the Gmail tool" + ); + + let calendar_tool_call = recorded_apps_tool_call_by_call_id(&server, "calendar-call-1").await; + assert_eq!( + calendar_tool_call.pointer("/params/_meta/_codex_apps"), + Some(&json!({ + "call_id": "calendar-call-1", + "resource_uri": CALENDAR_CREATE_EVENT_RESOURCE_URI, + "contains_mcp_source": true, + "connector_id": "calendar", + "used_connector_ids": [], + })) + ); + + let gmail_tool_call = recorded_apps_tool_call_by_call_id(&server, "gmail-call-1").await; + assert_eq!( + gmail_tool_call.pointer("/params/_meta/_codex_apps"), + Some(&json!({ + "call_id": "gmail-call-1", + "resource_uri": GMAIL_SEARCH_EMAIL_RESOURCE_URI, + "contains_mcp_source": true, + "connector_id": "gmail", + "used_connector_ids": ["calendar"], + })) + ); + + let state_db = test.codex.state_db().expect("state db enabled"); + let mut metadata = None; + for _ in 0..100 { + metadata = state_db + .get_thread(test.session_configured.thread_id) + .await?; + if metadata + .as_ref() + .is_some_and(|metadata| metadata.used_connector_ids.len() == 2) + { + break; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + let metadata = metadata.expect("thread metadata should be persisted"); + assert_eq!( + metadata.used_connector_ids, + vec!["calendar".to_string(), "gmail".to_string()] + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn app_only_tools_are_not_visible_or_runnable_by_direct_model_calls() -> Result<()> { skip_if_no_network!(Ok(()));