mirror of
https://github.com/openai/codex.git
synced 2026-04-04 21:04:48 +00:00
Compare commits
2 Commits
pr16640
...
dev/mzeng/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dff58de912 | ||
|
|
826e22b27b |
@@ -292,7 +292,7 @@ use crate::tasks::SessionTask;
|
||||
use crate::tasks::SessionTaskContext;
|
||||
use crate::tools::ToolRouter;
|
||||
use crate::tools::context::SharedTurnDiffTracker;
|
||||
use crate::tools::discoverable::DiscoverableTool;
|
||||
use crate::tools::discoverable_catalog;
|
||||
use crate::tools::js_repl::JsReplHandle;
|
||||
use crate::tools::js_repl::resolve_compatible_node;
|
||||
use crate::tools::network_approval::NetworkApprovalService;
|
||||
@@ -6459,17 +6459,18 @@ pub(crate) async fn built_tools(
|
||||
&& turn_context.tools_config.tool_suggest
|
||||
{
|
||||
if let Some(accessible_connectors) = accessible_connectors_with_enabled_state.as_ref() {
|
||||
match connectors::list_tool_suggest_discoverable_tools_with_auth(
|
||||
match discoverable_catalog::load_discoverable_tools(
|
||||
&turn_context.config,
|
||||
auth.as_ref(),
|
||||
sess.services.plugins_manager.as_ref(),
|
||||
&loaded_plugins,
|
||||
turn_context.cwd.as_path(),
|
||||
accessible_connectors.as_slice(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(connectors) if connectors.is_empty() => None,
|
||||
Ok(connectors) => {
|
||||
Some(connectors.into_iter().map(DiscoverableTool::from).collect())
|
||||
}
|
||||
Ok(discoverable_tools) if discoverable_tools.is_empty() => None,
|
||||
Ok(discoverable_tools) => Some(discoverable_tools),
|
||||
Err(err) => {
|
||||
warn!("failed to load discoverable tool suggestions: {err:#}");
|
||||
None
|
||||
|
||||
@@ -47,7 +47,7 @@ use crate::token_data::TokenData;
|
||||
pub use codex_connectors::CONNECTORS_CACHE_TTL;
|
||||
const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30);
|
||||
const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
const TOOL_SUGGEST_DISCOVERABLE_CONNECTOR_IDS: &[&str] = &[
|
||||
pub(crate) const TOOL_SUGGEST_DISCOVERABLE_CONNECTOR_IDS: &[&str] = &[
|
||||
"connector_2128aebfecb84f64a069897515042a44",
|
||||
"connector_68df038e0ba48191908c8434991bbac2",
|
||||
"asdk_app_69a1d78e929881919bba0dbda1f6436d",
|
||||
@@ -112,19 +112,6 @@ pub async fn list_accessible_connectors_from_mcp_tools(
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth(
|
||||
config: &Config,
|
||||
auth: Option<&CodexAuth>,
|
||||
accessible_connectors: &[AppInfo],
|
||||
) -> anyhow::Result<Vec<AppInfo>> {
|
||||
let directory_connectors =
|
||||
list_directory_connectors_for_tool_suggest_with_auth(config, auth).await?;
|
||||
Ok(filter_tool_suggest_discoverable_tools(
|
||||
directory_connectors,
|
||||
accessible_connectors,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn list_cached_accessible_connectors_from_mcp_tools(
|
||||
config: &Config,
|
||||
) -> Option<Vec<AppInfo>> {
|
||||
@@ -350,7 +337,7 @@ fn write_cached_accessible_connectors(
|
||||
});
|
||||
}
|
||||
|
||||
fn filter_tool_suggest_discoverable_tools(
|
||||
pub(crate) fn filter_tool_suggest_discoverable_tools(
|
||||
directory_connectors: Vec<AppInfo>,
|
||||
accessible_connectors: &[AppInfo],
|
||||
) -> Vec<AppInfo> {
|
||||
@@ -377,7 +364,7 @@ fn filter_tool_suggest_discoverable_tools(
|
||||
connectors
|
||||
}
|
||||
|
||||
async fn list_directory_connectors_for_tool_suggest_with_auth(
|
||||
pub(crate) async fn list_directory_connectors_with_auth(
|
||||
config: &Config,
|
||||
auth: Option<&CodexAuth>,
|
||||
) -> anyhow::Result<Vec<AppInfo>> {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::plugins::PluginCapabilitySummary;
|
||||
use crate::plugins::PluginDetailSummary;
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -63,10 +64,22 @@ impl DiscoverableTool {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn description(&self) -> Option<&str> {
|
||||
pub(crate) fn action(&self) -> DiscoverableToolAction {
|
||||
match self {
|
||||
Self::Connector(connector) => connector.description.as_deref(),
|
||||
Self::Plugin(plugin) => plugin.description.as_deref(),
|
||||
Self::Connector(connector) => {
|
||||
if connector.is_accessible && !connector.is_enabled {
|
||||
DiscoverableToolAction::Enable
|
||||
} else {
|
||||
DiscoverableToolAction::Install
|
||||
}
|
||||
}
|
||||
Self::Plugin(plugin) => {
|
||||
if plugin.installed && !plugin.enabled {
|
||||
DiscoverableToolAction::Enable
|
||||
} else {
|
||||
DiscoverableToolAction::Install
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,21 +104,44 @@ pub(crate) struct DiscoverablePluginInfo {
|
||||
pub(crate) has_skills: bool,
|
||||
pub(crate) mcp_server_names: Vec<String>,
|
||||
pub(crate) app_connector_ids: Vec<String>,
|
||||
pub(crate) marketplace_path: AbsolutePathBuf,
|
||||
pub(crate) plugin_name: String,
|
||||
pub(crate) installed: bool,
|
||||
pub(crate) enabled: bool,
|
||||
}
|
||||
|
||||
impl From<PluginCapabilitySummary> for DiscoverablePluginInfo {
|
||||
fn from(value: PluginCapabilitySummary) -> Self {
|
||||
impl DiscoverablePluginInfo {
|
||||
pub(crate) fn from_plugin_detail(
|
||||
marketplace_path: AbsolutePathBuf,
|
||||
plugin: PluginDetailSummary,
|
||||
) -> Self {
|
||||
let display_name = plugin
|
||||
.interface
|
||||
.as_ref()
|
||||
.and_then(|interface| interface.display_name.clone())
|
||||
.unwrap_or_else(|| plugin.name.clone());
|
||||
let description = plugin.description.clone().or_else(|| {
|
||||
plugin
|
||||
.interface
|
||||
.as_ref()
|
||||
.and_then(|interface| interface.short_description.clone())
|
||||
});
|
||||
|
||||
Self {
|
||||
id: value.config_name,
|
||||
name: value.display_name,
|
||||
description: value.description,
|
||||
has_skills: value.has_skills,
|
||||
mcp_server_names: value.mcp_server_names,
|
||||
app_connector_ids: value
|
||||
.app_connector_ids
|
||||
id: plugin.id,
|
||||
name: display_name,
|
||||
description,
|
||||
has_skills: !plugin.skills.is_empty(),
|
||||
mcp_server_names: plugin.mcp_server_names,
|
||||
app_connector_ids: plugin
|
||||
.apps
|
||||
.into_iter()
|
||||
.map(|connector_id| connector_id.0)
|
||||
.collect(),
|
||||
marketplace_path,
|
||||
plugin_name: plugin.name,
|
||||
installed: plugin.installed,
|
||||
enabled: plugin.enabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
246
codex-rs/core/src/tools/discoverable_catalog.rs
Normal file
246
codex-rs/core/src/tools/discoverable_catalog.rs
Normal file
@@ -0,0 +1,246 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::CodexAuth;
|
||||
use crate::config::Config;
|
||||
use crate::connectors;
|
||||
use crate::plugins::AppConnectorId;
|
||||
use crate::plugins::PluginCapabilitySummary;
|
||||
use crate::plugins::PluginLoadOutcome;
|
||||
use crate::plugins::PluginReadRequest;
|
||||
use crate::plugins::PluginsManager;
|
||||
use crate::tools::discoverable::DiscoverablePluginInfo;
|
||||
use crate::tools::discoverable::DiscoverableTool;
|
||||
|
||||
pub(crate) const TRUSTED_DISCOVERABLE_PLUGIN_IDS: &[&str] = &[
|
||||
"calendar@openai-curated",
|
||||
"gmail@openai-curated",
|
||||
"linear@openai-curated",
|
||||
"slack@openai-curated",
|
||||
];
|
||||
|
||||
pub(crate) async fn load_discoverable_tools(
|
||||
config: &Config,
|
||||
auth: Option<&CodexAuth>,
|
||||
plugins_manager: &PluginsManager,
|
||||
loaded_plugins: &PluginLoadOutcome,
|
||||
cwd: &Path,
|
||||
accessible_connectors: &[AppInfo],
|
||||
) -> anyhow::Result<Vec<DiscoverableTool>> {
|
||||
let directory_connectors =
|
||||
connectors::list_directory_connectors_with_auth(config, auth).await?;
|
||||
let mut discoverable_tools = build_discoverable_connector_tools(
|
||||
directory_connectors,
|
||||
accessible_connectors,
|
||||
&loaded_plugins.effective_apps(),
|
||||
loaded_plugins.capability_summaries(),
|
||||
)
|
||||
.into_iter()
|
||||
.map(DiscoverableTool::from)
|
||||
.collect::<Vec<_>>();
|
||||
discoverable_tools.extend(
|
||||
load_discoverable_plugins(config, plugins_manager, cwd)?
|
||||
.into_iter()
|
||||
.map(DiscoverableTool::from),
|
||||
);
|
||||
discoverable_tools.sort_by(|left, right| {
|
||||
left.name()
|
||||
.cmp(right.name())
|
||||
.then_with(|| left.id().cmp(right.id()))
|
||||
});
|
||||
Ok(discoverable_tools)
|
||||
}
|
||||
|
||||
pub(crate) fn build_discoverable_connector_tools(
|
||||
directory_connectors: Vec<AppInfo>,
|
||||
accessible_connectors: &[AppInfo],
|
||||
enabled_plugin_apps: &[AppConnectorId],
|
||||
capability_summaries: &[PluginCapabilitySummary],
|
||||
) -> Vec<AppInfo> {
|
||||
let accessible_by_id = accessible_connectors
|
||||
.iter()
|
||||
.map(|connector| (connector.id.as_str(), connector))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let mut discoverable_by_id = connectors::filter_tool_suggest_discoverable_tools(
|
||||
directory_connectors.clone(),
|
||||
accessible_connectors,
|
||||
)
|
||||
.into_iter()
|
||||
.map(|mut connector| {
|
||||
if let Some(accessible_connector) = accessible_by_id.get(connector.id.as_str()) {
|
||||
connector.is_accessible = accessible_connector.is_accessible;
|
||||
connector.is_enabled = accessible_connector.is_enabled;
|
||||
}
|
||||
(connector.id.clone(), connector)
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
let accessible_enabled_ids = accessible_connectors
|
||||
.iter()
|
||||
.filter(|connector| connector.is_accessible && connector.is_enabled)
|
||||
.map(|connector| connector.id.as_str())
|
||||
.collect::<HashSet<_>>();
|
||||
let plugin_connector_ids = enabled_plugin_apps
|
||||
.iter()
|
||||
.map(|connector_id| connector_id.0.as_str())
|
||||
.collect::<HashSet<_>>();
|
||||
let plugin_display_names = build_plugin_display_names_by_connector_id(capability_summaries);
|
||||
|
||||
for mut connector in connectors::filter_disallowed_connectors(connectors::merge_plugin_apps(
|
||||
directory_connectors,
|
||||
enabled_plugin_apps.to_vec(),
|
||||
)) {
|
||||
if accessible_enabled_ids.contains(connector.id.as_str())
|
||||
|| !plugin_connector_ids.contains(connector.id.as_str())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
connector.plugin_display_names = plugin_display_names
|
||||
.get(connector.id.as_str())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
discoverable_by_id
|
||||
.entry(connector.id.clone())
|
||||
.and_modify(|existing| {
|
||||
existing
|
||||
.plugin_display_names
|
||||
.extend(connector.plugin_display_names.iter().cloned());
|
||||
existing.plugin_display_names.sort_unstable();
|
||||
existing.plugin_display_names.dedup();
|
||||
})
|
||||
.or_insert(connector);
|
||||
}
|
||||
|
||||
let mut discoverable = discoverable_by_id.into_values().collect::<Vec<_>>();
|
||||
discoverable.sort_by(|left, right| {
|
||||
left.name
|
||||
.cmp(&right.name)
|
||||
.then_with(|| left.id.cmp(&right.id))
|
||||
});
|
||||
discoverable
|
||||
}
|
||||
|
||||
pub(crate) fn build_discoverable_plugin_tools(
|
||||
discoverable_plugins: Vec<DiscoverablePluginInfo>,
|
||||
) -> Vec<DiscoverablePluginInfo> {
|
||||
let trusted_plugin_ids = TRUSTED_DISCOVERABLE_PLUGIN_IDS
|
||||
.iter()
|
||||
.copied()
|
||||
.collect::<HashSet<_>>();
|
||||
let mut discoverable_plugins = discoverable_plugins
|
||||
.into_iter()
|
||||
.filter(|plugin| trusted_plugin_ids.contains(plugin.id.as_str()))
|
||||
.filter(|plugin| !(plugin.installed && plugin.enabled))
|
||||
.collect::<Vec<_>>();
|
||||
discoverable_plugins.sort_by(|left, right| {
|
||||
left.name
|
||||
.cmp(&right.name)
|
||||
.then_with(|| left.id.cmp(&right.id))
|
||||
});
|
||||
discoverable_plugins
|
||||
}
|
||||
|
||||
pub(crate) fn plugin_marketplace_roots(cwd: &Path) -> Vec<AbsolutePathBuf> {
|
||||
AbsolutePathBuf::try_from(cwd.to_path_buf())
|
||||
.map(|cwd| vec![cwd])
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn plugin_completion_verified(
|
||||
plugins_manager: &PluginsManager,
|
||||
config: &Config,
|
||||
cwd: &Path,
|
||||
plugin_id: &str,
|
||||
) -> bool {
|
||||
plugins_manager
|
||||
.list_marketplaces_for_config(config, &plugin_marketplace_roots(cwd))
|
||||
.ok()
|
||||
.and_then(|marketplaces| {
|
||||
marketplaces
|
||||
.into_iter()
|
||||
.flat_map(|marketplace| marketplace.plugins.into_iter())
|
||||
.find(|plugin| plugin.id == plugin_id)
|
||||
})
|
||||
.is_some_and(|plugin| plugin.installed && plugin.enabled)
|
||||
}
|
||||
|
||||
fn load_discoverable_plugins(
|
||||
config: &Config,
|
||||
plugins_manager: &PluginsManager,
|
||||
cwd: &Path,
|
||||
) -> anyhow::Result<Vec<DiscoverablePluginInfo>> {
|
||||
let marketplaces = match plugins_manager
|
||||
.list_marketplaces_for_config(config, &plugin_marketplace_roots(cwd))
|
||||
{
|
||||
Ok(marketplaces) => marketplaces,
|
||||
Err(err) => {
|
||||
warn!("failed to list plugin marketplaces for tool suggestions: {err}");
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
};
|
||||
|
||||
let mut discoverable_plugins = Vec::new();
|
||||
for marketplace in marketplaces {
|
||||
for plugin in marketplace.plugins {
|
||||
if !TRUSTED_DISCOVERABLE_PLUGIN_IDS.contains(&plugin.id.as_str())
|
||||
|| (plugin.installed && plugin.enabled)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let request = PluginReadRequest {
|
||||
marketplace_path: marketplace.path.clone(),
|
||||
plugin_name: plugin.name.clone(),
|
||||
};
|
||||
match plugins_manager.read_plugin_for_config(config, &request) {
|
||||
Ok(plugin_detail) => {
|
||||
discoverable_plugins.push(DiscoverablePluginInfo::from_plugin_detail(
|
||||
plugin_detail.marketplace_path,
|
||||
plugin_detail.plugin,
|
||||
))
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
plugin_id = plugin.id,
|
||||
"failed to read plugin details for tool suggestion: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(build_discoverable_plugin_tools(discoverable_plugins))
|
||||
}
|
||||
|
||||
fn build_plugin_display_names_by_connector_id(
|
||||
capability_summaries: &[PluginCapabilitySummary],
|
||||
) -> HashMap<&str, Vec<String>> {
|
||||
let mut plugin_display_names_by_connector_id: HashMap<&str, BTreeSet<String>> = HashMap::new();
|
||||
for plugin in capability_summaries {
|
||||
for connector_id in &plugin.app_connector_ids {
|
||||
plugin_display_names_by_connector_id
|
||||
.entry(connector_id.0.as_str())
|
||||
.or_default()
|
||||
.insert(plugin.display_name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
plugin_display_names_by_connector_id
|
||||
.into_iter()
|
||||
.map(|(connector_id, plugin_display_names)| {
|
||||
(
|
||||
connector_id,
|
||||
plugin_display_names.into_iter().collect::<Vec<_>>(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "discoverable_catalog_tests.rs"]
|
||||
mod tests;
|
||||
268
codex-rs/core/src/tools/discoverable_catalog_tests.rs
Normal file
268
codex-rs/core/src/tools/discoverable_catalog_tests.rs
Normal file
@@ -0,0 +1,268 @@
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::plugins::AppConnectorId;
|
||||
use crate::plugins::PluginCapabilitySummary;
|
||||
use crate::plugins::PluginInstallRequest;
|
||||
use crate::plugins::PluginsManager;
|
||||
use crate::tools::discoverable::DiscoverablePluginInfo;
|
||||
use crate::tools::discoverable::DiscoverableToolAction;
|
||||
use crate::tools::discoverable::DiscoverableToolType;
|
||||
|
||||
fn connector(id: &str, name: &str) -> AppInfo {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
fn discoverable_plugin(
|
||||
id: &str,
|
||||
name: &str,
|
||||
installed: bool,
|
||||
enabled: bool,
|
||||
) -> DiscoverablePluginInfo {
|
||||
DiscoverablePluginInfo {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
description: None,
|
||||
has_skills: false,
|
||||
mcp_server_names: Vec::new(),
|
||||
app_connector_ids: Vec::new(),
|
||||
marketplace_path: AbsolutePathBuf::from_absolute_path("/tmp").expect("absolute path"),
|
||||
plugin_name: name.to_string(),
|
||||
installed,
|
||||
enabled,
|
||||
}
|
||||
}
|
||||
|
||||
fn write_file(path: &Path, contents: &str) {
|
||||
fs::create_dir_all(path.parent().expect("file should have a parent")).expect("create dir");
|
||||
fs::write(path, contents).expect("write file");
|
||||
}
|
||||
|
||||
fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) {
|
||||
let plugin_root = root.join(dir_name);
|
||||
fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin dir");
|
||||
fs::create_dir_all(plugin_root.join("skills")).expect("create skills dir");
|
||||
fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
format!(r#"{{"name":"{manifest_name}"}}"#),
|
||||
)
|
||||
.expect("write manifest");
|
||||
fs::write(plugin_root.join("skills/SKILL.md"), "skill").expect("write skill");
|
||||
fs::write(plugin_root.join(".mcp.json"), r#"{"mcpServers":{}}"#).expect("write mcp config");
|
||||
}
|
||||
|
||||
fn write_openai_curated_marketplace(root: &Path, plugin_names: &[&str]) {
|
||||
fs::create_dir_all(root.join(".agents/plugins")).expect("create marketplace dir");
|
||||
let plugins = plugin_names
|
||||
.iter()
|
||||
.map(|plugin_name| {
|
||||
format!(
|
||||
r#"{{
|
||||
"name": "{plugin_name}",
|
||||
"source": {{
|
||||
"source": "local",
|
||||
"path": "./plugins/{plugin_name}"
|
||||
}}
|
||||
}}"#
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(",\n");
|
||||
fs::write(
|
||||
root.join(".agents/plugins/marketplace.json"),
|
||||
format!(
|
||||
r#"{{
|
||||
"name": "openai-curated",
|
||||
"plugins": [
|
||||
{plugins}
|
||||
]
|
||||
}}"#
|
||||
),
|
||||
)
|
||||
.expect("write marketplace");
|
||||
for plugin_name in plugin_names {
|
||||
write_plugin(root, &format!("plugins/{plugin_name}"), plugin_name);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_curated_plugin_sha(codex_home: &Path, sha: &str) {
|
||||
write_file(&codex_home.join(".tmp/plugins.sha"), &format!("{sha}\n"));
|
||||
}
|
||||
|
||||
fn write_plugins_feature_enabled(codex_home: &Path) {
|
||||
write_file(
|
||||
&codex_home.join("config.toml"),
|
||||
"[features]\nplugins = true\n",
|
||||
);
|
||||
}
|
||||
|
||||
async fn load_config(codex_home: &Path, cwd: &Path) -> Config {
|
||||
ConfigBuilder::default()
|
||||
.codex_home(codex_home.to_path_buf())
|
||||
.fallback_cwd(Some(cwd.to_path_buf()))
|
||||
.build()
|
||||
.await
|
||||
.expect("config should load")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_accessible_connectors_render_enable_action() {
|
||||
let discoverable = build_discoverable_connector_tools(
|
||||
vec![connector(
|
||||
"connector_68df038e0ba48191908c8434991bbac2",
|
||||
"Gmail",
|
||||
)],
|
||||
&[AppInfo {
|
||||
is_accessible: true,
|
||||
is_enabled: false,
|
||||
..connector("connector_68df038e0ba48191908c8434991bbac2", "Gmail")
|
||||
}],
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
|
||||
assert_eq!(discoverable.len(), 1);
|
||||
let tool = DiscoverableTool::from(discoverable[0].clone());
|
||||
assert_eq!(tool.tool_type(), DiscoverableToolType::Connector);
|
||||
assert_eq!(tool.action(), DiscoverableToolAction::Enable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_accessible_connectors_are_excluded() {
|
||||
let discoverable = build_discoverable_connector_tools(
|
||||
vec![connector(
|
||||
"connector_68df038e0ba48191908c8434991bbac2",
|
||||
"Gmail",
|
||||
)],
|
||||
&[AppInfo {
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
..connector("connector_68df038e0ba48191908c8434991bbac2", "Gmail")
|
||||
}],
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
|
||||
assert_eq!(discoverable, Vec::<AppInfo>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_sourced_inaccessible_connectors_are_included() {
|
||||
let discoverable = build_discoverable_connector_tools(
|
||||
Vec::new(),
|
||||
&[],
|
||||
&[AppConnectorId("connector_plugin_mail".to_string())],
|
||||
&[PluginCapabilitySummary {
|
||||
config_name: "gmail".to_string(),
|
||||
display_name: "Gmail Plugin".to_string(),
|
||||
description: None,
|
||||
has_skills: false,
|
||||
mcp_server_names: Vec::new(),
|
||||
app_connector_ids: vec![AppConnectorId("connector_plugin_mail".to_string())],
|
||||
}],
|
||||
);
|
||||
|
||||
assert_eq!(discoverable.len(), 1);
|
||||
assert_eq!(discoverable[0].id, "connector_plugin_mail");
|
||||
assert_eq!(
|
||||
discoverable[0].plugin_display_names,
|
||||
vec!["Gmail Plugin".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trusted_plugins_surface_with_install_and_enable_actions() {
|
||||
let discoverable_tools = build_discoverable_plugin_tools(vec![
|
||||
discoverable_plugin("gmail@openai-curated", "Gmail", false, false),
|
||||
discoverable_plugin("slack@openai-curated", "Slack", true, false),
|
||||
discoverable_plugin("calendar@openai-curated", "Calendar", true, true),
|
||||
])
|
||||
.into_iter()
|
||||
.map(DiscoverableTool::from)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(discoverable_tools.len(), 2);
|
||||
assert_eq!(discoverable_tools[0].id(), "gmail@openai-curated");
|
||||
assert_eq!(
|
||||
discoverable_tools[0].action(),
|
||||
DiscoverableToolAction::Install
|
||||
);
|
||||
assert_eq!(discoverable_tools[1].id(), "slack@openai-curated");
|
||||
assert_eq!(
|
||||
discoverable_tools[1].action(),
|
||||
DiscoverableToolAction::Enable
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_allowlisted_plugins_stay_hidden() {
|
||||
let discoverable = build_discoverable_plugin_tools(vec![
|
||||
discoverable_plugin("gmail@openai-curated", "Gmail", false, false),
|
||||
discoverable_plugin("not-trusted@test", "Not Trusted", false, false),
|
||||
]);
|
||||
|
||||
assert_eq!(discoverable.len(), 1);
|
||||
assert_eq!(discoverable[0].id, "gmail@openai-curated");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_completion_verified_requires_installed_and_enabled_plugin() {
|
||||
let codex_home = TempDir::new().expect("tempdir");
|
||||
let repo_root = TempDir::new().expect("tempdir");
|
||||
write_plugins_feature_enabled(codex_home.path());
|
||||
write_curated_plugin_sha(
|
||||
codex_home.path(),
|
||||
"0123456789abcdef0123456789abcdef01234567",
|
||||
);
|
||||
write_openai_curated_marketplace(repo_root.path(), &["gmail"]);
|
||||
|
||||
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
|
||||
let config = load_config(codex_home.path(), repo_root.path()).await;
|
||||
assert!(!plugin_completion_verified(
|
||||
&plugins_manager,
|
||||
&config,
|
||||
repo_root.path(),
|
||||
"gmail@openai-curated",
|
||||
));
|
||||
|
||||
plugins_manager
|
||||
.install_plugin(PluginInstallRequest {
|
||||
marketplace_path: AbsolutePathBuf::try_from(
|
||||
repo_root.path().join(".agents/plugins/marketplace.json"),
|
||||
)
|
||||
.expect("absolute marketplace path"),
|
||||
plugin_name: "gmail".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("install plugin");
|
||||
|
||||
let refreshed_config = load_config(codex_home.path(), repo_root.path()).await;
|
||||
plugins_manager.clear_cache();
|
||||
assert!(plugin_completion_verified(
|
||||
&plugins_manager,
|
||||
&refreshed_config,
|
||||
repo_root.path(),
|
||||
"gmail@openai-curated",
|
||||
));
|
||||
}
|
||||
@@ -8,12 +8,16 @@ 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_utils_absolute_path::AbsolutePathBuf;
|
||||
use rmcp::model::RequestId;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::CodexAuth;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::connectors;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
@@ -23,6 +27,7 @@ use crate::tools::context::ToolPayload;
|
||||
use crate::tools::discoverable::DiscoverableTool;
|
||||
use crate::tools::discoverable::DiscoverableToolAction;
|
||||
use crate::tools::discoverable::DiscoverableToolType;
|
||||
use crate::tools::discoverable_catalog;
|
||||
use crate::tools::handlers::parse_arguments;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::tools::registry::ToolKind;
|
||||
@@ -52,14 +57,26 @@ struct ToolSuggestResult {
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq, Eq)]
|
||||
struct ToolSuggestMeta<'a> {
|
||||
struct ToolSuggestMeta {
|
||||
codex_approval_kind: &'static str,
|
||||
tool_type: DiscoverableToolType,
|
||||
suggest_type: DiscoverableToolAction,
|
||||
suggest_reason: &'a str,
|
||||
tool_id: &'a str,
|
||||
tool_name: &'a str,
|
||||
install_url: &'a str,
|
||||
suggest_reason: String,
|
||||
tool_id: String,
|
||||
tool_name: String,
|
||||
#[serde(flatten)]
|
||||
tool_metadata: ToolSuggestMetaToolMetadata,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq, Eq)]
|
||||
#[serde(tag = "tool_type", rename_all = "snake_case")]
|
||||
enum ToolSuggestMetaToolMetadata {
|
||||
Connector {
|
||||
install_url: String,
|
||||
},
|
||||
Plugin {
|
||||
marketplace_path: AbsolutePathBuf,
|
||||
plugin_name: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -95,17 +112,6 @@ impl ToolHandler for ToolSuggestHandler {
|
||||
"suggest_reason must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if args.tool_type == DiscoverableToolType::Plugin {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"plugin tool suggestions are not currently available".to_string(),
|
||||
));
|
||||
}
|
||||
if args.action_type != DiscoverableToolAction::Install {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"connector tool suggestions currently support only action_type=\"install\""
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let auth = session.services.auth_manager.auth().await;
|
||||
let manager = session.services.mcp_connection_manager.read().await;
|
||||
@@ -115,37 +121,45 @@ impl ToolHandler for ToolSuggestHandler {
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||
&turn.config,
|
||||
);
|
||||
let discoverable_tools = connectors::list_tool_suggest_discoverable_tools_with_auth(
|
||||
let loaded_plugins = session
|
||||
.services
|
||||
.plugins_manager
|
||||
.plugins_for_config(&turn.config);
|
||||
let discoverable_tools = discoverable_catalog::load_discoverable_tools(
|
||||
&turn.config,
|
||||
auth.as_ref(),
|
||||
session.services.plugins_manager.as_ref(),
|
||||
&loaded_plugins,
|
||||
turn.cwd.as_path(),
|
||||
&accessible_connectors,
|
||||
)
|
||||
.await
|
||||
.map(|connectors| {
|
||||
connectors
|
||||
.into_iter()
|
||||
.map(DiscoverableTool::from)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"tool suggestions are unavailable right now: {err}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let connector = discoverable_tools
|
||||
let tool = discoverable_tools
|
||||
.into_iter()
|
||||
.find_map(|tool| match tool {
|
||||
DiscoverableTool::Connector(connector) if connector.id == args.tool_id => {
|
||||
Some(*connector)
|
||||
}
|
||||
DiscoverableTool::Connector(_) | DiscoverableTool::Plugin(_) => None,
|
||||
})
|
||||
.find(|tool| tool.id() == args.tool_id)
|
||||
.ok_or_else(|| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"tool_id must match one of the discoverable tools exposed by {TOOL_SUGGEST_TOOL_NAME}"
|
||||
))
|
||||
})?;
|
||||
if args.tool_type != tool.tool_type() {
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"tool_type must match the discoverable tool entry for tool_id `{}`",
|
||||
args.tool_id
|
||||
)));
|
||||
}
|
||||
if args.action_type != tool.action() {
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"action_type must match the discoverable tool entry for tool_id `{}`",
|
||||
args.tool_id
|
||||
)));
|
||||
}
|
||||
|
||||
let request_id = RequestId::String(format!("tool_suggestion_{call_id}").into());
|
||||
let params = build_tool_suggestion_elicitation_request(
|
||||
@@ -153,7 +167,7 @@ impl ToolHandler for ToolSuggestHandler {
|
||||
turn.sub_id.clone(),
|
||||
&args,
|
||||
suggest_reason,
|
||||
&connector,
|
||||
&tool,
|
||||
);
|
||||
let response = session
|
||||
.request_mcp_server_elicitation(turn.as_ref(), request_id, params)
|
||||
@@ -163,37 +177,31 @@ impl ToolHandler for ToolSuggestHandler {
|
||||
.is_some_and(|response| response.action == ElicitationAction::Accept);
|
||||
|
||||
let completed = if user_confirmed {
|
||||
let manager = session.services.mcp_connection_manager.read().await;
|
||||
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,
|
||||
match &tool {
|
||||
DiscoverableTool::Connector(connector) => {
|
||||
refresh_and_verify_connector_suggestion_completed(
|
||||
session.as_ref(),
|
||||
turn.as_ref(),
|
||||
auth.as_ref(),
|
||||
&mcp_tools,
|
||||
);
|
||||
verified_connector_suggestion_completed(
|
||||
args.action_type,
|
||||
connector.id.as_str(),
|
||||
&accessible_connectors,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"failed to refresh codex apps tools cache after tool suggestion for {}: {err:#}",
|
||||
connector.id
|
||||
);
|
||||
false
|
||||
DiscoverableTool::Plugin(plugin) => {
|
||||
refresh_and_verify_plugin_suggestion_completed(
|
||||
session.as_ref(),
|
||||
turn.as_ref(),
|
||||
plugin.id.as_str(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if completed {
|
||||
if completed && let DiscoverableTool::Connector(connector) = &tool {
|
||||
session
|
||||
.merge_connector_selection(HashSet::from([connector.id.clone()]))
|
||||
.await;
|
||||
@@ -204,8 +212,8 @@ impl ToolHandler for ToolSuggestHandler {
|
||||
user_confirmed,
|
||||
tool_type: args.tool_type,
|
||||
action_type: args.action_type,
|
||||
tool_id: connector.id,
|
||||
tool_name: connector.name,
|
||||
tool_id: tool.id().to_string(),
|
||||
tool_name: tool.name().to_string(),
|
||||
suggest_reason: suggest_reason.to_string(),
|
||||
})
|
||||
.map_err(|err| {
|
||||
@@ -223,18 +231,19 @@ fn build_tool_suggestion_elicitation_request(
|
||||
turn_id: String,
|
||||
args: &ToolSuggestArgs,
|
||||
suggest_reason: &str,
|
||||
connector: &AppInfo,
|
||||
tool: &DiscoverableTool,
|
||||
) -> McpServerElicitationRequestParams {
|
||||
let tool_name = connector.name.clone();
|
||||
let install_url = connector
|
||||
.install_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| connectors::connector_install_url(&tool_name, &connector.id));
|
||||
|
||||
let message = format!(
|
||||
"{tool_name} could help with this request.\n\n{suggest_reason}\n\nOpen ChatGPT to {} it, then confirm here if you finish.",
|
||||
args.action_type.as_str()
|
||||
);
|
||||
let tool_name = tool.name().to_string();
|
||||
let message = match tool {
|
||||
DiscoverableTool::Connector(_) => format!(
|
||||
"{tool_name} could help with this request.\n\n{suggest_reason}\n\nOpen ChatGPT to {} it, then confirm here if you finish.",
|
||||
args.action_type.as_str()
|
||||
),
|
||||
DiscoverableTool::Plugin(_) => format!(
|
||||
"{tool_name} could help with this request.\n\n{suggest_reason}\n\nUse Codex to {} it, then confirm here if you finish.",
|
||||
args.action_type.as_str()
|
||||
),
|
||||
};
|
||||
|
||||
McpServerElicitationRequestParams {
|
||||
thread_id,
|
||||
@@ -242,12 +251,9 @@ fn build_tool_suggestion_elicitation_request(
|
||||
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,
|
||||
connector.id.as_str(),
|
||||
tool_name.as_str(),
|
||||
install_url.as_str(),
|
||||
tool,
|
||||
))),
|
||||
message,
|
||||
requested_schema: McpElicitationSchema {
|
||||
@@ -260,22 +266,56 @@ fn build_tool_suggestion_elicitation_request(
|
||||
}
|
||||
}
|
||||
|
||||
fn build_tool_suggestion_meta<'a>(
|
||||
tool_type: DiscoverableToolType,
|
||||
fn build_tool_suggestion_meta(
|
||||
action_type: DiscoverableToolAction,
|
||||
suggest_reason: &'a str,
|
||||
tool_id: &'a str,
|
||||
tool_name: &'a str,
|
||||
install_url: &'a str,
|
||||
) -> ToolSuggestMeta<'a> {
|
||||
suggest_reason: &str,
|
||||
tool: &DiscoverableTool,
|
||||
) -> ToolSuggestMeta {
|
||||
ToolSuggestMeta {
|
||||
codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE,
|
||||
tool_type,
|
||||
suggest_type: action_type,
|
||||
suggest_reason,
|
||||
tool_id,
|
||||
tool_name,
|
||||
install_url,
|
||||
suggest_reason: suggest_reason.to_string(),
|
||||
tool_id: tool.id().to_string(),
|
||||
tool_name: tool.name().to_string(),
|
||||
tool_metadata: match tool {
|
||||
DiscoverableTool::Connector(connector) => ToolSuggestMetaToolMetadata::Connector {
|
||||
install_url: connector_install_url(connector.as_ref()),
|
||||
},
|
||||
DiscoverableTool::Plugin(plugin) => ToolSuggestMetaToolMetadata::Plugin {
|
||||
marketplace_path: plugin.marketplace_path.clone(),
|
||||
plugin_name: plugin.plugin_name.clone(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn refresh_and_verify_connector_suggestion_completed(
|
||||
session: &Session,
|
||||
turn: &TurnContext,
|
||||
auth: Option<&CodexAuth>,
|
||||
action_type: DiscoverableToolAction,
|
||||
tool_id: &str,
|
||||
) -> bool {
|
||||
let manager = session.services.mcp_connection_manager.read().await;
|
||||
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,
|
||||
);
|
||||
verified_connector_suggestion_completed(action_type, tool_id, &accessible_connectors)
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"failed to refresh codex apps tools cache after tool suggestion for {tool_id}: {err:#}"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,6 +333,42 @@ fn verified_connector_suggestion_completed(
|
||||
})
|
||||
}
|
||||
|
||||
async fn refresh_and_verify_plugin_suggestion_completed(
|
||||
session: &Session,
|
||||
turn: &TurnContext,
|
||||
tool_id: &str,
|
||||
) -> bool {
|
||||
let auth = session.services.auth_manager.auth().await;
|
||||
session.reload_user_config_layer().await;
|
||||
let refreshed_turn = session
|
||||
.new_default_turn_with_sub_id(turn.sub_id.clone())
|
||||
.await;
|
||||
let refresh_servers = session
|
||||
.services
|
||||
.mcp_manager
|
||||
.effective_servers(&refreshed_turn.config, auth.as_ref());
|
||||
session
|
||||
.refresh_mcp_servers_now(
|
||||
refreshed_turn.as_ref(),
|
||||
refresh_servers,
|
||||
refreshed_turn.config.mcp_oauth_credentials_store_mode,
|
||||
)
|
||||
.await;
|
||||
discoverable_catalog::plugin_completion_verified(
|
||||
session.services.plugins_manager.as_ref(),
|
||||
&refreshed_turn.config,
|
||||
refreshed_turn.cwd.as_path(),
|
||||
tool_id,
|
||||
)
|
||||
}
|
||||
|
||||
fn connector_install_url(connector: &AppInfo) -> String {
|
||||
connector
|
||||
.install_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| connectors::connector_install_url(&connector.name, &connector.id))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tool_suggest_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,15 +1,44 @@
|
||||
use super::*;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::tools::discoverable::DiscoverablePluginInfo;
|
||||
|
||||
fn discoverable_connector(connector: AppInfo) -> DiscoverableTool {
|
||||
DiscoverableTool::Connector(Box::new(connector))
|
||||
}
|
||||
|
||||
fn discoverable_plugin(installed: bool, enabled: bool) -> DiscoverableTool {
|
||||
DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo {
|
||||
id: "gmail@openai-curated".to_string(),
|
||||
name: "Gmail Plugin".to_string(),
|
||||
description: Some("Read and search Gmail".to_string()),
|
||||
has_skills: true,
|
||||
mcp_server_names: vec!["gmail-mcp".to_string()],
|
||||
app_connector_ids: vec!["connector_gmail".to_string()],
|
||||
marketplace_path: AbsolutePathBuf::try_from(PathBuf::from(
|
||||
"/tmp/marketplaces/openai-curated",
|
||||
))
|
||||
.expect("absolute path"),
|
||||
plugin_name: "gmail".to_string(),
|
||||
installed,
|
||||
enabled,
|
||||
}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_tool_suggestion_elicitation_request_uses_expected_shape() {
|
||||
fn build_tool_suggestion_elicitation_request_uses_expected_shape_for_connector() {
|
||||
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 = AppInfo {
|
||||
let connector = discoverable_connector(AppInfo {
|
||||
id: "connector_2128aebfecb84f64a069897515042a44".to_string(),
|
||||
name: "Google Calendar".to_string(),
|
||||
description: Some("Plan events and schedules.".to_string()),
|
||||
@@ -26,7 +55,7 @@ fn build_tool_suggestion_elicitation_request_uses_expected_shape() {
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
};
|
||||
});
|
||||
|
||||
let request = build_tool_suggestion_elicitation_request(
|
||||
"thread-1".to_string(),
|
||||
@@ -37,54 +66,152 @@ fn build_tool_suggestion_elicitation_request_uses_expected_shape() {
|
||||
);
|
||||
|
||||
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: "https://chatgpt.com/apps/google-calendar/connector_2128aebfecb84f64a069897515042a44",
|
||||
})),
|
||||
message: "Google Calendar could help with this request.\n\nPlan and reference events from your calendar\n\nOpen ChatGPT to install it, then confirm here if you finish.".to_string(),
|
||||
requested_schema: McpElicitationSchema {
|
||||
schema_uri: None,
|
||||
type_: McpElicitationObjectType::Object,
|
||||
properties: BTreeMap::new(),
|
||||
required: None,
|
||||
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,
|
||||
suggest_type: DiscoverableToolAction::Install,
|
||||
suggest_reason: "Plan and reference events from your calendar".to_string(),
|
||||
tool_id: "connector_2128aebfecb84f64a069897515042a44".to_string(),
|
||||
tool_name: "Google Calendar".to_string(),
|
||||
tool_metadata: ToolSuggestMetaToolMetadata::Connector {
|
||||
install_url: "https://chatgpt.com/apps/google-calendar/connector_2128aebfecb84f64a069897515042a44"
|
||||
.to_string(),
|
||||
},
|
||||
})),
|
||||
message: "Google Calendar could help with this request.\n\nPlan and reference events from your calendar\n\nOpen ChatGPT to install it, then confirm here if you finish.".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() {
|
||||
fn build_tool_suggestion_elicitation_request_uses_expected_shape_for_plugin() {
|
||||
let args = ToolSuggestArgs {
|
||||
tool_type: DiscoverableToolType::Plugin,
|
||||
action_type: DiscoverableToolAction::Enable,
|
||||
tool_id: "gmail@openai-curated".to_string(),
|
||||
suggest_reason: "Search your inbox directly from Codex".to_string(),
|
||||
};
|
||||
let plugin = discoverable_plugin(true, false);
|
||||
|
||||
let request = build_tool_suggestion_elicitation_request(
|
||||
"thread-1".to_string(),
|
||||
"turn-1".to_string(),
|
||||
&args,
|
||||
"Search your inbox directly from Codex",
|
||||
&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,
|
||||
suggest_type: DiscoverableToolAction::Enable,
|
||||
suggest_reason: "Search your inbox directly from Codex".to_string(),
|
||||
tool_id: "gmail@openai-curated".to_string(),
|
||||
tool_name: "Gmail Plugin".to_string(),
|
||||
tool_metadata: ToolSuggestMetaToolMetadata::Plugin {
|
||||
marketplace_path: AbsolutePathBuf::try_from(PathBuf::from(
|
||||
"/tmp/marketplaces/openai-curated",
|
||||
))
|
||||
.expect("absolute path"),
|
||||
plugin_name: "gmail".to_string(),
|
||||
},
|
||||
})),
|
||||
message: "Gmail Plugin could help with this request.\n\nSearch your inbox directly from Codex\n\nUse Codex to enable it, then confirm here if you finish.".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_for_connector() {
|
||||
let connector = discoverable_connector(AppInfo {
|
||||
id: "connector_68df038e0ba48191908c8434991bbac2".to_string(),
|
||||
name: "Gmail".to_string(),
|
||||
description: Some("Search email".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/gmail/connector_68df038e0ba48191908c8434991bbac2".to_string(),
|
||||
),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
});
|
||||
let meta = build_tool_suggestion_meta(
|
||||
DiscoverableToolType::Connector,
|
||||
DiscoverableToolAction::Install,
|
||||
"Find and reference emails from your inbox",
|
||||
"connector_68df038e0ba48191908c8434991bbac2",
|
||||
"Gmail",
|
||||
"https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2",
|
||||
&connector,
|
||||
);
|
||||
|
||||
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: "https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2",
|
||||
suggest_reason: "Find and reference emails from your inbox".to_string(),
|
||||
tool_id: "connector_68df038e0ba48191908c8434991bbac2".to_string(),
|
||||
tool_name: "Gmail".to_string(),
|
||||
tool_metadata: ToolSuggestMetaToolMetadata::Connector {
|
||||
install_url:
|
||||
"https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2"
|
||||
.to_string(),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_tool_suggestion_meta_uses_expected_shape_for_plugin() {
|
||||
let plugin = discoverable_plugin(true, false);
|
||||
let meta = build_tool_suggestion_meta(
|
||||
DiscoverableToolAction::Enable,
|
||||
"Search your inbox directly from Codex",
|
||||
&plugin,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
meta,
|
||||
ToolSuggestMeta {
|
||||
codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE,
|
||||
suggest_type: DiscoverableToolAction::Enable,
|
||||
suggest_reason: "Search your inbox directly from Codex".to_string(),
|
||||
tool_id: "gmail@openai-curated".to_string(),
|
||||
tool_name: "Gmail Plugin".to_string(),
|
||||
tool_metadata: ToolSuggestMetaToolMetadata::Plugin {
|
||||
marketplace_path: AbsolutePathBuf::try_from(PathBuf::from(
|
||||
"/tmp/marketplaces/openai-curated",
|
||||
))
|
||||
.expect("absolute path"),
|
||||
plugin_name: "gmail".to_string(),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod code_mode;
|
||||
pub(crate) mod code_mode_description;
|
||||
pub mod context;
|
||||
pub(crate) mod discoverable;
|
||||
pub(crate) mod discoverable_catalog;
|
||||
pub mod events;
|
||||
pub(crate) mod handlers;
|
||||
pub mod js_repl;
|
||||
|
||||
@@ -19,8 +19,6 @@ use crate::tools::code_mode::wait_tool_description as code_mode_wait_tool_descri
|
||||
use crate::tools::code_mode_description::augment_tool_spec_for_code_mode;
|
||||
use crate::tools::discoverable::DiscoverablePluginInfo;
|
||||
use crate::tools::discoverable::DiscoverableTool;
|
||||
use crate::tools::discoverable::DiscoverableToolAction;
|
||||
use crate::tools::discoverable::DiscoverableToolType;
|
||||
use crate::tools::handlers::PLAN_TOOL;
|
||||
use crate::tools::handlers::TOOL_SEARCH_DEFAULT_LIMIT;
|
||||
use crate::tools::handlers::TOOL_SEARCH_TOOL_NAME;
|
||||
@@ -1814,24 +1812,19 @@ fn format_discoverable_tools(discoverable_tools: &[DiscoverableTool]) -> String
|
||||
discoverable_tools
|
||||
.into_iter()
|
||||
.map(|tool| {
|
||||
let description = tool
|
||||
.description()
|
||||
.filter(|description| !description.trim().is_empty())
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| match &tool {
|
||||
DiscoverableTool::Connector(_) => "No description provided.".to_string(),
|
||||
DiscoverableTool::Plugin(plugin) => format_plugin_summary(plugin.as_ref()),
|
||||
});
|
||||
let default_action = match tool.tool_type() {
|
||||
DiscoverableToolType::Connector => DiscoverableToolAction::Install,
|
||||
DiscoverableToolType::Plugin => DiscoverableToolAction::Enable,
|
||||
let description = match &tool {
|
||||
DiscoverableTool::Connector(connector) => format_connector_summary(
|
||||
connector.as_ref(),
|
||||
connector.plugin_display_names.as_slice(),
|
||||
),
|
||||
DiscoverableTool::Plugin(plugin) => format_plugin_summary(plugin.as_ref()),
|
||||
};
|
||||
format!(
|
||||
"- {} (id: `{}`, type: {}, action: {}): {}",
|
||||
tool.name(),
|
||||
tool.id(),
|
||||
tool.tool_type().as_str(),
|
||||
default_action.as_str(),
|
||||
tool.action().as_str(),
|
||||
description
|
||||
)
|
||||
})
|
||||
@@ -1839,6 +1832,33 @@ fn format_discoverable_tools(discoverable_tools: &[DiscoverableTool]) -> String
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn format_connector_summary(
|
||||
connector: &codex_app_server_protocol::AppInfo,
|
||||
plugin_display_names: &[String],
|
||||
) -> String {
|
||||
let mut details = Vec::new();
|
||||
if let Some(description) = connector
|
||||
.description
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|description| !description.is_empty())
|
||||
{
|
||||
details.push(description.to_string());
|
||||
}
|
||||
if !plugin_display_names.is_empty() {
|
||||
details.push(format!(
|
||||
"available through plugins: {}",
|
||||
plugin_display_names.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
if details.is_empty() {
|
||||
"No description provided.".to_string()
|
||||
} else {
|
||||
details.join("; ")
|
||||
}
|
||||
}
|
||||
|
||||
fn format_plugin_summary(plugin: &DiscoverablePluginInfo) -> String {
|
||||
let mut details = Vec::new();
|
||||
if plugin.has_skills {
|
||||
|
||||
@@ -2054,11 +2054,24 @@ fn tool_suggest_description_lists_discoverable_tools() {
|
||||
});
|
||||
|
||||
let discoverable_tools = vec![
|
||||
discoverable_connector(
|
||||
"connector_2128aebfecb84f64a069897515042a44",
|
||||
"Google Calendar",
|
||||
"Plan events and schedules.",
|
||||
),
|
||||
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!["Calendar Plugin".to_string()],
|
||||
})),
|
||||
discoverable_connector(
|
||||
"connector_68df038e0ba48191908c8434991bbac2",
|
||||
"Gmail",
|
||||
@@ -2071,6 +2084,11 @@ fn tool_suggest_description_lists_discoverable_tools() {
|
||||
has_skills: true,
|
||||
mcp_server_names: vec!["sample-docs".to_string()],
|
||||
app_connector_ids: vec!["connector_sample".to_string()],
|
||||
marketplace_path: AbsolutePathBuf::try_from(PathBuf::from("/tmp/openai-curated"))
|
||||
.expect("absolute path"),
|
||||
plugin_name: "sample".to_string(),
|
||||
installed: true,
|
||||
enabled: false,
|
||||
})),
|
||||
];
|
||||
|
||||
@@ -2097,10 +2115,15 @@ fn tool_suggest_description_lists_discoverable_tools() {
|
||||
assert!(description.contains("Sample Plugin"));
|
||||
assert!(description.contains("Plan events and schedules."));
|
||||
assert!(description.contains("Find and summarize email threads."));
|
||||
assert!(description.contains("available through plugins: Calendar Plugin"));
|
||||
assert!(description.contains("id: `sample@test`, type: plugin, action: enable"));
|
||||
assert!(
|
||||
description.contains("skills; MCP servers: sample-docs; app connectors: connector_sample")
|
||||
);
|
||||
assert!(
|
||||
description
|
||||
.contains("the tool should become available on the next router rebuild or next turn")
|
||||
);
|
||||
assert!(description.contains("DO NOT explore or recommend tools that are not on this list."));
|
||||
let JsonSchema::Object { required, .. } = parameters else {
|
||||
panic!("expected object parameters");
|
||||
|
||||
@@ -21,5 +21,5 @@ Workflow:
|
||||
- `tool_id`: exact id from the discoverable tools list above
|
||||
- `suggest_reason`: concise one-line user-facing reason this tool can help with the current request
|
||||
3. After the suggestion flow completes:
|
||||
- if the user finished the install or enable flow, continue by searching again or using the newly available tool
|
||||
- if the user finished the install or enable flow, continue by searching again; the tool should become available on the next router rebuild or next turn
|
||||
- if the user did not finish, continue without that tool, and don't suggest that tool again unless the user explicitly asks you to.
|
||||
|
||||
@@ -56,6 +56,7 @@ use codex_core::features::Feature;
|
||||
use codex_core::models_manager::manager::RefreshStrategy;
|
||||
use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG;
|
||||
use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG;
|
||||
use codex_core::plugins::PluginInstallRequest;
|
||||
#[cfg(target_os = "windows")]
|
||||
use codex_core::windows_sandbox::WindowsSandboxLevelExt;
|
||||
use codex_otel::SessionTelemetry;
|
||||
@@ -801,6 +802,81 @@ impl App {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn finish_plugin_suggestion_update(&mut self, action: &str) {
|
||||
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
|
||||
tracing::warn!(error = %err, action, "failed to refresh config after plugin update");
|
||||
}
|
||||
self.chat_widget.submit_op(Op::ReloadUserConfig);
|
||||
self.chat_widget.refresh_connectors(true);
|
||||
}
|
||||
|
||||
async fn install_suggested_plugin(
|
||||
&mut self,
|
||||
marketplace_path: AbsolutePathBuf,
|
||||
plugin_name: String,
|
||||
) {
|
||||
let enable_plugins_feature = ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
.set_feature_enabled(Feature::Plugins.key(), true)
|
||||
.apply()
|
||||
.await;
|
||||
if let Err(err) = enable_plugins_feature {
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to enable plugins feature for {plugin_name}: {err}"
|
||||
));
|
||||
return;
|
||||
}
|
||||
if let Err(err) = self.config.features.set_enabled(Feature::Plugins, true) {
|
||||
tracing::warn!(error = %err, "failed to update in-memory plugins feature state");
|
||||
}
|
||||
self.chat_widget.set_feature_enabled(Feature::Plugins, true);
|
||||
|
||||
match self
|
||||
.server
|
||||
.plugins_manager()
|
||||
.install_plugin(PluginInstallRequest {
|
||||
marketplace_path,
|
||||
plugin_name: plugin_name.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
self.finish_plugin_suggestion_update("plugin install").await;
|
||||
}
|
||||
Err(err) => {
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to install plugin {plugin_name}: {err}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn enable_suggested_plugin(&mut self, plugin_id: String) {
|
||||
match ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
.set_feature_enabled(Feature::Plugins.key(), true)
|
||||
.with_edits([ConfigEdit::SetPath {
|
||||
segments: vec![
|
||||
"plugins".to_string(),
|
||||
plugin_id.clone(),
|
||||
"enabled".to_string(),
|
||||
],
|
||||
value: true.into(),
|
||||
}])
|
||||
.apply()
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
if let Err(err) = self.config.features.set_enabled(Feature::Plugins, true) {
|
||||
tracing::warn!(error = %err, "failed to update in-memory plugins feature state");
|
||||
}
|
||||
self.chat_widget.set_feature_enabled(Feature::Plugins, true);
|
||||
self.finish_plugin_suggestion_update("plugin enable").await;
|
||||
}
|
||||
Err(err) => {
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to enable plugin {plugin_id}: {err}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn refresh_in_memory_config_from_disk_best_effort(&mut self, action: &str) {
|
||||
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
|
||||
tracing::warn!(
|
||||
@@ -3583,6 +3659,16 @@ impl App {
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::InstallSuggestedPlugin {
|
||||
marketplace_path,
|
||||
plugin_name,
|
||||
} => {
|
||||
self.install_suggested_plugin(marketplace_path, plugin_name)
|
||||
.await;
|
||||
}
|
||||
AppEvent::EnableSuggestedPlugin { plugin_id } => {
|
||||
self.enable_suggested_plugin(plugin_id).await;
|
||||
}
|
||||
AppEvent::OpenPermissionsPopup => {
|
||||
self.chat_widget.open_permissions_popup();
|
||||
}
|
||||
@@ -4236,14 +4322,17 @@ mod tests {
|
||||
use codex_protocol::protocol::UserMessageEvent;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use insta::assert_snapshot;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::prelude::Line;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use tempfile::tempdir;
|
||||
use tokio::sync::mpsc::UnboundedReceiver;
|
||||
use tokio::time;
|
||||
|
||||
#[test]
|
||||
@@ -5932,6 +6021,94 @@ guardian_approval = true
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_suggested_plugin_updates_config_and_reloads_user_config() -> Result<()> {
|
||||
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
||||
let codex_home = tempdir()?;
|
||||
set_test_app_home(&mut app, codex_home.path().to_path_buf()).await?;
|
||||
let repo_root = codex_home.path().join("repo");
|
||||
let marketplace_path = write_test_plugin_marketplace(&repo_root, "debug", "sample-plugin");
|
||||
|
||||
app.install_suggested_plugin(marketplace_path, "sample-plugin".to_string())
|
||||
.await;
|
||||
|
||||
assert!(app.config.features.enabled(Feature::Plugins));
|
||||
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
|
||||
assert!(config.contains("plugins = true"));
|
||||
assert!(
|
||||
config.contains(r#"[plugins."sample-plugin@debug"]"#),
|
||||
"unexpected config: {config}"
|
||||
);
|
||||
assert!(config.contains("enabled = true"));
|
||||
expect_reload_user_config_op(&mut op_rx);
|
||||
assert!(drain_history_messages(&mut app_event_rx).is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_suggested_plugin_reports_errors_without_reloading_user_config() -> Result<()> {
|
||||
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
||||
let codex_home = tempdir()?;
|
||||
set_test_app_home(&mut app, codex_home.path().to_path_buf()).await?;
|
||||
let repo_root = codex_home.path().join("repo");
|
||||
let marketplace_path = write_test_plugin_marketplace(&repo_root, "debug", "other-plugin");
|
||||
|
||||
app.install_suggested_plugin(marketplace_path, "missing-plugin".to_string())
|
||||
.await;
|
||||
|
||||
let history = drain_history_messages(&mut app_event_rx).join("\n");
|
||||
assert!(history.contains("Failed to install plugin missing-plugin"));
|
||||
assert_no_reload_user_config_op(&mut op_rx);
|
||||
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
|
||||
assert!(config.contains("plugins = true"));
|
||||
assert!(!config.contains(r#"[plugins."missing-plugin@debug"]"#));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enable_suggested_plugin_updates_config_and_reloads_user_config() -> Result<()> {
|
||||
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
||||
let codex_home = tempdir()?;
|
||||
set_test_app_home(&mut app, codex_home.path().to_path_buf()).await?;
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
r#"[features]
|
||||
plugins = false
|
||||
|
||||
[plugins."sample-plugin@debug"]
|
||||
enabled = false
|
||||
"#,
|
||||
)?;
|
||||
|
||||
app.enable_suggested_plugin("sample-plugin@debug".to_string())
|
||||
.await;
|
||||
|
||||
assert!(app.config.features.enabled(Feature::Plugins));
|
||||
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
|
||||
assert!(config.contains("plugins = true"));
|
||||
assert!(config.contains(r#"[plugins."sample-plugin@debug"]"#));
|
||||
assert!(config.contains("enabled = true"));
|
||||
expect_reload_user_config_op(&mut op_rx);
|
||||
assert!(drain_history_messages(&mut app_event_rx).is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enable_suggested_plugin_reports_errors_without_reloading_user_config() -> Result<()> {
|
||||
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
||||
let codex_home = tempdir()?;
|
||||
set_test_app_home(&mut app, codex_home.path().to_path_buf()).await?;
|
||||
std::fs::write(codex_home.path().join("config.toml"), "[broken")?;
|
||||
|
||||
app.enable_suggested_plugin("sample-plugin@debug".to_string())
|
||||
.await;
|
||||
|
||||
let history = drain_history_messages(&mut app_event_rx).join("\n");
|
||||
assert!(history.contains("Failed to enable plugin sample-plugin@debug"));
|
||||
assert_no_reload_user_config_op(&mut op_rx);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_agent_picker_allows_existing_agent_threads_when_feature_is_disabled() -> Result<()>
|
||||
{
|
||||
@@ -6470,6 +6647,21 @@ guardian_approval = true
|
||||
)
|
||||
}
|
||||
|
||||
async fn set_test_app_home(app: &mut App, codex_home: PathBuf) -> Result<()> {
|
||||
app.config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.clone())
|
||||
.build()
|
||||
.await?;
|
||||
app.server = Arc::new(
|
||||
codex_core::test_support::thread_manager_with_models_provider_and_home(
|
||||
CodexAuth::from_api_key("Test API Key"),
|
||||
app.config.model_provider.clone(),
|
||||
codex_home,
|
||||
),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn next_user_turn_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) -> Op {
|
||||
let mut seen = Vec::new();
|
||||
while let Ok(op) = op_rx.try_recv() {
|
||||
@@ -6481,6 +6673,85 @@ guardian_approval = true
|
||||
panic!("expected UserTurn op, saw: {seen:?}");
|
||||
}
|
||||
|
||||
fn expect_reload_user_config_op(op_rx: &mut UnboundedReceiver<Op>) {
|
||||
let mut seen = Vec::new();
|
||||
while let Ok(op) = op_rx.try_recv() {
|
||||
if matches!(op, Op::ReloadUserConfig) {
|
||||
return;
|
||||
}
|
||||
seen.push(format!("{op:?}"));
|
||||
}
|
||||
panic!("expected ReloadUserConfig op, saw: {seen:?}");
|
||||
}
|
||||
|
||||
fn assert_no_reload_user_config_op(op_rx: &mut UnboundedReceiver<Op>) {
|
||||
while let Ok(op) = op_rx.try_recv() {
|
||||
assert!(
|
||||
!matches!(op, Op::ReloadUserConfig),
|
||||
"unexpected ReloadUserConfig op: {op:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_history_messages(app_event_rx: &mut UnboundedReceiver<AppEvent>) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
while let Ok(event) = app_event_rx.try_recv() {
|
||||
if let AppEvent::InsertHistoryCell(cell) = event {
|
||||
out.push(
|
||||
cell.display_lines(120)
|
||||
.into_iter()
|
||||
.map(|line| line.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn write_test_plugin_marketplace(
|
||||
repo_root: &Path,
|
||||
marketplace_name: &str,
|
||||
plugin_name: &str,
|
||||
) -> AbsolutePathBuf {
|
||||
let plugin_root = repo_root.join(plugin_name);
|
||||
std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("plugin metadata dir");
|
||||
std::fs::create_dir_all(plugin_root.join("skills")).expect("plugin skills dir");
|
||||
std::fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
format!(r#"{{"name":"{plugin_name}"}}"#),
|
||||
)
|
||||
.expect("plugin manifest");
|
||||
std::fs::write(plugin_root.join("skills/SKILL.md"), "skill").expect("skill file");
|
||||
std::fs::write(plugin_root.join(".mcp.json"), r#"{"mcpServers":{}}"#)
|
||||
.expect("mcp manifest");
|
||||
|
||||
let marketplace_path = repo_root.join(".agents/plugins/marketplace.json");
|
||||
let parent = marketplace_path.parent().expect("marketplace parent");
|
||||
std::fs::create_dir_all(parent).expect("marketplace dir");
|
||||
std::fs::write(
|
||||
&marketplace_path,
|
||||
format!(
|
||||
r#"{{
|
||||
"name": "{marketplace_name}",
|
||||
"plugins": [
|
||||
{{
|
||||
"name": "{plugin_name}",
|
||||
"source": {{
|
||||
"source": "local",
|
||||
"path": "./{plugin_name}"
|
||||
}},
|
||||
"authPolicy": "ON_USE"
|
||||
}}
|
||||
]
|
||||
}}"#
|
||||
),
|
||||
)
|
||||
.expect("marketplace file");
|
||||
|
||||
AbsolutePathBuf::try_from(marketplace_path).expect("absolute marketplace path")
|
||||
}
|
||||
|
||||
fn test_session_telemetry(config: &Config, model: &str) -> SessionTelemetry {
|
||||
let model_info = codex_core::test_support::construct_model_info_offline(model, config);
|
||||
SessionTelemetry::new(
|
||||
|
||||
@@ -16,6 +16,7 @@ use codex_protocol::ThreadId;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::RateLimitSnapshot;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_approval_presets::ApprovalPreset;
|
||||
|
||||
use crate::bottom_pane::ApprovalRequest;
|
||||
@@ -379,6 +380,17 @@ pub(crate) enum AppEvent {
|
||||
enabled: bool,
|
||||
},
|
||||
|
||||
/// Install a suggested plugin from the given marketplace.
|
||||
InstallSuggestedPlugin {
|
||||
marketplace_path: AbsolutePathBuf,
|
||||
plugin_name: String,
|
||||
},
|
||||
|
||||
/// Enable a suggested plugin by plugin ID.
|
||||
EnableSuggestedPlugin {
|
||||
plugin_id: String,
|
||||
},
|
||||
|
||||
/// Notify that the manage skills popup was closed.
|
||||
ManageSkillsClosed,
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ use codex_protocol::approvals::ElicitationRequestEvent;
|
||||
use codex_protocol::mcp::RequestId as McpRequestId;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
@@ -70,6 +71,8 @@ const TOOL_NAME_KEY: &str = "tool_name";
|
||||
const TOOL_SUGGEST_SUGGEST_TYPE_KEY: &str = "suggest_type";
|
||||
const TOOL_SUGGEST_REASON_KEY: &str = "suggest_reason";
|
||||
const TOOL_SUGGEST_INSTALL_URL_KEY: &str = "install_url";
|
||||
const TOOL_SUGGEST_MARKETPLACE_PATH_KEY: &str = "marketplace_path";
|
||||
const TOOL_SUGGEST_PLUGIN_NAME_KEY: &str = "plugin_name";
|
||||
|
||||
#[derive(Clone, PartialEq, Default)]
|
||||
struct ComposerDraft {
|
||||
@@ -130,26 +133,30 @@ enum McpServerElicitationResponseMode {
|
||||
ApprovalAction,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ToolSuggestionToolType {
|
||||
Connector,
|
||||
Plugin,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ToolSuggestionType {
|
||||
Install,
|
||||
Enable,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ToolSuggestionPayload {
|
||||
Connector {
|
||||
install_url: String,
|
||||
},
|
||||
Plugin {
|
||||
marketplace_path: AbsolutePathBuf,
|
||||
plugin_name: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct ToolSuggestionRequest {
|
||||
pub(crate) tool_type: ToolSuggestionToolType,
|
||||
pub(crate) suggest_type: ToolSuggestionType,
|
||||
pub(crate) suggest_reason: String,
|
||||
pub(crate) tool_id: String,
|
||||
pub(crate) tool_name: String,
|
||||
pub(crate) install_url: String,
|
||||
pub(crate) payload: ToolSuggestionPayload,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -348,11 +355,6 @@ fn parse_tool_suggestion_request(meta: Option<&Value>) -> Option<ToolSuggestionR
|
||||
return None;
|
||||
}
|
||||
|
||||
let tool_type = match meta.get(TOOL_TYPE_KEY).and_then(Value::as_str) {
|
||||
Some("connector") => ToolSuggestionToolType::Connector,
|
||||
Some("plugin") => ToolSuggestionToolType::Plugin,
|
||||
_ => return None,
|
||||
};
|
||||
let suggest_type = match meta
|
||||
.get(TOOL_SUGGEST_SUGGEST_TYPE_KEY)
|
||||
.and_then(Value::as_str)
|
||||
@@ -362,8 +364,28 @@ fn parse_tool_suggestion_request(meta: Option<&Value>) -> Option<ToolSuggestionR
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let payload = match meta.get(TOOL_TYPE_KEY).and_then(Value::as_str) {
|
||||
Some("connector") => ToolSuggestionPayload::Connector {
|
||||
install_url: meta
|
||||
.get(TOOL_SUGGEST_INSTALL_URL_KEY)
|
||||
.and_then(Value::as_str)?
|
||||
.to_string(),
|
||||
},
|
||||
Some("plugin") => ToolSuggestionPayload::Plugin {
|
||||
marketplace_path: AbsolutePathBuf::try_from(PathBuf::from(
|
||||
meta.get(TOOL_SUGGEST_MARKETPLACE_PATH_KEY)
|
||||
.and_then(Value::as_str)?,
|
||||
))
|
||||
.ok()?,
|
||||
plugin_name: meta
|
||||
.get(TOOL_SUGGEST_PLUGIN_NAME_KEY)
|
||||
.and_then(Value::as_str)?
|
||||
.to_string(),
|
||||
},
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some(ToolSuggestionRequest {
|
||||
tool_type,
|
||||
suggest_type,
|
||||
suggest_reason: meta
|
||||
.get(TOOL_SUGGEST_REASON_KEY)
|
||||
@@ -371,10 +393,7 @@ fn parse_tool_suggestion_request(meta: Option<&Value>) -> Option<ToolSuggestionR
|
||||
.to_string(),
|
||||
tool_id: meta.get(TOOL_ID_KEY).and_then(Value::as_str)?.to_string(),
|
||||
tool_name: meta.get(TOOL_NAME_KEY).and_then(Value::as_str)?.to_string(),
|
||||
install_url: meta
|
||||
.get(TOOL_SUGGEST_INSTALL_URL_KEY)
|
||||
.and_then(Value::as_str)?
|
||||
.to_string(),
|
||||
payload,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1940,12 +1959,52 @@ mod tests {
|
||||
assert_eq!(
|
||||
request.tool_suggestion(),
|
||||
Some(&ToolSuggestionRequest {
|
||||
tool_type: ToolSuggestionToolType::Connector,
|
||||
suggest_type: ToolSuggestionType::Install,
|
||||
suggest_reason: "Plan and reference events from your calendar".to_string(),
|
||||
tool_id: "connector_2128aebfecb84f64a069897515042a44".to_string(),
|
||||
tool_name: "Google Calendar".to_string(),
|
||||
install_url: "https://example.test/google-calendar".to_string(),
|
||||
payload: ToolSuggestionPayload::Connector {
|
||||
install_url: "https://example.test/google-calendar".to_string(),
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_tool_suggestion_meta_is_parsed_into_request_payload() {
|
||||
let request = McpServerElicitationFormRequest::from_event(
|
||||
ThreadId::default(),
|
||||
form_request(
|
||||
"Suggest Gmail Plugin",
|
||||
empty_object_schema(),
|
||||
Some(serde_json::json!({
|
||||
"codex_approval_kind": "tool_suggestion",
|
||||
"tool_type": "plugin",
|
||||
"suggest_type": "enable",
|
||||
"suggest_reason": "Search your inbox directly from Codex",
|
||||
"tool_id": "gmail@openai-curated",
|
||||
"tool_name": "Gmail Plugin",
|
||||
"marketplace_path": "/tmp/openai-curated",
|
||||
"plugin_name": "gmail",
|
||||
})),
|
||||
),
|
||||
)
|
||||
.expect("expected tool suggestion form");
|
||||
|
||||
assert_eq!(
|
||||
request.tool_suggestion(),
|
||||
Some(&ToolSuggestionRequest {
|
||||
suggest_type: ToolSuggestionType::Enable,
|
||||
suggest_reason: "Search your inbox directly from Codex".to_string(),
|
||||
tool_id: "gmail@openai-curated".to_string(),
|
||||
tool_name: "Gmail Plugin".to_string(),
|
||||
payload: ToolSuggestionPayload::Plugin {
|
||||
marketplace_path: AbsolutePathBuf::try_from(std::path::PathBuf::from(
|
||||
"/tmp/openai-curated",
|
||||
))
|
||||
.expect("absolute path"),
|
||||
plugin_name: "gmail".to_string(),
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ mod app_link_view;
|
||||
mod approval_overlay;
|
||||
mod mcp_server_elicitation;
|
||||
mod multi_select_picker;
|
||||
mod plugin_suggestion_view;
|
||||
mod request_user_input;
|
||||
mod status_line_setup;
|
||||
pub(crate) use app_link_view::AppLinkElicitationTarget;
|
||||
@@ -56,6 +57,9 @@ pub(crate) use approval_overlay::ApprovalRequest;
|
||||
pub(crate) use approval_overlay::format_requested_permissions_rule;
|
||||
pub(crate) use mcp_server_elicitation::McpServerElicitationFormRequest;
|
||||
pub(crate) use mcp_server_elicitation::McpServerElicitationOverlay;
|
||||
pub(crate) use plugin_suggestion_view::PluginSuggestionElicitationTarget;
|
||||
pub(crate) use plugin_suggestion_view::PluginSuggestionView;
|
||||
pub(crate) use plugin_suggestion_view::PluginSuggestionViewParams;
|
||||
pub(crate) use request_user_input::RequestUserInputOverlay;
|
||||
mod bottom_pane_view;
|
||||
|
||||
@@ -966,48 +970,79 @@ impl BottomPane {
|
||||
};
|
||||
|
||||
if let Some(tool_suggestion) = request.tool_suggestion() {
|
||||
let suggestion_type = match tool_suggestion.suggest_type {
|
||||
mcp_server_elicitation::ToolSuggestionType::Install => {
|
||||
AppLinkSuggestionType::Install
|
||||
}
|
||||
mcp_server_elicitation::ToolSuggestionType::Enable => AppLinkSuggestionType::Enable,
|
||||
};
|
||||
let is_installed = matches!(
|
||||
tool_suggestion.suggest_type,
|
||||
mcp_server_elicitation::ToolSuggestionType::Enable
|
||||
);
|
||||
let view = AppLinkView::new(
|
||||
AppLinkViewParams {
|
||||
app_id: tool_suggestion.tool_id.clone(),
|
||||
title: tool_suggestion.tool_name.clone(),
|
||||
description: None,
|
||||
instructions: match suggestion_type {
|
||||
AppLinkSuggestionType::Install => {
|
||||
"Install this app in your browser, then return here.".to_string()
|
||||
}
|
||||
AppLinkSuggestionType::Enable => {
|
||||
"Enable this app to use it for the current request.".to_string()
|
||||
}
|
||||
},
|
||||
url: tool_suggestion.install_url.clone(),
|
||||
is_installed,
|
||||
is_enabled: false,
|
||||
suggest_reason: Some(tool_suggestion.suggest_reason.clone()),
|
||||
suggestion_type: Some(suggestion_type),
|
||||
elicitation_target: Some(AppLinkElicitationTarget {
|
||||
thread_id: request.thread_id(),
|
||||
server_name: request.server_name().to_string(),
|
||||
request_id: request.request_id().clone(),
|
||||
}),
|
||||
},
|
||||
self.app_event_tx.clone(),
|
||||
);
|
||||
self.pause_status_timer_for_modal();
|
||||
self.set_composer_input_enabled(
|
||||
/*enabled*/ false,
|
||||
Some("Respond to the tool suggestion to continue.".to_string()),
|
||||
);
|
||||
self.push_view(Box::new(view));
|
||||
match &tool_suggestion.payload {
|
||||
mcp_server_elicitation::ToolSuggestionPayload::Connector { install_url } => {
|
||||
let elicitation_target = Some(AppLinkElicitationTarget {
|
||||
thread_id: request.thread_id(),
|
||||
server_name: request.server_name().to_string(),
|
||||
request_id: request.request_id().clone(),
|
||||
});
|
||||
let suggestion_type = match tool_suggestion.suggest_type {
|
||||
mcp_server_elicitation::ToolSuggestionType::Install => {
|
||||
AppLinkSuggestionType::Install
|
||||
}
|
||||
mcp_server_elicitation::ToolSuggestionType::Enable => {
|
||||
AppLinkSuggestionType::Enable
|
||||
}
|
||||
};
|
||||
let is_installed = matches!(
|
||||
tool_suggestion.suggest_type,
|
||||
mcp_server_elicitation::ToolSuggestionType::Enable
|
||||
);
|
||||
let view = AppLinkView::new(
|
||||
AppLinkViewParams {
|
||||
app_id: tool_suggestion.tool_id.clone(),
|
||||
title: tool_suggestion.tool_name.clone(),
|
||||
description: None,
|
||||
instructions: match suggestion_type {
|
||||
AppLinkSuggestionType::Install => {
|
||||
"Install this app in your browser, then return here."
|
||||
.to_string()
|
||||
}
|
||||
AppLinkSuggestionType::Enable => {
|
||||
"Enable this app to use it for the current request.".to_string()
|
||||
}
|
||||
},
|
||||
url: install_url.clone(),
|
||||
is_installed,
|
||||
is_enabled: false,
|
||||
suggest_reason: Some(tool_suggestion.suggest_reason.clone()),
|
||||
suggestion_type: Some(suggestion_type),
|
||||
elicitation_target,
|
||||
},
|
||||
self.app_event_tx.clone(),
|
||||
);
|
||||
self.push_view(Box::new(view));
|
||||
}
|
||||
mcp_server_elicitation::ToolSuggestionPayload::Plugin {
|
||||
marketplace_path,
|
||||
plugin_name,
|
||||
} => {
|
||||
let elicitation_target = PluginSuggestionElicitationTarget {
|
||||
thread_id: request.thread_id(),
|
||||
server_name: request.server_name().to_string(),
|
||||
request_id: request.request_id().clone(),
|
||||
};
|
||||
let view = PluginSuggestionView::new(
|
||||
PluginSuggestionViewParams {
|
||||
plugin_id: tool_suggestion.tool_id.clone(),
|
||||
title: tool_suggestion.tool_name.clone(),
|
||||
plugin_name: plugin_name.clone(),
|
||||
marketplace_path: marketplace_path.clone(),
|
||||
suggest_reason: tool_suggestion.suggest_reason.clone(),
|
||||
suggest_type: tool_suggestion.suggest_type,
|
||||
elicitation_target,
|
||||
},
|
||||
self.app_event_tx.clone(),
|
||||
);
|
||||
self.push_view(Box::new(view));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
604
codex-rs/tui/src/bottom_pane/plugin_suggestion_view.rs
Normal file
604
codex-rs/tui/src/bottom_pane/plugin_suggestion_view.rs
Normal file
@@ -0,0 +1,604 @@
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::approvals::ElicitationAction;
|
||||
use codex_protocol::mcp::RequestId as McpRequestId;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Constraint;
|
||||
use ratatui::layout::Layout;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::Block;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
use ratatui::widgets::Wrap;
|
||||
use textwrap::wrap;
|
||||
|
||||
use super::CancellationEvent;
|
||||
use super::bottom_pane_view::BottomPaneView;
|
||||
use super::mcp_server_elicitation::ToolSuggestionType;
|
||||
use super::scroll_state::ScrollState;
|
||||
use super::selection_popup_common::GenericDisplayRow;
|
||||
use super::selection_popup_common::measure_rows_height;
|
||||
use super::selection_popup_common::render_rows;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::key_hint;
|
||||
use crate::render::Insets;
|
||||
use crate::render::RectExt as _;
|
||||
use crate::style::user_message_style;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct PluginSuggestionElicitationTarget {
|
||||
pub(crate) thread_id: ThreadId,
|
||||
pub(crate) server_name: String,
|
||||
pub(crate) request_id: McpRequestId,
|
||||
}
|
||||
|
||||
pub(crate) struct PluginSuggestionViewParams {
|
||||
pub(crate) plugin_id: String,
|
||||
pub(crate) title: String,
|
||||
pub(crate) plugin_name: String,
|
||||
pub(crate) marketplace_path: AbsolutePathBuf,
|
||||
pub(crate) suggest_reason: String,
|
||||
pub(crate) suggest_type: ToolSuggestionType,
|
||||
pub(crate) elicitation_target: PluginSuggestionElicitationTarget,
|
||||
}
|
||||
|
||||
pub(crate) struct PluginSuggestionView {
|
||||
plugin_id: String,
|
||||
title: String,
|
||||
plugin_name: String,
|
||||
marketplace_path: AbsolutePathBuf,
|
||||
suggest_reason: String,
|
||||
suggest_type: ToolSuggestionType,
|
||||
elicitation_target: PluginSuggestionElicitationTarget,
|
||||
app_event_tx: AppEventSender,
|
||||
selected_action: usize,
|
||||
complete: bool,
|
||||
}
|
||||
|
||||
impl PluginSuggestionView {
|
||||
pub(crate) fn new(params: PluginSuggestionViewParams, app_event_tx: AppEventSender) -> Self {
|
||||
let PluginSuggestionViewParams {
|
||||
plugin_id,
|
||||
title,
|
||||
plugin_name,
|
||||
marketplace_path,
|
||||
suggest_reason,
|
||||
suggest_type,
|
||||
elicitation_target,
|
||||
} = params;
|
||||
Self {
|
||||
plugin_id,
|
||||
title,
|
||||
plugin_name,
|
||||
marketplace_path,
|
||||
suggest_reason,
|
||||
suggest_type,
|
||||
elicitation_target,
|
||||
app_event_tx,
|
||||
selected_action: 0,
|
||||
complete: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn action_labels(&self) -> [&'static str; 2] {
|
||||
match self.suggest_type {
|
||||
ToolSuggestionType::Install => ["Install plugin", "Back"],
|
||||
ToolSuggestionType::Enable => ["Enable plugin", "Back"],
|
||||
}
|
||||
}
|
||||
|
||||
fn instructions(&self) -> &'static str {
|
||||
match self.suggest_type {
|
||||
ToolSuggestionType::Install => {
|
||||
"Install this plugin in Codex to make its tools available."
|
||||
}
|
||||
ToolSuggestionType::Enable => {
|
||||
"Enable this plugin in Codex to use it for the current request."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn move_selection_prev(&mut self) {
|
||||
self.selected_action = self.selected_action.saturating_sub(1);
|
||||
}
|
||||
|
||||
fn move_selection_next(&mut self) {
|
||||
self.selected_action = (self.selected_action + 1).min(self.action_labels().len() - 1);
|
||||
}
|
||||
|
||||
fn resolve_elicitation(&self, decision: ElicitationAction) {
|
||||
self.app_event_tx.send(AppEvent::SubmitThreadOp {
|
||||
thread_id: self.elicitation_target.thread_id,
|
||||
op: Op::ResolveElicitation {
|
||||
server_name: self.elicitation_target.server_name.clone(),
|
||||
request_id: self.elicitation_target.request_id.clone(),
|
||||
decision,
|
||||
content: None,
|
||||
meta: None,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
fn decline(&mut self) {
|
||||
self.resolve_elicitation(ElicitationAction::Decline);
|
||||
self.complete = true;
|
||||
}
|
||||
|
||||
fn submit_action(&mut self) {
|
||||
match self.suggest_type {
|
||||
ToolSuggestionType::Install => {
|
||||
self.app_event_tx.send(AppEvent::InstallSuggestedPlugin {
|
||||
marketplace_path: self.marketplace_path.clone(),
|
||||
plugin_name: self.plugin_name.clone(),
|
||||
})
|
||||
}
|
||||
ToolSuggestionType::Enable => self.app_event_tx.send(AppEvent::EnableSuggestedPlugin {
|
||||
plugin_id: self.plugin_id.clone(),
|
||||
}),
|
||||
}
|
||||
self.resolve_elicitation(ElicitationAction::Accept);
|
||||
self.complete = true;
|
||||
}
|
||||
|
||||
fn activate_selected_action(&mut self) {
|
||||
match self.selected_action {
|
||||
0 => self.submit_action(),
|
||||
1 => self.decline(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn content_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
let usable_width = width.max(1) as usize;
|
||||
let mut lines = vec![Line::from(self.title.clone().bold()), Line::from("")];
|
||||
|
||||
for line in wrap(self.suggest_reason.trim(), usable_width) {
|
||||
lines.push(Line::from(line.into_owned().italic()));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
|
||||
for line in wrap(self.instructions(), usable_width) {
|
||||
lines.push(Line::from(line.into_owned()));
|
||||
}
|
||||
for line in wrap(
|
||||
"Changes take effect after the next router rebuild or next turn.",
|
||||
usable_width,
|
||||
) {
|
||||
lines.push(Line::from(line.into_owned()));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(vec![
|
||||
"Plugin ID: ".dim(),
|
||||
self.plugin_id.clone().into(),
|
||||
]));
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
fn action_rows(&self) -> Vec<GenericDisplayRow> {
|
||||
self.action_labels()
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, label)| {
|
||||
let prefix = if self.selected_action == index {
|
||||
'›'
|
||||
} else {
|
||||
' '
|
||||
};
|
||||
GenericDisplayRow {
|
||||
name: format!("{prefix} {}. {label}", index + 1),
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn action_state(&self) -> ScrollState {
|
||||
let mut state = ScrollState::new();
|
||||
state.selected_idx = Some(self.selected_action);
|
||||
state
|
||||
}
|
||||
|
||||
fn action_rows_height(&self, width: u16) -> u16 {
|
||||
let rows = self.action_rows();
|
||||
let state = self.action_state();
|
||||
measure_rows_height(&rows, &state, rows.len().max(1), width.max(1))
|
||||
}
|
||||
|
||||
fn hint_line(&self) -> Line<'static> {
|
||||
Line::from(vec![
|
||||
"Use ".into(),
|
||||
key_hint::plain(KeyCode::Tab).into(),
|
||||
" / ".into(),
|
||||
key_hint::plain(KeyCode::Up).into(),
|
||||
" ".into(),
|
||||
key_hint::plain(KeyCode::Down).into(),
|
||||
" to move, ".into(),
|
||||
key_hint::plain(KeyCode::Enter).into(),
|
||||
" to select, ".into(),
|
||||
key_hint::plain(KeyCode::Esc).into(),
|
||||
" to close".into(),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
impl BottomPaneView for PluginSuggestionView {
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Esc, ..
|
||||
} => {
|
||||
self.on_ctrl_c();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Up, ..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Left,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::BackTab,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('k'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('h'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => self.move_selection_prev(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Down,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Right,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Tab, ..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('j'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('l'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => self.move_selection_next(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
if let Some(index) = c
|
||||
.to_digit(10)
|
||||
.and_then(|digit| digit.checked_sub(1))
|
||||
.map(|index| index as usize)
|
||||
&& index < self.action_labels().len()
|
||||
{
|
||||
self.selected_action = index;
|
||||
self.activate_selected_action();
|
||||
}
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => self.activate_selected_action(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_ctrl_c(&mut self) -> CancellationEvent {
|
||||
self.decline();
|
||||
CancellationEvent::Handled
|
||||
}
|
||||
|
||||
fn is_complete(&self) -> bool {
|
||||
self.complete
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::render::renderable::Renderable for PluginSuggestionView {
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
let content_width = width.saturating_sub(4).max(1);
|
||||
let content_rows = Paragraph::new(self.content_lines(content_width))
|
||||
.wrap(Wrap { trim: false })
|
||||
.line_count(content_width)
|
||||
.max(1) as u16;
|
||||
let action_rows_height = self.action_rows_height(content_width);
|
||||
content_rows + action_rows_height + 3
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
if area.height == 0 || area.width == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
Block::default()
|
||||
.style(user_message_style())
|
||||
.render(area, buf);
|
||||
|
||||
let actions_height = self.action_rows_height(area.width.saturating_sub(4));
|
||||
let [content_area, actions_area, hint_area] = Layout::vertical([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(actions_height),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.areas(area);
|
||||
|
||||
let inner = content_area.inset(Insets::vh(1, 2));
|
||||
let content_width = inner.width.max(1);
|
||||
Paragraph::new(self.content_lines(content_width))
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(inner, buf);
|
||||
|
||||
if actions_area.height > 0 {
|
||||
let actions_area = Rect {
|
||||
x: actions_area.x.saturating_add(2),
|
||||
y: actions_area.y,
|
||||
width: actions_area.width.saturating_sub(2),
|
||||
height: actions_area.height,
|
||||
};
|
||||
let action_rows = self.action_rows();
|
||||
let action_state = self.action_state();
|
||||
render_rows(
|
||||
actions_area,
|
||||
buf,
|
||||
&action_rows,
|
||||
&action_state,
|
||||
action_rows.len().max(1),
|
||||
"No actions",
|
||||
);
|
||||
}
|
||||
|
||||
if hint_area.height > 0 {
|
||||
let hint_area = Rect {
|
||||
x: hint_area.x.saturating_add(2),
|
||||
y: hint_area.y,
|
||||
width: hint_area.width.saturating_sub(2),
|
||||
height: hint_area.height,
|
||||
};
|
||||
self.hint_line().dim().render(hint_area, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::render::renderable::Renderable;
|
||||
use insta::assert_snapshot;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
fn suggestion_target() -> PluginSuggestionElicitationTarget {
|
||||
PluginSuggestionElicitationTarget {
|
||||
thread_id: ThreadId::try_from("00000000-0000-0000-0000-000000000001")
|
||||
.expect("valid thread id"),
|
||||
server_name: "codex_apps".to_string(),
|
||||
request_id: McpRequestId::String("request-1".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_snapshot(view: &PluginSuggestionView, area: Rect) -> String {
|
||||
let mut buf = Buffer::empty(area);
|
||||
view.render(area, &mut buf);
|
||||
(0..area.height)
|
||||
.map(|y| {
|
||||
(0..area.width)
|
||||
.map(|x| {
|
||||
let symbol = buf[(x, y)].symbol();
|
||||
if symbol.is_empty() {
|
||||
' '
|
||||
} else {
|
||||
symbol.chars().next().unwrap_or(' ')
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
.trim_end()
|
||||
.to_string()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn marketplace_path() -> AbsolutePathBuf {
|
||||
AbsolutePathBuf::try_from("/tmp/marketplaces/openai-curated").expect("absolute path")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_plugin_suggestion_sends_install_event_and_accepts() {
|
||||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut view = PluginSuggestionView::new(
|
||||
PluginSuggestionViewParams {
|
||||
plugin_id: "gmail@openai-curated".to_string(),
|
||||
title: "Gmail Plugin".to_string(),
|
||||
plugin_name: "gmail".to_string(),
|
||||
marketplace_path: marketplace_path(),
|
||||
suggest_reason: "Search your inbox directly from Codex".to_string(),
|
||||
suggest_type: ToolSuggestionType::Install,
|
||||
elicitation_target: suggestion_target(),
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
match rx.try_recv() {
|
||||
Ok(AppEvent::InstallSuggestedPlugin {
|
||||
marketplace_path: installed_marketplace_path,
|
||||
plugin_name,
|
||||
}) => {
|
||||
assert_eq!(installed_marketplace_path, marketplace_path());
|
||||
assert_eq!(plugin_name, "gmail");
|
||||
}
|
||||
Ok(other) => panic!("unexpected app event: {other:?}"),
|
||||
Err(err) => panic!("missing app event: {err}"),
|
||||
}
|
||||
match rx.try_recv() {
|
||||
Ok(AppEvent::SubmitThreadOp { thread_id, op }) => {
|
||||
assert_eq!(thread_id, suggestion_target().thread_id);
|
||||
assert_eq!(
|
||||
op,
|
||||
Op::ResolveElicitation {
|
||||
server_name: "codex_apps".to_string(),
|
||||
request_id: McpRequestId::String("request-1".to_string()),
|
||||
decision: ElicitationAction::Accept,
|
||||
content: None,
|
||||
meta: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
Ok(other) => panic!("unexpected app event: {other:?}"),
|
||||
Err(err) => panic!("missing app event: {err}"),
|
||||
}
|
||||
assert!(view.is_complete());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enable_plugin_suggestion_sends_enable_event_and_accepts() {
|
||||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut view = PluginSuggestionView::new(
|
||||
PluginSuggestionViewParams {
|
||||
plugin_id: "gmail@openai-curated".to_string(),
|
||||
title: "Gmail Plugin".to_string(),
|
||||
plugin_name: "gmail".to_string(),
|
||||
marketplace_path: marketplace_path(),
|
||||
suggest_reason: "Search your inbox directly from Codex".to_string(),
|
||||
suggest_type: ToolSuggestionType::Enable,
|
||||
elicitation_target: suggestion_target(),
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
match rx.try_recv() {
|
||||
Ok(AppEvent::EnableSuggestedPlugin { plugin_id }) => {
|
||||
assert_eq!(plugin_id, "gmail@openai-curated");
|
||||
}
|
||||
Ok(other) => panic!("unexpected app event: {other:?}"),
|
||||
Err(err) => panic!("missing app event: {err}"),
|
||||
}
|
||||
match rx.try_recv() {
|
||||
Ok(AppEvent::SubmitThreadOp { thread_id, op }) => {
|
||||
assert_eq!(thread_id, suggestion_target().thread_id);
|
||||
assert_eq!(
|
||||
op,
|
||||
Op::ResolveElicitation {
|
||||
server_name: "codex_apps".to_string(),
|
||||
request_id: McpRequestId::String("request-1".to_string()),
|
||||
decision: ElicitationAction::Accept,
|
||||
content: None,
|
||||
meta: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
Ok(other) => panic!("unexpected app event: {other:?}"),
|
||||
Err(err) => panic!("missing app event: {err}"),
|
||||
}
|
||||
assert!(view.is_complete());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declined_plugin_suggestion_resolves_decline() {
|
||||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut view = PluginSuggestionView::new(
|
||||
PluginSuggestionViewParams {
|
||||
plugin_id: "gmail@openai-curated".to_string(),
|
||||
title: "Gmail Plugin".to_string(),
|
||||
plugin_name: "gmail".to_string(),
|
||||
marketplace_path: marketplace_path(),
|
||||
suggest_reason: "Search your inbox directly from Codex".to_string(),
|
||||
suggest_type: ToolSuggestionType::Install,
|
||||
elicitation_target: suggestion_target(),
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE));
|
||||
|
||||
match rx.try_recv() {
|
||||
Ok(AppEvent::SubmitThreadOp { thread_id, op }) => {
|
||||
assert_eq!(thread_id, suggestion_target().thread_id);
|
||||
assert_eq!(
|
||||
op,
|
||||
Op::ResolveElicitation {
|
||||
server_name: "codex_apps".to_string(),
|
||||
request_id: McpRequestId::String("request-1".to_string()),
|
||||
decision: ElicitationAction::Decline,
|
||||
content: None,
|
||||
meta: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
Ok(other) => panic!("unexpected app event: {other:?}"),
|
||||
Err(err) => panic!("missing app event: {err}"),
|
||||
}
|
||||
assert!(view.is_complete());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_plugin_suggestion_snapshot() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let view = PluginSuggestionView::new(
|
||||
PluginSuggestionViewParams {
|
||||
plugin_id: "gmail@openai-curated".to_string(),
|
||||
title: "Gmail Plugin".to_string(),
|
||||
plugin_name: "gmail".to_string(),
|
||||
marketplace_path: marketplace_path(),
|
||||
suggest_reason: "Search your inbox directly from Codex".to_string(),
|
||||
suggest_type: ToolSuggestionType::Install,
|
||||
elicitation_target: suggestion_target(),
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
assert_snapshot!(
|
||||
"plugin_suggestion_view_install",
|
||||
render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enable_plugin_suggestion_snapshot() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let view = PluginSuggestionView::new(
|
||||
PluginSuggestionViewParams {
|
||||
plugin_id: "gmail@openai-curated".to_string(),
|
||||
title: "Gmail Plugin".to_string(),
|
||||
plugin_name: "gmail".to_string(),
|
||||
marketplace_path: marketplace_path(),
|
||||
suggest_reason: "Search your inbox directly from Codex".to_string(),
|
||||
suggest_type: ToolSuggestionType::Enable,
|
||||
elicitation_target: suggestion_target(),
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
assert_snapshot!(
|
||||
"plugin_suggestion_view_enable",
|
||||
render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/plugin_suggestion_view.rs
|
||||
expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))"
|
||||
---
|
||||
|
||||
Gmail Plugin
|
||||
|
||||
Search your inbox directly from Codex
|
||||
|
||||
Enable this plugin in Codex to use it for the current request.
|
||||
Changes take effect after the next router rebuild or next turn.
|
||||
|
||||
Plugin ID: gmail@openai-curated
|
||||
|
||||
› 1. Enable plugin
|
||||
2. Back
|
||||
Use tab / ↑ ↓ to move, enter to select, esc to close
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/plugin_suggestion_view.rs
|
||||
expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))"
|
||||
---
|
||||
|
||||
Gmail Plugin
|
||||
|
||||
Search your inbox directly from Codex
|
||||
|
||||
Install this plugin in Codex to make its tools available.
|
||||
Changes take effect after the next router rebuild or next turn.
|
||||
|
||||
Plugin ID: gmail@openai-curated
|
||||
|
||||
› 1. Install plugin
|
||||
2. Back
|
||||
Use tab / ↑ ↓ to move, enter to select, esc to close
|
||||
Reference in New Issue
Block a user