diff --git a/codex-rs/ext/plugin-installs/src/lib.rs b/codex-rs/ext/plugin-installs/src/lib.rs index 0e42665a1b..141881940f 100644 --- a/codex-rs/ext/plugin-installs/src/lib.rs +++ b/codex-rs/ext/plugin-installs/src/lib.rs @@ -1,6 +1,9 @@ mod domain; +mod spec; +mod validation; pub const REQUEST_PLUGIN_INSTALLS_TOOL_NAME: &str = "request_plugin_installs"; +pub(crate) const MAX_REQUEST_PLUGIN_INSTALLS_ENTRIES: usize = 16; pub use domain::REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE; pub use domain::REQUEST_PLUGIN_INSTALL_PERSIST_KEY; @@ -14,3 +17,7 @@ pub use domain::RequestPluginInstallsResult; pub use domain::all_requested_connectors_picked_up; pub use domain::build_request_plugin_installs_elicitation_request; pub use domain::verified_connector_install_completed; +pub use spec::ToolSuggestPresentation; +pub use spec::create_request_plugin_installs_tool; +pub use spec::create_request_plugin_installs_tool_for_tui; +pub use validation::request_plugin_install_picker_completed; diff --git a/codex-rs/ext/plugin-installs/src/spec.rs b/codex-rs/ext/plugin-installs/src/spec.rs new file mode 100644 index 0000000000..d99687261f --- /dev/null +++ b/codex-rs/ext/plugin-installs/src/spec.rs @@ -0,0 +1,199 @@ +use codex_tools::JsonSchema; +use codex_tools::LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME; +use codex_tools::ResponsesApiTool; +use codex_tools::ToolSpec; +use serde_json::json; +use std::collections::BTreeMap; + +use crate::MAX_REQUEST_PLUGIN_INSTALLS_ENTRIES; +use crate::REQUEST_PLUGIN_INSTALLS_TOOL_NAME; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ToolSuggestPresentation { + ListTool, + RecommendationContext, +} + +pub fn create_request_plugin_installs_tool(presentation: ToolSuggestPresentation) -> ToolSpec { + let description = request_plugin_installs_description( + presentation, + RequestPluginInstallsSchema::MultipleEntries, + ); + + ToolSpec::Function(ResponsesApiTool { + name: REQUEST_PLUGIN_INSTALLS_TOOL_NAME.to_string(), + description, + strict: false, + defer_loading: None, + parameters: picker_schema(), + output_schema: None, + }) +} + +pub fn create_request_plugin_installs_tool_for_tui( + presentation: ToolSuggestPresentation, +) -> ToolSpec { + let description = + request_plugin_installs_description(presentation, RequestPluginInstallsSchema::SingleEntry); + + ToolSpec::Function(ResponsesApiTool { + name: REQUEST_PLUGIN_INSTALLS_TOOL_NAME.to_string(), + description, + strict: false, + defer_loading: None, + parameters: single_entry_picker_schema(), + output_schema: None, + }) +} + +#[derive(Clone, Copy)] +enum RequestPluginInstallsSchema { + SingleEntry, + MultipleEntries, +} + +fn request_plugin_installs_description( + presentation: ToolSuggestPresentation, + schema: RequestPluginInstallsSchema, +) -> String { + match (presentation, schema) { + (ToolSuggestPresentation::ListTool, RequestPluginInstallsSchema::SingleEntry) => format!( + "# Request plugin/connector install\n\nUse this tool only after `{LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}` returns a connector that exactly matches the user's explicit request.\n\nDo not use it for adjacent capabilities, broad recommendations, or tools that merely seem useful. Make one call with exactly one `entries` item. Pass only exact `tool_type` and `tool_id` values returned by `{LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}`; Codex resolves picker labels and metadata from that known tool list.\n\nWhen this tool returns, the user-visible install picker has resolved and is no longer visible.\n\nIMPORTANT: DO NOT call this tool in parallel with other tools." + ), + (ToolSuggestPresentation::ListTool, RequestPluginInstallsSchema::MultipleEntries) => format!( + "# Request plugin/connector install\n\nUse this tool only after `{LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}` returns one or more plugins or connectors that exactly match the user's explicit request.\n\nDo not use it for adjacent capabilities, broad recommendations, or tools that merely seem useful. Make one call with `entries` for a flat list or `categories` when alternatives are organized by category; use one flat `entries` item for a single target, with at most {MAX_REQUEST_PLUGIN_INSTALLS_ENTRIES} entries total. Pass only exact `tool_type` and `tool_id` values returned by `{LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}`; Codex resolves picker labels and metadata from that known tool list.\n\nWhen this tool returns, the user-visible install picker has resolved and is no longer visible.\n\nIMPORTANT: DO NOT call this tool in parallel with other tools." + ), + ( + ToolSuggestPresentation::RecommendationContext, + RequestPluginInstallsSchema::SingleEntry, + ) => + "# Suggest a recommended plugin installation\n\nSuggest installing exactly one connector from the `` list when it would help with the user's current request.\n\nWhen this tool returns, the user-visible install picker has resolved and is no longer visible.\n\nIMPORTANT: DO NOT call this tool in parallel with other tools.".to_string(), + ( + ToolSuggestPresentation::RecommendationContext, + RequestPluginInstallsSchema::MultipleEntries, + ) => format!( + "# Suggest recommended plugin installations\n\nSuggest installing one or more plugins from the `` list when they would help with the user's current request. Make one call with `entries` for a flat list or `categories` when alternatives are organized by category; use one flat `entries` item for a single target, with at most {MAX_REQUEST_PLUGIN_INSTALLS_ENTRIES} entries total.\n\nWhen this tool returns, the user-visible install picker has resolved and is no longer visible.\n\nIMPORTANT: DO NOT call this tool in parallel with other tools." + ), + } +} + +fn picker_schema() -> JsonSchema { + JsonSchema::object( + BTreeMap::from([ + ("action_type".to_string(), install_action_schema()), + ( + "entries".to_string(), + JsonSchema::array( + picker_entry_schema(), + Some("Flat list of exact install candidates.".to_string()), + ), + ), + ( + "categories".to_string(), + JsonSchema::array( + picker_category_schema(), + Some("Grouped exact install candidates.".to_string()), + ), + ), + ]), + Some(vec!["action_type".to_string()]), + Some(false.into()), + ) +} + +fn single_entry_picker_schema() -> JsonSchema { + JsonSchema::object( + BTreeMap::from([ + ("action_type".to_string(), install_action_schema()), + ( + "entries".to_string(), + JsonSchema::array( + connector_picker_entry_schema(), + Some("Exactly one connector install candidate.".to_string()), + ), + ), + ]), + Some(vec!["action_type".to_string(), "entries".to_string()]), + Some(false.into()), + ) +} + +fn connector_picker_entry_schema() -> JsonSchema { + JsonSchema::object( + BTreeMap::from([ + ( + "tool_id".to_string(), + JsonSchema::string(Some( + "Exact connector id returned by list_available_plugins_to_install.".to_string(), + )), + ), + ( + "tool_type".to_string(), + JsonSchema::string_enum( + vec![json!("connector")], + Some( + "Use the connector type returned by list_available_plugins_to_install." + .to_string(), + ), + ), + ), + ]), + Some(vec!["tool_id".to_string(), "tool_type".to_string()]), + Some(false.into()), + ) +} + +fn picker_entry_schema() -> JsonSchema { + JsonSchema::object( + BTreeMap::from([ + ( + "tool_id".to_string(), + JsonSchema::string(Some( + "Exact connector or plugin id returned by list_available_plugins_to_install." + .to_string(), + )), + ), + ( + "tool_type".to_string(), + tool_type_schema("Type returned by list_available_plugins_to_install.".to_string()), + ), + ]), + Some(vec!["tool_id".to_string(), "tool_type".to_string()]), + Some(false.into()), + ) +} + +fn picker_category_schema() -> JsonSchema { + JsonSchema::object( + BTreeMap::from([ + ( + "title".to_string(), + JsonSchema::string(Some("User-facing category title.".to_string())), + ), + ( + "entries".to_string(), + JsonSchema::array( + picker_entry_schema(), + Some("Install candidates in this category.".to_string()), + ), + ), + ]), + Some(vec!["title".to_string(), "entries".to_string()]), + Some(false.into()), + ) +} + +fn tool_type_schema(description: String) -> JsonSchema { + JsonSchema::string_enum(vec![json!("connector"), json!("plugin")], Some(description)) +} + +fn install_action_schema() -> JsonSchema { + JsonSchema::string_enum( + vec![json!("install")], + Some("Suggested action for the tool. Use \"install\".".to_string()), + ) +} + +#[cfg(test)] +#[path = "spec_tests.rs"] +mod tests; diff --git a/codex-rs/ext/plugin-installs/src/spec_tests.rs b/codex-rs/ext/plugin-installs/src/spec_tests.rs new file mode 100644 index 0000000000..9aff8a679c --- /dev/null +++ b/codex-rs/ext/plugin-installs/src/spec_tests.rs @@ -0,0 +1,64 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn create_request_plugin_installs_tool_uses_expected_wire_shape() { + let expected_description = concat!( + "# Request plugin/connector install\n\n", + "Use this tool only after `list_available_plugins_to_install` returns one or more plugins or connectors that exactly match the user's explicit request.\n\n", + "Do not use it for adjacent capabilities, broad recommendations, or tools that merely seem useful. Make one call with `entries` for a flat list or `categories` when alternatives are organized by category; use one flat `entries` item for a single target, with at most 16 entries total. Pass only exact `tool_type` and `tool_id` values returned by `list_available_plugins_to_install`; Codex resolves picker labels and metadata from that known tool list.\n\n", + "When this tool returns, the user-visible install picker has resolved and is no longer visible.\n\n", + "IMPORTANT: DO NOT call this tool in parallel with other tools.", + ); + + assert_eq!( + create_request_plugin_installs_tool(ToolSuggestPresentation::ListTool), + ToolSpec::Function(ResponsesApiTool { + name: "request_plugin_installs".to_string(), + description: expected_description.to_string(), + strict: false, + defer_loading: None, + parameters: picker_schema(), + output_schema: None, + }) + ); +} + +#[test] +fn create_request_plugin_installs_tool_for_tui_uses_single_entry_shape() { + let expected_description = concat!( + "# Request plugin/connector install\n\n", + "Use this tool only after `list_available_plugins_to_install` returns a connector that exactly matches the user's explicit request.\n\n", + "Do not use it for adjacent capabilities, broad recommendations, or tools that merely seem useful. Make one call with exactly one `entries` item. Pass only exact `tool_type` and `tool_id` values returned by `list_available_plugins_to_install`; Codex resolves picker labels and metadata from that known tool list.\n\n", + "When this tool returns, the user-visible install picker has resolved and is no longer visible.\n\n", + "IMPORTANT: DO NOT call this tool in parallel with other tools.", + ); + + assert_eq!( + create_request_plugin_installs_tool_for_tui(ToolSuggestPresentation::ListTool), + ToolSpec::Function(ResponsesApiTool { + name: "request_plugin_installs".to_string(), + description: expected_description.to_string(), + strict: false, + defer_loading: None, + parameters: single_entry_picker_schema(), + output_schema: None, + }) + ); +} + +#[test] +fn plural_developer_recommendations_change_only_the_description() { + let mut expected = create_request_plugin_installs_tool(ToolSuggestPresentation::ListTool); + let recommendations = + create_request_plugin_installs_tool(ToolSuggestPresentation::RecommendationContext); + + let ToolSpec::Function(expected_function) = &mut expected else { + panic!("expected function tool specs"); + }; + expected_function.description = format!( + "# Suggest recommended plugin installations\n\nSuggest installing one or more plugins from the `` list when they would help with the user's current request. Make one call with `entries` for a flat list or `categories` when alternatives are organized by category; use one flat `entries` item for a single target, with at most {MAX_REQUEST_PLUGIN_INSTALLS_ENTRIES} entries total.\n\nWhen this tool returns, the user-visible install picker has resolved and is no longer visible.\n\nIMPORTANT: DO NOT call this tool in parallel with other tools." + ); + + assert_eq!(recommendations, expected); +} diff --git a/codex-rs/ext/plugin-installs/src/validation.rs b/codex-rs/ext/plugin-installs/src/validation.rs new file mode 100644 index 0000000000..5c7835b26d --- /dev/null +++ b/codex-rs/ext/plugin-installs/src/validation.rs @@ -0,0 +1,191 @@ +use std::collections::HashSet; + +use crate::MAX_REQUEST_PLUGIN_INSTALLS_ENTRIES; +use crate::RequestPluginInstallEntryResult; +use crate::RequestPluginInstallPickerCategory; +use crate::RequestPluginInstallPickerEntry; +use crate::RequestPluginInstallResolvedPickerEntry; +use crate::RequestPluginInstallsArgs; +use crate::ToolSuggestPresentation; +use codex_tools::DiscoverableTool; +use codex_tools::DiscoverableToolType; +use codex_tools::FunctionCallError; +use codex_tools::LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME; + +pub fn validate_request_plugin_install_picker_args( + args: &RequestPluginInstallsArgs, + discoverable_tools: &[DiscoverableTool], + app_server_client_name: Option<&str>, + presentation: ToolSuggestPresentation, +) -> Result, FunctionCallError> { + if app_server_client_name == Some("codex-tui") + && (args.categories.is_some() + || args + .entries + .as_ref() + .is_some_and(|entries| entries.len() != 1)) + { + return Err(FunctionCallError::RespondToModel( + "multi-tool install requests are not available in codex-tui yet".to_string(), + )); + } + + let mut resolved_entries = Vec::new(); + let mut seen_tools = HashSet::new(); + + match (&args.entries, &args.categories) { + (Some(entries), None) => { + if entries.len() > MAX_REQUEST_PLUGIN_INSTALLS_ENTRIES { + return Err(too_many_request_plugin_installs_entries_error()); + } + for entry in entries { + resolved_entries.push(validate_request_plugin_install_picker_entry( + /*category_index*/ None, + entry, + discoverable_tools, + app_server_client_name, + presentation, + &mut seen_tools, + )?); + } + if resolved_entries.is_empty() { + return Err(FunctionCallError::RespondToModel( + "picker install requests must include at least one entry".to_string(), + )); + } + } + (None, Some(categories)) => { + if categories + .iter() + .try_fold(0usize, |count, category| { + count.checked_add(category.entries.len()) + }) + .is_none_or(|count| count > MAX_REQUEST_PLUGIN_INSTALLS_ENTRIES) + { + return Err(too_many_request_plugin_installs_entries_error()); + } + validate_request_plugin_install_picker_categories( + categories, + discoverable_tools, + app_server_client_name, + presentation, + &mut seen_tools, + &mut resolved_entries, + )?; + } + _ => { + return Err(FunctionCallError::RespondToModel( + "picker install requests must include exactly one of entries or categories" + .to_string(), + )); + } + } + + Ok(resolved_entries) +} + +fn too_many_request_plugin_installs_entries_error() -> FunctionCallError { + FunctionCallError::RespondToModel(format!( + "picker install requests support at most {MAX_REQUEST_PLUGIN_INSTALLS_ENTRIES} entries" + )) +} + +fn validate_request_plugin_install_picker_categories( + categories: &[RequestPluginInstallPickerCategory], + discoverable_tools: &[DiscoverableTool], + app_server_client_name: Option<&str>, + presentation: ToolSuggestPresentation, + seen_tools: &mut HashSet<(DiscoverableToolType, String)>, + resolved_entries: &mut Vec, +) -> Result<(), FunctionCallError> { + if categories.is_empty() { + return Err(FunctionCallError::RespondToModel( + "picker install requests must include at least one category".to_string(), + )); + } + + for (category_index, category) in categories.iter().enumerate() { + if category.title.trim().is_empty() { + return Err(FunctionCallError::RespondToModel( + "categories[].title must not be empty".to_string(), + )); + } + if category.entries.is_empty() { + return Err(FunctionCallError::RespondToModel( + "categories[].entries must include at least one install candidate".to_string(), + )); + } + for entry in &category.entries { + resolved_entries.push(validate_request_plugin_install_picker_entry( + Some(category_index), + entry, + discoverable_tools, + app_server_client_name, + presentation, + seen_tools, + )?); + } + } + + Ok(()) +} + +fn validate_request_plugin_install_picker_entry( + category_index: Option, + entry: &RequestPluginInstallPickerEntry, + discoverable_tools: &[DiscoverableTool], + app_server_client_name: Option<&str>, + presentation: ToolSuggestPresentation, + seen_tools: &mut HashSet<(DiscoverableToolType, String)>, +) -> Result { + if entry.tool_id.trim().is_empty() { + return Err(FunctionCallError::RespondToModel( + "entries[].tool_id must not be empty".to_string(), + )); + } + if entry.tool_type == DiscoverableToolType::Plugin + && app_server_client_name == Some("codex-tui") + { + return Err(FunctionCallError::RespondToModel( + "plugin install requests are not available in codex-tui yet".to_string(), + )); + } + + if !seen_tools.insert((entry.tool_type, entry.tool_id.clone())) { + return Err(FunctionCallError::RespondToModel( + "picker install requests must not repeat a tool_type/tool_id pair".to_string(), + )); + } + + let tool = discoverable_tools + .iter() + .find(|tool| tool.tool_type() == entry.tool_type && tool.id() == entry.tool_id) + .ok_or_else(|| { + let source = match presentation { + ToolSuggestPresentation::ListTool => format!( + "the discoverable tools returned by {LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}" + ), + ToolSuggestPresentation::RecommendationContext => { + "the list".to_string() + } + }; + FunctionCallError::RespondToModel(format!( + "entries[].tool_id must match one of {source}" + )) + })?; + + Ok(RequestPluginInstallResolvedPickerEntry { + category_index, + tool: tool.clone(), + }) +} + +pub fn request_plugin_install_picker_completed( + entries: &[RequestPluginInstallEntryResult], +) -> bool { + entries.iter().any(|entry| entry.completed) +} + +#[cfg(test)] +#[path = "validation_tests.rs"] +mod tests; diff --git a/codex-rs/ext/plugin-installs/src/validation_tests.rs b/codex-rs/ext/plugin-installs/src/validation_tests.rs new file mode 100644 index 0000000000..ee225aef1f --- /dev/null +++ b/codex-rs/ext/plugin-installs/src/validation_tests.rs @@ -0,0 +1,223 @@ +use super::*; + +use codex_app_server_protocol::AppInfo; +use codex_tools::DiscoverableToolAction; +use pretty_assertions::assert_eq; + +use crate::RequestPluginInstallPickerCategory; +use crate::RequestPluginInstallPickerEntry; +use crate::RequestPluginInstallsArgs; + +#[test] +fn validate_request_plugin_install_picker_args_supports_categories() { + let args = RequestPluginInstallsArgs { + action_type: DiscoverableToolAction::Install, + entries: None, + categories: Some(vec![RequestPluginInstallPickerCategory { + title: "Calendar".to_string(), + entries: vec![RequestPluginInstallPickerEntry { + tool_id: "connector_calendar".to_string(), + tool_type: DiscoverableToolType::Connector, + }], + }]), + }; + let discoverable_tools = vec![connector_tool("connector_calendar", "Google Calendar")]; + + let resolved_entries = validate_request_plugin_install_picker_args( + &args, + &discoverable_tools, + /*app_server_client_name*/ None, + ToolSuggestPresentation::ListTool, + ) + .expect("categorized picker args"); + + assert_eq!(resolved_entries.len(), 1); + assert_eq!(resolved_entries[0].category_index, Some(0)); + assert_eq!(resolved_entries[0].tool.id(), "connector_calendar"); +} + +#[test] +fn validate_request_plugin_install_picker_args_rejects_mixed_sources() { + let entry = RequestPluginInstallPickerEntry { + tool_id: "connector_calendar".to_string(), + tool_type: DiscoverableToolType::Connector, + }; + let args = RequestPluginInstallsArgs { + action_type: DiscoverableToolAction::Install, + entries: Some(vec![entry]), + categories: Some(vec![RequestPluginInstallPickerCategory { + title: "Calendar".to_string(), + entries: vec![RequestPluginInstallPickerEntry { + tool_id: "connector_calendar".to_string(), + tool_type: DiscoverableToolType::Connector, + }], + }]), + }; + let discoverable_tools = vec![connector_tool("connector_calendar", "Google Calendar")]; + + assert_eq!( + validate_request_plugin_install_picker_args( + &args, + &discoverable_tools, + /*app_server_client_name*/ None, + ToolSuggestPresentation::ListTool, + ) + .expect_err("mixed picker args"), + FunctionCallError::RespondToModel( + "picker install requests must include exactly one of entries or categories".to_string(), + ), + ); +} + +#[test] +fn validate_request_plugin_install_picker_args_rejects_duplicate_tools() { + let entry = RequestPluginInstallPickerEntry { + tool_id: "connector_calendar".to_string(), + tool_type: DiscoverableToolType::Connector, + }; + let args = RequestPluginInstallsArgs { + action_type: DiscoverableToolAction::Install, + entries: None, + categories: Some(vec![ + RequestPluginInstallPickerCategory { + title: "Calendar".to_string(), + entries: vec![entry], + }, + RequestPluginInstallPickerCategory { + title: "Meetings".to_string(), + entries: vec![RequestPluginInstallPickerEntry { + tool_id: "connector_calendar".to_string(), + tool_type: DiscoverableToolType::Connector, + }], + }, + ]), + }; + let discoverable_tools = vec![connector_tool("connector_calendar", "Google Calendar")]; + + assert_eq!( + validate_request_plugin_install_picker_args( + &args, + &discoverable_tools, + /*app_server_client_name*/ None, + ToolSuggestPresentation::ListTool, + ) + .expect_err("duplicate picker tool"), + FunctionCallError::RespondToModel( + "picker install requests must not repeat a tool_type/tool_id pair".to_string(), + ), + ); +} + +#[test] +fn validate_request_plugin_install_picker_args_rejects_multi_tool_tui_requests() { + let args = RequestPluginInstallsArgs { + action_type: DiscoverableToolAction::Install, + entries: Some(vec![ + RequestPluginInstallPickerEntry { + tool_id: "connector_calendar".to_string(), + tool_type: DiscoverableToolType::Connector, + }, + RequestPluginInstallPickerEntry { + tool_id: "connector_gmail".to_string(), + tool_type: DiscoverableToolType::Connector, + }, + ]), + categories: None, + }; + let discoverable_tools = vec![ + connector_tool("connector_calendar", "Google Calendar"), + connector_tool("connector_gmail", "Gmail"), + ]; + + assert_eq!( + validate_request_plugin_install_picker_args( + &args, + &discoverable_tools, + Some("codex-tui"), + ToolSuggestPresentation::ListTool, + ) + .expect_err("multi-tool TUI request"), + FunctionCallError::RespondToModel( + "multi-tool install requests are not available in codex-tui yet".to_string(), + ), + ); +} + +#[test] +fn validate_request_plugin_install_picker_args_caps_entries() { + let entries = || { + (0..=MAX_REQUEST_PLUGIN_INSTALLS_ENTRIES) + .map(|index| RequestPluginInstallPickerEntry { + tool_id: format!("connector_{index}"), + tool_type: DiscoverableToolType::Connector, + }) + .collect() + }; + + for args in [ + RequestPluginInstallsArgs { + action_type: DiscoverableToolAction::Install, + entries: Some(entries()), + categories: None, + }, + RequestPluginInstallsArgs { + action_type: DiscoverableToolAction::Install, + entries: None, + categories: Some(vec![RequestPluginInstallPickerCategory { + title: "Connectors".to_string(), + entries: entries(), + }]), + }, + ] { + assert_eq!( + validate_request_plugin_install_picker_args( + &args, + &[], + /*app_server_client_name*/ None, + ToolSuggestPresentation::ListTool, + ) + .expect_err("oversized picker args"), + FunctionCallError::RespondToModel(format!( + "picker install requests support at most {MAX_REQUEST_PLUGIN_INSTALLS_ENTRIES} entries" + )), + ); + } +} + +#[test] +fn picker_completion_only_requires_one_completed_entry() { + let entries = vec![ + RequestPluginInstallEntryResult { + tool_type: DiscoverableToolType::Connector, + tool_id: "connector_salesforce".to_string(), + tool_name: "Salesforce".to_string(), + completed: true, + }, + RequestPluginInstallEntryResult { + tool_type: DiscoverableToolType::Connector, + tool_id: "connector_hubspot".to_string(), + tool_name: "HubSpot".to_string(), + completed: false, + }, + ]; + + assert!(request_plugin_install_picker_completed(&entries)); +} + +fn connector_tool(id: &str, name: &str) -> DiscoverableTool { + DiscoverableTool::Connector(Box::new(AppInfo { + id: id.to_string(), + name: name.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: false, + is_enabled: true, + plugin_display_names: Vec::new(), + })) +} diff --git a/codex-rs/tools/src/tool_discovery.rs b/codex-rs/tools/src/tool_discovery.rs index 2584d7f17a..311c496b68 100644 --- a/codex-rs/tools/src/tool_discovery.rs +++ b/codex-rs/tools/src/tool_discovery.rs @@ -14,7 +14,7 @@ pub struct ToolSearchSourceInfo { pub description: Option, } -#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, Deserialize, Hash, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum DiscoverableToolType { Connector,