From d1068e057a2fae156eef53fa446baae786255505 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 1 Apr 2026 20:49:15 -0700 Subject: [PATCH 1/2] Extract tool-suggest wire helpers into codex-tools (#16499) ## Why This is another straight-refactor step in the `codex-tools` migration. `core/src/tools/handlers/tool_suggest.rs` still owned request/response payload structs, elicitation metadata shaping, and connector-completion predicates that do not depend on `codex-core` session/runtime internals. Per the `AGENTS.md` guidance to keep shrinking `codex-core`, this moves that pure wire-format logic into `codex-rs/tools` so the core handler keeps only session orchestration, plugin/config refresh, and MCP cache updates. ## What changed - Added `codex-rs/tools/src/tool_suggest.rs` and exported its API from `codex-rs/tools/src/lib.rs`. - Moved `ToolSuggestArgs`, `ToolSuggestResult`, `ToolSuggestMeta`, `build_tool_suggestion_elicitation_request()`, `all_suggested_connectors_picked_up()`, and `verified_connector_suggestion_completed()` into `codex-tools`. - Rewired `core/src/tools/handlers/tool_suggest.rs` to consume those exports directly. - Ported the existing pure helper tests from `core/src/tools/handlers/tool_suggest_tests.rs` to `tools/src/tool_suggest_tests.rs` without adding new behavior coverage. ## Validation ```shell cargo test -p codex-tools cargo test -p codex-core tools::handlers::tool_suggest::tests just argument-comment-lint ``` --- .../core/src/tools/handlers/tool_suggest.rs | 120 +--------- .../src/tools/handlers/tool_suggest_tests.rs | 209 ------------------ codex-rs/tools/src/lib.rs | 8 + codex-rs/tools/src/tool_suggest.rs | 125 +++++++++++ codex-rs/tools/src/tool_suggest_tests.rs | 207 +++++++++++++++++ 5 files changed, 346 insertions(+), 323 deletions(-) create mode 100644 codex-rs/tools/src/tool_suggest.rs create mode 100644 codex-rs/tools/src/tool_suggest_tests.rs diff --git a/codex-rs/core/src/tools/handlers/tool_suggest.rs b/codex-rs/core/src/tools/handlers/tool_suggest.rs index f0bd5f2424..45b9fada27 100644 --- a/codex-rs/core/src/tools/handlers/tool_suggest.rs +++ b/codex-rs/core/src/tools/handlers/tool_suggest.rs @@ -1,22 +1,19 @@ -use std::collections::BTreeMap; use std::collections::HashSet; use async_trait::async_trait; use codex_app_server_protocol::AppInfo; -use codex_app_server_protocol::McpElicitationObjectType; -use codex_app_server_protocol::McpElicitationSchema; -use codex_app_server_protocol::McpServerElicitationRequest; -use codex_app_server_protocol::McpServerElicitationRequestParams; use codex_rmcp_client::ElicitationAction; use codex_tools::DiscoverableTool; use codex_tools::DiscoverableToolAction; use codex_tools::DiscoverableToolType; use codex_tools::TOOL_SUGGEST_TOOL_NAME; +use codex_tools::ToolSuggestArgs; +use codex_tools::ToolSuggestResult; +use codex_tools::all_suggested_connectors_picked_up; +use codex_tools::build_tool_suggestion_elicitation_request; use codex_tools::filter_tool_suggest_discoverable_tools_for_client; +use codex_tools::verified_connector_suggestion_completed; use rmcp::model::RequestId; -use serde::Deserialize; -use serde::Serialize; -use serde_json::json; use tracing::warn; use crate::connectors; @@ -31,39 +28,6 @@ use crate::tools::registry::ToolKind; pub struct ToolSuggestHandler; -const TOOL_SUGGEST_APPROVAL_KIND_VALUE: &str = "tool_suggestion"; - -#[derive(Debug, Deserialize)] -struct ToolSuggestArgs { - tool_type: DiscoverableToolType, - action_type: DiscoverableToolAction, - tool_id: String, - suggest_reason: String, -} - -#[derive(Debug, Serialize, PartialEq, Eq)] -struct ToolSuggestResult { - completed: bool, - user_confirmed: bool, - tool_type: DiscoverableToolType, - action_type: DiscoverableToolAction, - tool_id: String, - tool_name: String, - suggest_reason: String, -} - -#[derive(Debug, Serialize, PartialEq, Eq)] -struct ToolSuggestMeta<'a> { - codex_approval_kind: &'static str, - tool_type: DiscoverableToolType, - suggest_type: DiscoverableToolAction, - suggest_reason: &'a str, - tool_id: &'a str, - tool_name: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] - install_url: Option<&'a str>, -} - #[async_trait] impl ToolHandler for ToolSuggestHandler { type Output = FunctionToolOutput; @@ -147,6 +111,7 @@ impl ToolHandler for ToolSuggestHandler { let request_id = RequestId::String(format!("tool_suggestion_{call_id}").into()); let params = build_tool_suggestion_elicitation_request( + CODEX_APPS_MCP_SERVER_NAME, session.conversation_id.to_string(), turn.sub_id.clone(), &args, @@ -191,60 +156,6 @@ impl ToolHandler for ToolSuggestHandler { } } -fn build_tool_suggestion_elicitation_request( - thread_id: String, - turn_id: String, - args: &ToolSuggestArgs, - suggest_reason: &str, - tool: &DiscoverableTool, -) -> McpServerElicitationRequestParams { - let tool_name = tool.name().to_string(); - let install_url = tool.install_url().map(ToString::to_string); - let message = suggest_reason.to_string(); - - McpServerElicitationRequestParams { - thread_id, - turn_id: Some(turn_id), - server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), - request: McpServerElicitationRequest::Form { - meta: Some(json!(build_tool_suggestion_meta( - args.tool_type, - args.action_type, - suggest_reason, - tool.id(), - tool_name.as_str(), - install_url.as_deref(), - ))), - message, - requested_schema: McpElicitationSchema { - schema_uri: None, - type_: McpElicitationObjectType::Object, - properties: BTreeMap::new(), - required: None, - }, - }, - } -} - -fn build_tool_suggestion_meta<'a>( - tool_type: DiscoverableToolType, - action_type: DiscoverableToolAction, - suggest_reason: &'a str, - tool_id: &'a str, - tool_name: &'a str, - install_url: Option<&'a str>, -) -> ToolSuggestMeta<'a> { - ToolSuggestMeta { - codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE, - tool_type, - suggest_type: action_type, - suggest_reason, - tool_id, - tool_name, - install_url, - } -} - async fn verify_tool_suggestion_completed( session: &crate::codex::Session, turn: &crate::codex::TurnContext, @@ -327,25 +238,6 @@ async fn refresh_missing_suggested_connectors( } } -fn all_suggested_connectors_picked_up( - expected_connector_ids: &[String], - accessible_connectors: &[AppInfo], -) -> bool { - expected_connector_ids.iter().all(|connector_id| { - verified_connector_suggestion_completed(connector_id, accessible_connectors) - }) -} - -fn verified_connector_suggestion_completed( - tool_id: &str, - accessible_connectors: &[AppInfo], -) -> bool { - accessible_connectors - .iter() - .find(|connector| connector.id == tool_id) - .is_some_and(|connector| connector.is_accessible) -} - fn verified_plugin_suggestion_completed( tool_id: &str, config: &crate::config::Config, diff --git a/codex-rs/core/src/tools/handlers/tool_suggest_tests.rs b/codex-rs/core/src/tools/handlers/tool_suggest_tests.rs index 56089a1aa9..77ab793dc0 100644 --- a/codex-rs/core/src/tools/handlers/tool_suggest_tests.rs +++ b/codex-rs/core/src/tools/handlers/tool_suggest_tests.rs @@ -5,218 +5,9 @@ use crate::plugins::test_support::load_plugins_config; use crate::plugins::test_support::write_curated_plugin_sha; use crate::plugins::test_support::write_openai_curated_marketplace; use crate::plugins::test_support::write_plugins_feature_config; -use codex_app_server_protocol::AppInfo; -use codex_tools::DiscoverablePluginInfo; -use codex_tools::DiscoverableTool; -use codex_tools::DiscoverableToolAction; -use codex_tools::DiscoverableToolType; use codex_utils_absolute_path::AbsolutePathBuf; -use pretty_assertions::assert_eq; -use serde_json::json; -use std::collections::BTreeMap; use tempfile::tempdir; -#[test] -fn build_tool_suggestion_elicitation_request_uses_expected_shape() { - let args = ToolSuggestArgs { - tool_type: DiscoverableToolType::Connector, - action_type: DiscoverableToolAction::Install, - tool_id: "connector_2128aebfecb84f64a069897515042a44".to_string(), - suggest_reason: "Plan and reference events from your calendar".to_string(), - }; - let connector = DiscoverableTool::Connector(Box::new(AppInfo { - id: "connector_2128aebfecb84f64a069897515042a44".to_string(), - name: "Google Calendar".to_string(), - description: Some("Plan events and schedules.".to_string()), - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: Some( - "https://chatgpt.com/apps/google-calendar/connector_2128aebfecb84f64a069897515042a44" - .to_string(), - ), - is_accessible: false, - is_enabled: true, - plugin_display_names: Vec::new(), - })); - - let request = build_tool_suggestion_elicitation_request( - "thread-1".to_string(), - "turn-1".to_string(), - &args, - "Plan and reference events from your calendar", - &connector, - ); - - assert_eq!( - request, - McpServerElicitationRequestParams { - thread_id: "thread-1".to_string(), - turn_id: Some("turn-1".to_string()), - server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), - request: McpServerElicitationRequest::Form { - meta: Some(json!(ToolSuggestMeta { - codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE, - tool_type: DiscoverableToolType::Connector, - suggest_type: DiscoverableToolAction::Install, - suggest_reason: "Plan and reference events from your calendar", - tool_id: "connector_2128aebfecb84f64a069897515042a44", - tool_name: "Google Calendar", - install_url: Some( - "https://chatgpt.com/apps/google-calendar/connector_2128aebfecb84f64a069897515042a44" - ), - })), - message: "Plan and reference events from your calendar".to_string(), - requested_schema: McpElicitationSchema { - schema_uri: None, - type_: McpElicitationObjectType::Object, - properties: BTreeMap::new(), - required: None, - }, - }, - } - ); -} - -#[test] -fn build_tool_suggestion_elicitation_request_for_plugin_omits_install_url() { - let args = ToolSuggestArgs { - tool_type: DiscoverableToolType::Plugin, - action_type: DiscoverableToolAction::Install, - tool_id: "sample@openai-curated".to_string(), - suggest_reason: "Use the sample plugin's skills and MCP server".to_string(), - }; - let plugin = DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo { - id: "sample@openai-curated".to_string(), - name: "Sample Plugin".to_string(), - description: Some("Includes skills, MCP servers, and apps.".to_string()), - has_skills: true, - mcp_server_names: vec!["sample-docs".to_string()], - app_connector_ids: vec!["connector_calendar".to_string()], - })); - - let request = build_tool_suggestion_elicitation_request( - "thread-1".to_string(), - "turn-1".to_string(), - &args, - "Use the sample plugin's skills and MCP server", - &plugin, - ); - - assert_eq!( - request, - McpServerElicitationRequestParams { - thread_id: "thread-1".to_string(), - turn_id: Some("turn-1".to_string()), - server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), - request: McpServerElicitationRequest::Form { - meta: Some(json!(ToolSuggestMeta { - codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE, - tool_type: DiscoverableToolType::Plugin, - suggest_type: DiscoverableToolAction::Install, - suggest_reason: "Use the sample plugin's skills and MCP server", - tool_id: "sample@openai-curated", - tool_name: "Sample Plugin", - install_url: None, - })), - message: "Use the sample plugin's skills and MCP server".to_string(), - requested_schema: McpElicitationSchema { - schema_uri: None, - type_: McpElicitationObjectType::Object, - properties: BTreeMap::new(), - required: None, - }, - }, - } - ); -} - -#[test] -fn build_tool_suggestion_meta_uses_expected_shape() { - let meta = build_tool_suggestion_meta( - DiscoverableToolType::Connector, - DiscoverableToolAction::Install, - "Find and reference emails from your inbox", - "connector_68df038e0ba48191908c8434991bbac2", - "Gmail", - Some("https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2"), - ); - - assert_eq!( - meta, - ToolSuggestMeta { - codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE, - tool_type: DiscoverableToolType::Connector, - suggest_type: DiscoverableToolAction::Install, - suggest_reason: "Find and reference emails from your inbox", - tool_id: "connector_68df038e0ba48191908c8434991bbac2", - tool_name: "Gmail", - install_url: Some( - "https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2" - ), - } - ); -} - -#[test] -fn verified_connector_suggestion_completed_requires_accessible_connector() { - let accessible_connectors = vec![AppInfo { - id: "calendar".to_string(), - name: "Google Calendar".to_string(), - description: None, - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: None, - is_accessible: true, - is_enabled: false, - plugin_display_names: Vec::new(), - }]; - - assert!(verified_connector_suggestion_completed( - "calendar", - &accessible_connectors, - )); - assert!(!verified_connector_suggestion_completed( - "gmail", - &accessible_connectors, - )); -} - -#[test] -fn all_suggested_connectors_picked_up_requires_every_expected_connector() { - let accessible_connectors = vec![AppInfo { - id: "calendar".to_string(), - name: "Google Calendar".to_string(), - description: None, - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: None, - is_accessible: true, - is_enabled: false, - plugin_display_names: Vec::new(), - }]; - - assert!(all_suggested_connectors_picked_up( - &["calendar".to_string()], - &accessible_connectors, - )); - assert!(!all_suggested_connectors_picked_up( - &["calendar".to_string(), "gmail".to_string()], - &accessible_connectors, - )); -} - #[tokio::test] async fn verified_plugin_suggestion_completed_requires_installed_plugin() { let codex_home = tempdir().expect("tempdir should succeed"); diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index 45cfdc15c4..cb10ba27b0 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -19,6 +19,7 @@ mod tool_config; mod tool_definition; mod tool_discovery; mod tool_spec; +mod tool_suggest; mod utility_tool; mod view_image; @@ -112,6 +113,13 @@ pub use tool_spec::create_image_generation_tool; pub use tool_spec::create_local_shell_tool; pub use tool_spec::create_tools_json_for_responses_api; pub use tool_spec::create_web_search_tool; +pub use tool_suggest::TOOL_SUGGEST_APPROVAL_KIND_VALUE; +pub use tool_suggest::ToolSuggestArgs; +pub use tool_suggest::ToolSuggestMeta; +pub use tool_suggest::ToolSuggestResult; +pub use tool_suggest::all_suggested_connectors_picked_up; +pub use tool_suggest::build_tool_suggestion_elicitation_request; +pub use tool_suggest::verified_connector_suggestion_completed; pub use utility_tool::create_list_dir_tool; pub use utility_tool::create_test_sync_tool; pub use view_image::ViewImageToolOptions; diff --git a/codex-rs/tools/src/tool_suggest.rs b/codex-rs/tools/src/tool_suggest.rs new file mode 100644 index 0000000000..7a6e87ff84 --- /dev/null +++ b/codex-rs/tools/src/tool_suggest.rs @@ -0,0 +1,125 @@ +use std::collections::BTreeMap; + +use codex_app_server_protocol::AppInfo; +use codex_app_server_protocol::McpElicitationObjectType; +use codex_app_server_protocol::McpElicitationSchema; +use codex_app_server_protocol::McpServerElicitationRequest; +use codex_app_server_protocol::McpServerElicitationRequestParams; +use serde::Deserialize; +use serde::Serialize; +use serde_json::json; + +use crate::DiscoverableTool; +use crate::DiscoverableToolAction; +use crate::DiscoverableToolType; + +pub const TOOL_SUGGEST_APPROVAL_KIND_VALUE: &str = "tool_suggestion"; + +#[derive(Debug, Deserialize)] +pub struct ToolSuggestArgs { + pub tool_type: DiscoverableToolType, + pub action_type: DiscoverableToolAction, + pub tool_id: String, + pub suggest_reason: String, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +pub struct ToolSuggestResult { + pub completed: bool, + pub user_confirmed: bool, + pub tool_type: DiscoverableToolType, + pub action_type: DiscoverableToolAction, + pub tool_id: String, + pub tool_name: String, + pub suggest_reason: String, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +pub struct ToolSuggestMeta<'a> { + pub codex_approval_kind: &'static str, + pub tool_type: DiscoverableToolType, + pub suggest_type: DiscoverableToolAction, + pub suggest_reason: &'a str, + pub tool_id: &'a str, + pub tool_name: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + pub install_url: Option<&'a str>, +} + +pub fn build_tool_suggestion_elicitation_request( + server_name: &str, + thread_id: String, + turn_id: String, + args: &ToolSuggestArgs, + suggest_reason: &str, + tool: &DiscoverableTool, +) -> McpServerElicitationRequestParams { + let tool_name = tool.name().to_string(); + let install_url = tool.install_url().map(ToString::to_string); + let message = suggest_reason.to_string(); + + McpServerElicitationRequestParams { + thread_id, + turn_id: Some(turn_id), + server_name: server_name.to_string(), + request: McpServerElicitationRequest::Form { + meta: Some(json!(build_tool_suggestion_meta( + args.tool_type, + args.action_type, + suggest_reason, + tool.id(), + tool_name.as_str(), + install_url.as_deref(), + ))), + message, + requested_schema: McpElicitationSchema { + schema_uri: None, + type_: McpElicitationObjectType::Object, + properties: BTreeMap::new(), + required: None, + }, + }, + } +} + +pub fn all_suggested_connectors_picked_up( + expected_connector_ids: &[String], + accessible_connectors: &[AppInfo], +) -> bool { + expected_connector_ids.iter().all(|connector_id| { + verified_connector_suggestion_completed(connector_id, accessible_connectors) + }) +} + +pub fn verified_connector_suggestion_completed( + tool_id: &str, + accessible_connectors: &[AppInfo], +) -> bool { + accessible_connectors + .iter() + .find(|connector| connector.id == tool_id) + .is_some_and(|connector| connector.is_accessible) +} + +fn build_tool_suggestion_meta<'a>( + tool_type: DiscoverableToolType, + action_type: DiscoverableToolAction, + suggest_reason: &'a str, + tool_id: &'a str, + tool_name: &'a str, + install_url: Option<&'a str>, +) -> ToolSuggestMeta<'a> { + ToolSuggestMeta { + codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE, + tool_type, + suggest_type: action_type, + suggest_reason, + tool_id, + tool_name, + install_url, + } +} + +#[cfg(test)] +#[path = "tool_suggest_tests.rs"] +mod tests; diff --git a/codex-rs/tools/src/tool_suggest_tests.rs b/codex-rs/tools/src/tool_suggest_tests.rs new file mode 100644 index 0000000000..d3c37bf110 --- /dev/null +++ b/codex-rs/tools/src/tool_suggest_tests.rs @@ -0,0 +1,207 @@ +use super::*; +use crate::DiscoverablePluginInfo; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[test] +fn build_tool_suggestion_elicitation_request_uses_expected_shape() { + let args = ToolSuggestArgs { + tool_type: DiscoverableToolType::Connector, + action_type: DiscoverableToolAction::Install, + tool_id: "connector_2128aebfecb84f64a069897515042a44".to_string(), + suggest_reason: "Plan and reference events from your calendar".to_string(), + }; + let connector = DiscoverableTool::Connector(Box::new(AppInfo { + id: "connector_2128aebfecb84f64a069897515042a44".to_string(), + name: "Google Calendar".to_string(), + description: Some("Plan events and schedules.".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some( + "https://chatgpt.com/apps/google-calendar/connector_2128aebfecb84f64a069897515042a44" + .to_string(), + ), + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + })); + + let request = build_tool_suggestion_elicitation_request( + "codex-apps", + "thread-1".to_string(), + "turn-1".to_string(), + &args, + "Plan and reference events from your calendar", + &connector, + ); + + assert_eq!( + request, + McpServerElicitationRequestParams { + thread_id: "thread-1".to_string(), + turn_id: Some("turn-1".to_string()), + server_name: "codex-apps".to_string(), + request: McpServerElicitationRequest::Form { + meta: Some(json!(ToolSuggestMeta { + codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE, + tool_type: DiscoverableToolType::Connector, + suggest_type: DiscoverableToolAction::Install, + suggest_reason: "Plan and reference events from your calendar", + tool_id: "connector_2128aebfecb84f64a069897515042a44", + tool_name: "Google Calendar", + install_url: Some( + "https://chatgpt.com/apps/google-calendar/connector_2128aebfecb84f64a069897515042a44" + ), + })), + message: "Plan and reference events from your calendar".to_string(), + requested_schema: McpElicitationSchema { + schema_uri: None, + type_: McpElicitationObjectType::Object, + properties: BTreeMap::new(), + required: None, + }, + }, + }, + ); +} + +#[test] +fn build_tool_suggestion_elicitation_request_for_plugin_omits_install_url() { + let args = ToolSuggestArgs { + tool_type: DiscoverableToolType::Plugin, + action_type: DiscoverableToolAction::Install, + tool_id: "sample@openai-curated".to_string(), + suggest_reason: "Use the sample plugin's skills and MCP server".to_string(), + }; + let plugin = DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo { + id: "sample@openai-curated".to_string(), + name: "Sample Plugin".to_string(), + description: Some("Includes skills, MCP servers, and apps.".to_string()), + has_skills: true, + mcp_server_names: vec!["sample-docs".to_string()], + app_connector_ids: vec!["connector_calendar".to_string()], + })); + + let request = build_tool_suggestion_elicitation_request( + "codex-apps", + "thread-1".to_string(), + "turn-1".to_string(), + &args, + "Use the sample plugin's skills and MCP server", + &plugin, + ); + + assert_eq!( + request, + McpServerElicitationRequestParams { + thread_id: "thread-1".to_string(), + turn_id: Some("turn-1".to_string()), + server_name: "codex-apps".to_string(), + request: McpServerElicitationRequest::Form { + meta: Some(json!(ToolSuggestMeta { + codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE, + tool_type: DiscoverableToolType::Plugin, + suggest_type: DiscoverableToolAction::Install, + suggest_reason: "Use the sample plugin's skills and MCP server", + tool_id: "sample@openai-curated", + tool_name: "Sample Plugin", + install_url: None, + })), + message: "Use the sample plugin's skills and MCP server".to_string(), + requested_schema: McpElicitationSchema { + schema_uri: None, + type_: McpElicitationObjectType::Object, + properties: BTreeMap::new(), + required: None, + }, + }, + }, + ); +} + +#[test] +fn build_tool_suggestion_meta_uses_expected_shape() { + let meta = build_tool_suggestion_meta( + DiscoverableToolType::Connector, + DiscoverableToolAction::Install, + "Find and reference emails from your inbox", + "connector_68df038e0ba48191908c8434991bbac2", + "Gmail", + Some("https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2"), + ); + + assert_eq!( + meta, + ToolSuggestMeta { + codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE, + tool_type: DiscoverableToolType::Connector, + suggest_type: DiscoverableToolAction::Install, + suggest_reason: "Find and reference emails from your inbox", + tool_id: "connector_68df038e0ba48191908c8434991bbac2", + tool_name: "Gmail", + install_url: Some( + "https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2" + ), + }, + ); +} + +#[test] +fn verified_connector_suggestion_completed_requires_accessible_connector() { + let accessible_connectors = vec![AppInfo { + id: "calendar".to_string(), + name: "Google Calendar".to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: true, + is_enabled: false, + plugin_display_names: Vec::new(), + }]; + + assert!(verified_connector_suggestion_completed( + "calendar", + &accessible_connectors, + )); + assert!(!verified_connector_suggestion_completed( + "gmail", + &accessible_connectors, + )); +} + +#[test] +fn all_suggested_connectors_picked_up_requires_every_expected_connector() { + let accessible_connectors = vec![AppInfo { + id: "calendar".to_string(), + name: "Google Calendar".to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: true, + is_enabled: false, + plugin_display_names: Vec::new(), + }]; + + assert!(all_suggested_connectors_picked_up( + &["calendar".to_string()], + &accessible_connectors, + )); + assert!(!all_suggested_connectors_picked_up( + &["calendar".to_string(), "gmail".to_string()], + &accessible_connectors, + )); +} From 622273ed826c5d005fd740f580387cee74620083 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 1 Apr 2026 20:54:25 -0700 Subject: [PATCH 2/2] Extract request_user_input args normalization into codex-tools --- .../src/tools/handlers/request_user_input.rs | 32 +++++++------------ codex-rs/core/src/tools/spec.rs | 3 +- codex-rs/tools/src/lib.rs | 2 ++ codex-rs/tools/src/request_user_input_tool.rs | 23 ++++++++++++- 4 files changed, 38 insertions(+), 22 deletions(-) diff --git a/codex-rs/core/src/tools/handlers/request_user_input.rs b/codex-rs/core/src/tools/handlers/request_user_input.rs index a5d43d5748..f12defd0e7 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input.rs @@ -7,6 +7,8 @@ use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; use async_trait::async_trait; use codex_protocol::request_user_input::RequestUserInputArgs; +use codex_tools::REQUEST_USER_INPUT_TOOL_NAME; +use codex_tools::normalize_request_user_input_args; use codex_tools::request_user_input_unavailable_message; pub struct RequestUserInputHandler { @@ -33,9 +35,9 @@ impl ToolHandler for RequestUserInputHandler { let arguments = match payload { ToolPayload::Function { arguments } => arguments, _ => { - return Err(FunctionCallError::RespondToModel( - "request_user_input handler received unsupported payload".to_string(), - )); + return Err(FunctionCallError::RespondToModel(format!( + "{REQUEST_USER_INPUT_TOOL_NAME} handler received unsupported payload" + ))); } }; @@ -46,31 +48,21 @@ impl ToolHandler for RequestUserInputHandler { return Err(FunctionCallError::RespondToModel(message)); } - let mut args: RequestUserInputArgs = parse_arguments(&arguments)?; - let missing_options = args - .questions - .iter() - .any(|question| question.options.as_ref().is_none_or(Vec::is_empty)); - if missing_options { - return Err(FunctionCallError::RespondToModel( - "request_user_input requires non-empty options for every question".to_string(), - )); - } - for question in &mut args.questions { - question.is_other = true; - } + let args: RequestUserInputArgs = parse_arguments(&arguments)?; + let args = + normalize_request_user_input_args(args).map_err(FunctionCallError::RespondToModel)?; let response = session .request_user_input(turn.as_ref(), call_id, args) .await .ok_or_else(|| { - FunctionCallError::RespondToModel( - "request_user_input was cancelled before receiving a response".to_string(), - ) + FunctionCallError::RespondToModel(format!( + "{REQUEST_USER_INPUT_TOOL_NAME} was cancelled before receiving a response" + )) })?; let content = serde_json::to_string(&response).map_err(|err| { FunctionCallError::Fatal(format!( - "failed to serialize request_user_input response: {err}" + "failed to serialize {REQUEST_USER_INPUT_TOOL_NAME} response: {err}" )) })?; diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 289205eaf1..88ee0a6dea 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -15,6 +15,7 @@ use codex_protocol::openai_models::ApplyPatchToolType; use codex_protocol::openai_models::ConfigShellToolType; use codex_tools::CommandToolOptions; use codex_tools::DiscoverableTool; +use codex_tools::REQUEST_USER_INPUT_TOOL_NAME; use codex_tools::ShellToolOptions; use codex_tools::SpawnAgentToolOptions; use codex_tools::TOOL_SEARCH_DEFAULT_LIMIT; @@ -349,7 +350,7 @@ pub(crate) fn build_specs_with_discoverable_tools( /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, ); - builder.register_handler("request_user_input", request_user_input_handler); + builder.register_handler(REQUEST_USER_INPUT_TOOL_NAME, request_user_input_handler); } if config.request_permissions_tool_enabled { diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index cb10ba27b0..2e58fa1529 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -67,7 +67,9 @@ pub use mcp_resource_tool::create_read_mcp_resource_tool; pub use mcp_tool::mcp_call_tool_result_output_schema; pub use mcp_tool::parse_mcp_tool; pub use plan_tool::create_update_plan_tool; +pub use request_user_input_tool::REQUEST_USER_INPUT_TOOL_NAME; pub use request_user_input_tool::create_request_user_input_tool; +pub use request_user_input_tool::normalize_request_user_input_args; pub use request_user_input_tool::request_user_input_tool_description; pub use request_user_input_tool::request_user_input_unavailable_message; pub use responses_api::FreeformTool; diff --git a/codex-rs/tools/src/request_user_input_tool.rs b/codex-rs/tools/src/request_user_input_tool.rs index dd4f4f9954..77c5367384 100644 --- a/codex-rs/tools/src/request_user_input_tool.rs +++ b/codex-rs/tools/src/request_user_input_tool.rs @@ -3,8 +3,11 @@ use crate::ResponsesApiTool; use crate::ToolSpec; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::TUI_VISIBLE_COLLABORATION_MODES; +use codex_protocol::request_user_input::RequestUserInputArgs; use std::collections::BTreeMap; +pub const REQUEST_USER_INPUT_TOOL_NAME: &str = "request_user_input"; + pub fn create_request_user_input_tool(description: String) -> ToolSpec { let option_props = BTreeMap::from([ ( @@ -78,7 +81,7 @@ pub fn create_request_user_input_tool(description: String) -> ToolSpec { let properties = BTreeMap::from([("questions".to_string(), questions_schema)]); ToolSpec::Function(ResponsesApiTool { - name: "request_user_input".to_string(), + name: REQUEST_USER_INPUT_TOOL_NAME.to_string(), description, strict: false, defer_loading: None, @@ -105,6 +108,24 @@ pub fn request_user_input_unavailable_message( } } +pub fn normalize_request_user_input_args( + mut args: RequestUserInputArgs, +) -> Result { + let missing_options = args + .questions + .iter() + .any(|question| question.options.as_ref().is_none_or(Vec::is_empty)); + if missing_options { + return Err("request_user_input requires non-empty options for every question".to_string()); + } + + for question in &mut args.questions { + question.is_other = true; + } + + Ok(args) +} + pub fn request_user_input_tool_description(default_mode_request_user_input: bool) -> String { let allowed_modes = format_allowed_modes(default_mode_request_user_input); format!(