mirror of
https://github.com/openai/codex.git
synced 2026-09-05 15:18:41 +00:00
Remove singular plugin install tool
This commit is contained in:
@@ -1,420 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use codex_config::types::ToolSuggestDisabledTool;
|
||||
use codex_core_plugins::remote::REMOTE_GLOBAL_MARKETPLACE_NAME;
|
||||
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_rmcp_client::ElicitationAction;
|
||||
use codex_rmcp_client::ElicitationResponse;
|
||||
use codex_tools::DiscoverableTool;
|
||||
use codex_tools::DiscoverableToolAction;
|
||||
use codex_tools::DiscoverableToolType;
|
||||
use codex_tools::LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME;
|
||||
use codex_tools::REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE;
|
||||
use codex_tools::REQUEST_PLUGIN_INSTALL_PERSIST_KEY;
|
||||
use codex_tools::REQUEST_PLUGIN_INSTALL_TOOL_NAME;
|
||||
use codex_tools::RequestPluginInstallArgs;
|
||||
use codex_tools::RequestPluginInstallResult;
|
||||
use codex_tools::ToolName;
|
||||
use codex_tools::ToolSpec;
|
||||
use codex_tools::all_requested_connectors_picked_up;
|
||||
use codex_tools::build_request_plugin_install_elicitation_request;
|
||||
use codex_tools::filter_request_plugin_install_discoverable_tools_for_client;
|
||||
use codex_tools::verified_connector_install_completed;
|
||||
use rmcp::model::RequestId;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::config::edit::ConfigEdit;
|
||||
use crate::config::edit::ConfigEditsBuilder;
|
||||
use crate::connectors;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::context::boxed_tool_output;
|
||||
use crate::tools::handlers::parse_arguments;
|
||||
use crate::tools::handlers::request_plugin_install_spec::create_request_plugin_install_tool;
|
||||
use crate::tools::registry::CoreToolRuntime;
|
||||
use crate::tools::registry::ToolExecutor;
|
||||
use crate::tools::router::ToolSuggestPresentation;
|
||||
|
||||
#[derive(Debug, Deserialize, PartialEq, Eq)]
|
||||
struct RecommendedPluginInstallArgs {
|
||||
#[serde(alias = "tool_id")]
|
||||
plugin_id: String,
|
||||
suggest_reason: String,
|
||||
}
|
||||
|
||||
pub struct RequestPluginInstallHandler {
|
||||
discoverable_tools: Vec<DiscoverableTool>,
|
||||
presentation: ToolSuggestPresentation,
|
||||
}
|
||||
|
||||
impl RequestPluginInstallHandler {
|
||||
pub(crate) fn new(
|
||||
discoverable_tools: Vec<DiscoverableTool>,
|
||||
presentation: ToolSuggestPresentation,
|
||||
) -> Self {
|
||||
Self {
|
||||
discoverable_tools,
|
||||
presentation,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolExecutor<ToolInvocation> for RequestPluginInstallHandler {
|
||||
fn tool_name(&self) -> ToolName {
|
||||
ToolName::plain(REQUEST_PLUGIN_INSTALL_TOOL_NAME)
|
||||
}
|
||||
|
||||
fn spec(&self) -> ToolSpec {
|
||||
create_request_plugin_install_tool(self.presentation)
|
||||
}
|
||||
|
||||
fn supports_parallel_tool_calls(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
|
||||
Box::pin(self.handle_call(invocation))
|
||||
}
|
||||
}
|
||||
|
||||
impl RequestPluginInstallHandler {
|
||||
async fn handle_call(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
|
||||
let ToolInvocation {
|
||||
payload,
|
||||
session,
|
||||
turn,
|
||||
call_id,
|
||||
..
|
||||
} = invocation;
|
||||
|
||||
let arguments = match payload {
|
||||
ToolPayload::Function { arguments } => arguments,
|
||||
_ => {
|
||||
return Err(FunctionCallError::Fatal(format!(
|
||||
"{REQUEST_PLUGIN_INSTALL_TOOL_NAME} handler received unsupported payload"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let (requested_tool_id, requested_tool_type, suggest_reason) = match self.presentation {
|
||||
ToolSuggestPresentation::ListTool => {
|
||||
let args: RequestPluginInstallArgs = parse_arguments(&arguments)?;
|
||||
if args.action_type != DiscoverableToolAction::Install {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"plugin install requests currently support only action_type=\"install\""
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
(args.tool_id, Some(args.tool_type), args.suggest_reason)
|
||||
}
|
||||
ToolSuggestPresentation::RecommendationContext => {
|
||||
let args: RecommendedPluginInstallArgs = parse_arguments(&arguments)?;
|
||||
(args.plugin_id, None, args.suggest_reason)
|
||||
}
|
||||
};
|
||||
let suggest_reason = suggest_reason.trim();
|
||||
if suggest_reason.is_empty() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"suggest_reason must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if (requested_tool_type == Some(DiscoverableToolType::Plugin)
|
||||
|| self.presentation == ToolSuggestPresentation::RecommendationContext)
|
||||
&& turn.app_server_client_name.as_deref() == Some("codex-tui")
|
||||
{
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"plugin install requests are not available in codex-tui yet".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let discoverable_tools = filter_request_plugin_install_discoverable_tools_for_client(
|
||||
self.discoverable_tools.clone(),
|
||||
turn.app_server_client_name.as_deref(),
|
||||
);
|
||||
|
||||
let tool = discoverable_tools
|
||||
.into_iter()
|
||||
.find(|tool| {
|
||||
tool.id() == requested_tool_id
|
||||
&& match self.presentation {
|
||||
ToolSuggestPresentation::ListTool => {
|
||||
Some(tool.tool_type()) == requested_tool_type
|
||||
}
|
||||
ToolSuggestPresentation::RecommendationContext => {
|
||||
matches!(tool, DiscoverableTool::Plugin(_))
|
||||
}
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
let (argument_name, source) = match self.presentation {
|
||||
ToolSuggestPresentation::ListTool => (
|
||||
"tool_id",
|
||||
format!(
|
||||
"the discoverable tools returned by {LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}"
|
||||
),
|
||||
),
|
||||
ToolSuggestPresentation::RecommendationContext => (
|
||||
"plugin_id",
|
||||
"the entries in the <recommended_plugins> list".to_string(),
|
||||
),
|
||||
};
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"{argument_name} must match one of {source}"
|
||||
))
|
||||
})?;
|
||||
let tool_type = tool.tool_type();
|
||||
|
||||
let request_id = RequestId::String(format!("request_plugin_install_{call_id}").into());
|
||||
let params = build_request_plugin_install_elicitation_request(
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
session.thread_id.to_string(),
|
||||
turn.sub_id.clone(),
|
||||
suggest_reason,
|
||||
&tool,
|
||||
);
|
||||
let elicitation = session
|
||||
.request_mcp_server_elicitation(turn.as_ref(), request_id, params)
|
||||
.await;
|
||||
let response = elicitation.response;
|
||||
if let Some(response) = response.as_ref() {
|
||||
maybe_persist_disabled_install_request(&session, &turn, &tool, response).await;
|
||||
}
|
||||
let user_confirmed = response
|
||||
.as_ref()
|
||||
.is_some_and(|response| response.action == ElicitationAction::Accept);
|
||||
|
||||
let auth = session.services.auth_manager.auth().await;
|
||||
let completed = if user_confirmed {
|
||||
verify_request_plugin_install_completed(&session, &turn, &tool, auth.as_ref()).await
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if completed && let DiscoverableTool::Connector(connector) = &tool {
|
||||
session
|
||||
.merge_connector_selection(HashSet::from([connector.id.clone()]))
|
||||
.await;
|
||||
}
|
||||
|
||||
if elicitation.sent {
|
||||
let tool_type = match tool_type {
|
||||
DiscoverableToolType::Connector => "connector",
|
||||
DiscoverableToolType::Plugin => "plugin",
|
||||
};
|
||||
let response_action = match response.as_ref().map(|response| &response.action) {
|
||||
Some(ElicitationAction::Accept) => "accept",
|
||||
Some(ElicitationAction::Decline) => "decline",
|
||||
Some(ElicitationAction::Cancel) => "cancel",
|
||||
None => "unavailable",
|
||||
};
|
||||
turn.session_telemetry.record_plugin_install_suggestion(
|
||||
tool_type,
|
||||
tool.id(),
|
||||
tool.name(),
|
||||
response_action,
|
||||
user_confirmed,
|
||||
completed,
|
||||
);
|
||||
}
|
||||
|
||||
let content = serde_json::to_string(&RequestPluginInstallResult {
|
||||
completed,
|
||||
user_confirmed,
|
||||
tool_type,
|
||||
action_type: DiscoverableToolAction::Install,
|
||||
tool_id: tool.id().to_string(),
|
||||
tool_name: tool.name().to_string(),
|
||||
suggest_reason: suggest_reason.to_string(),
|
||||
})
|
||||
.map_err(|err| {
|
||||
FunctionCallError::Fatal(format!(
|
||||
"failed to serialize {REQUEST_PLUGIN_INSTALL_TOOL_NAME} response: {err}"
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(boxed_tool_output(FunctionToolOutput::from_text(
|
||||
content,
|
||||
Some(true),
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
impl CoreToolRuntime for RequestPluginInstallHandler {}
|
||||
|
||||
async fn maybe_persist_disabled_install_request(
|
||||
session: &crate::session::session::Session,
|
||||
turn: &crate::session::turn_context::TurnContext,
|
||||
tool: &DiscoverableTool,
|
||||
response: &ElicitationResponse,
|
||||
) {
|
||||
if !request_plugin_install_response_requests_persistent_disable(response) {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(err) = persist_disabled_install_request(&turn.config.codex_home, tool).await {
|
||||
warn!(
|
||||
error = %err,
|
||||
tool_id = tool.id(),
|
||||
"failed to persist disabled tool suggestion"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
session.reload_user_config_layer().await;
|
||||
}
|
||||
|
||||
fn request_plugin_install_response_requests_persistent_disable(
|
||||
response: &ElicitationResponse,
|
||||
) -> bool {
|
||||
if response.action != ElicitationAction::Decline {
|
||||
return false;
|
||||
}
|
||||
|
||||
response
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|meta| meta.get(REQUEST_PLUGIN_INSTALL_PERSIST_KEY))
|
||||
.and_then(Value::as_str)
|
||||
== Some(REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE)
|
||||
}
|
||||
|
||||
async fn persist_disabled_install_request(
|
||||
codex_home: &codex_utils_absolute_path::AbsolutePathBuf,
|
||||
tool: &DiscoverableTool,
|
||||
) -> anyhow::Result<()> {
|
||||
ConfigEditsBuilder::new(codex_home)
|
||||
.with_edits([ConfigEdit::AddToolSuggestDisabledTool(
|
||||
disabled_install_request(tool),
|
||||
)])
|
||||
.apply()
|
||||
.await
|
||||
}
|
||||
|
||||
fn disabled_install_request(tool: &DiscoverableTool) -> ToolSuggestDisabledTool {
|
||||
match tool {
|
||||
DiscoverableTool::Connector(connector) => {
|
||||
ToolSuggestDisabledTool::connector(connector.id.as_str())
|
||||
}
|
||||
DiscoverableTool::Plugin(plugin) => ToolSuggestDisabledTool::plugin(plugin.id.as_str()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn verify_request_plugin_install_completed(
|
||||
session: &crate::session::session::Session,
|
||||
turn: &crate::session::turn_context::TurnContext,
|
||||
tool: &DiscoverableTool,
|
||||
auth: Option<&codex_login::CodexAuth>,
|
||||
) -> bool {
|
||||
match tool {
|
||||
DiscoverableTool::Connector(connector) => refresh_missing_requested_connectors(
|
||||
session,
|
||||
turn,
|
||||
auth,
|
||||
std::slice::from_ref(&connector.id),
|
||||
connector.id.as_str(),
|
||||
)
|
||||
.await
|
||||
.is_some_and(|accessible_connectors| {
|
||||
verified_connector_install_completed(connector.id.as_str(), &accessible_connectors)
|
||||
}),
|
||||
DiscoverableTool::Plugin(plugin) => {
|
||||
if is_remote_plugin_install_suggestion(&plugin.id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
session.reload_user_config_layer().await;
|
||||
let config = session.get_config().await;
|
||||
let completed = verified_plugin_install_completed(
|
||||
plugin.id.as_str(),
|
||||
config.as_ref(),
|
||||
session.services.plugins_manager.as_ref(),
|
||||
);
|
||||
let _ = refresh_missing_requested_connectors(
|
||||
session,
|
||||
turn,
|
||||
auth,
|
||||
&plugin.app_connector_ids,
|
||||
plugin.id.as_str(),
|
||||
)
|
||||
.await;
|
||||
completed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_remote_plugin_install_suggestion(plugin_id: &str) -> bool {
|
||||
plugin_id
|
||||
.rsplit_once('@')
|
||||
.is_some_and(|(_, marketplace_name)| marketplace_name == REMOTE_GLOBAL_MARKETPLACE_NAME)
|
||||
}
|
||||
|
||||
async fn refresh_missing_requested_connectors(
|
||||
session: &crate::session::session::Session,
|
||||
turn: &crate::session::turn_context::TurnContext,
|
||||
auth: Option<&codex_login::CodexAuth>,
|
||||
expected_connector_ids: &[String],
|
||||
tool_id: &str,
|
||||
) -> Option<Vec<AppInfo>> {
|
||||
if expected_connector_ids.is_empty() {
|
||||
return Some(Vec::new());
|
||||
}
|
||||
|
||||
let manager = session.services.mcp_connection_manager.load_full();
|
||||
let mcp_tools = manager.list_all_tools().await;
|
||||
let accessible_connectors = connectors::with_app_enabled_state(
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||
&turn.config,
|
||||
);
|
||||
if all_requested_connectors_picked_up(expected_connector_ids, &accessible_connectors) {
|
||||
return Some(accessible_connectors);
|
||||
}
|
||||
|
||||
match manager.hard_refresh_codex_apps_tools_cache().await {
|
||||
Ok(mcp_tools) => {
|
||||
let accessible_connectors = connectors::with_app_enabled_state(
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||
&turn.config,
|
||||
);
|
||||
connectors::refresh_accessible_connectors_cache_from_mcp_tools(
|
||||
&turn.config,
|
||||
auth,
|
||||
&mcp_tools,
|
||||
);
|
||||
Some(accessible_connectors)
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"failed to refresh codex apps tools cache after plugin install request for {tool_id}: {err:#}"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn verified_plugin_install_completed(
|
||||
tool_id: &str,
|
||||
config: &crate::config::Config,
|
||||
plugins_manager: &codex_core_plugins::PluginsManager,
|
||||
) -> bool {
|
||||
let plugins_input = config.plugins_config_input();
|
||||
plugins_manager
|
||||
.list_marketplaces_for_config(&plugins_input, &[], /*include_openai_curated*/ true)
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flat_map(|outcome| outcome.marketplaces)
|
||||
.flat_map(|marketplace| marketplace.plugins.into_iter())
|
||||
.any(|plugin| plugin.id == tool_id && plugin.installed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "request_plugin_install_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,177 +0,0 @@
|
||||
use codex_tools::JsonSchema;
|
||||
use codex_tools::LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME;
|
||||
use codex_tools::REQUEST_PLUGIN_INSTALL_TOOL_NAME;
|
||||
use codex_tools::ResponsesApiTool;
|
||||
use codex_tools::ToolSpec;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::tools::router::ToolSuggestPresentation;
|
||||
|
||||
pub(crate) fn create_request_plugin_install_tool(
|
||||
presentation: ToolSuggestPresentation,
|
||||
) -> ToolSpec {
|
||||
let (properties, required, description) = match presentation {
|
||||
ToolSuggestPresentation::ListTool => (
|
||||
BTreeMap::from([
|
||||
(
|
||||
"tool_type".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Type of discoverable tool to suggest. Use \"connector\" or \"plugin\"."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"action_type".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Suggested action for the tool. Use \"install\".".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"tool_id".to_string(),
|
||||
JsonSchema::string(Some("Connector or plugin id to suggest.".to_string())),
|
||||
),
|
||||
(
|
||||
"suggest_reason".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Concise one-line user-facing reason why this plugin or connector can help with the current request."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
]),
|
||||
vec![
|
||||
"tool_type".to_string(),
|
||||
"action_type".to_string(),
|
||||
"tool_id".to_string(),
|
||||
"suggest_reason".to_string(),
|
||||
],
|
||||
format!(
|
||||
"# Request plugin/connector install\n\nUse this tool only after `{LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}` returns a plugin or 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. Pass the returned `tool_type` through directly, and pass the returned `id` as `tool_id`.\n\nIMPORTANT: DO NOT call this tool in parallel with other tools."
|
||||
),
|
||||
),
|
||||
ToolSuggestPresentation::RecommendationContext => (
|
||||
BTreeMap::from([
|
||||
(
|
||||
"plugin_id".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Plugin id from the `<recommended_plugins>` list.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"suggest_reason".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Concise one-line user-facing reason why this plugin can help with the current request."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
]),
|
||||
vec!["plugin_id".to_string(), "suggest_reason".to_string()],
|
||||
"# Suggest a recommended plugin installation\n\nSuggest installing a plugin from the `<recommended_plugins>` list when it would help with the user's current request. Briefly explain why in `suggest_reason`.".to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: REQUEST_PLUGIN_INSTALL_TOOL_NAME.to_string(),
|
||||
description,
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::object(properties, Some(required), Some(false.into())),
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_tools::JsonSchema;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn create_request_plugin_install_tool_uses_expected_legacy_wire_shape() {
|
||||
let expected_description = concat!(
|
||||
"# Request plugin/connector install\n\n",
|
||||
"Use this tool only after `list_available_plugins_to_install` returns a plugin or 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. Pass the returned `tool_type` through directly, and pass the returned `id` as `tool_id`.\n\n",
|
||||
"IMPORTANT: DO NOT call this tool in parallel with other tools.",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
create_request_plugin_install_tool(ToolSuggestPresentation::ListTool),
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "request_plugin_install".to_string(),
|
||||
description: expected_description.to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::object(BTreeMap::from([
|
||||
(
|
||||
"action_type".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Suggested action for the tool. Use \"install\"."
|
||||
.to_string(),
|
||||
),),
|
||||
),
|
||||
(
|
||||
"suggest_reason".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Concise one-line user-facing reason why this plugin or connector can help with the current request."
|
||||
.to_string(),
|
||||
),),
|
||||
),
|
||||
(
|
||||
"tool_id".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Connector or plugin id to suggest."
|
||||
.to_string(),
|
||||
),),
|
||||
),
|
||||
(
|
||||
"tool_type".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Type of discoverable tool to suggest. Use \"connector\" or \"plugin\"."
|
||||
.to_string(),
|
||||
),),
|
||||
),
|
||||
]), Some(vec![
|
||||
"tool_type".to_string(),
|
||||
"action_type".to_string(),
|
||||
"tool_id".to_string(),
|
||||
"suggest_reason".to_string(),
|
||||
]), Some(false.into())),
|
||||
output_schema: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommendation_context_uses_simplified_plugin_wire_shape() {
|
||||
assert_eq!(
|
||||
create_request_plugin_install_tool(ToolSuggestPresentation::RecommendationContext),
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "request_plugin_install".to_string(),
|
||||
description: "# Suggest a recommended plugin installation\n\nSuggest installing a plugin from the `<recommended_plugins>` list when it would help with the user's current request. Briefly explain why in `suggest_reason`.".to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::object(
|
||||
BTreeMap::from([
|
||||
(
|
||||
"plugin_id".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Plugin id from the `<recommended_plugins>` list.".to_string(),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"suggest_reason".to_string(),
|
||||
JsonSchema::string(Some(
|
||||
"Concise one-line user-facing reason why this plugin can help with the current request."
|
||||
.to_string(),
|
||||
)),
|
||||
),
|
||||
]),
|
||||
Some(vec!["plugin_id".to_string(), "suggest_reason".to_string()]),
|
||||
Some(false.into()),
|
||||
),
|
||||
output_schema: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
use super::*;
|
||||
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_config::CONFIG_TOML_FILE;
|
||||
use codex_config::config_toml::ConfigToml;
|
||||
use codex_config::types::ToolSuggestConfig;
|
||||
use codex_config::types::ToolSuggestDisabledTool;
|
||||
use codex_config::types::ToolSuggestDiscoverable;
|
||||
use codex_config::types::ToolSuggestDiscoverableType;
|
||||
use codex_core_plugins::PluginInstallRequest;
|
||||
use codex_core_plugins::PluginsManager;
|
||||
use codex_core_plugins::startup_sync::curated_plugins_repo_path;
|
||||
use codex_rmcp_client::ElicitationResponse;
|
||||
use codex_tools::DiscoverablePluginInfo;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use core_test_support::PathExt;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rmcp::model::ElicitationAction;
|
||||
use serde_json::json;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn verified_plugin_install_completed_requires_installed_plugin() {
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
let curated_root = curated_plugins_repo_path(codex_home.path());
|
||||
write_openai_curated_marketplace(&curated_root, &["sample"]);
|
||||
write_curated_plugin_sha(codex_home.path());
|
||||
write_plugins_feature_config(codex_home.path());
|
||||
|
||||
let config = load_plugins_config(codex_home.path()).await;
|
||||
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
|
||||
|
||||
assert!(!verified_plugin_install_completed(
|
||||
"sample@openai-curated",
|
||||
&config,
|
||||
&plugins_manager,
|
||||
));
|
||||
|
||||
plugins_manager
|
||||
.install_plugin(PluginInstallRequest {
|
||||
plugin_name: "sample".to_string(),
|
||||
marketplace_path: AbsolutePathBuf::try_from(
|
||||
curated_root.join(".agents/plugins/marketplace.json"),
|
||||
)
|
||||
.expect("marketplace path"),
|
||||
})
|
||||
.await
|
||||
.expect("plugin should install");
|
||||
|
||||
let refreshed_config = load_plugins_config(codex_home.path()).await;
|
||||
assert!(verified_plugin_install_completed(
|
||||
"sample@openai-curated",
|
||||
&refreshed_config,
|
||||
&plugins_manager,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_plugin_install_suggestions_skip_core_installed_verification() {
|
||||
assert!(is_remote_plugin_install_suggestion(
|
||||
"snowflake@openai-curated-remote"
|
||||
));
|
||||
assert!(!is_remote_plugin_install_suggestion(
|
||||
"snowflake@openai-curated"
|
||||
));
|
||||
assert!(!is_remote_plugin_install_suggestion("Plugin_123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommended_plugin_install_args_accept_legacy_tool_id() {
|
||||
let current: RecommendedPluginInstallArgs = serde_json::from_value(json!({
|
||||
"plugin_id": "google-drive@openai-curated-remote",
|
||||
"suggest_reason": "Use Google Drive for this request"
|
||||
}))
|
||||
.expect("current arguments should deserialize");
|
||||
let legacy: RecommendedPluginInstallArgs = serde_json::from_value(json!({
|
||||
"tool_type": "plugin",
|
||||
"action_type": "install",
|
||||
"tool_id": "google-drive@openai-curated-remote",
|
||||
"suggest_reason": "Use Google Drive for this request"
|
||||
}))
|
||||
.expect("legacy arguments should deserialize");
|
||||
|
||||
assert_eq!(current, legacy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_plugin_install_response_persists_only_decline_always_mode() {
|
||||
assert!(request_plugin_install_response_requests_persistent_disable(
|
||||
&ElicitationResponse {
|
||||
action: ElicitationAction::Decline,
|
||||
content: None,
|
||||
meta: Some(json!({
|
||||
REQUEST_PLUGIN_INSTALL_PERSIST_KEY: REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE
|
||||
})),
|
||||
}
|
||||
));
|
||||
assert!(
|
||||
!request_plugin_install_response_requests_persistent_disable(&ElicitationResponse {
|
||||
action: ElicitationAction::Accept,
|
||||
content: None,
|
||||
meta: Some(json!({
|
||||
REQUEST_PLUGIN_INSTALL_PERSIST_KEY: REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE
|
||||
})),
|
||||
})
|
||||
);
|
||||
assert!(
|
||||
!request_plugin_install_response_requests_persistent_disable(&ElicitationResponse {
|
||||
action: ElicitationAction::Decline,
|
||||
content: None,
|
||||
meta: Some(json!({ REQUEST_PLUGIN_INSTALL_PERSIST_KEY: "session" })),
|
||||
})
|
||||
);
|
||||
assert!(
|
||||
!request_plugin_install_response_requests_persistent_disable(&ElicitationResponse {
|
||||
action: ElicitationAction::Decline,
|
||||
content: None,
|
||||
meta: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persist_disabled_install_request_writes_connector_config() {
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
let tool = connector_tool("connector_calendar", "Google Calendar");
|
||||
|
||||
persist_disabled_install_request(&codex_home.path().abs(), &tool)
|
||||
.await
|
||||
.expect("persist connector disable");
|
||||
|
||||
let contents =
|
||||
std::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).expect("read config");
|
||||
let parsed: ConfigToml = toml::from_str(&contents).expect("parse config");
|
||||
assert_eq!(
|
||||
parsed.tool_suggest,
|
||||
Some(ToolSuggestConfig {
|
||||
discoverables: Vec::new(),
|
||||
disabled_tools: vec![ToolSuggestDisabledTool::connector("connector_calendar")],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persist_disabled_install_request_writes_plugin_config() {
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
let tool = DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo {
|
||||
id: "slack@openai-curated".to_string(),
|
||||
remote_plugin_id: None,
|
||||
name: "Slack".to_string(),
|
||||
description: None,
|
||||
has_skills: true,
|
||||
mcp_server_names: Vec::new(),
|
||||
app_connector_ids: Vec::new(),
|
||||
}));
|
||||
|
||||
persist_disabled_install_request(&codex_home.path().abs(), &tool)
|
||||
.await
|
||||
.expect("persist plugin disable");
|
||||
|
||||
let contents =
|
||||
std::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).expect("read config");
|
||||
let parsed: ConfigToml = toml::from_str(&contents).expect("parse config");
|
||||
assert_eq!(
|
||||
parsed.tool_suggest,
|
||||
Some(ToolSuggestConfig {
|
||||
discoverables: Vec::new(),
|
||||
disabled_tools: vec![ToolSuggestDisabledTool::plugin("slack@openai-curated")],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persist_disabled_install_request_dedupes_existing_disabled_tools() {
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
let tool = connector_tool("connector_calendar", "Google Calendar");
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
r#"
|
||||
[tool_suggest]
|
||||
discoverables = [
|
||||
{ type = "plugin", id = "sample@openai-curated" }
|
||||
]
|
||||
|
||||
[[tool_suggest.disabled_tools]]
|
||||
type = "connector"
|
||||
id = " connector_calendar "
|
||||
|
||||
[[tool_suggest.disabled_tools]]
|
||||
type = "connector"
|
||||
id = "connector_calendar"
|
||||
|
||||
[[tool_suggest.disabled_tools]]
|
||||
type = "connector"
|
||||
id = " "
|
||||
|
||||
[[tool_suggest.disabled_tools]]
|
||||
type = "plugin"
|
||||
id = "slack@openai-curated"
|
||||
"#,
|
||||
)
|
||||
.expect("write config");
|
||||
|
||||
persist_disabled_install_request(&codex_home.path().abs(), &tool)
|
||||
.await
|
||||
.expect("persist connector disable");
|
||||
|
||||
let contents =
|
||||
std::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).expect("read config");
|
||||
let parsed: ConfigToml = toml::from_str(&contents).expect("parse config");
|
||||
assert_eq!(
|
||||
parsed.tool_suggest,
|
||||
Some(ToolSuggestConfig {
|
||||
discoverables: vec![ToolSuggestDiscoverable {
|
||||
kind: ToolSuggestDiscoverableType::Plugin,
|
||||
id: "sample@openai-curated".to_string(),
|
||||
}],
|
||||
disabled_tools: vec![
|
||||
ToolSuggestDisabledTool::connector("connector_calendar"),
|
||||
ToolSuggestDisabledTool::plugin("slack@openai-curated"),
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
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(),
|
||||
}))
|
||||
}
|
||||
@@ -7,7 +7,6 @@ mod function_call_error;
|
||||
mod image_detail;
|
||||
mod json_schema;
|
||||
mod mcp_tool;
|
||||
mod request_plugin_install;
|
||||
mod response_history;
|
||||
mod responses_api;
|
||||
mod tool_call;
|
||||
@@ -39,15 +38,6 @@ pub use json_schema::parse_tool_input_schema;
|
||||
pub use json_schema::parse_tool_input_schema_without_compaction;
|
||||
pub use mcp_tool::mcp_call_tool_result_output_schema;
|
||||
pub use mcp_tool::parse_mcp_tool;
|
||||
pub use request_plugin_install::REQUEST_PLUGIN_INSTALL_APPROVAL_KIND_VALUE;
|
||||
pub use request_plugin_install::REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE;
|
||||
pub use request_plugin_install::REQUEST_PLUGIN_INSTALL_PERSIST_KEY;
|
||||
pub use request_plugin_install::RequestPluginInstallArgs;
|
||||
pub use request_plugin_install::RequestPluginInstallMeta;
|
||||
pub use request_plugin_install::RequestPluginInstallResult;
|
||||
pub use request_plugin_install::all_requested_connectors_picked_up;
|
||||
pub use request_plugin_install::build_request_plugin_install_elicitation_request;
|
||||
pub use request_plugin_install::verified_connector_install_completed;
|
||||
pub use response_history::retain_tail_from_last_n_user_messages;
|
||||
pub use response_history::truncate_assistant_output_text_to_token_budget;
|
||||
pub use responses_api::FreeformTool;
|
||||
@@ -86,7 +76,6 @@ pub use tool_discovery::DiscoverableToolAction;
|
||||
pub use tool_discovery::DiscoverableToolType;
|
||||
pub use tool_discovery::LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME;
|
||||
pub use tool_discovery::ListAvailablePluginsToInstallResult;
|
||||
pub use tool_discovery::REQUEST_PLUGIN_INSTALL_TOOL_NAME;
|
||||
pub use tool_discovery::RequestPluginInstallEntry;
|
||||
pub use tool_discovery::TOOL_SEARCH_DEFAULT_LIMIT;
|
||||
pub use tool_discovery::TOOL_SEARCH_TOOL_NAME;
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
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 REQUEST_PLUGIN_INSTALL_APPROVAL_KIND_VALUE: &str = "tool_suggestion";
|
||||
pub const REQUEST_PLUGIN_INSTALL_PERSIST_KEY: &str = "persist";
|
||||
pub const REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE: &str = "always";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RequestPluginInstallArgs {
|
||||
pub tool_type: DiscoverableToolType,
|
||||
pub action_type: DiscoverableToolAction,
|
||||
pub tool_id: String,
|
||||
pub suggest_reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq, Eq)]
|
||||
pub struct RequestPluginInstallResult {
|
||||
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 RequestPluginInstallMeta<'a> {
|
||||
pub codex_approval_kind: &'static str,
|
||||
pub persist: &'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>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub remote_plugin_id: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub app_connector_ids: Option<&'a [String]>,
|
||||
}
|
||||
|
||||
pub fn build_request_plugin_install_elicitation_request(
|
||||
server_name: &str,
|
||||
thread_id: String,
|
||||
turn_id: String,
|
||||
suggest_reason: &str,
|
||||
tool: &DiscoverableTool,
|
||||
) -> McpServerElicitationRequestParams {
|
||||
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_request_plugin_install_meta(
|
||||
suggest_reason,
|
||||
tool,
|
||||
))),
|
||||
message,
|
||||
requested_schema: McpElicitationSchema {
|
||||
schema_uri: None,
|
||||
type_: McpElicitationObjectType::Object,
|
||||
properties: BTreeMap::new(),
|
||||
required: None,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all_requested_connectors_picked_up(
|
||||
expected_connector_ids: &[String],
|
||||
accessible_connectors: &[AppInfo],
|
||||
) -> bool {
|
||||
expected_connector_ids.iter().all(|connector_id| {
|
||||
verified_connector_install_completed(connector_id, accessible_connectors)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn verified_connector_install_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_request_plugin_install_meta<'a>(
|
||||
suggest_reason: &'a str,
|
||||
tool: &'a DiscoverableTool,
|
||||
) -> RequestPluginInstallMeta<'a> {
|
||||
let (tool_type, remote_plugin_id, app_connector_ids) = match tool {
|
||||
DiscoverableTool::Connector(_) => (DiscoverableToolType::Connector, None, None),
|
||||
DiscoverableTool::Plugin(plugin) => (
|
||||
DiscoverableToolType::Plugin,
|
||||
plugin.remote_plugin_id.as_deref(),
|
||||
Some(plugin.app_connector_ids.as_slice()),
|
||||
),
|
||||
};
|
||||
RequestPluginInstallMeta {
|
||||
codex_approval_kind: REQUEST_PLUGIN_INSTALL_APPROVAL_KIND_VALUE,
|
||||
persist: REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE,
|
||||
tool_type,
|
||||
suggest_type: DiscoverableToolAction::Install,
|
||||
suggest_reason,
|
||||
tool_id: tool.id(),
|
||||
tool_name: tool.name(),
|
||||
install_url: tool.install_url(),
|
||||
remote_plugin_id,
|
||||
app_connector_ids,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "request_plugin_install_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,214 +0,0 @@
|
||||
use super::*;
|
||||
use crate::DiscoverablePluginInfo;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn build_request_plugin_install_elicitation_request_uses_expected_shape() {
|
||||
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_request_plugin_install_elicitation_request(
|
||||
"codex-apps",
|
||||
"thread-1".to_string(),
|
||||
"turn-1".to_string(),
|
||||
"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!(RequestPluginInstallMeta {
|
||||
codex_approval_kind: REQUEST_PLUGIN_INSTALL_APPROVAL_KIND_VALUE,
|
||||
persist: REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_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"
|
||||
),
|
||||
remote_plugin_id: None,
|
||||
app_connector_ids: None,
|
||||
})),
|
||||
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_request_plugin_install_elicitation_request_injects_plugin_metadata() {
|
||||
let plugin = DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo {
|
||||
id: "sample@openai-curated-remote".to_string(),
|
||||
remote_plugin_id: Some("plugins~Plugin_sample".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_request_plugin_install_elicitation_request(
|
||||
"codex-apps",
|
||||
"thread-1".to_string(),
|
||||
"turn-1".to_string(),
|
||||
"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!(RequestPluginInstallMeta {
|
||||
codex_approval_kind: REQUEST_PLUGIN_INSTALL_APPROVAL_KIND_VALUE,
|
||||
persist: REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_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-remote",
|
||||
tool_name: "Sample Plugin",
|
||||
install_url: None,
|
||||
remote_plugin_id: Some("plugins~Plugin_sample"),
|
||||
app_connector_ids: Some(&["connector_calendar".to_string()]),
|
||||
})),
|
||||
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_request_plugin_install_meta_uses_expected_shape() {
|
||||
let connector = DiscoverableTool::Connector(Box::new(AppInfo {
|
||||
id: "connector_68df038e0ba48191908c8434991bbac2".to_string(),
|
||||
name: "Gmail".to_string(),
|
||||
description: None,
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: Some(
|
||||
"https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2".to_string(),
|
||||
),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}));
|
||||
let meta =
|
||||
build_request_plugin_install_meta("Find and reference emails from your inbox", &connector);
|
||||
|
||||
assert_eq!(
|
||||
meta,
|
||||
RequestPluginInstallMeta {
|
||||
codex_approval_kind: REQUEST_PLUGIN_INSTALL_APPROVAL_KIND_VALUE,
|
||||
persist: REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_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"
|
||||
),
|
||||
remote_plugin_id: None,
|
||||
app_connector_ids: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verified_connector_install_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_install_completed(
|
||||
"calendar",
|
||||
&accessible_connectors,
|
||||
));
|
||||
assert!(!verified_connector_install_completed(
|
||||
"gmail",
|
||||
&accessible_connectors,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_requested_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_requested_connectors_picked_up(
|
||||
&["calendar".to_string()],
|
||||
&accessible_connectors,
|
||||
));
|
||||
assert!(!all_requested_connectors_picked_up(
|
||||
&["calendar".to_string(), "gmail".to_string()],
|
||||
&accessible_connectors,
|
||||
));
|
||||
}
|
||||
@@ -6,7 +6,6 @@ const TUI_CLIENT_NAME: &str = "codex-tui";
|
||||
pub const TOOL_SEARCH_TOOL_NAME: &str = "tool_search";
|
||||
pub const TOOL_SEARCH_DEFAULT_LIMIT: usize = 8;
|
||||
pub const LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME: &str = "list_available_plugins_to_install";
|
||||
pub const REQUEST_PLUGIN_INSTALL_TOOL_NAME: &str = "request_plugin_install";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ToolSearchSourceInfo {
|
||||
|
||||
Reference in New Issue
Block a user