Codex Handbook
core/src/tools/spec_plan_tests.rs 1467 lines
use std::collections::BTreeMap;use std::sync::Arc;use codex_features::Feature;use codex_login::AuthManager;use codex_login::CodexAuth;use codex_mcp::ToolInfo;use codex_model_provider::create_model_provider;use codex_model_provider_info::AMAZON_BEDROCK_PROVIDER_ID;use codex_model_provider_info::ModelProviderInfo;use codex_protocol::config_types::WebSearchMode;use codex_protocol::dynamic_tools::DynamicToolSpec;use codex_protocol::openai_models::ApplyPatchToolType;use codex_protocol::openai_models::ConfigShellToolType;use codex_protocol::openai_models::InputModality;use codex_protocol::openai_models::ToolMode;use codex_protocol::openai_models::WebSearchToolType;use codex_protocol::protocol::SessionSource;use codex_protocol::protocol::SubAgentSource;use codex_tools::DiscoverablePluginInfo;use codex_tools::DiscoverableTool;use codex_tools::ResponsesApiNamespaceTool;use codex_tools::ResponsesApiTool;use codex_tools::ToolCall as ExtensionToolCall;use codex_tools::ToolExecutor;use codex_tools::ToolExposure;use codex_tools::ToolName;use codex_tools::ToolOutput;use codex_tools::ToolSpec;use pretty_assertions::assert_eq;use serde_json::json;use crate::session::tests::make_session_and_context;use crate::session::turn_context::TurnContext;use crate::tools::handlers::ToolSearchHandlerCache;use crate::tools::handlers::multi_agents_spec::MULTI_AGENT_V1_NAMESPACE;use crate::tools::router::ToolRouter;use crate::tools::router::ToolRouterParams;#[derive(Default)]struct ToolPlanInputs {    mcp_tools: Option<Vec<ToolInfo>>,    deferred_mcp_tools: Option<Vec<ToolInfo>>,    discoverable_tools: Option<Vec<DiscoverableTool>>,    extension_tool_executors: Vec<Arc<dyn ToolExecutor<ExtensionToolCall>>>,    dynamic_tools: Vec<DynamicToolSpec>,}struct ToolPlanProbe {    visible_specs: Vec<ToolSpec>,    visible_names: Vec<String>,    namespace_functions: BTreeMap<String, Vec<String>>,    registered_names: Vec<String>,    exposures: BTreeMap<String, ToolExposure>,}impl ToolPlanProbe {    fn from_router(router: ToolRouter) -> Self {        let visible_specs = router.model_visible_specs();        let visible_names = visible_specs            .iter()            .map(|spec| spec.name().to_string())            .collect::<Vec<_>>();        let namespace_functions = visible_specs            .iter()            .filter_map(|spec| match spec {                ToolSpec::Namespace(namespace) => Some((                    namespace.name.clone(),                    namespace                        .tools                        .iter()                        .map(|tool| match tool {                            ResponsesApiNamespaceTool::Function(tool) => tool.name.clone(),                        })                        .collect::<Vec<_>>(),                )),                ToolSpec::Function(_)                | ToolSpec::ToolSearch { .. }                | ToolSpec::ImageGeneration { .. }                | ToolSpec::WebSearch { .. }                | ToolSpec::Freeform(_) => None,            })            .collect::<BTreeMap<_, _>>();        let registered_tool_names = router.registered_tool_names_for_test();        let registered_names = registered_tool_names            .iter()            .map(ToString::to_string)            .collect::<Vec<_>>();        let exposures = registered_tool_names            .iter()            .filter_map(|name| {                router                    .tool_exposure_for_test(name)                    .map(|exposure| (name.to_string(), exposure))            })            .collect::<BTreeMap<_, _>>();        Self {            visible_specs,            visible_names,            namespace_functions,            registered_names,            exposures,        }    }    fn assert_visible_contains(&self, expected: &[&str]) {        for name in expected {            assert!(                self.visible_names.iter().any(|visible| visible == name),                "expected visible tool `{name}` in {:?}",                self.visible_names            );        }    }    fn assert_visible_lacks(&self, expected_absent: &[&str]) {        for name in expected_absent {            assert!(                !self.visible_names.iter().any(|visible| visible == name),                "expected visible tool `{name}` to be absent from {:?}",                self.visible_names            );        }    }    fn assert_registered_contains(&self, expected: &[&str]) {        for name in expected {            assert!(                self.registered_names                    .iter()                    .any(|registered| registered == name),                "expected registered tool `{name}` in {:?}",                self.registered_names            );        }    }    fn assert_registered_lacks(&self, expected_absent: &[&str]) {        for name in expected_absent {            assert!(                !self                    .registered_names                    .iter()                    .any(|registered| registered == name),                "expected registered tool `{name}` to be absent from {:?}",                self.registered_names            );        }    }    fn namespace_function_names(&self, namespace: &str) -> &[String] {        self.namespace_functions            .get(namespace)            .map_or(&[], Vec::as_slice)    }    fn visible_spec(&self, name: &str) -> &ToolSpec {        self.visible_specs            .iter()            .find(|spec| spec.name() == name)            .unwrap_or_else(|| panic!("expected visible spec `{name}` in {:?}", self.visible_names))    }    fn exposure(&self, name: &str) -> ToolExposure {        *self            .exposures            .get(name)            .unwrap_or_else(|| panic!("expected registered tool `{name}`"))    }}async fn probe_with(    configure_turn: impl FnOnce(&mut TurnContext),    inputs: ToolPlanInputs,) -> ToolPlanProbe {    let (_session, mut turn) = make_session_and_context().await;    configure_turn(&mut turn);    let router = ToolRouter::from_turn_context(        &turn,        ToolRouterParams {            mcp_tools: inputs.mcp_tools,            deferred_mcp_tools: inputs.deferred_mcp_tools,            discoverable_tools: inputs.discoverable_tools,            extension_tool_executors: inputs.extension_tool_executors,            dynamic_tools: inputs.dynamic_tools.as_slice(),        },        &Default::default(),    );    ToolPlanProbe::from_router(router)}async fn probe(configure_turn: impl FnOnce(&mut TurnContext)) -> ToolPlanProbe {    probe_with(configure_turn, ToolPlanInputs::default()).await}fn set_feature(turn: &mut TurnContext, feature: Feature, enabled: bool) {    if enabled {        turn.features            .enable(feature)            .expect("test feature should be enableable");    } else {        turn.features            .disable(feature)            .expect("test feature should be disableable");    }    let mut config = (*turn.config).clone();    if enabled {        config            .features            .enable(feature)            .expect("test feature should be enableable in config");    } else {        config            .features            .disable(feature)            .expect("test feature should be disableable in config");    }    turn.multi_agent_version = config.multi_agent_version_from_features();    turn.config = Arc::new(config);    turn.tool_mode = turn.model_info.tool_mode.unwrap_or_else(|| {        if turn.config.features.enabled(Feature::CodeModeOnly) {            ToolMode::CodeModeOnly        } else if turn.config.features.enabled(Feature::CodeMode) {            ToolMode::CodeMode        } else {            ToolMode::Direct        }    });}fn set_features(turn: &mut TurnContext, features: &[Feature]) {    for feature in features {        set_feature(turn, *feature, /*enabled*/ true);    }}fn zsh_fork_config_for_spec_plan_tests() -> codex_tools::ZshForkConfig {    let placeholder_exe = codex_utils_absolute_path::AbsolutePathBuf::try_from(        std::env::current_exe().expect("current exe path"),    )    .expect("current exe should be absolute");    // Spec planning only checks whether the shell mode is ZshFork. These paths    // are never executed, so use a stable absolute placeholder instead of    // depending on packaged zsh-fork artifacts in schema tests.    codex_tools::ZshForkConfig {        shell_zsh_path: placeholder_exe.clone(),        main_execve_wrapper_exe: placeholder_exe,    }}fn update_config(turn: &mut TurnContext, update: impl FnOnce(&mut crate::config::Config)) {    let mut config = (*turn.config).clone();    update(&mut config);    turn.config = Arc::new(config);}fn set_web_search_mode(turn: &mut TurnContext, mode: WebSearchMode) {    update_config(turn, |config| {        config            .web_search_mode            .set(mode)            .expect("test web search mode should be accepted");    });}fn use_chatgpt_auth(turn: &mut TurnContext) {    turn.auth_manager = Some(AuthManager::from_auth_for_testing(        CodexAuth::create_dummy_chatgpt_auth_for_testing(),    ));    turn.provider = create_model_provider(        turn.config.model_provider.clone(),        turn.auth_manager.clone(),    );}fn use_bedrock_provider(turn: &mut TurnContext) {    let provider_info = ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None);    update_config(turn, |config| {        config.model_provider_id = AMAZON_BEDROCK_PROVIDER_ID.to_string();        config.model_provider = provider_info.clone();    });    turn.provider = create_model_provider(provider_info, turn.auth_manager.clone());}struct WebRunExtensionTool;impl ToolExecutor<ExtensionToolCall> for WebRunExtensionTool {    fn tool_name(&self) -> ToolName {        ToolName::namespaced("web", "run")    }    fn spec(&self) -> ToolSpec {        ToolSpec::Namespace(codex_tools::ResponsesApiNamespace {            name: "web".to_string(),            description: "Test web namespace.".to_string(),            tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool {                name: "run".to_string(),                description: "Test standalone web search tool.".to_string(),                strict: false,                defer_loading: None,                parameters: codex_tools::JsonSchema::default(),                output_schema: None,            })],        })    }    fn handle(&self, _call: ExtensionToolCall) -> codex_tools::ToolExecutorFuture<'_> {        Box::pin(async {            Ok(Box::new(codex_tools::JsonToolOutput::new(json!({}))) as Box<dyn ToolOutput>)        })    }}struct DeferredExtensionTool;impl ToolExecutor<ExtensionToolCall> for DeferredExtensionTool {    fn tool_name(&self) -> ToolName {        ToolName::plain("extension_echo")    }    fn spec(&self) -> ToolSpec {        ToolSpec::Function(ResponsesApiTool {            name: "extension_echo".to_string(),            description: "Echoes arguments through an extension tool.".to_string(),            strict: true,            defer_loading: None,            parameters: codex_tools::JsonSchema::object(                BTreeMap::from([(                    "message".to_string(),                    codex_tools::JsonSchema::string(/*description*/ None),                )]),                Some(vec!["message".to_string()]),                Some(false.into()),            ),            output_schema: None,        })    }    fn exposure(&self) -> ToolExposure {        ToolExposure::Deferred    }    fn handle(&self, _call: ExtensionToolCall) -> codex_tools::ToolExecutorFuture<'_> {        Box::pin(async { panic!("spec planning should not execute extension tools") })    }}fn duplicate_primary_environment(turn: &mut TurnContext) {    let mut second_environment = turn.environments.turn_environments[0].clone();    second_environment.environment_id = "secondary".to_string();    turn.environments.turn_environments.push(second_environment);}fn mcp_tool(server: &str, namespace: &str, name: &str) -> ToolInfo {    ToolInfo {        server_name: server.to_string(),        supports_parallel_tool_calls: false,        server_origin: None,        callable_name: name.to_string(),        callable_namespace: namespace.to_string(),        namespace_description: Some(format!("Tools from {server}.")),        tool: rmcp::model::Tool::new(            name.to_string(),            format!("{name} test tool"),            Arc::new(rmcp::model::object(json!({                "type": "object",                "properties": {},                "additionalProperties": false,            }))),        ),        connector_id: None,        connector_name: None,        plugin_display_names: Vec::new(),    }}fn invalid_mcp_tool(server: &str, namespace: &str, name: &str) -> ToolInfo {    let mut tool = mcp_tool(server, namespace, name);    tool.tool.input_schema = Arc::new(rmcp::model::object(json!({        "type": "null",    })));    tool}fn dynamic_tool(namespace: Option<&str>, name: &str, defer_loading: bool) -> DynamicToolSpec {    let function = codex_protocol::dynamic_tools::DynamicToolFunctionSpec {        name: name.to_string(),        description: format!("{name} dynamic tool"),        input_schema: json!({            "type": "object",            "properties": {},            "additionalProperties": false,        }),        defer_loading,    };    match namespace {        Some(namespace) => {            DynamicToolSpec::Namespace(codex_protocol::dynamic_tools::DynamicToolNamespaceSpec {                name: namespace.to_string(),                description: format!("{namespace} dynamic tools"),                tools: vec![                    codex_protocol::dynamic_tools::DynamicToolNamespaceTool::Function(function),                ],            })        }        None => DynamicToolSpec::Function(function),    }}fn discoverable_plugin(id: &str, name: &str) -> DiscoverableTool {    DiscoverablePluginInfo {        id: id.to_string(),        remote_plugin_id: None,        name: name.to_string(),        description: Some(format!("{name} plugin")),        has_skills: false,        mcp_server_names: Vec::new(),        app_connector_ids: Vec::new(),    }    .into()}fn has_parameter(spec: &ToolSpec, parameter_name: &str) -> bool {    serde_json::to_value(spec)        .expect("tool spec should serialize")        .pointer(&format!("/parameters/properties/{parameter_name}"))        .is_some()}fn apply_patch_accepts_environment_id(spec: &ToolSpec) -> bool {    match spec {        ToolSpec::Freeform(tool) if tool.name == "apply_patch" => {            tool.format.definition.contains("Environment ID")        }        _ => false,    }}#[tokio::test]async fn request_user_input_tool_respects_experimental_config_gate() {    let enabled = probe(|_| {}).await;    enabled.assert_visible_contains(&["request_user_input"]);    enabled.assert_registered_contains(&["request_user_input"]);    assert_eq!(        enabled.exposure("request_user_input"),        ToolExposure::DirectModelOnly    );    let disabled = probe(|turn| {        update_config(turn, |config| {            config.experimental_request_user_input_enabled = false;        });    })    .await;    disabled.assert_visible_lacks(&["request_user_input"]);    disabled.assert_registered_lacks(&["request_user_input"]);}#[tokio::test]async fn request_user_input_stays_direct_in_code_mode_only() {    let plan = probe(|turn| {        set_features(turn, &[Feature::CodeMode, Feature::CodeModeOnly]);    })    .await;    plan.assert_visible_contains(&[        "request_user_input",        codex_code_mode::PUBLIC_TOOL_NAME,        codex_code_mode::WAIT_TOOL_NAME,    ]);    plan.assert_registered_contains(&["request_user_input"]);    assert_eq!(        plan.exposure("request_user_input"),        ToolExposure::DirectModelOnly    );    let ToolSpec::Freeform(exec) = plan.visible_spec(codex_code_mode::PUBLIC_TOOL_NAME) else {        panic!("expected code mode exec tool");    };    assert!(!exec.description.contains("request_user_input"));}#[tokio::test]async fn shell_family_registers_visible_unified_exec_and_hidden_legacy_shell() {    let plan = probe(|turn| {        set_features(turn, &[Feature::ShellTool, Feature::UnifiedExec]);        set_feature(turn, Feature::ShellZshFork, /*enabled*/ false);        turn.model_info.shell_type = ConfigShellToolType::ShellCommand;    })    .await;    plan.assert_visible_contains(&["exec_command", "write_stdin"]);    plan.assert_visible_lacks(&["shell_command"]);    plan.assert_registered_contains(&["exec_command", "write_stdin", "shell_command"]);    assert_eq!(plan.exposure("shell_command"), ToolExposure::Hidden);    assert!(has_parameter(plan.visible_spec("exec_command"), "shell"));}#[tokio::test]async fn shell_zsh_fork_stays_standalone_until_unified_exec_composition_is_enabled() {    let standalone = probe(|turn| {        set_features(turn, &[Feature::ShellTool, Feature::UnifiedExec]);        set_feature(turn, Feature::ShellZshFork, /*enabled*/ true);        set_feature(turn, Feature::UnifiedExecZshFork, /*enabled*/ false);        turn.model_info.shell_type = ConfigShellToolType::ShellCommand;    })    .await;    standalone.assert_visible_contains(&["shell_command"]);    standalone.assert_visible_lacks(&["exec_command", "write_stdin"]);    standalone.assert_registered_contains(&["shell_command"]);    standalone.assert_registered_lacks(&["exec_command", "write_stdin"]);    let composed = probe(|turn| {        set_features(            turn,            &[                Feature::ShellTool,                Feature::UnifiedExec,                Feature::ShellZshFork,                Feature::UnifiedExecZshFork,            ],        );        turn.model_info.shell_type = ConfigShellToolType::ShellCommand;    })    .await;    if codex_utils_pty::conpty_supported() {        composed.assert_visible_contains(&["exec_command", "write_stdin"]);        composed.assert_visible_lacks(&["shell_command"]);        composed.assert_registered_contains(&["exec_command", "write_stdin", "shell_command"]);        assert_eq!(composed.exposure("shell_command"), ToolExposure::Hidden);    } else {        composed.assert_visible_contains(&["shell_command"]);        composed.assert_visible_lacks(&["exec_command", "write_stdin"]);    }}#[tokio::test]async fn zsh_fork_unified_exec_hides_shell_parameter() {    if !codex_utils_pty::conpty_supported() {        return;    }    let plan = probe(|turn| {        set_features(            turn,            &[                Feature::ShellTool,                Feature::UnifiedExec,                Feature::ShellZshFork,                Feature::UnifiedExecZshFork,            ],        );        turn.unified_exec_shell_mode =            codex_tools::UnifiedExecShellMode::ZshFork(zsh_fork_config_for_spec_plan_tests());    })    .await;    plan.assert_visible_contains(&["exec_command", "write_stdin"]);    assert!(!has_parameter(plan.visible_spec("exec_command"), "shell"));}#[tokio::test]async fn zsh_fork_unified_exec_keeps_shell_parameter_when_remote_environment_available() {    if !codex_utils_pty::conpty_supported() {        return;    }    let plan = probe(|turn| {        set_features(            turn,            &[                Feature::ShellTool,                Feature::UnifiedExec,                Feature::ShellZshFork,                Feature::UnifiedExecZshFork,            ],        );        turn.unified_exec_shell_mode =            codex_tools::UnifiedExecShellMode::ZshFork(zsh_fork_config_for_spec_plan_tests());        let remote_cwd = turn            .environments            .primary()            .expect("primary environment")            .cwd()            .clone();        turn.environments.turn_environments.push(            crate::session::turn_context::TurnEnvironment::new(                "remote".to_string(),                Arc::new(                    codex_exec_server::Environment::create_for_tests(Some(                        "ws://127.0.0.1:1/remote-exec-server".to_string(),                    ))                    .expect("remote test environment"),                ),                remote_cwd,                /*shell*/ None,            ),        );    })    .await;    plan.assert_visible_contains(&["exec_command", "write_stdin"]);    assert!(has_parameter(plan.visible_spec("exec_command"), "shell"));    assert!(has_parameter(        plan.visible_spec("exec_command"),        "environment_id"    ));}#[tokio::test]async fn environment_count_controls_environment_backed_tools() {    let no_environment = probe(|turn| {        turn.environments.turn_environments.clear();        set_feature(turn, Feature::ShellTool, /*enabled*/ true);        turn.model_info.apply_patch_tool_type = Some(ApplyPatchToolType::Freeform);    })    .await;    no_environment.assert_visible_lacks(&[        "shell_command",        "exec_command",        "apply_patch",        "view_image",    ]);    no_environment.assert_registered_lacks(&[        "shell_command",        "exec_command",        "apply_patch",        "view_image",    ]);    let multiple_environments = probe(|turn| {        duplicate_primary_environment(turn);        set_feature(turn, Feature::ShellTool, /*enabled*/ true);        set_feature(turn, Feature::UnifiedExec, /*enabled*/ true);        turn.model_info.apply_patch_tool_type = Some(ApplyPatchToolType::Freeform);    })    .await;    multiple_environments.assert_visible_contains(&["exec_command", "apply_patch", "view_image"]);    assert!(has_parameter(        multiple_environments.visible_spec("exec_command"),        "environment_id"    ));    assert!(apply_patch_accepts_environment_id(        multiple_environments.visible_spec("apply_patch")    ));    assert!(has_parameter(        multiple_environments.visible_spec("view_image"),        "environment_id"    ));}#[tokio::test]async fn host_context_gates_agent_job_tools() {    let normal_agent_job = probe(|turn| {        set_feature(turn, Feature::SpawnCsv, /*enabled*/ true);    })    .await;    normal_agent_job.assert_visible_contains(&["spawn_agents_on_csv"]);    normal_agent_job.assert_visible_lacks(&["report_agent_job_result"]);    let worker_agent_job = probe(|turn| {        set_feature(turn, Feature::SpawnCsv, /*enabled*/ true);        turn.session_source =            SessionSource::SubAgent(SubAgentSource::Other("agent_job:42".to_string()));    })    .await;    worker_agent_job.assert_visible_contains(&["spawn_agents_on_csv", "report_agent_job_result"]);}#[tokio::test]async fn sleep_tool_follows_feature_gate() {    let disabled = probe(|turn| {        set_feature(turn, Feature::SleepTool, /*enabled*/ false);    })    .await;    disabled.assert_visible_lacks(&["sleep"]);    let enabled = probe(|turn| {        set_feature(turn, Feature::SleepTool, /*enabled*/ true);    })    .await;    enabled.assert_visible_contains(&["sleep"]);}#[tokio::test]async fn mcp_and_tool_search_follow_direct_and_deferred_tool_exposure() {    let direct_mcp = probe_with(        |_| {},        ToolPlanInputs {            mcp_tools: Some(vec![mcp_tool("direct", "mcp__direct", "lookup")]),            ..ToolPlanInputs::default()        },    )    .await;    direct_mcp.assert_visible_contains(&[        "list_mcp_resources",        "list_mcp_resource_templates",        "read_mcp_resource",    ]);    assert_eq!(        direct_mcp.namespace_function_names("mcp__direct"),        &["lookup".to_string()]    );    let searchable_mcp = ToolPlanInputs {        deferred_mcp_tools: Some(vec![mcp_tool("searchable", "mcp__searchable", "lookup")]),        ..ToolPlanInputs::default()    };    let missing_model_capability = probe_with(        |turn| {            turn.model_info.supports_search_tool = false;        },        ToolPlanInputs {            deferred_mcp_tools: searchable_mcp.deferred_mcp_tools.clone(),            ..ToolPlanInputs::default()        },    )    .await;    missing_model_capability.assert_visible_lacks(&["tool_search"]);    let missing_deferred_tools = probe(|turn| {        set_feature(turn, Feature::Collab, /*enabled*/ false);        turn.model_info.supports_search_tool = true;    })    .await;    missing_deferred_tools.assert_visible_lacks(&["tool_search"]);    missing_deferred_tools.assert_visible_lacks(&[        "list_mcp_resources",        "list_mcp_resource_templates",        "read_mcp_resource",    ]);    let bedrock_namespace_capability = probe_with(        |turn| {            turn.model_info.supports_search_tool = true;            use_bedrock_provider(turn);        },        ToolPlanInputs {            deferred_mcp_tools: searchable_mcp.deferred_mcp_tools.clone(),            ..ToolPlanInputs::default()        },    )    .await;    bedrock_namespace_capability.assert_visible_contains(&["tool_search"]);    let enabled = probe_with(        |turn| {            turn.model_info.supports_search_tool = true;        },        searchable_mcp,    )    .await;    enabled.assert_visible_contains(&["tool_search"]);    enabled.assert_registered_contains(&[        "tool_search",        &ToolName::namespaced("mcp__searchable", "lookup").to_string(),    ]);}#[tokio::test]async fn deferred_extension_tools_are_discoverable_with_tool_search() {    let plan = probe_with(        |turn| {            turn.model_info.supports_search_tool = true;        },        ToolPlanInputs {            extension_tool_executors: vec![Arc::new(DeferredExtensionTool)],            ..ToolPlanInputs::default()        },    )    .await;    plan.assert_visible_contains(&["tool_search"]);    plan.assert_visible_lacks(&["extension_echo"]);    plan.assert_registered_contains(&["extension_echo"]);    assert_eq!(plan.exposure("extension_echo"), ToolExposure::Deferred);}#[tokio::test]async fn tool_search_cache_rebuilds_when_deferred_sources_change() {    let cache = ToolSearchHandlerCache::default();    let (_session, mut first_turn) = make_session_and_context().await;    first_turn.model_info.supports_search_tool = true;    let first_router = ToolRouter::from_turn_context(        &first_turn,        ToolRouterParams {            mcp_tools: None,            deferred_mcp_tools: Some(vec![mcp_tool("first", "mcp__first", "lookup")]),            discoverable_tools: None,            extension_tool_executors: Vec::new(),            dynamic_tools: &[],        },        &cache,    );    let first_plan = ToolPlanProbe::from_router(first_router);    let (_session, mut second_turn) = make_session_and_context().await;    second_turn.model_info.supports_search_tool = true;    let second_router = ToolRouter::from_turn_context(        &second_turn,        ToolRouterParams {            mcp_tools: None,            deferred_mcp_tools: Some(vec![mcp_tool("second", "mcp__second", "lookup")]),            discoverable_tools: None,            extension_tool_executors: Vec::new(),            dynamic_tools: &[],        },        &cache,    );    let second_plan = ToolPlanProbe::from_router(second_router);    let ToolSpec::ToolSearch {        description: first_description,        ..    } = first_plan.visible_spec("tool_search")    else {        panic!("expected first tool_search spec");    };    assert!(first_description.contains("- first: Tools from first."));    assert!(!first_description.contains("- second: Tools from second."));    let ToolSpec::ToolSearch {        description: second_description,        ..    } = second_plan.visible_spec("tool_search")    else {        panic!("expected second tool_search spec");    };    assert!(second_description.contains("- second: Tools from second."));    assert!(!second_description.contains("- first: Tools from first."));}#[tokio::test]async fn invalid_mcp_tools_are_not_registered() {    let plan = probe_with(        |_| {},        ToolPlanInputs {            mcp_tools: Some(vec![invalid_mcp_tool("invalid", "mcp__invalid", "lookup")]),            ..ToolPlanInputs::default()        },    )    .await;    plan.assert_visible_lacks(&["mcp__invalid"]);    plan.assert_registered_lacks(&[&ToolName::namespaced("mcp__invalid", "lookup").to_string()]);}#[tokio::test]async fn request_plugin_install_requires_all_discovery_features_and_discoverable_tools() {    let discoverable_tools = Some(vec![discoverable_plugin("github", "GitHub")]);    for disabled_feature in [Feature::ToolSuggest, Feature::Apps, Feature::Plugins] {        let plan = probe_with(            |turn| {                set_features(                    turn,                    &[Feature::ToolSuggest, Feature::Apps, Feature::Plugins],                );                set_feature(turn, disabled_feature, /*enabled*/ false);            },            ToolPlanInputs {                discoverable_tools: discoverable_tools.clone(),                ..ToolPlanInputs::default()            },        )        .await;        plan.assert_visible_lacks(&[            "list_available_plugins_to_install",            "request_plugin_install",        ]);    }    let no_candidates = probe(|turn| {        set_features(            turn,            &[Feature::ToolSuggest, Feature::Apps, Feature::Plugins],        );    })    .await;    no_candidates.assert_visible_lacks(&[        "list_available_plugins_to_install",        "request_plugin_install",    ]);    let enabled = probe_with(        |turn| {            set_features(                turn,                &[Feature::ToolSuggest, Feature::Apps, Feature::Plugins],            );        },        ToolPlanInputs {            discoverable_tools,            ..ToolPlanInputs::default()        },    )    .await;    enabled.assert_visible_contains(&[        "list_available_plugins_to_install",        "request_plugin_install",    ]);}#[tokio::test]async fn install_suggestion_tools_stay_visible_without_tool_search() {    let plan = probe_with(        |turn| {            turn.model_info.supports_search_tool = false;            set_features(                turn,                &[Feature::ToolSuggest, Feature::Apps, Feature::Plugins],            );        },        ToolPlanInputs {            discoverable_tools: Some(vec![discoverable_plugin("github", "GitHub")]),            ..ToolPlanInputs::default()        },    )    .await;    plan.assert_visible_contains(&[        "list_available_plugins_to_install",        "request_plugin_install",    ]);    plan.assert_visible_lacks(&["tool_search"]);}#[tokio::test]async fn request_plugin_install_description_defers_inventory_to_list_tool() {    let plan = probe_with(        |turn| {            set_features(                turn,                &[Feature::ToolSuggest, Feature::Apps, Feature::Plugins],            );        },        ToolPlanInputs {            discoverable_tools: Some(vec![discoverable_plugin("github", "GitHub")]),            ..ToolPlanInputs::default()        },    )    .await;    let ToolSpec::Function(ResponsesApiTool {        description: list_description,        ..    }) = plan.visible_spec("list_available_plugins_to_install")    else {        panic!("expected list_available_plugins_to_install function spec");    };    assert!(list_description.contains(        "Returns known plugins and connectors that can be passed to `request_plugin_install`."    ));    let ToolSpec::Function(ResponsesApiTool {        description: request_description,        ..    }) = plan.visible_spec("request_plugin_install")    else {        panic!("expected request_plugin_install function spec");    };    assert!(request_description.contains(        "Use this tool only after `list_available_plugins_to_install` returns a plugin or connector that exactly matches the user's explicit request."    ));    assert!(!request_description.contains("github"));}#[tokio::test]async fn code_mode_only_exposes_code_executor_and_hides_nested_tools() {    let input = ToolPlanInputs {        dynamic_tools: vec![dynamic_tool(            Some("codex_app"),            "lookup",            /*defer_loading*/ false,        )],        ..ToolPlanInputs::default()    };    let plain = probe_with(|_| {}, input).await;    assert_eq!(        plain.namespace_function_names("codex_app"),        &["lookup".to_string()]    );    plain.assert_visible_lacks(&[        codex_code_mode::PUBLIC_TOOL_NAME,        codex_code_mode::WAIT_TOOL_NAME,    ]);    let code_mode_only = probe_with(        |turn| {            set_features(turn, &[Feature::CodeMode, Feature::CodeModeOnly]);        },        ToolPlanInputs {            dynamic_tools: vec![dynamic_tool(                Some("codex_app"),                "lookup",                /*defer_loading*/ false,            )],            ..ToolPlanInputs::default()        },    )    .await;    code_mode_only.assert_visible_contains(&[        codex_code_mode::PUBLIC_TOOL_NAME,        codex_code_mode::WAIT_TOOL_NAME,    ]);    assert_eq!(        code_mode_only.namespace_function_names("codex_app"),        Vec::<String>::new().as_slice()    );}#[tokio::test]async fn excluded_deferred_namespaces_do_not_enable_nested_tool_guidance() {    let plan = probe_with(        |turn| {            set_features(turn, &[Feature::CodeMode, Feature::CodeModeOnly]);            set_feature(turn, Feature::Collab, /*enabled*/ false);            turn.model_info.supports_search_tool = true;            update_config(turn, |config| {                config.code_mode.excluded_tool_namespaces = vec!["excluded".to_string()];            });        },        ToolPlanInputs {            dynamic_tools: vec![dynamic_tool(                Some("excluded"),                "lookup",                /*defer_loading*/ true,            )],            ..ToolPlanInputs::default()        },    )    .await;    let ToolSpec::Freeform(exec) = plan.visible_spec(codex_code_mode::PUBLIC_TOOL_NAME) else {        panic!("expected code mode exec tool");    };    assert!(        !exec            .description            .contains("Some deferred nested tools may be omitted")    );    plan.assert_registered_contains(&[        &ToolName::namespaced("excluded", "lookup").to_string(),        "tool_search",    ]);}#[tokio::test]async fn multi_agent_feature_selects_one_agent_tool_family() {    let v1 = probe(|turn| {        set_feature(turn, Feature::Collab, /*enabled*/ true);        set_feature(turn, Feature::MultiAgentV2, /*enabled*/ false);    })    .await;    v1.assert_visible_contains(&[MULTI_AGENT_V1_NAMESPACE]);    v1.assert_visible_lacks(&[        "spawn_agent",        "send_input",        "resume_agent",        "wait_agent",        "close_agent",        "interrupt_agent",        "send_message",        "followup_task",        "assign_task",        "list_agents",    ]);    assert_eq!(        v1.namespace_function_names(MULTI_AGENT_V1_NAMESPACE),        &[            "close_agent".to_string(),            "resume_agent".to_string(),            "send_input".to_string(),            "spawn_agent".to_string(),            "wait_agent".to_string(),        ]    );    let ToolSpec::Namespace(namespace) = v1.visible_spec(MULTI_AGENT_V1_NAMESPACE) else {        panic!("expected v1 multi-agent namespace");    };    let Some(ResponsesApiNamespaceTool::Function(spawn_agent)) =        namespace.tools.iter().find(|tool| {            matches!(                tool,                ResponsesApiNamespaceTool::Function(tool) if tool.name == "spawn_agent"            )        })    else {        panic!("expected v1 spawn_agent function");    };    let properties = spawn_agent        .parameters        .properties        .as_ref()        .expect("spawn_agent should use object params");    for property in ["agent_type", "model", "reasoning_effort", "service_tier"] {        assert!(            properties.contains_key(property),            "expected v1 spawn_agent to expose `{property}`"        );    }    let v2 = probe(|turn| {        set_feature(turn, Feature::MultiAgentV2, /*enabled*/ true);        update_config(turn, |config| {            config.multi_agent_v2.max_concurrent_threads_per_session = 17;        });    })    .await;    v2.assert_visible_contains(&[        "spawn_agent",        "send_message",        "followup_task",        "wait_agent",        "interrupt_agent",        "list_agents",    ]);    v2.assert_visible_lacks(&["send_input", "resume_agent", "assign_task", "close_agent"]);    let spawn_agent_description = match v2.visible_spec("spawn_agent") {        ToolSpec::Function(tool) => tool.description.as_str(),        other => panic!("expected spawn_agent function spec, got {other:?}"),    };    assert!(!spawn_agent_description.contains("max_concurrent_threads_per_session"));    assert!(spawn_agent_description.contains(        "Note that passing `fork_turns=\"none\"` will not pass any surrounding context to the spawned subagent"    ));    let direct_model_only = probe(|turn| {        set_features(            turn,            &[                Feature::CodeMode,                Feature::CodeModeOnly,                Feature::MultiAgentV2,            ],        );        update_config(turn, |config| {            config.multi_agent_v2.non_code_mode_only = true;        });    })    .await;    direct_model_only.assert_visible_contains(&["spawn_agent", "send_message", "wait_agent"]);    assert_eq!(        direct_model_only.exposure("spawn_agent"),        ToolExposure::DirectModelOnly    );}#[tokio::test]async fn multi_agent_v2_message_schemas_are_encrypted() {    let plan = probe(|turn| {        set_feature(turn, Feature::MultiAgentV2, /*enabled*/ true);    })    .await;    for tool_name in ["spawn_agent", "send_message", "followup_task"] {        let ToolSpec::Function(tool) = plan.visible_spec(tool_name) else {            panic!("expected {tool_name} function spec");        };        let properties = tool            .parameters            .properties            .as_ref()            .expect("tool should use object params");        assert_eq!(            properties                .get("message")                .and_then(|schema| schema.encrypted),            Some(true)        );    }}#[tokio::test]async fn tool_mode_selector_overrides_feature_flags() {    let direct = probe(|turn| {        set_features(turn, &[Feature::CodeMode, Feature::CodeModeOnly]);        turn.model_info.tool_mode = Some(ToolMode::Direct);        turn.tool_mode = ToolMode::Direct;    })    .await;    direct.assert_visible_lacks(&[        codex_code_mode::PUBLIC_TOOL_NAME,        codex_code_mode::WAIT_TOOL_NAME,    ]);}#[tokio::test]async fn v1_multi_agent_tools_defer_when_tool_search_available() {    let plan = probe(|turn| {        turn.model_info.supports_search_tool = true;        set_feature(turn, Feature::Collab, /*enabled*/ true);        set_feature(turn, Feature::MultiAgentV2, /*enabled*/ false);    })    .await;    plan.assert_visible_contains(&["tool_search"]);    plan.assert_visible_lacks(&[        "spawn_agent",        "send_input",        "resume_agent",        "wait_agent",        "close_agent",        "interrupt_agent",    ]);    for tool_name in [        "spawn_agent",        "send_input",        "resume_agent",        "wait_agent",        "close_agent",    ] {        let namespaced_tool_name = ToolName::namespaced(MULTI_AGENT_V1_NAMESPACE, tool_name);        let namespaced_tool_name = namespaced_tool_name.to_string();        assert!(            plan.registered_names.contains(&namespaced_tool_name),            "expected namespaced runtime for {tool_name}"        );        assert!(            !plan                .registered_names                .contains(&ToolName::plain(tool_name).to_string()),            "expected no plain runtime for deferred {tool_name}"        );        assert_eq!(plan.exposure(&namespaced_tool_name), ToolExposure::Deferred);    }    let ToolSpec::ToolSearch { description, .. } = plan.visible_spec("tool_search") else {        panic!("expected visible tool_search spec");    };    assert!(description.contains("- Multi-agent tools: Spawn and manage sub-agents."));}#[tokio::test]async fn multi_agent_v2_can_use_configured_tool_namespace() {    let namespaced = probe(|turn| {        set_feature(turn, Feature::MultiAgentV2, /*enabled*/ true);        update_config(turn, |config| {            config.multi_agent_v2.tool_namespace = Some("agents".to_string());        });    })    .await;    namespaced.assert_visible_contains(&["agents"]);    namespaced.assert_visible_lacks(&["assign_task"]);    assert!(        !namespaced            .registered_names            .contains(&ToolName::namespaced("agents", "assign_task").to_string()),        "expected no namespaced runtime for assign_task"    );    assert!(        !namespaced            .namespace_function_names("agents")            .iter()            .any(|name| name == "assign_task"),        "expected assign_task to be absent from agents namespace"    );    for tool_name in [        "spawn_agent",        "send_message",        "followup_task",        "wait_agent",        "interrupt_agent",        "list_agents",    ] {        namespaced.assert_visible_lacks(&[tool_name]);        assert!(            namespaced                .registered_names                .contains(&ToolName::namespaced("agents", tool_name).to_string()),            "expected namespaced runtime for {tool_name}"        );        assert!(            !namespaced                .registered_names                .contains(&ToolName::plain(tool_name).to_string()),            "expected no plain runtime for {tool_name}"        );        assert!(            namespaced                .namespace_function_names("agents")                .iter()                .any(|name| name == tool_name),            "expected {tool_name} in agents namespace"        );    }}#[tokio::test]async fn multi_agent_v2_namespace_is_supported_by_bedrock_provider() {    let plan = probe(|turn| {        set_feature(turn, Feature::MultiAgentV2, /*enabled*/ true);        update_config(turn, |config| {            config.multi_agent_v2.tool_namespace = Some("agents".to_string());        });        use_bedrock_provider(turn);    })    .await;    plan.assert_visible_contains(&["agents"]);    plan.assert_visible_lacks(&["spawn_agent", "send_message", "list_agents"]);    assert!(        !plan            .registered_names            .contains(&ToolName::plain("spawn_agent").to_string())    );    assert!(        plan.registered_names            .contains(&ToolName::namespaced("agents", "spawn_agent").to_string())    );}#[tokio::test]async fn code_mode_only_can_expose_namespaced_multi_agent_v2_as_normal_tools() {    let plan = probe(|turn| {        set_features(            turn,            &[                Feature::CodeMode,                Feature::CodeModeOnly,                Feature::MultiAgentV2,            ],        );        update_config(turn, |config| {            config.multi_agent_v2.non_code_mode_only = true;            config.multi_agent_v2.tool_namespace = Some("agents".to_string());        });    })    .await;    assert_eq!(        plan.visible_names,        vec![            "exec",            "wait",            "request_user_input",            "agents",            // Hosted Responses tools.            "web_search",        ]    );    assert!(        !plan            .namespace_function_names("agents")            .iter()            .any(|name| name == "assign_task"),        "expected assign_task to be absent from agents namespace"    );    for tool_name in [        "spawn_agent",        "send_message",        "followup_task",        "wait_agent",        "interrupt_agent",        "list_agents",    ] {        assert!(            plan.namespace_function_names("agents")                .iter()                .any(|name| name == tool_name),            "expected {tool_name} in agents namespace"        );    }}#[tokio::test]async fn hosted_tools_follow_provider_auth_model_and_config_gates() {    let api_key_auth = probe(|turn| {        set_feature(turn, Feature::ImageGeneration, /*enabled*/ true);        turn.model_info.input_modalities = vec![InputModality::Image];    })    .await;    api_key_auth.assert_visible_lacks(&["image_generation"]);    let image_generation = probe(|turn| {        use_chatgpt_auth(turn);        set_feature(turn, Feature::ImageGeneration, /*enabled*/ true);        turn.model_info.input_modalities = vec![InputModality::Image];    })    .await;    image_generation.assert_visible_contains(&["image_generation"]);    let extension_flag_without_imagegen_tool = probe(|turn| {        use_chatgpt_auth(turn);        set_feature(turn, Feature::ImageGeneration, /*enabled*/ true);        set_feature(turn, Feature::ImageGenExt, /*enabled*/ true);        turn.model_info.input_modalities = vec![InputModality::Image];    })    .await;    extension_flag_without_imagegen_tool.assert_visible_contains(&["image_generation"]);    extension_flag_without_imagegen_tool.assert_visible_lacks(&["image_gen"]);    let live_web_search = probe(|turn| {        set_web_search_mode(turn, WebSearchMode::Live);        turn.model_info.web_search_tool_type = WebSearchToolType::TextAndImage;    })    .await;    assert_eq!(        live_web_search.visible_spec("web_search"),        &ToolSpec::WebSearch {            external_web_access: Some(true),            filters: None,            user_location: None,            search_context_size: None,            search_content_types: Some(vec!["text".to_string(), "image".to_string()]),        }    );    let code_mode_only = probe(|turn| {        use_chatgpt_auth(turn);        set_features(turn, &[Feature::CodeModeOnly, Feature::MultiAgentV2]);        set_web_search_mode(turn, WebSearchMode::Live);        turn.model_info.input_modalities = vec![InputModality::Image];    })    .await;    assert_eq!(        code_mode_only.visible_names,        vec![            // Code-mode entrypoints.            codex_code_mode::PUBLIC_TOOL_NAME,            codex_code_mode::WAIT_TOOL_NAME,            "request_user_input",            // Multi-agent v2 tools.            "spawn_agent",            "send_message",            "followup_task",            "wait_agent",            "interrupt_agent",            "list_agents",            // Hosted Responses tools.            "web_search",            "image_generation",        ]    );    let standalone_web_search_without_web_run = probe(|turn| {        set_feature(turn, Feature::StandaloneWebSearch, /*enabled*/ true);        set_web_search_mode(turn, WebSearchMode::Live);    })    .await;    standalone_web_search_without_web_run.assert_visible_contains(&["web_search"]);    let standalone_web_search = probe_with(        |turn| {            set_feature(turn, Feature::StandaloneWebSearch, /*enabled*/ true);            set_web_search_mode(turn, WebSearchMode::Live);        },        ToolPlanInputs {            extension_tool_executors: vec![Arc::new(WebRunExtensionTool)],            ..Default::default()        },    )    .await;    standalone_web_search.assert_visible_lacks(&["web_search"]);    let unsupported_provider = probe(|turn| {        set_web_search_mode(turn, WebSearchMode::Live);        use_bedrock_provider(turn);    })    .await;    unsupported_provider.assert_visible_lacks(&["web_search"]);}