Codex Handbook
core/tests/suite/search_tool.rs 1617 lines
#![cfg(not(target_os = "windows"))]#![allow(clippy::unwrap_used)]use anyhow::Result;use codex_config::types::McpServerConfig;use codex_config::types::McpServerTransportConfig;use codex_features::Feature;use codex_login::CodexAuth;use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem;use codex_protocol::dynamic_tools::DynamicToolFunctionSpec;use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec;use codex_protocol::dynamic_tools::DynamicToolNamespaceTool;use codex_protocol::dynamic_tools::DynamicToolResponse;use codex_protocol::dynamic_tools::DynamicToolSpec;use codex_protocol::models::FunctionCallOutputPayload;use codex_protocol::models::PermissionProfile;use codex_protocol::protocol::AskForApproval;use codex_protocol::protocol::EventMsg;use codex_protocol::protocol::McpInvocation;use codex_protocol::protocol::Op;use codex_protocol::user_input::UserInput;use core_test_support::apps_test_server::AppsTestServer;use core_test_support::apps_test_server::AppsTestToolLoading;use core_test_support::apps_test_server::CALENDAR_CREATE_EVENT_MCP_APP_RESOURCE_URI;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::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::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;use core_test_support::apps_test_server::recorded_apps_tool_call_by_call_id;use core_test_support::apps_test_server::recorded_apps_tool_calls;use core_test_support::apps_test_server::search_capable_apps_builder as configured_builder;use core_test_support::responses::ResponsesRequest;use core_test_support::responses::ev_assistant_message;use core_test_support::responses::ev_completed;use core_test_support::responses::ev_function_call_with_namespace;use core_test_support::responses::ev_response_created;use core_test_support::responses::ev_tool_search_call;use core_test_support::responses::mount_sse_once;use core_test_support::responses::mount_sse_sequence;use core_test_support::responses::namespace_child_tool;use core_test_support::responses::sse;use core_test_support::responses::start_mock_server;use core_test_support::skip_if_no_network;use core_test_support::stdio_server_bin;use core_test_support::test_codex::test_codex;use core_test_support::wait_for_event;use core_test_support::wait_for_mcp_server;use pretty_assertions::assert_eq;use serde_json::Value;use serde_json::json;use std::collections::HashMap;use std::time::Duration;const SEARCH_TOOL_DESCRIPTION_SNIPPETS: [&str; 2] = [    "You have access to tools from the following sources",    "- Calendar: Plan events and manage your calendar.",];const TOOL_SEARCH_TOOL_NAME: &str = "tool_search";fn tool_names(body: &Value) -> Vec<String> {    body.get("tools")        .and_then(Value::as_array)        .map(|tools| {            tools                .iter()                .filter_map(|tool| {                    tool.get("name")                        .or_else(|| tool.get("type"))                        .and_then(Value::as_str)                        .map(str::to_string)                })                .collect()        })        .unwrap_or_default()}fn tool_search_description(body: &Value) -> Option<String> {    body.get("tools")        .and_then(Value::as_array)        .and_then(|tools| {            tools.iter().find_map(|tool| {                if tool.get("type").and_then(Value::as_str) == Some(TOOL_SEARCH_TOOL_NAME) {                    tool.get("description")                        .and_then(Value::as_str)                        .map(str::to_string)                } else {                    None                }            })        })}fn tool_search_output_item(request: &ResponsesRequest, call_id: &str) -> Value {    request.tool_search_output(call_id)}fn tool_search_output_tools(request: &ResponsesRequest, call_id: &str) -> Vec<Value> {    tool_search_output_item(request, call_id)        .get("tools")        .and_then(Value::as_array)        .cloned()        .unwrap_or_default()}fn tool_search_output_has_namespace_child(    request: &ResponsesRequest,    call_id: &str,    namespace: &str,    tool_name: &str,) -> bool {    let output = json!({        "tools": tool_search_output_tools(request, call_id),    });    namespace_child_tool(&output, namespace, tool_name).is_some()}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn search_tool_enabled_by_default_adds_tool_search() -> Result<()> {    skip_if_no_network!(Ok(()));    let server = start_mock_server().await;    let apps_server = AppsTestServer::mount_searchable(&server).await?;    let mock = mount_sse_once(        &server,        sse(vec![            ev_response_created("resp-1"),            ev_assistant_message("msg-1", "done"),            ev_completed("resp-1"),        ]),    )    .await;    let mut builder = configured_builder(apps_server.chatgpt_base_url.clone());    let test = builder.build(&server).await?;    test.submit_turn_with_approval_and_permission_profile(        "list tools",        AskForApproval::Never,        PermissionProfile::Disabled,    )    .await?;    let body = mock.single_request().body_json();    let tools = body        .get("tools")        .and_then(Value::as_array)        .expect("tools array should exist");    let tool_search = tools        .iter()        .find(|tool| tool.get("type").and_then(Value::as_str) == Some(TOOL_SEARCH_TOOL_NAME))        .cloned()        .expect("tool_search should be present");    assert_eq!(        tool_search,        json!({            "type": "tool_search",            "execution": "client",            "description": tool_search["description"].as_str().expect("description should exist"),            "parameters": {                "type": "object",                "properties": {                    "query": {"type": "string", "description": "Search query for deferred tools."},                    "limit": {"type": "number", "description": "Maximum number of tools to return. Defaults to 8."},                },                "required": ["query"],                "additionalProperties": false,            }        })    );    Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn always_defer_feature_hides_small_app_tool_sets() -> Result<()> {    skip_if_no_network!(Ok(()));    let server = start_mock_server().await;    let apps_server = AppsTestServer::mount(&server).await?;    let mock = mount_sse_once(        &server,        sse(vec![            ev_response_created("resp-1"),            ev_assistant_message("msg-1", "done"),            ev_completed("resp-1"),        ]),    )    .await;    let mut builder =        configured_builder(apps_server.chatgpt_base_url.clone()).with_config(|config| {            config                .features                .enable(Feature::ToolSearchAlwaysDeferMcpTools)                .expect("test config should allow feature update");        });    let test = builder.build(&server).await?;    test.submit_turn_with_approval_and_permission_profile(        "list tools",        AskForApproval::Never,        PermissionProfile::Disabled,    )    .await?;    let body = mock.single_request().body_json();    let tools = tool_names(&body);    assert!(        tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME),        "small app tool sets should be deferred behind tool_search: {tools:?}"    );    assert!(        tools.iter().all(|name| !name.starts_with("mcp__")),        "MCP tools should not be directly exposed: {tools:?}"    );    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(()));    let server = start_mock_server().await;    let apps_server =        AppsTestServer::mount_with_app_only_tool(&server, AppsTestToolLoading::Direct).await?;    let call_id = "app-only-direct-call";    let mock = mount_sse_sequence(        &server,        vec![            sse(vec![                ev_response_created("resp-1"),                ev_function_call_with_namespace(                    call_id,                    SEARCH_CALENDAR_NAMESPACE,                    SEARCH_CALENDAR_APP_ONLY_TOOL,                    "{}",                ),                ev_completed("resp-1"),            ]),            sse(vec![                ev_response_created("resp-2"),                ev_assistant_message("msg-1", "done"),                ev_completed("resp-2"),            ]),        ],    )    .await;    let mut builder = apps_enabled_builder(apps_server.chatgpt_base_url.clone());    let test = builder.build(&server).await?;    test.submit_turn_with_approval_and_permission_profile(        "Try to call the app-only calendar tool.",        AskForApproval::Never,        PermissionProfile::Disabled,    )    .await?;    let requests = mock.requests();    assert!(        namespace_child_tool(            &requests[0].body_json(),            SEARCH_CALENDAR_NAMESPACE,            SEARCH_CALENDAR_CREATE_TOOL        )        .is_some(),        "visible tool from the app-only tool's connector should be declared"    );    assert!(        namespace_child_tool(            &requests[0].body_json(),            SEARCH_CALENDAR_NAMESPACE,            SEARCH_CALENDAR_APP_ONLY_TOOL        )        .is_none(),        "app-only tool should not be declared to a direct model"    );    assert!(        requests[1]            .function_call_output(call_id)            .get("output")            .and_then(Value::as_str)            .is_some_and(|output| output.contains("unsupported call")),        "forced app-only direct call should not dispatch"    );    assert!(        recorded_apps_tool_calls(&server).await.is_empty(),        "forced app-only direct call should not reach the MCP server"    );    Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn app_search_sources_are_hidden_for_api_key_auth() -> Result<()> {    skip_if_no_network!(Ok(()));    let server = start_mock_server().await;    let apps_server = AppsTestServer::mount(&server).await?;    let mock = mount_sse_once(        &server,        sse(vec![            ev_response_created("resp-1"),            ev_assistant_message("msg-1", "done"),            ev_completed("resp-1"),        ]),    )    .await;    let mut builder = test_codex()        .with_auth(CodexAuth::from_api_key("Test API Key"))        .with_config(move |config| {            configure_search_capable_apps(config, apps_server.chatgpt_base_url.as_str())        });    let test = builder.build(&server).await?;    test.submit_turn_with_approval_and_permission_profile(        "list tools",        AskForApproval::Never,        PermissionProfile::Disabled,    )    .await?;    let body = mock.single_request().body_json();    let tools = tool_names(&body);    assert!(        !tools.iter().any(|name| name == SEARCH_CALENDAR_NAMESPACE),        "tools list should not include app tools for API key auth: {tools:?}"    );    let description = tool_search_description(&body).unwrap_or_default();    assert!(        !description.contains("Calendar"),        "tool_search description should not include app sources for API key auth: {description}"    );    Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn search_tool_adds_discovery_instructions_to_tool_description() -> Result<()> {    skip_if_no_network!(Ok(()));    let server = start_mock_server().await;    let apps_server = AppsTestServer::mount_searchable(&server).await?;    let mock = mount_sse_once(        &server,        sse(vec![            ev_response_created("resp-1"),            ev_assistant_message("msg-1", "done"),            ev_completed("resp-1"),        ]),    )    .await;    let mut builder = configured_builder(apps_server.chatgpt_base_url.clone());    let test = builder.build(&server).await?;    test.submit_turn_with_approval_and_permission_profile(        "list tools",        AskForApproval::Never,        PermissionProfile::Disabled,    )    .await?;    let body = mock.single_request().body_json();    let description = tool_search_description(&body).expect("tool_search description should exist");    assert!(        SEARCH_TOOL_DESCRIPTION_SNIPPETS            .iter()            .all(|snippet| description.contains(snippet)),        "tool_search description should include the updated workflow: {description:?}"    );    assert!(        !description.contains("remainder of the current session/thread"),        "tool_search description should not mention legacy client-side persistence: {description:?}"    );    Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn search_tool_hides_apps_tools_without_search() -> Result<()> {    skip_if_no_network!(Ok(()));    let server = start_mock_server().await;    let apps_server = AppsTestServer::mount_searchable(&server).await?;    let mock = mount_sse_once(        &server,        sse(vec![            ev_response_created("resp-1"),            ev_assistant_message("msg-1", "done"),            ev_completed("resp-1"),        ]),    )    .await;    let mut builder = configured_builder(apps_server.chatgpt_base_url.clone());    let test = builder.build(&server).await?;    test.submit_turn_with_approval_and_permission_profile(        "hello tools",        AskForApproval::Never,        PermissionProfile::Disabled,    )    .await?;    let body = mock.single_request().body_json();    let tools = tool_names(&body);    assert!(tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME));    assert!(!tools.iter().any(|name| name == CALENDAR_CREATE_TOOL));    assert!(!tools.iter().any(|name| name == CALENDAR_LIST_TOOL));    assert!(!tools.iter().any(|name| name == SEARCH_CALENDAR_NAMESPACE));    Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn explicit_app_mentions_respect_always_defer() -> Result<()> {    skip_if_no_network!(Ok(()));    let server = start_mock_server().await;    let apps_server = AppsTestServer::mount(&server).await?;    let mock = mount_sse_once(        &server,        sse(vec![            ev_response_created("resp-1"),            ev_assistant_message("msg-1", "done"),            ev_completed("resp-1"),        ]),    )    .await;    let mut builder =        configured_builder(apps_server.chatgpt_base_url.clone()).with_config(|config| {            config                .features                .enable(Feature::ToolSearchAlwaysDeferMcpTools)                .expect("test config should allow feature update");        });    let test = builder.build(&server).await?;    test.submit_turn_with_approval_and_permission_profile(        "Use [$calendar](app://calendar) and then call tools.",        AskForApproval::Never,        PermissionProfile::Disabled,    )    .await?;    let body = mock.single_request().body_json();    let tools = tool_names(&body);    assert!(        tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME),        "explicit app mentions should leave app tools deferred when always-defer is active: {tools:?}"    );    assert!(        namespace_child_tool(            &body,            SEARCH_CALENDAR_NAMESPACE,            SEARCH_CALENDAR_CREATE_TOOL        )        .is_none(),        "explicit app mentions should not directly expose create tool, got tools: {tools:?}"    );    assert!(        namespace_child_tool(&body, SEARCH_CALENDAR_NAMESPACE, SEARCH_CALENDAR_LIST_TOOL).is_none(),        "explicit app mentions should not directly expose list tool, got tools: {tools:?}"    );    Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() -> Result<()> {    skip_if_no_network!(Ok(()));    let server = start_mock_server().await;    let apps_server = AppsTestServer::mount_searchable(&server).await?;    let call_id = "tool-search-1";    let mock = mount_sse_sequence(        &server,        vec![            sse(vec![                ev_response_created("resp-1"),                ev_tool_search_call(                    call_id,                    &json!({                        "query": "create calendar event",                        "limit": 1,                    }),                ),                ev_completed("resp-1"),            ]),            sse(vec![                ev_response_created("resp-2"),                json!({                    "type": "response.output_item.done",                    "item": {                        "type": "function_call",                        "call_id": "calendar-call-1",                        "name": SEARCH_CALENDAR_CREATE_TOOL,                        "namespace": SEARCH_CALENDAR_NAMESPACE,                        "arguments": serde_json::to_string(&json!({                            "title": "Lunch",                            "starts_at": "2026-03-10T12:00:00Z"                        })).expect("serialize calendar 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 = configured_builder(apps_server.chatgpt_base_url.clone());    let test = builder.build(&server).await?;    test.codex        .submit(Op::UserInput {            items: vec![UserInput::Text {                text: "Find the calendar create tool".to_string(),                text_elements: Vec::new(),            }],            final_output_json_schema: None,            responsesapi_client_metadata: None,            additional_context: Default::default(),            thread_settings: Default::default(),        })        .await?;    let EventMsg::McpToolCallBegin(begin) = wait_for_event(&test.codex, |event| {        matches!(event, EventMsg::McpToolCallBegin(_))    })    .await    else {        unreachable!("event guard guarantees McpToolCallBegin");    };    assert_eq!(begin.call_id, "calendar-call-1");    assert_eq!(        begin.mcp_app_resource_uri.as_deref(),        Some(CALENDAR_CREATE_EVENT_MCP_APP_RESOURCE_URI)    );    let EventMsg::McpToolCallEnd(end) = wait_for_event(&test.codex, |event| {        matches!(event, EventMsg::McpToolCallEnd(_))    })    .await    else {        unreachable!("event guard guarantees McpToolCallEnd");    };    assert_eq!(end.call_id, "calendar-call-1");    assert_eq!(        end.mcp_app_resource_uri.as_deref(),        Some(CALENDAR_CREATE_EVENT_MCP_APP_RESOURCE_URI)    );    assert_eq!(        end.invocation,        McpInvocation {            server: "codex_apps".to_string(),            tool: "calendar_create_event".to_string(),            arguments: Some(json!({                "title": "Lunch",                "starts_at": "2026-03-10T12:00:00Z"            })),        }    );    assert_eq!(        end.result            .as_ref()            .expect("tool call should succeed")            .structured_content,        Some(json!({            "_codex_apps": {                "call_id": "calendar-call-1",                "resource_uri": CALENDAR_CREATE_EVENT_RESOURCE_URI,                "contains_mcp_source": true,                "connector_id": "calendar",            },        }))    );    wait_for_event(&test.codex, |event| {        matches!(event, EventMsg::TurnComplete(_))    })    .await;    let requests = mock.requests();    assert_eq!(requests.len(), 3);    let first_request_body = requests[0].body_json();    let apps_tool_call = recorded_apps_tool_call_by_call_id(&server, "calendar-call-1").await;    assert_eq!(        apps_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",        }))    );    assert_eq!(        apps_tool_call.pointer("/params/_meta/x-codex-turn-metadata/session_id"),        Some(&json!(test.session_configured.session_id.to_string()))    );    assert_eq!(        apps_tool_call.pointer("/params/_meta/x-codex-turn-metadata/thread_id"),        Some(&json!(test.session_configured.thread_id.to_string()))    );    assert!(        apps_tool_call            .pointer("/params/_meta/x-codex-turn-metadata/turn_id")            .and_then(Value::as_str)            .is_some_and(|turn_id| !turn_id.is_empty()),        "apps tools/call should include turn metadata turn_id: {apps_tool_call:?}"    );    assert_eq!(        apps_tool_call            .pointer("/params/_meta/x-codex-turn-metadata/model")            .and_then(Value::as_str),        Some("gpt-5.4")    );    let first_request_reasoning_effort = first_request_body        .pointer("/reasoning/effort")        .and_then(Value::as_str)        .expect("first response request should include reasoning effort");    assert_eq!(        apps_tool_call            .pointer("/params/_meta/x-codex-turn-metadata/reasoning_effort")            .and_then(Value::as_str),        Some(first_request_reasoning_effort)    );    let mcp_turn_started_at_unix_ms = apps_tool_call        .pointer("/params/_meta/x-codex-turn-metadata/turn_started_at_unix_ms")        .and_then(Value::as_i64)        .expect("apps tools/call should include turn_started_at_unix_ms");    assert!(        mcp_turn_started_at_unix_ms > 0,        "apps tools/call should include a positive turn_started_at_unix_ms: {apps_tool_call:?}"    );    let first_request_turn_metadata: Value = serde_json::from_str(        &requests[0]            .header("x-codex-turn-metadata")            .expect("first response request should include turn metadata"),    )    .expect("first response request turn metadata should be valid JSON");    assert_eq!(        first_request_turn_metadata            .get("turn_started_at_unix_ms")            .and_then(Value::as_i64),        Some(mcp_turn_started_at_unix_ms)    );    let first_request_tools = tool_names(&first_request_body);    assert!(        first_request_tools            .iter()            .any(|name| name == TOOL_SEARCH_TOOL_NAME),        "first request should advertise tool_search: {first_request_tools:?}"    );    assert!(        !first_request_tools            .iter()            .any(|name| name == CALENDAR_CREATE_TOOL),        "app tools should still be hidden before search: {first_request_tools:?}"    );    assert!(        !first_request_tools            .iter()            .any(|name| name == SEARCH_CALENDAR_NAMESPACE),        "app namespace should still be hidden before search: {first_request_tools:?}"    );    let output_item = tool_search_output_item(&requests[1], call_id);    assert_eq!(        output_item.get("status").and_then(Value::as_str),        Some("completed")    );    assert_eq!(        output_item.get("execution").and_then(Value::as_str),        Some("client")    );    let tools = tool_search_output_tools(&requests[1], call_id);    assert_eq!(        tools,        vec![json!({            "type": "namespace",            "name": SEARCH_CALENDAR_NAMESPACE,            "description": "Plan events and manage your calendar.",            "tools": [                {                    "type": "function",                    "name": SEARCH_CALENDAR_CREATE_TOOL,                    "description": "Create a calendar event.",                    "strict": false,                    "defer_loading": true,                    "parameters": {                        "type": "object",                        "properties": {                            "starts_at": {"type": "string"},                            "timezone": {"type": "string"},                            "title": {"type": "string"},                        },                        "required": ["title", "starts_at"],                        "additionalProperties": false,                    }                }            ]        })]    );    let second_request_tools = tool_names(&requests[1].body_json());    assert!(        !second_request_tools            .iter()            .any(|name| name == CALENDAR_CREATE_TOOL),        "follow-up request should rely on tool_search_output history, not tool injection: {second_request_tools:?}"    );    assert!(        !second_request_tools            .iter()            .any(|name| name == SEARCH_CALENDAR_NAMESPACE),        "follow-up request should rely on tool_search_output history, not namespace injection: {second_request_tools:?}"    );    let output_item = requests[2].function_call_output("calendar-call-1");    assert_eq!(        output_item.get("call_id").and_then(Value::as_str),        Some("calendar-call-1")    );    let third_request_tools = tool_names(&requests[2].body_json());    assert!(        !third_request_tools            .iter()            .any(|name| name == CALENDAR_CREATE_TOOL),        "post-tool follow-up should still rely on tool_search_output history, not tool injection: {third_request_tools:?}"    );    assert!(        !third_request_tools            .iter()            .any(|name| name == SEARCH_CALENDAR_NAMESPACE),        "post-tool follow-up should still rely on tool_search_output history, not namespace injection: {third_request_tools:?}"    );    Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn tool_search_returns_deferred_v1_multi_agent_tools() -> Result<()> {    skip_if_no_network!(Ok(()));    let server = start_mock_server().await;    let call_id = "tool-search-spawn-agent";    let mock = mount_sse_sequence(        &server,        vec![            sse(vec![                ev_response_created("resp-1"),                ev_tool_search_call(                    call_id,                    &json!({                        "query": "spawn agent",                        "limit": 1,                    }),                ),                ev_completed("resp-1"),            ]),            sse(vec![                ev_response_created("resp-2"),                ev_assistant_message("msg-1", "done"),                ev_completed("resp-2"),            ]),        ],    )    .await;    let mut builder = test_codex().with_config(configure_search_capable_model);    let test = builder.build(&server).await?;    test.submit_turn_with_approval_and_permission_profile(        "Find the spawn agent tool",        AskForApproval::Never,        PermissionProfile::Disabled,    )    .await?;    let requests = mock.requests();    assert_eq!(requests.len(), 2);    let first_request_body = requests[0].body_json();    let first_request_tools = tool_names(&first_request_body);    assert!(        first_request_tools            .iter()            .any(|name| name == TOOL_SEARCH_TOOL_NAME),        "first request should advertise tool_search: {first_request_tools:?}"    );    for tool_name in [        "spawn_agent",        "send_input",        "resume_agent",        "wait_agent",        "close_agent",    ] {        assert!(            !first_request_tools.iter().any(|name| name == tool_name),            "v1 multi-agent tools should be hidden before search: {first_request_tools:?}"        );    }    assert!(        !first_request_body            .to_string()            .contains("Only use `spawn_agent` if and only if"),        "deferred v1 multi-agent guidance should stay out of initial developer context"    );    let tools = tool_search_output_tools(&requests[1], call_id);    assert!(        !tools.iter().any(|tool| {            tool.get("type").and_then(Value::as_str) == Some("function")                && tool.get("name").and_then(Value::as_str) == Some("spawn_agent")        }),        "spawn_agent should be returned as a namespace child, not a flat function: {tools:?}"    );    assert!(        tools.iter().any(|tool| {            tool.get("type").and_then(Value::as_str) == Some("namespace")                && tool.get("name").and_then(Value::as_str) == Some("multi_agent_v1")        }),        "expected tool_search to return multi_agent_v1 namespace: {tools:?}"    );    let output = tool_search_output_item(&requests[1], call_id);    let spawn_agent = namespace_child_tool(&output, "multi_agent_v1", "spawn_agent")        .expect("tool_search should return multi_agent_v1.spawn_agent");    assert_eq!(        spawn_agent.get("defer_loading").and_then(Value::as_bool),        Some(true)    );    let description = spawn_agent        .get("description")        .and_then(Value::as_str)        .expect("spawn_agent description should be present");    assert!(description.contains(        "Do not spawn sub-agents unless the user explicitly asks for sub-agents, delegation, or parallel agent work."    ));    assert!(description.contains("### Designing delegated subtasks"));    assert!(!description.contains("### When to delegate vs. do the subtask yourself"));    Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call() -> Result<()> {    skip_if_no_network!(Ok(()));    let server = start_mock_server().await;    let search_call_id = "tool-search-1";    let dynamic_call_id = "dyn-search-call-1";    let tool_name = "automation_update";    let tool_description = "Create, update, view, or delete recurring automations.";    let tool_args = json!({ "mode": "create" });    let tool_call_arguments = serde_json::to_string(&tool_args)?;    let mock = mount_sse_sequence(        &server,        vec![            sse(vec![                ev_response_created("resp-1"),                ev_tool_search_call(                    search_call_id,                    &json!({                        "query": "recurring automations",                        "limit": 8,                    }),                ),                ev_completed("resp-1"),            ]),            sse(vec![                ev_response_created("resp-2"),                json!({                    "type": "response.output_item.done",                    "item": {                        "type": "function_call",                        "call_id": dynamic_call_id,                        "namespace": "codex_app",                        "name": tool_name,                        "arguments": tool_call_arguments,                    }                }),                ev_completed("resp-2"),            ]),            sse(vec![                ev_response_created("resp-3"),                ev_assistant_message("msg-1", "done"),                ev_completed("resp-3"),            ]),        ],    )    .await;    let input_schema = json!({        "type": "object",        "properties": {            "mode": { "type": "string" },        },        "required": ["mode"],        "additionalProperties": false,    });    let dynamic_tool = DynamicToolSpec::Namespace(DynamicToolNamespaceSpec {        name: "codex_app".to_string(),        description: "Automation tools.".to_string(),        tools: vec![DynamicToolNamespaceTool::Function(            DynamicToolFunctionSpec {                name: tool_name.to_string(),                description: tool_description.to_string(),                input_schema: input_schema.clone(),                defer_loading: true,            },        )],    });    let mut builder = test_codex().with_config(configure_search_capable_model);    let base_test = builder.build(&server).await?;    let new_thread = base_test        .thread_manager        .start_thread_with_tools(base_test.config.clone(), vec![dynamic_tool])        .await?;    let mut test = base_test;    test.codex = new_thread.thread;    test.session_configured = new_thread.session_configured;    test.codex        .submit(Op::UserInput {            items: vec![UserInput::Text {                text: "Use the automation tool".to_string(),                text_elements: Vec::new(),            }],            final_output_json_schema: None,            responsesapi_client_metadata: None,            additional_context: Default::default(),            thread_settings: Default::default(),        })        .await?;    let EventMsg::DynamicToolCallRequest(request) = wait_for_event(&test.codex, |event| {        matches!(event, EventMsg::DynamicToolCallRequest(_))    })    .await    else {        unreachable!("event guard guarantees DynamicToolCallRequest");    };    assert_eq!(request.call_id, dynamic_call_id);    assert_eq!(request.namespace.as_deref(), Some("codex_app"));    assert_eq!(request.tool, tool_name);    assert_eq!(request.arguments, tool_args);    test.codex        .submit(Op::DynamicToolResponse {            id: request.call_id,            response: DynamicToolResponse {                content_items: vec![DynamicToolCallOutputContentItem::InputText {                    text: "dynamic-search-ok".to_string(),                }],                success: true,            },        })        .await?;    wait_for_event(&test.codex, |event| {        matches!(event, EventMsg::TurnComplete(_))    })    .await;    let requests = mock.requests();    assert_eq!(requests.len(), 3);    let first_request_body = requests[0].body_json();    let first_request_tools = tool_names(&first_request_body);    assert!(        first_request_tools            .iter()            .any(|name| name == TOOL_SEARCH_TOOL_NAME),        "first request should advertise tool_search: {first_request_tools:?}"    );    assert!(        !first_request_tools.iter().any(|name| name == tool_name),        "deferred dynamic tool should be hidden before search: {first_request_tools:?}"    );    let tools = tool_search_output_tools(&requests[1], search_call_id);    assert_eq!(        tools,        vec![json!({            "type": "namespace",            "name": "codex_app",            "description": "Automation tools.",            "tools": [{                "type": "function",                "name": tool_name,                "description": tool_description,                "strict": false,                "defer_loading": true,                "parameters": input_schema,            }],        })]    );    let second_request_body = requests[1].body_json();    let second_request_tools = tool_names(&second_request_body);    assert!(        !second_request_tools.iter().any(|name| name == tool_name),        "follow-up request should rely on tool_search_output history, not tool injection: {second_request_tools:?}"    );    let output = requests[2]        .function_call_output(dynamic_call_id)        .get("output")        .cloned()        .expect("dynamic tool output should be present");    let payload: FunctionCallOutputPayload = serde_json::from_value(output)?;    assert_eq!(        payload,        FunctionCallOutputPayload::from_text("dynamic-search-ok".to_string())    );    let third_request_body = requests[2].body_json();    let third_request_tools = tool_names(&third_request_body);    assert!(        !third_request_tools.iter().any(|name| name == tool_name),        "post-tool follow-up should rely on tool_search_output history, not tool injection: {third_request_tools:?}"    );    Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn tool_search_indexes_only_enabled_non_app_mcp_tools() -> Result<()> {    skip_if_no_network!(Ok(()));    let server = start_mock_server().await;    let apps_server = AppsTestServer::mount_searchable(&server).await?;    let echo_call_id = "tool-search-echo";    let image_call_id = "tool-search-image";    let mock = mount_sse_sequence(        &server,        vec![            sse(vec![                ev_response_created("resp-1"),                ev_tool_search_call(                    echo_call_id,                    &json!({                        "query": "Echo back the provided message and include environment data.",                        "limit": 8,                    }),                ),                ev_tool_search_call(                    image_call_id,                    &json!({                        "query": "Return a single image content block.",                        "limit": 8,                    }),                ),                ev_completed("resp-1"),            ]),            sse(vec![                ev_response_created("resp-2"),                ev_assistant_message("msg-1", "done"),                ev_completed("resp-2"),            ]),        ],    )    .await;    let rmcp_test_server_bin = stdio_server_bin()?;    let mut builder =        configured_builder(apps_server.chatgpt_base_url.clone()).with_config(move |config| {            let mut servers = config.mcp_servers.get().clone();            servers.insert(                "rmcp".to_string(),                McpServerConfig {                    transport: McpServerTransportConfig::Stdio {                        command: rmcp_test_server_bin,                        args: Vec::new(),                        env: None,                        env_vars: Vec::new(),                        cwd: None,                    },                    environment_id: "local".to_string(),                    enabled: true,                    required: false,                    disabled_reason: None,                    startup_timeout_sec: Some(Duration::from_secs(10)),                    tool_timeout_sec: None,                    default_tools_approval_mode: None,                    enabled_tools: Some(vec!["echo".to_string(), "image".to_string()]),                    disabled_tools: Some(vec!["image".to_string()]),                    scopes: None,                    oauth: None,                    oauth_resource: None,                    supports_parallel_tool_calls: false,                    tools: HashMap::new(),                },            );            config                .mcp_servers                .set(servers)                .expect("test mcp servers should accept any configuration");        });    let test = builder.build(&server).await?;    wait_for_mcp_server(&test.codex, "rmcp").await?;    test.submit_turn_with_approval_and_permission_profile(        "Find the rmcp echo and image tools.",        AskForApproval::Never,        PermissionProfile::Disabled,    )    .await?;    let requests = mock.requests();    assert_eq!(requests.len(), 2);    let first_request_tools = tool_names(&requests[0].body_json());    assert!(        first_request_tools            .iter()            .any(|name| name == TOOL_SEARCH_TOOL_NAME),        "first request should advertise tool_search: {first_request_tools:?}"    );    assert!(        !first_request_tools            .iter()            .any(|name| name == "mcp__rmcp__echo"),        "non-app MCP tools should be hidden before search in large-search mode: {first_request_tools:?}"    );    assert!(        !first_request_tools.iter().any(|name| name == "mcp__rmcp"),        "non-app MCP namespace should be hidden before search in large-search mode: {first_request_tools:?}"    );    let echo_tools = tool_search_output_tools(&requests[1], echo_call_id);    let echo_output = json!({ "tools": echo_tools });    let rmcp_echo_tool = namespace_child_tool(&echo_output, "mcp__rmcp", "echo")        .expect("tool_search should return rmcp echo as a namespace child tool");    assert_eq!(        rmcp_echo_tool.get("type").and_then(Value::as_str),        Some("function")    );    let image_tools = tool_search_output_tools(&requests[1], image_call_id);    let found_rmcp_image_tool = image_tools        .iter()        .filter(|tool| tool.get("name").and_then(Value::as_str) == Some("mcp__rmcp"))        .flat_map(|namespace| namespace.get("tools").and_then(Value::as_array))        .flatten()        .any(|tool| tool.get("name").and_then(Value::as_str).is_some());    assert!(        !found_rmcp_image_tool,        "disabled non-app MCP tools should not be searchable: {image_tools:?}"    );    Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn tool_search_surfaced_mcp_tool_errors_are_returned_to_model() -> Result<()> {    skip_if_no_network!(Ok(()));    let server = start_mock_server().await;    let apps_server = AppsTestServer::mount_searchable(&server).await?;    let search_call_id = "tool-search-rmcp-echo";    let tool_call_id = "rmcp-echo-error";    let mock = mount_sse_sequence(        &server,        vec![            sse(vec![                ev_response_created("resp-1"),                ev_tool_search_call(                    search_call_id,                    &json!({                        "query": "Echo back the provided message and include environment data.",                        "limit": 8,                    }),                ),                ev_completed("resp-1"),            ]),            sse(vec![                ev_response_created("resp-2"),                ev_function_call_with_namespace(tool_call_id, "mcp__rmcp", "echo", "{}"),                ev_completed("resp-2"),            ]),            sse(vec![                ev_response_created("resp-3"),                ev_assistant_message("msg-1", "done"),                ev_completed("resp-3"),            ]),        ],    )    .await;    let rmcp_test_server_bin = stdio_server_bin()?;    let mut builder =        configured_builder(apps_server.chatgpt_base_url.clone()).with_config(move |config| {            config                .features                .enable(Feature::ToolSearchAlwaysDeferMcpTools)                .expect("test config should allow feature update");            let mut servers = config.mcp_servers.get().clone();            servers.insert(                "rmcp".to_string(),                McpServerConfig {                    transport: McpServerTransportConfig::Stdio {                        command: rmcp_test_server_bin,                        args: Vec::new(),                        env: None,                        env_vars: Vec::new(),                        cwd: None,                    },                    environment_id: "local".to_string(),                    enabled: true,                    required: false,                    disabled_reason: None,                    startup_timeout_sec: Some(Duration::from_secs(10)),                    tool_timeout_sec: None,                    default_tools_approval_mode: None,                    enabled_tools: Some(vec!["echo".to_string()]),                    disabled_tools: None,                    scopes: None,                    oauth: None,                    oauth_resource: None,                    supports_parallel_tool_calls: false,                    tools: HashMap::new(),                },            );            config                .mcp_servers                .set(servers)                .expect("test mcp servers should accept any configuration");        });    let test = builder.build(&server).await?;    wait_for_mcp_server(&test.codex, "rmcp").await?;    test.codex        .submit(Op::UserInput {            items: vec![UserInput::Text {                text: "Find the rmcp echo tool and call it.".to_string(),                text_elements: Vec::new(),            }],            final_output_json_schema: None,            responsesapi_client_metadata: None,            additional_context: Default::default(),            thread_settings: Default::default(),        })        .await?;    let EventMsg::McpToolCallEnd(end) = wait_for_event(&test.codex, |event| {        matches!(event, EventMsg::McpToolCallEnd(_))    })    .await    else {        unreachable!("event guard guarantees McpToolCallEnd");    };    assert_eq!(end.call_id, tool_call_id);    assert!(!end.is_success());    let tool_error = end        .result        .as_ref()        .expect_err("rmcp echo error should stay in the MCP result");    assert!(        tool_error.contains("tool call error:")            && tool_error.contains("missing field")            && tool_error.contains("message"),        "MCP invocation should report the execution failure: {tool_error}"    );    wait_for_event(&test.codex, |event| {        matches!(event, EventMsg::TurnComplete(_))    })    .await;    let requests = mock.requests();    assert_eq!(requests.len(), 3);    let first_request_tools = tool_names(&requests[0].body_json());    assert!(        first_request_tools            .iter()            .any(|name| name == TOOL_SEARCH_TOOL_NAME),        "first request should advertise tool_search: {first_request_tools:?}"    );    assert!(        !first_request_tools.iter().any(|name| name == "mcp__rmcp"),        "deferred rmcp namespace should not be directly exposed before search: {first_request_tools:?}"    );    assert!(        tool_search_output_has_namespace_child(&requests[1], search_call_id, "mcp__rmcp", "echo"),        "tool_search should return the rmcp echo tool"    );    let output = requests[2].function_call_output(tool_call_id);    let output_text = match output.get("output") {        Some(Value::String(text)) => text.clone(),        Some(Value::Array(items)) => items            .iter()            .filter_map(|item| item.get("text").and_then(Value::as_str))            .collect::<Vec<_>>()            .join("\n"),        other => panic!("unexpected MCP error output payload: {other:?}"),    };    assert!(        output_text.contains("missing field") && output_text.contains("message"),        "MCP error output should be model visible: {output_text}"    );    assert!(        !output_text.contains("unsupported call"),        "search-surfaced MCP calls should not fall through to unsupported call: {output_text}"    );    Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn tool_search_uses_non_app_mcp_server_instructions_as_namespace_description() -> Result<()> {    skip_if_no_network!(Ok(()));    let server = start_mock_server().await;    let apps_server = AppsTestServer::mount_searchable(&server).await?;    let search_call_id = "tool-search-echo";    let mock = mount_sse_sequence(        &server,        vec![            sse(vec![                ev_response_created("resp-1"),                ev_tool_search_call(                    search_call_id,                    &json!({                        "query": "Echo back the provided message and include environment data.",                        "limit": 8,                    }),                ),                ev_completed("resp-1"),            ]),            sse(vec![                ev_response_created("resp-2"),                ev_assistant_message("msg-1", "done"),                ev_completed("resp-2"),            ]),        ],    )    .await;    let rmcp_test_server_bin = stdio_server_bin()?;    let mut builder =        configured_builder(apps_server.chatgpt_base_url.clone()).with_config(move |config| {            let mut servers = config.mcp_servers.get().clone();            servers.insert(                "rmcp".to_string(),                McpServerConfig {                    transport: McpServerTransportConfig::Stdio {                        command: rmcp_test_server_bin,                        args: Vec::new(),                        env: None,                        env_vars: Vec::new(),                        cwd: None,                    },                    environment_id: "local".to_string(),                    enabled: true,                    required: false,                    disabled_reason: None,                    startup_timeout_sec: Some(Duration::from_secs(10)),                    tool_timeout_sec: None,                    default_tools_approval_mode: None,                    enabled_tools: Some(vec!["echo".to_string()]),                    disabled_tools: None,                    scopes: None,                    oauth: None,                    oauth_resource: None,                    supports_parallel_tool_calls: false,                    tools: HashMap::new(),                },            );            config                .mcp_servers                .set(servers)                .expect("test mcp servers should accept any configuration");        });    let test = builder.build(&server).await?;    wait_for_mcp_server(&test.codex, "rmcp").await?;    test.submit_turn_with_approval_and_permission_profile(        "Find the rmcp echo tool.",        AskForApproval::Never,        PermissionProfile::Disabled,    )    .await?;    let requests = mock.requests();    assert_eq!(requests.len(), 2);    let tools = tool_search_output_tools(&requests[1], search_call_id);    let rmcp_namespace = tools        .iter()        .find(|tool| tool.get("name").and_then(Value::as_str) == Some("mcp__rmcp"))        .expect("tool_search should return the rmcp namespace");    assert_eq!(        rmcp_namespace.get("description").and_then(Value::as_str),        Some("Use these tools to exercise the rmcp test server.")    );    Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn tool_search_matches_mcp_tools_by_distinct_name_description_and_schema_terms() -> Result<()>{    skip_if_no_network!(Ok(()));    let server = start_mock_server().await;    let apps_server = AppsTestServer::mount_searchable(&server).await?;    let query_cases = [        ("tool-search-mcp-raw-name", "calendar_timezone_option_99"),        ("tool-search-mcp-description", "uploaded document"),        ("tool-search-mcp-schema", "starts_at"),    ];    let mock = mount_sse_sequence(        &server,        vec![            sse(std::iter::once(ev_response_created("resp-1"))                .chain(query_cases.into_iter().map(|(call_id, query)| {                    ev_tool_search_call(                        call_id,                        &json!({                            "query": query,                            "limit": 8,                        }),                    )                }))                .chain(std::iter::once(ev_completed("resp-1")))                .collect()),            sse(vec![                ev_response_created("resp-2"),                ev_assistant_message("msg-1", "done"),                ev_completed("resp-2"),            ]),        ],    )    .await;    let mut builder = configured_builder(apps_server.chatgpt_base_url.clone());    let test = builder.build(&server).await?;    test.submit_turn_with_approval_and_permission_profile(        "Search for calendar tooling.",        AskForApproval::Never,        PermissionProfile::Disabled,    )    .await?;    let requests = mock.requests();    assert_eq!(requests.len(), 2);    assert!(        tool_search_output_has_namespace_child(            &requests[1],            "tool-search-mcp-raw-name",            SEARCH_CALENDAR_NAMESPACE,            "_timezone_option_99"        ),        "expected raw MCP tool-name query to surface _timezone_option_99: {:?}",        tool_search_output_tools(&requests[1], "tool-search-mcp-raw-name")    );    assert!(        tool_search_output_has_namespace_child(            &requests[1],            "tool-search-mcp-description",            SEARCH_CALENDAR_NAMESPACE,            "_extract_text"        ),        "expected MCP description query to surface _extract_text: {:?}",        tool_search_output_tools(&requests[1], "tool-search-mcp-description")    );    assert!(        tool_search_output_has_namespace_child(            &requests[1],            "tool-search-mcp-schema",            SEARCH_CALENDAR_NAMESPACE,            SEARCH_CALENDAR_CREATE_TOOL        ),        "expected MCP schema query to surface {SEARCH_CALENDAR_CREATE_TOOL}: {:?}",        tool_search_output_tools(&requests[1], "tool-search-mcp-schema")    );    Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn tool_search_matches_dynamic_tools_by_name_description_namespace_and_schema_terms()-> Result<()> {    skip_if_no_network!(Ok(()));    let server = start_mock_server().await;    let query_cases = [        ("tool-search-dynamic-name", "quasar_ping_beacon"),        ("tool-search-dynamic-spaces", "quasar ping beacon"),        ("tool-search-dynamic-description", "saffron metronome"),        ("tool-search-dynamic-namespace", "orbit_ops"),        ("tool-search-dynamic-schema", "chrono_spec"),    ];    let mock = mount_sse_sequence(        &server,        vec![            sse(std::iter::once(ev_response_created("resp-1"))                .chain(query_cases.into_iter().map(|(call_id, query)| {                    ev_tool_search_call(                        call_id,                        &json!({                            "query": query,                            "limit": 8,                        }),                    )                }))                .chain(std::iter::once(ev_completed("resp-1")))                .collect()),            sse(vec![                ev_response_created("resp-2"),                ev_assistant_message("msg-1", "done"),                ev_completed("resp-2"),            ]),        ],    )    .await;    let dynamic_tool = DynamicToolSpec::Namespace(DynamicToolNamespaceSpec {        name: "orbit_ops".to_string(),        description: "Orbital reminder operations.".to_string(),        tools: vec![DynamicToolNamespaceTool::Function(            DynamicToolFunctionSpec {                name: "quasar_ping_beacon".to_string(),                description: "Trigger the saffron metronome workflow for reminder follow-ups."                    .to_string(),                input_schema: json!({                    "type": "object",                    "properties": {                        "chrono_spec": { "type": "string" },                        "targetThreadId": { "type": "string" },                    },                    "required": ["chrono_spec"],                    "additionalProperties": false,                }),                defer_loading: true,            },        )],    });    let mut builder = test_codex().with_config(configure_search_capable_model);    let base_test = builder.build(&server).await?;    let new_thread = base_test        .thread_manager        .start_thread_with_tools(base_test.config.clone(), vec![dynamic_tool])        .await?;    let mut test = base_test;    test.codex = new_thread.thread;    test.session_configured = new_thread.session_configured;    test.codex        .submit(Op::UserInput {            items: vec![UserInput::Text {                text: "Search for the dynamic tool".to_string(),                text_elements: Vec::new(),            }],            final_output_json_schema: None,            responsesapi_client_metadata: None,            additional_context: Default::default(),            thread_settings: Default::default(),        })        .await?;    wait_for_event(&test.codex, |event| {        matches!(event, EventMsg::TurnComplete(_))    })    .await;    let requests = mock.requests();    assert_eq!(requests.len(), 2);    for call_id in [        "tool-search-dynamic-name",        "tool-search-dynamic-spaces",        "tool-search-dynamic-description",        "tool-search-dynamic-namespace",        "tool-search-dynamic-schema",    ] {        assert!(            tool_search_output_has_namespace_child(                &requests[1],                call_id,                "orbit_ops",                "quasar_ping_beacon"            ),            "expected query {call_id} to surface the quasar_ping_beacon tool: {:?}",            tool_search_output_tools(&requests[1], call_id)        );    }    Ok(())}