Compare commits

...

2 Commits

Author SHA1 Message Date
Matthew Zeng
dff58de912 Merge branch 'main' of github.com:openai/codex into dev/mzeng/plugin_suggesitons 2026-03-16 18:55:06 -07:00
Matthew Zeng
826e22b27b update 2026-03-12 23:12:36 -07:00
18 changed files with 2030 additions and 230 deletions

View File

@@ -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

View File

@@ -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>> {

View File

@@ -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,
}
}
}

View 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;

View 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",
));
}

View File

@@ -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;

View File

@@ -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(),
},
}
);
}

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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");

View File

@@ -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.

View File

@@ -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(

View File

@@ -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,

View File

@@ -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(),
},
})
);
}

View File

@@ -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;
}

View 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)))
);
}
}

View File

@@ -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

View File

@@ -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