Compare commits

...

12 Commits

Author SHA1 Message Date
Matthew Zeng
5ef74e74b5 update 2026-03-10 12:33:04 -07:00
Matthew Zeng
4c7a1d800f update 2026-03-10 00:13:15 -07:00
Matthew Zeng
afa07909fa Merge branch 'main' of github.com:openai/codex into dev/mzeng/tool_discovery 2026-03-09 22:48:12 -07:00
Matthew Zeng
7b56706351 update 2026-03-06 11:38:55 -08:00
Matthew Zeng
fc564cd0d9 update 2026-03-06 00:59:06 -08:00
Matthew Zeng
3a56201e63 update 2026-03-06 00:09:13 -08:00
Matthew Zeng
d089ea29cc update 2026-03-05 23:22:59 -08:00
Matthew Zeng
650627c891 update 2026-03-05 23:13:39 -08:00
Matthew Zeng
98beea1502 Merge branch 'main' of https://github.com/openai/codex into dev/mzeng/new_elicitation 2026-03-05 23:10:24 -08:00
Matthew Zeng
b699ec52b3 update 2026-03-05 23:09:20 -08:00
Matthew Zeng
84f66b3309 update 2026-03-05 21:52:07 -08:00
Matthew Zeng
db979dc5de update 2026-03-05 11:20:38 -08:00
17 changed files with 3051 additions and 159 deletions

View File

@@ -5984,7 +5984,9 @@ fn filter_connectors_for_input(
) -> Vec<connectors::AppInfo> {
let connectors: Vec<connectors::AppInfo> = connectors
.iter()
.filter(|connector| connector.is_enabled)
.filter(|connector| {
connector.is_enabled || explicitly_enabled_connectors.contains(&connector.id)
})
.cloned()
.collect::<Vec<_>>();
if connectors.is_empty() {
@@ -6056,6 +6058,7 @@ fn filter_codex_apps_mcp_tools(
mcp_tools: &HashMap<String, crate::mcp_connection_manager::ToolInfo>,
connectors: &[connectors::AppInfo],
config: &Config,
enabled_connector_overrides: &HashSet<String>,
) -> HashMap<String, crate::mcp_connection_manager::ToolInfo> {
let allowed: HashSet<&str> = connectors
.iter()
@@ -6071,7 +6074,12 @@ fn filter_codex_apps_mcp_tools(
let Some(connector_id) = codex_apps_connector_id(tool) else {
return false;
};
allowed.contains(connector_id) && connectors::codex_app_tool_is_enabled(config, tool)
allowed.contains(connector_id)
&& connectors::codex_app_tool_is_enabled_with_enabled_connector_overrides(
config,
tool,
enabled_connector_overrides,
)
})
.map(|(name, tool)| (name.clone(), tool.clone()))
.collect()
@@ -6266,8 +6274,28 @@ async fn built_tools(
// Keep the connector-grouped app view around for the router even though
// app tools only become prompt-visible after explicit selection/discovery.
let app_tools = connectors.as_ref().map(|connectors| {
filter_codex_apps_mcp_tools(&mcp_tools, connectors, &turn_context.config)
filter_codex_apps_mcp_tools(
&mcp_tools,
connectors,
&turn_context.config,
&effective_explicitly_enabled_connectors,
)
});
let search_tool_connectors =
if turn_context.tools_config.search_tool && turn_context.apps_enabled() {
match connectors::list_cached_connectors(&turn_context.config).await {
Some(connectors) => Some(connectors),
None => match connectors::list_connectors(&turn_context.config).await {
Ok(connectors) => Some(connectors),
Err(err) => {
warn!("failed to load connectors for search tool description: {err}");
None
}
},
}
} else {
None
};
if let Some(connectors) = connectors.as_ref() {
let skill_name_counts_lower = skills_outcome.map_or_else(HashMap::new, |outcome| {
@@ -6292,11 +6320,14 @@ async fn built_tools(
explicitly_enabled.as_ref(),
));
mcp_tools =
connectors::filter_codex_apps_tools_by_policy(selected_mcp_tools, &turn_context.config);
mcp_tools = connectors::filter_codex_apps_tools_by_policy_with_enabled_connector_overrides(
selected_mcp_tools,
&turn_context.config,
&effective_explicitly_enabled_connectors,
);
}
Ok(Arc::new(ToolRouter::from_config(
Ok(Arc::new(ToolRouter::from_config_with_connectors(
&turn_context.tools_config,
has_mcp_servers.then(|| {
mcp_tools
@@ -6306,6 +6337,7 @@ async fn built_tools(
}),
app_tools,
turn_context.dynamic_tools.as_slice(),
search_tool_connectors.as_deref(),
)))
}

View File

@@ -16,6 +16,7 @@ pub use codex_app_server_protocol::AppMetadata;
use codex_protocol::protocol::SandboxPolicy;
use rmcp::model::ToolAnnotations;
use serde::Deserialize;
use serde::de::DeserializeOwned;
use tracing::warn;
use crate::AuthManager;
@@ -24,6 +25,7 @@ use crate::SandboxState;
use crate::config::Config;
use crate::config::types::AppToolApproval;
use crate::config::types::AppsConfigToml;
use crate::default_client::create_client;
use crate::default_client::is_first_party_chat_originator;
use crate::default_client::originator;
use crate::features::Feature;
@@ -40,6 +42,7 @@ use crate::token_data::TokenData;
pub const CONNECTORS_CACHE_TTL: Duration = Duration::from_secs(3600);
const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30);
const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct AppToolPolicy {
@@ -74,12 +77,163 @@ struct CachedAccessibleConnectors {
static ACCESSIBLE_CONNECTORS_CACHE: LazyLock<StdMutex<Option<CachedAccessibleConnectors>>> =
LazyLock::new(|| StdMutex::new(None));
#[derive(Clone, PartialEq, Eq)]
struct AllConnectorsCacheKey {
chatgpt_base_url: String,
account_id: Option<String>,
chatgpt_user_id: Option<String>,
is_workspace_account: bool,
}
#[derive(Clone)]
struct CachedAllConnectors {
key: AllConnectorsCacheKey,
expires_at: Instant,
connectors: Vec<AppInfo>,
}
static ALL_CONNECTORS_CACHE: LazyLock<StdMutex<Option<CachedAllConnectors>>> =
LazyLock::new(|| StdMutex::new(None));
#[derive(Debug, Deserialize)]
struct DirectoryListResponse {
apps: Vec<DirectoryApp>,
#[serde(alias = "nextToken")]
next_token: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
struct DirectoryApp {
id: String,
name: String,
description: Option<String>,
#[serde(alias = "appMetadata")]
app_metadata: Option<AppMetadata>,
branding: Option<AppBranding>,
labels: Option<HashMap<String, String>>,
#[serde(alias = "logoUrl")]
logo_url: Option<String>,
#[serde(alias = "logoUrlDark")]
logo_url_dark: Option<String>,
#[serde(alias = "distributionChannel")]
distribution_channel: Option<String>,
visibility: Option<String>,
}
#[derive(Debug, Clone)]
pub struct AccessibleConnectorsStatus {
pub connectors: Vec<AppInfo>,
pub codex_apps_ready: bool,
}
pub async fn list_connectors(config: &Config) -> anyhow::Result<Vec<AppInfo>> {
if !config.features.enabled(Feature::Apps) {
return Ok(Vec::new());
}
let (all_result, accessible_result) = tokio::join!(
list_all_connectors(config),
list_accessible_connectors_from_mcp_tools(config)
);
let all_connectors = all_result?;
let accessible_connectors = accessible_result?;
Ok(with_app_enabled_state(
merge_connectors_with_accessible(all_connectors, accessible_connectors, true),
config,
))
}
pub async fn list_cached_connectors(config: &Config) -> Option<Vec<AppInfo>> {
if !config.features.enabled(Feature::Apps) {
return Some(Vec::new());
}
let all_connectors = list_cached_all_connectors(config).await?;
let accessible_connectors = list_cached_accessible_connectors_from_mcp_tools(config).await?;
Some(with_app_enabled_state(
merge_connectors_with_accessible(all_connectors, accessible_connectors, true),
config,
))
}
pub async fn list_all_connectors(config: &Config) -> anyhow::Result<Vec<AppInfo>> {
list_all_connectors_with_options(config, false).await
}
pub async fn list_cached_all_connectors(config: &Config) -> Option<Vec<AppInfo>> {
if !config.features.enabled(Feature::Apps) {
return Some(Vec::new());
}
let auth_manager = auth_manager_from_config(config);
let auth = auth_manager.auth().await?;
let token_data = auth.get_token_data().ok()?;
let cache_key = all_connectors_cache_key(config, &token_data);
read_cached_all_connectors(&cache_key).map(|connectors| {
let connectors = merge_plugin_apps(connectors, plugin_apps_for_config(config));
filter_disallowed_connectors(connectors)
})
}
pub async fn list_all_connectors_with_options(
config: &Config,
force_refetch: bool,
) -> anyhow::Result<Vec<AppInfo>> {
if !config.features.enabled(Feature::Apps) {
return Ok(Vec::new());
}
let auth_manager = auth_manager_from_config(config);
let auth = auth_manager
.auth()
.await
.ok_or_else(|| anyhow::anyhow!("ChatGPT auth not available"))?;
let token_data = auth
.get_token_data()
.map_err(anyhow::Error::from)
.map_err(|_| anyhow::anyhow!("ChatGPT token not available"))?;
let cache_key = all_connectors_cache_key(config, &token_data);
if !force_refetch && let Some(cached_connectors) = read_cached_all_connectors(&cache_key) {
let connectors = merge_plugin_apps(cached_connectors, plugin_apps_for_config(config));
return Ok(filter_disallowed_connectors(connectors));
}
let mut apps = list_directory_connectors(config).await?;
if token_data.id_token.is_workspace_account() {
apps.extend(list_workspace_connectors(config).await?);
}
let mut connectors = merge_directory_apps(apps)
.into_iter()
.map(directory_app_to_app_info)
.collect::<Vec<_>>();
for connector in &mut connectors {
let install_url = match connector.install_url.take() {
Some(install_url) => install_url,
None => connector_install_url(&connector.name, &connector.id),
};
connector.name = normalize_connector_name(&connector.name, &connector.id);
connector.description = normalize_connector_value(connector.description.as_deref());
connector.install_url = Some(install_url);
connector.is_accessible = false;
}
connectors.sort_by(|left, right| {
left.name
.cmp(&right.name)
.then_with(|| left.id.cmp(&right.id))
});
let connectors = filter_disallowed_connectors(connectors);
write_cached_all_connectors(cache_key, &connectors);
let connectors = merge_plugin_apps(connectors, plugin_apps_for_config(config));
Ok(filter_disallowed_connectors(connectors))
}
pub async fn list_accessible_connectors_from_mcp_tools(
config: &Config,
) -> anyhow::Result<Vec<AppInfo>> {
@@ -247,6 +401,15 @@ fn accessible_connectors_cache_key(
}
}
fn all_connectors_cache_key(config: &Config, token_data: &TokenData) -> AllConnectorsCacheKey {
AllConnectorsCacheKey {
chatgpt_base_url: config.chatgpt_base_url.clone(),
account_id: token_data.account_id.clone(),
chatgpt_user_id: token_data.id_token.chatgpt_user_id.clone(),
is_workspace_account: token_data.id_token.is_workspace_account(),
}
}
fn read_cached_accessible_connectors(
cache_key: &AccessibleConnectorsCacheKey,
) -> Option<Vec<AppInfo>> {
@@ -267,6 +430,24 @@ fn read_cached_accessible_connectors(
None
}
fn read_cached_all_connectors(cache_key: &AllConnectorsCacheKey) -> Option<Vec<AppInfo>> {
let mut cache_guard = ALL_CONNECTORS_CACHE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let now = Instant::now();
if let Some(cached) = cache_guard.as_ref() {
if now < cached.expires_at && cached.key == *cache_key {
return Some(cached.connectors.clone());
}
if now >= cached.expires_at {
*cache_guard = None;
}
}
None
}
fn write_cached_accessible_connectors(
cache_key: AccessibleConnectorsCacheKey,
connectors: &[AppInfo],
@@ -281,6 +462,17 @@ fn write_cached_accessible_connectors(
});
}
fn write_cached_all_connectors(cache_key: AllConnectorsCacheKey, connectors: &[AppInfo]) {
let mut cache_guard = ALL_CONNECTORS_CACHE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*cache_guard = Some(CachedAllConnectors {
key: cache_key,
expires_at: Instant::now() + CONNECTORS_CACHE_TTL,
connectors: connectors.to_vec(),
});
}
fn auth_manager_from_config(config: &Config) -> std::sync::Arc<AuthManager> {
AuthManager::shared(
config.codex_home.clone(),
@@ -401,6 +593,27 @@ pub fn merge_plugin_apps(
merged
}
pub fn merge_connectors_with_accessible(
connectors: Vec<AppInfo>,
accessible_connectors: Vec<AppInfo>,
all_connectors_loaded: bool,
) -> Vec<AppInfo> {
let accessible_connectors = if all_connectors_loaded {
let connector_ids: HashSet<&str> = connectors
.iter()
.map(|connector| connector.id.as_str())
.collect();
accessible_connectors
.into_iter()
.filter(|connector| connector_ids.contains(connector.id.as_str()))
.collect()
} else {
accessible_connectors
};
filter_disallowed_connectors(merge_connectors(connectors, accessible_connectors))
}
pub fn merge_plugin_apps_with_accessible(
plugin_apps: Vec<AppConnectorId>,
accessible_connectors: Vec<AppInfo>,
@@ -439,46 +652,57 @@ pub fn with_app_plugin_sources(
connectors
}
pub(crate) fn app_tool_policy(
pub(crate) fn app_tool_policy_with_enabled_connector_overrides(
config: &Config,
connector_id: Option<&str>,
tool_name: &str,
tool_title: Option<&str>,
annotations: Option<&ToolAnnotations>,
enabled_connector_overrides: &HashSet<String>,
) -> AppToolPolicy {
let apps_config = read_apps_config(config);
app_tool_policy_from_apps_config(
app_tool_policy_from_apps_config_with_enabled_connector_overrides(
apps_config.as_ref(),
connector_id,
tool_name,
tool_title,
annotations,
enabled_connector_overrides,
)
}
pub(crate) fn codex_app_tool_is_enabled(
pub(crate) fn codex_app_tool_is_enabled_with_enabled_connector_overrides(
config: &Config,
tool_info: &crate::mcp_connection_manager::ToolInfo,
enabled_connector_overrides: &HashSet<String>,
) -> bool {
if tool_info.server_name != CODEX_APPS_MCP_SERVER_NAME {
return true;
}
app_tool_policy(
app_tool_policy_with_enabled_connector_overrides(
config,
tool_info.connector_id.as_deref(),
&tool_info.tool_name,
tool_info.tool.title.as_deref(),
tool_info.tool.annotations.as_ref(),
enabled_connector_overrides,
)
.enabled
}
pub(crate) fn filter_codex_apps_tools_by_policy(
pub(crate) fn filter_codex_apps_tools_by_policy_with_enabled_connector_overrides(
mut mcp_tools: HashMap<String, crate::mcp_connection_manager::ToolInfo>,
config: &Config,
enabled_connector_overrides: &HashSet<String>,
) -> HashMap<String, crate::mcp_connection_manager::ToolInfo> {
mcp_tools.retain(|_, tool_info| codex_app_tool_is_enabled(config, tool_info));
mcp_tools.retain(|_, tool_info| {
codex_app_tool_is_enabled_with_enabled_connector_overrides(
config,
tool_info,
enabled_connector_overrides,
)
});
mcp_tools
}
@@ -543,12 +767,31 @@ fn app_is_enabled(apps_config: &AppsConfigToml, connector_id: Option<&str>) -> b
.unwrap_or(default_enabled)
}
#[cfg(test)]
fn app_tool_policy_from_apps_config(
apps_config: Option<&AppsConfigToml>,
connector_id: Option<&str>,
tool_name: &str,
tool_title: Option<&str>,
annotations: Option<&ToolAnnotations>,
) -> AppToolPolicy {
app_tool_policy_from_apps_config_with_enabled_connector_overrides(
apps_config,
connector_id,
tool_name,
tool_title,
annotations,
&HashSet::new(),
)
}
fn app_tool_policy_from_apps_config_with_enabled_connector_overrides(
apps_config: Option<&AppsConfigToml>,
connector_id: Option<&str>,
tool_name: &str,
tool_title: Option<&str>,
annotations: Option<&ToolAnnotations>,
enabled_connector_overrides: &HashSet<String>,
) -> AppToolPolicy {
let Some(apps_config) = apps_config else {
return AppToolPolicy::default();
@@ -567,7 +810,10 @@ fn app_tool_policy_from_apps_config(
.or_else(|| app.and_then(|app| app.default_tools_approval_mode))
.unwrap_or(AppToolApproval::Auto);
if !app_is_enabled(apps_config, connector_id) {
let connector_is_temporarily_enabled =
connector_id.is_some_and(|connector_id| enabled_connector_overrides.contains(connector_id));
if !connector_is_temporarily_enabled && !app_is_enabled(apps_config, connector_id) {
return AppToolPolicy {
enabled: false,
approval,
@@ -688,6 +934,12 @@ fn plugin_app_to_app_info(connector_id: AppConnectorId) -> AppInfo {
}
}
fn plugin_apps_for_config(config: &Config) -> Vec<AppConnectorId> {
PluginsManager::new(config.codex_home.clone())
.plugins_for_config(config)
.effective_apps()
}
fn normalize_connector_value(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
@@ -695,6 +947,284 @@ fn normalize_connector_value(value: Option<&str>) -> Option<String> {
.map(str::to_string)
}
fn normalize_connector_name(name: &str, connector_id: &str) -> String {
let trimmed = name.trim();
if trimmed.is_empty() {
connector_id.to_string()
} else {
trimmed.to_string()
}
}
async fn list_directory_connectors(config: &Config) -> anyhow::Result<Vec<DirectoryApp>> {
let mut apps = Vec::new();
let mut next_token: Option<String> = None;
loop {
let path = match next_token.as_deref() {
Some(token) => {
let encoded =
url::form_urlencoded::byte_serialize(token.as_bytes()).collect::<String>();
format!(
"/connectors/directory/list?tier=categorized&token={encoded}&external_logos=true"
)
}
None => "/connectors/directory/list?tier=categorized&external_logos=true".to_string(),
};
let response: DirectoryListResponse = chatgpt_get_request_with_timeout(
config,
path.as_str(),
Some(DIRECTORY_CONNECTORS_TIMEOUT),
)
.await?;
apps.extend(
response
.apps
.into_iter()
.filter(|app| !is_hidden_directory_app(app)),
);
next_token = response
.next_token
.map(|token| token.trim().to_string())
.filter(|token| !token.is_empty());
if next_token.is_none() {
break;
}
}
Ok(apps)
}
async fn list_workspace_connectors(config: &Config) -> anyhow::Result<Vec<DirectoryApp>> {
let response: anyhow::Result<DirectoryListResponse> = chatgpt_get_request_with_timeout(
config,
"/connectors/directory/list_workspace?external_logos=true",
Some(DIRECTORY_CONNECTORS_TIMEOUT),
)
.await;
match response {
Ok(response) => Ok(response
.apps
.into_iter()
.filter(|app| !is_hidden_directory_app(app))
.collect()),
Err(_) => Ok(Vec::new()),
}
}
fn merge_directory_apps(apps: Vec<DirectoryApp>) -> Vec<DirectoryApp> {
let mut merged = HashMap::new();
for app in apps {
if let Some(existing) = merged.get_mut(&app.id) {
merge_directory_app(existing, app);
} else {
merged.insert(app.id.clone(), app);
}
}
merged.into_values().collect()
}
fn merge_directory_app(existing: &mut DirectoryApp, incoming: DirectoryApp) {
let DirectoryApp {
id: _,
name,
description,
app_metadata,
branding,
labels,
logo_url,
logo_url_dark,
distribution_channel,
visibility: _,
} = incoming;
let incoming_name_is_empty = name.trim().is_empty();
if existing.name.trim().is_empty() && !incoming_name_is_empty {
existing.name = name;
}
let incoming_description_present = description
.as_deref()
.map(|value| !value.trim().is_empty())
.unwrap_or(false);
if incoming_description_present {
existing.description = description;
}
if existing.logo_url.is_none() && logo_url.is_some() {
existing.logo_url = logo_url;
}
if existing.logo_url_dark.is_none() && logo_url_dark.is_some() {
existing.logo_url_dark = logo_url_dark;
}
if existing.distribution_channel.is_none() && distribution_channel.is_some() {
existing.distribution_channel = distribution_channel;
}
if let Some(incoming_branding) = branding {
if let Some(existing_branding) = existing.branding.as_mut() {
if existing_branding.category.is_none() && incoming_branding.category.is_some() {
existing_branding.category = incoming_branding.category;
}
if existing_branding.developer.is_none() && incoming_branding.developer.is_some() {
existing_branding.developer = incoming_branding.developer;
}
if existing_branding.website.is_none() && incoming_branding.website.is_some() {
existing_branding.website = incoming_branding.website;
}
if existing_branding.privacy_policy.is_none()
&& incoming_branding.privacy_policy.is_some()
{
existing_branding.privacy_policy = incoming_branding.privacy_policy;
}
if existing_branding.terms_of_service.is_none()
&& incoming_branding.terms_of_service.is_some()
{
existing_branding.terms_of_service = incoming_branding.terms_of_service;
}
if !existing_branding.is_discoverable_app && incoming_branding.is_discoverable_app {
existing_branding.is_discoverable_app = true;
}
} else {
existing.branding = Some(incoming_branding);
}
}
if let Some(incoming_app_metadata) = app_metadata {
if let Some(existing_app_metadata) = existing.app_metadata.as_mut() {
if existing_app_metadata.review.is_none() && incoming_app_metadata.review.is_some() {
existing_app_metadata.review = incoming_app_metadata.review;
}
if existing_app_metadata.categories.is_none()
&& incoming_app_metadata.categories.is_some()
{
existing_app_metadata.categories = incoming_app_metadata.categories;
}
if existing_app_metadata.sub_categories.is_none()
&& incoming_app_metadata.sub_categories.is_some()
{
existing_app_metadata.sub_categories = incoming_app_metadata.sub_categories;
}
if existing_app_metadata.seo_description.is_none()
&& incoming_app_metadata.seo_description.is_some()
{
existing_app_metadata.seo_description = incoming_app_metadata.seo_description;
}
if existing_app_metadata.screenshots.is_none()
&& incoming_app_metadata.screenshots.is_some()
{
existing_app_metadata.screenshots = incoming_app_metadata.screenshots;
}
if existing_app_metadata.developer.is_none()
&& incoming_app_metadata.developer.is_some()
{
existing_app_metadata.developer = incoming_app_metadata.developer;
}
if existing_app_metadata.version.is_none() && incoming_app_metadata.version.is_some() {
existing_app_metadata.version = incoming_app_metadata.version;
}
if existing_app_metadata.version_id.is_none()
&& incoming_app_metadata.version_id.is_some()
{
existing_app_metadata.version_id = incoming_app_metadata.version_id;
}
if existing_app_metadata.version_notes.is_none()
&& incoming_app_metadata.version_notes.is_some()
{
existing_app_metadata.version_notes = incoming_app_metadata.version_notes;
}
if existing_app_metadata.first_party_type.is_none()
&& incoming_app_metadata.first_party_type.is_some()
{
existing_app_metadata.first_party_type = incoming_app_metadata.first_party_type;
}
if existing_app_metadata.first_party_requires_install.is_none()
&& incoming_app_metadata.first_party_requires_install.is_some()
{
existing_app_metadata.first_party_requires_install =
incoming_app_metadata.first_party_requires_install;
}
if existing_app_metadata
.show_in_composer_when_unlinked
.is_none()
&& incoming_app_metadata
.show_in_composer_when_unlinked
.is_some()
{
existing_app_metadata.show_in_composer_when_unlinked =
incoming_app_metadata.show_in_composer_when_unlinked;
}
} else {
existing.app_metadata = Some(incoming_app_metadata);
}
}
if existing.labels.is_none() && labels.is_some() {
existing.labels = labels;
}
}
fn is_hidden_directory_app(app: &DirectoryApp) -> bool {
matches!(app.visibility.as_deref(), Some("HIDDEN"))
}
fn directory_app_to_app_info(app: DirectoryApp) -> AppInfo {
AppInfo {
id: app.id,
name: app.name,
description: app.description,
logo_url: app.logo_url,
logo_url_dark: app.logo_url_dark,
distribution_channel: app.distribution_channel,
branding: app.branding,
app_metadata: app.app_metadata,
labels: app.labels,
install_url: None,
is_accessible: false,
is_enabled: true,
plugin_display_names: Vec::new(),
}
}
async fn chatgpt_get_request_with_timeout<T: DeserializeOwned>(
config: &Config,
path: &str,
timeout: Option<Duration>,
) -> anyhow::Result<T> {
let auth_manager = auth_manager_from_config(config);
let auth = auth_manager
.auth()
.await
.ok_or_else(|| anyhow::anyhow!("ChatGPT auth not available"))?;
let token_data = auth
.get_token_data()
.map_err(anyhow::Error::from)
.map_err(|_| anyhow::anyhow!("ChatGPT token not available"))?;
let account_id = token_data.account_id.ok_or_else(|| {
anyhow::anyhow!("ChatGPT account ID not available, please re-run `codex login`")
})?;
let url = format!("{}{}", config.chatgpt_base_url.trim_end_matches('/'), path);
let mut request = create_client()
.get(url)
.bearer_auth(token_data.access_token)
.header("chatgpt-account-id", account_id)
.header("Content-Type", "application/json");
if let Some(timeout) = timeout {
request = request.timeout(timeout);
}
let response = request.send().await?;
if response.status().is_success() {
return Ok(response.json().await?);
}
let status = response.status();
let body = response.text().await.unwrap_or_default();
anyhow::bail!("Request failed with status {status}: {body}")
}
pub fn connector_install_url(name: &str, connector_id: &str) -> String {
let slug = connector_name_slug(name);
format!("https://chatgpt.com/apps/{slug}/{connector_id}")
@@ -1026,6 +1556,36 @@ mod tests {
);
}
#[test]
fn app_tool_policy_allows_temporarily_enabled_connector_when_default_is_disabled() {
let apps_config = AppsConfigToml {
default: Some(AppsDefaultConfig {
enabled: false,
destructive_enabled: true,
open_world_enabled: true,
}),
apps: HashMap::new(),
};
let enabled_connector_overrides = HashSet::from(["calendar".to_string()]);
let policy = app_tool_policy_from_apps_config_with_enabled_connector_overrides(
Some(&apps_config),
Some("calendar"),
"events/list",
None,
Some(&annotations(None, None)),
&enabled_connector_overrides,
);
assert_eq!(
policy,
AppToolPolicy {
enabled: true,
approval: AppToolApproval::Auto,
}
);
}
#[test]
fn app_tool_policy_allows_per_app_enable_when_default_is_disabled() {
let apps_config = AppsConfigToml {

View File

@@ -87,8 +87,9 @@ pub(crate) async fn handle_mcp_tool_call(
let metadata =
lookup_mcp_tool_metadata(sess.as_ref(), turn_context.as_ref(), &server, &tool_name).await;
let enabled_connector_overrides = sess.get_connector_selection().await;
let app_tool_policy = if server == CODEX_APPS_MCP_SERVER_NAME {
connectors::app_tool_policy(
connectors::app_tool_policy_with_enabled_connector_overrides(
&turn_context.config,
metadata
.as_ref()
@@ -100,6 +101,7 @@ pub(crate) async fn handle_mcp_tool_call(
metadata
.as_ref()
.and_then(|metadata| metadata.annotations.as_ref()),
&enabled_connector_overrides,
)
} else {
connectors::AppToolPolicy::default()

View File

@@ -16,6 +16,7 @@ mod request_user_input;
mod search_tool_bm25;
mod shell;
mod test_sync;
mod tool_suggest;
pub(crate) mod unified_exec;
mod view_image;
@@ -51,11 +52,14 @@ pub(crate) use request_permissions::request_permissions_tool_description;
pub use request_user_input::RequestUserInputHandler;
pub(crate) use request_user_input::request_user_input_tool_description;
pub(crate) use search_tool_bm25::DEFAULT_LIMIT as SEARCH_TOOL_BM25_DEFAULT_LIMIT;
pub(crate) use search_tool_bm25::INSTALLABLE_DISCOVERABLE_CONNECTOR_IDS;
pub(crate) use search_tool_bm25::SEARCH_TOOL_BM25_TOOL_NAME;
pub use search_tool_bm25::SearchToolBm25Handler;
pub use shell::ShellCommandHandler;
pub use shell::ShellHandler;
pub use test_sync::TestSyncHandler;
pub(crate) use tool_suggest::TOOL_SUGGEST_TOOL_NAME;
pub use tool_suggest::ToolSuggestHandler;
pub use unified_exec::UnifiedExecHandler;
pub use view_image::ViewImageHandler;

View File

@@ -4,9 +4,11 @@ use bm25::Language;
use bm25::SearchEngineBuilder;
use codex_app_server_protocol::AppInfo;
use serde::Deserialize;
use serde::Serialize;
use serde_json::json;
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::LazyLock;
use crate::connectors;
use crate::function_tool::FunctionCallError;
@@ -16,6 +18,8 @@ use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::handlers::parse_arguments;
use crate::tools::handlers::tool_suggest::ToolSuggestionToolType;
use crate::tools::handlers::tool_suggest::ToolSuggestionType;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
@@ -23,16 +27,29 @@ pub struct SearchToolBm25Handler;
pub(crate) const SEARCH_TOOL_BM25_TOOL_NAME: &str = "search_tool_bm25";
pub(crate) const DEFAULT_LIMIT: usize = 8;
const ALLOWLISTED_DISCOVERABLE_CONNECTOR_ID: &str = "connector_2128aebfecb84f64a069897515042a44";
pub(crate) static INSTALLABLE_DISCOVERABLE_CONNECTOR_IDS: LazyLock<HashSet<&str>> =
LazyLock::new(|| HashSet::from([ALLOWLISTED_DISCOVERABLE_CONNECTOR_ID]));
fn default_limit() -> usize {
DEFAULT_LIMIT
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, Eq, PartialEq)]
#[serde(rename_all = "lowercase")]
pub(crate) enum SearchToolMode {
#[default]
Enabled,
Discoverable,
}
#[derive(Deserialize)]
struct SearchToolBm25Args {
query: String,
#[serde(default = "default_limit")]
limit: usize,
#[serde(default)]
mode: SearchToolMode,
}
#[derive(Clone)]
@@ -71,6 +88,28 @@ impl ToolEntry {
}
}
#[derive(Clone)]
struct ConnectorEntry {
connector_id: String,
connector_name: String,
connector_description: Option<String>,
suggestion_type: ToolSuggestionType,
search_text: String,
}
impl ConnectorEntry {
fn new(connector: AppInfo, suggestion_type: ToolSuggestionType) -> Self {
let search_text = build_connector_search_text(&connector);
Self {
connector_id: connector.id,
connector_name: connector.name,
connector_description: connector.description,
suggestion_type,
search_text,
}
}
}
#[async_trait]
impl ToolHandler for SearchToolBm25Handler {
type Output = FunctionToolOutput;
@@ -110,77 +149,160 @@ impl ToolHandler for SearchToolBm25Handler {
));
}
let limit = args.limit;
let content = match args.mode {
SearchToolMode::Enabled => {
let mcp_tools = session
.services
.mcp_connection_manager
.read()
.await
.list_all_tools()
.await;
let enabled_connector_overrides = session.get_connector_selection().await;
let mcp_tools = session
.services
.mcp_connection_manager
.read()
.await
.list_all_tools()
.await;
let connectors = connectors::with_app_enabled_state(
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
&turn.config,
);
let mcp_tools = filter_codex_apps_mcp_tools(
mcp_tools,
&connectors,
&enabled_connector_overrides,
);
let mcp_tools =
connectors::filter_codex_apps_tools_by_policy_with_enabled_connector_overrides(
mcp_tools,
&turn.config,
&enabled_connector_overrides,
);
let connectors = connectors::with_app_enabled_state(
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
&turn.config,
);
let mcp_tools = filter_codex_apps_mcp_tools(mcp_tools, &connectors);
let mcp_tools = connectors::filter_codex_apps_tools_by_policy(mcp_tools, &turn.config);
let mut entries: Vec<ToolEntry> = mcp_tools
.into_iter()
.map(|(name, info)| ToolEntry::new(name, info))
.collect();
entries.sort_by(|a, b| a.name.cmp(&b.name));
let mut entries: Vec<ToolEntry> = mcp_tools
.into_iter()
.map(|(name, info)| ToolEntry::new(name, info))
.collect();
entries.sort_by(|a, b| a.name.cmp(&b.name));
if entries.is_empty() {
let active_selected_tools =
session.get_mcp_tool_selection().await.unwrap_or_default();
json!({
"query": query,
"mode": SearchToolMode::Enabled,
"total_tools": 0,
"active_selected_tools": active_selected_tools,
"tools": [],
})
.to_string()
} else {
let documents: Vec<Document<usize>> = entries
.iter()
.enumerate()
.map(|(idx, entry)| Document::new(idx, entry.search_text.clone()))
.collect();
let search_engine =
SearchEngineBuilder::<usize>::with_documents(Language::English, documents)
.build();
let results = search_engine.search(query, args.limit);
if entries.is_empty() {
let active_selected_tools = session.get_mcp_tool_selection().await.unwrap_or_default();
let content = json!({
"query": query,
"total_tools": 0,
"active_selected_tools": active_selected_tools,
"tools": [],
})
.to_string();
return Ok(FunctionToolOutput::from_text(content, Some(true)));
}
let mut selected_tools = Vec::new();
let mut result_payloads = Vec::new();
for result in results {
let Some(entry) = entries.get(result.document.id) else {
continue;
};
selected_tools.push(entry.name.clone());
result_payloads.push(json!({
"name": entry.name.clone(),
"server": entry.server_name.clone(),
"title": entry.title.clone(),
"description": entry.description.clone(),
"connector_name": entry.connector_name.clone(),
"input_keys": entry.input_keys.clone(),
"score": result.score,
}));
}
let documents: Vec<Document<usize>> = entries
.iter()
.enumerate()
.map(|(idx, entry)| Document::new(idx, entry.search_text.clone()))
.collect();
let search_engine =
SearchEngineBuilder::<usize>::with_documents(Language::English, documents).build();
let results = search_engine.search(query, limit);
let active_selected_tools =
session.merge_mcp_tool_selection(selected_tools).await;
let mut selected_tools = Vec::new();
let mut result_payloads = Vec::new();
for result in results {
let Some(entry) = entries.get(result.document.id) else {
continue;
};
selected_tools.push(entry.name.clone());
result_payloads.push(json!({
"name": entry.name.clone(),
"server": entry.server_name.clone(),
"title": entry.title.clone(),
"description": entry.description.clone(),
"connector_name": entry.connector_name.clone(),
"input_keys": entry.input_keys.clone(),
"score": result.score,
}));
}
json!({
"query": query,
"mode": SearchToolMode::Enabled,
"total_tools": entries.len(),
"active_selected_tools": active_selected_tools,
"tools": result_payloads,
})
.to_string()
}
}
SearchToolMode::Discoverable => {
let all_connectors = match connectors::list_cached_connectors(&turn.config).await {
Some(connectors) => connectors,
None => connectors::list_connectors(&turn.config)
.await
.map_err(|err| {
FunctionCallError::RespondToModel(format!(
"failed to load discoverable apps: {err}"
))
})?,
};
let active_selected_tools =
session.get_mcp_tool_selection().await.unwrap_or_default();
let (installable_entries, disabled_entries) =
discoverable_connector_entries(all_connectors);
let total_connectors = installable_entries.len() + disabled_entries.len();
let active_selected_tools = session.merge_mcp_tool_selection(selected_tools).await;
if total_connectors == 0 {
json!({
"query": query,
"mode": SearchToolMode::Discoverable,
"total_connectors": 0,
"active_selected_tools": active_selected_tools,
"connectors": [],
})
.to_string()
} else {
let mut result_payloads = Vec::new();
let installable_results =
search_connector_entries(&installable_entries, query, args.limit);
for (entry, score) in installable_results {
result_payloads.push(json!({
"connector_id": entry.connector_id.clone(),
"connector_name": entry.connector_name.clone(),
"connector_description": entry.connector_description.clone(),
"tool_type": ToolSuggestionToolType::Connector,
"suggestion_type": entry.suggestion_type,
"score": score,
}));
}
let content = json!({
"query": query,
"total_tools": entries.len(),
"active_selected_tools": active_selected_tools,
"tools": result_payloads,
})
.to_string();
let remaining = args.limit.saturating_sub(result_payloads.len());
if remaining > 0 {
for (entry, score) in
search_connector_entries(&disabled_entries, query, remaining)
{
result_payloads.push(json!({
"connector_id": entry.connector_id.clone(),
"connector_name": entry.connector_name.clone(),
"connector_description": entry.connector_description.clone(),
"tool_type": ToolSuggestionToolType::Connector,
"suggestion_type": entry.suggestion_type,
"score": score,
}));
}
}
json!({
"query": query,
"mode": SearchToolMode::Discoverable,
"total_connectors": total_connectors,
"active_selected_tools": active_selected_tools,
"connectors": result_payloads,
})
.to_string()
}
}
};
Ok(FunctionToolOutput::from_text(content, Some(true)))
}
@@ -189,10 +311,13 @@ impl ToolHandler for SearchToolBm25Handler {
fn filter_codex_apps_mcp_tools(
mut mcp_tools: HashMap<String, ToolInfo>,
connectors: &[AppInfo],
enabled_connector_overrides: &HashSet<String>,
) -> HashMap<String, ToolInfo> {
let enabled_connectors: HashSet<&str> = connectors
.iter()
.filter(|connector| connector.is_enabled)
.filter(|connector| {
connector.is_enabled || enabled_connector_overrides.contains(connector.id.as_str())
})
.map(|connector| connector.id.as_str())
.collect();
@@ -208,6 +333,65 @@ fn filter_codex_apps_mcp_tools(
mcp_tools
}
fn discoverable_connector_entries(
connectors: Vec<AppInfo>,
) -> (Vec<ConnectorEntry>, Vec<ConnectorEntry>) {
let mut installable_entries = Vec::new();
let mut disabled_entries = Vec::new();
for connector in connectors {
if !connector.is_accessible {
if INSTALLABLE_DISCOVERABLE_CONNECTOR_IDS.contains(connector.id.as_str()) {
installable_entries
.push(ConnectorEntry::new(connector, ToolSuggestionType::Install));
}
} else if !connector.is_enabled {
disabled_entries.push(ConnectorEntry::new(connector, ToolSuggestionType::Enable));
}
}
installable_entries.sort_by(|a, b| {
a.connector_name
.cmp(&b.connector_name)
.then_with(|| a.connector_id.cmp(&b.connector_id))
});
disabled_entries.sort_by(|a, b| {
a.connector_name
.cmp(&b.connector_name)
.then_with(|| a.connector_id.cmp(&b.connector_id))
});
(installable_entries, disabled_entries)
}
fn search_connector_entries<'a>(
entries: &'a [ConnectorEntry],
query: &str,
limit: usize,
) -> Vec<(&'a ConnectorEntry, f32)> {
if entries.is_empty() || limit == 0 {
return Vec::new();
}
let documents: Vec<Document<usize>> = entries
.iter()
.enumerate()
.map(|(idx, entry)| Document::new(idx, entry.search_text.clone()))
.collect();
let search_engine =
SearchEngineBuilder::<usize>::with_documents(Language::English, documents).build();
let results = search_engine.search(query, limit);
results
.into_iter()
.filter_map(|result| {
entries
.get(result.document.id)
.map(|entry| (entry, result.score))
})
.collect()
}
fn build_search_text(name: &str, info: &ToolInfo, input_keys: &[String]) -> String {
let mut parts = vec![
name.to_string(),
@@ -240,6 +424,62 @@ fn build_search_text(name: &str, info: &ToolInfo, input_keys: &[String]) -> Stri
parts.join(" ")
}
fn build_connector_search_text(connector: &AppInfo) -> String {
let mut parts = vec![connector.id.clone(), connector.name.clone()];
if let Some(description) = connector.description.as_deref()
&& !description.trim().is_empty()
{
parts.push(description.to_string());
}
if let Some(labels) = connector.labels.as_ref() {
parts.extend(
labels
.iter()
.flat_map(|(key, value)| [key.clone(), value.clone()]),
);
}
if let Some(branding) = connector.branding.as_ref() {
if let Some(category) = branding.category.as_deref()
&& !category.trim().is_empty()
{
parts.push(category.to_string());
}
if let Some(developer) = branding.developer.as_deref()
&& !developer.trim().is_empty()
{
parts.push(developer.to_string());
}
}
if let Some(app_metadata) = connector.app_metadata.as_ref() {
if let Some(categories) = app_metadata.categories.as_ref() {
parts.extend(categories.iter().cloned());
}
if let Some(sub_categories) = app_metadata.sub_categories.as_ref() {
parts.extend(sub_categories.iter().cloned());
}
if let Some(seo_description) = app_metadata.seo_description.as_deref()
&& !seo_description.trim().is_empty()
{
parts.push(seo_description.to_string());
}
if let Some(developer) = app_metadata.developer.as_deref()
&& !developer.trim().is_empty()
{
parts.push(developer.to_string());
}
}
if !connector.plugin_display_names.is_empty() {
parts.extend(connector.plugin_display_names.iter().cloned());
}
parts.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -318,14 +558,37 @@ mod tests {
make_connector("drive", true),
];
let mut filtered: Vec<String> = filter_codex_apps_mcp_tools(mcp_tools, &connectors)
.into_keys()
.collect();
let mut filtered: Vec<String> =
filter_codex_apps_mcp_tools(mcp_tools, &connectors, &HashSet::new())
.into_keys()
.collect();
filtered.sort();
assert_eq!(filtered, vec!["mcp__codex_apps__drive_search".to_string()]);
}
#[test]
fn filter_codex_apps_mcp_tools_keeps_temporarily_enabled_apps() {
let mcp_tools = HashMap::from([make_tool(
"mcp__codex_apps__calendar_create_event",
CODEX_APPS_MCP_SERVER_NAME,
"calendar_create_event",
Some("calendar"),
)]);
let connectors = vec![make_connector("calendar", false)];
let enabled_connector_overrides = HashSet::from(["calendar".to_string()]);
let filtered: Vec<String> =
filter_codex_apps_mcp_tools(mcp_tools, &connectors, &enabled_connector_overrides)
.into_keys()
.collect();
assert_eq!(
filtered,
vec!["mcp__codex_apps__calendar_create_event".to_string()]
);
}
#[test]
fn filter_codex_apps_mcp_tools_drops_apps_without_connector_id() {
let mcp_tools = HashMap::from([
@@ -338,12 +601,88 @@ mod tests {
make_tool("mcp__rmcp__echo", "rmcp", "echo", None),
]);
let mut filtered: Vec<String> =
filter_codex_apps_mcp_tools(mcp_tools, &[make_connector("calendar", true)])
.into_keys()
.collect();
let mut filtered: Vec<String> = filter_codex_apps_mcp_tools(
mcp_tools,
&[make_connector("calendar", true)],
&HashSet::new(),
)
.into_keys()
.collect();
filtered.sort();
assert_eq!(filtered, Vec::<String>::new());
}
#[test]
fn discoverable_connector_entries_prioritize_install_before_enable() {
let installable = AppInfo {
id: ALLOWLISTED_DISCOVERABLE_CONNECTOR_ID.to_string(),
name: "Calendar".to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: None,
is_accessible: false,
is_enabled: false,
plugin_display_names: Vec::new(),
};
let non_allowlisted_installable = AppInfo {
id: "calendar".to_string(),
name: "Calendar".to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: None,
is_accessible: false,
is_enabled: false,
plugin_display_names: Vec::new(),
};
let disabled = AppInfo {
id: "drive".to_string(),
name: "Drive".to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: None,
is_accessible: true,
is_enabled: false,
plugin_display_names: Vec::new(),
};
let (installable_entries, disabled_entries) = discoverable_connector_entries(vec![
disabled,
installable,
non_allowlisted_installable,
]);
assert_eq!(
installable_entries
.iter()
.map(|entry| (entry.connector_id.clone(), entry.suggestion_type))
.collect::<Vec<_>>(),
vec![(
ALLOWLISTED_DISCOVERABLE_CONNECTOR_ID.to_string(),
ToolSuggestionType::Install,
)]
);
assert_eq!(
disabled_entries
.iter()
.map(|entry| (entry.connector_id.clone(), entry.suggestion_type))
.collect::<Vec<_>>(),
vec![("drive".to_string(), ToolSuggestionType::Enable)]
);
}
}

View File

@@ -0,0 +1,440 @@
use std::collections::BTreeMap;
use std::collections::HashSet;
use async_trait::async_trait;
use codex_app_server_protocol::McpElicitationObjectType;
use codex_app_server_protocol::McpElicitationSchema;
use codex_app_server_protocol::McpServerElicitationRequest;
use codex_app_server_protocol::McpServerElicitationRequestParams;
use codex_rmcp_client::ElicitationAction;
use codex_rmcp_client::ElicitationResponse;
use rmcp::model::RequestId;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
use serde_json::json;
use crate::connectors;
use crate::function_tool::FunctionCallError;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::handlers::parse_arguments;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
pub struct ToolSuggestHandler;
pub(crate) const TOOL_SUGGEST_TOOL_NAME: &str = "tool_suggest";
const TOOL_SUGGEST_DECISION_INSTALL: &str = "install";
const TOOL_SUGGEST_DECISION_ENABLE: &str = "enable";
const TOOL_SUGGEST_DECISION_NOT_NOW: &str = "not_now";
const TOOL_SUGGEST_META_KIND_KEY: &str = "codex_approval_kind";
const TOOL_SUGGEST_META_KIND_VALUE: &str = "tool_suggestion";
#[derive(Clone, Copy, Debug, Deserialize, Serialize, Eq, PartialEq)]
#[serde(rename_all = "lowercase")]
pub(crate) enum ToolSuggestionToolType {
Connector,
Plugin,
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize, Eq, PartialEq)]
#[serde(rename_all = "lowercase")]
pub(crate) enum ToolSuggestionType {
Install,
Enable,
}
impl ToolSuggestionType {
fn decision(self) -> &'static str {
match self {
Self::Install => TOOL_SUGGEST_DECISION_INSTALL,
Self::Enable => TOOL_SUGGEST_DECISION_ENABLE,
}
}
fn verb(self) -> &'static str {
match self {
Self::Install => "Install",
Self::Enable => "Enable",
}
}
}
#[derive(Deserialize)]
struct ToolSuggestArgs {
connector_id: String,
tool_type: ToolSuggestionToolType,
suggestion_type: ToolSuggestionType,
suggest_reason: Option<String>,
}
#[derive(Deserialize)]
struct ToolSuggestElicitationContent {
decision: String,
}
fn tool_suggest_message(
suggestion_type: ToolSuggestionType,
connector_name: &str,
suggest_reason: Option<&str>,
connector_description: Option<&str>,
url: &str,
) -> String {
let mut parts = vec![format!(
"{} {connector_name} to continue?",
suggestion_type.verb()
)];
if let Some(suggest_reason) = normalized_optional_text(suggest_reason) {
parts.push(format!("Reason: {suggest_reason}"));
}
if let Some(description) = normalized_optional_text(connector_description) {
parts.push(description);
}
parts.push(format!("Open URL: {url}"));
parts.join(" | ")
}
fn normalized_optional_text(text: Option<&str>) -> Option<String> {
text.and_then(|text| {
let normalized = text.split_whitespace().collect::<Vec<_>>().join(" ");
if normalized.is_empty() {
None
} else {
Some(normalized)
}
})
}
fn tool_suggest_requested_schema() -> McpElicitationSchema {
McpElicitationSchema {
schema_uri: None,
type_: McpElicitationObjectType::Object,
properties: BTreeMap::new(),
required: None,
}
}
fn tool_suggest_elicitation_meta(
tool_type: ToolSuggestionToolType,
suggestion_type: ToolSuggestionType,
connector_id: &str,
connector_name: &str,
suggest_reason: Option<&str>,
connector_description: Option<&str>,
install_url: &str,
) -> Value {
let mut meta = json!({
TOOL_SUGGEST_META_KIND_KEY: TOOL_SUGGEST_META_KIND_VALUE,
"tool_type": tool_type,
"suggestion_type": suggestion_type,
"connector_id": connector_id,
"connector_name": connector_name,
"connector_description": connector_description,
"install_url": install_url,
});
if let Some(suggest_reason) = suggest_reason
&& let Some(meta) = meta.as_object_mut()
{
meta.insert("suggest_reason".to_string(), json!(suggest_reason));
}
meta
}
fn parse_tool_suggest_elicitation_response(
response: ElicitationResponse,
suggestion_type: ToolSuggestionType,
) -> (&'static str, String) {
let elicitation_action = match response.action {
ElicitationAction::Accept => "accept",
ElicitationAction::Decline => "decline",
ElicitationAction::Cancel => "cancel",
};
let user_decision = match response.action {
ElicitationAction::Accept => response
.content
.and_then(|content| {
serde_json::from_value::<ToolSuggestElicitationContent>(content).ok()
})
.map(|content| content.decision)
.filter(|decision| {
matches!(
decision.as_str(),
TOOL_SUGGEST_DECISION_INSTALL
| TOOL_SUGGEST_DECISION_ENABLE
| TOOL_SUGGEST_DECISION_NOT_NOW
)
})
.unwrap_or_else(|| suggestion_type.decision().to_string()),
ElicitationAction::Decline | ElicitationAction::Cancel => {
TOOL_SUGGEST_DECISION_NOT_NOW.to_string()
}
};
(elicitation_action, user_decision)
}
#[async_trait]
impl ToolHandler for ToolSuggestHandler {
type Output = FunctionToolOutput;
fn kind(&self) -> ToolKind {
ToolKind::Function
}
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let ToolInvocation {
session,
payload,
turn,
call_id,
..
} = invocation;
let arguments = match payload {
ToolPayload::Function { arguments } => arguments,
_ => {
return Err(FunctionCallError::Fatal(format!(
"{TOOL_SUGGEST_TOOL_NAME} handler received unsupported payload"
)));
}
};
let args: ToolSuggestArgs = parse_arguments(&arguments)?;
let connector_id = args.connector_id.trim();
if connector_id.is_empty() {
return Err(FunctionCallError::RespondToModel(
"connector_id must not be empty".to_string(),
));
}
if args.tool_type != ToolSuggestionToolType::Connector {
return Err(FunctionCallError::RespondToModel(format!(
"tool_type `{}` is not supported by {TOOL_SUGGEST_TOOL_NAME} yet",
match args.tool_type {
ToolSuggestionToolType::Connector => "connector",
ToolSuggestionToolType::Plugin => "plugin",
}
)));
}
let connectors = match connectors::list_cached_connectors(&turn.config).await {
Some(connectors) => connectors,
None => connectors::list_connectors(&turn.config)
.await
.map_err(|err| {
FunctionCallError::RespondToModel(format!(
"failed to load discoverable apps: {err}"
))
})?,
};
let connector = connectors
.into_iter()
.find(|connector| connector.id == connector_id)
.ok_or_else(|| {
FunctionCallError::RespondToModel(format!("unknown connector_id `{connector_id}`"))
})?;
let enabled_connector_overrides = session.get_connector_selection().await;
let connector_is_enabled =
connector.is_enabled || enabled_connector_overrides.contains(connector_id);
match args.suggestion_type {
ToolSuggestionType::Install => {
if connector.is_accessible {
return Err(FunctionCallError::RespondToModel(format!(
"connector_id `{connector_id}` is already installed; use search_tool_bm25 with mode `enabled`, or mode `discoverable` with suggestion_type `enable` if it is disabled"
)));
}
}
ToolSuggestionType::Enable => {
if !connector.is_accessible {
return Err(FunctionCallError::RespondToModel(format!(
"connector_id `{connector_id}` is not installed; use search_tool_bm25 with mode `discoverable` and suggestion_type `install` instead"
)));
}
if connector_is_enabled {
return Err(FunctionCallError::RespondToModel(format!(
"connector_id `{connector_id}` is already enabled; use its tools or search_tool_bm25 with mode `enabled` instead"
)));
}
}
}
let install_url = connector
.install_url
.clone()
.unwrap_or_else(|| connectors::connector_install_url(&connector.name, &connector.id));
let suggest_reason = normalized_optional_text(args.suggest_reason.as_deref());
let request_id = RequestId::String(format!("{TOOL_SUGGEST_TOOL_NAME}_{call_id}").into());
let elicitation_response = session
.request_mcp_server_elicitation(
turn.as_ref(),
request_id,
McpServerElicitationRequestParams {
thread_id: session.conversation_id.to_string(),
turn_id: Some(turn.sub_id.clone()),
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
request: McpServerElicitationRequest::Form {
meta: Some(tool_suggest_elicitation_meta(
args.tool_type,
args.suggestion_type,
&connector.id,
&connector.name,
suggest_reason.as_deref(),
connector.description.as_deref(),
&install_url,
)),
message: tool_suggest_message(
args.suggestion_type,
&connector.name,
suggest_reason.as_deref(),
connector.description.as_deref(),
&install_url,
),
requested_schema: tool_suggest_requested_schema(),
},
},
)
.await
.ok_or_else(|| {
FunctionCallError::RespondToModel(
"tool_suggest was cancelled before receiving a response".to_string(),
)
})?;
let (elicitation_action, user_decision) =
parse_tool_suggest_elicitation_response(elicitation_response, args.suggestion_type);
if user_decision == TOOL_SUGGEST_DECISION_INSTALL
|| user_decision == TOOL_SUGGEST_DECISION_ENABLE
{
session
.merge_connector_selection(HashSet::from([connector.id.clone()]))
.await;
}
let user_declined_suggestion =
user_decision == TOOL_SUGGEST_DECISION_NOT_NOW && elicitation_action != "cancel";
let assistant_instruction = if user_decision == TOOL_SUGGEST_DECISION_INSTALL {
"The user confirmed they completed the install flow. Treat this connector as selectable for this turn, but verify its tools appear in a `search_tool_bm25` search with `mode: \"enabled\"` before trying to use them. If they still do not appear, it may also need enabling."
} else if user_decision == TOOL_SUGGEST_DECISION_ENABLE {
"The user confirmed they enabled this connector. Treat it as selectable for this turn, but verify its tools appear in a `search_tool_bm25` search with `mode: \"enabled\"` before trying to use them."
} else if user_declined_suggestion {
"The user declined this suggestion. Do not ask them again to install or enable this connector unless they later ask for it. Do not try to use this tool in this turn."
} else {
"The user did not complete this suggestion flow. Do not try to use this tool in this turn."
};
let mut content = json!({
"connector_id": connector.id,
"connector_name": connector.name,
"connector_description": connector.description,
"install_url": install_url,
"tool_type": args.tool_type,
"suggestion_type": args.suggestion_type,
"elicitation_action": elicitation_action,
"user_decision": user_decision,
"assistant_instruction": assistant_instruction,
});
if let Some(suggest_reason) = suggest_reason
&& let Some(content) = content.as_object_mut()
{
content.insert("suggest_reason".to_string(), json!(suggest_reason));
}
let content = content.to_string();
Ok(FunctionToolOutput::from_text(content, Some(true)))
}
}
#[cfg(test)]
mod tests {
use codex_rmcp_client::ElicitationAction;
use codex_rmcp_client::ElicitationResponse;
use pretty_assertions::assert_eq;
use super::TOOL_SUGGEST_DECISION_ENABLE;
use super::TOOL_SUGGEST_DECISION_INSTALL;
use super::TOOL_SUGGEST_DECISION_NOT_NOW;
use super::ToolSuggestionType;
use super::parse_tool_suggest_elicitation_response;
use super::tool_suggest_message;
#[test]
fn tool_suggest_message_uses_single_line_text() {
let message = tool_suggest_message(
ToolSuggestionType::Install,
"Docs & Notes",
Some("Need access to\n workspace docs"),
Some("Install <now> & sync"),
"https://example.com/apps?name=Docs",
);
assert_eq!(
message,
"Install Docs & Notes to continue? | Reason: Need access to workspace docs | Install <now> & sync | Open URL: https://example.com/apps?name=Docs"
);
}
#[test]
fn accepted_tool_suggest_defaults_to_install_without_form_content() {
let (elicitation_action, user_decision) = parse_tool_suggest_elicitation_response(
ElicitationResponse {
action: ElicitationAction::Accept,
content: None,
meta: None,
},
ToolSuggestionType::Install,
);
assert_eq!(elicitation_action, "accept");
assert_eq!(user_decision, TOOL_SUGGEST_DECISION_INSTALL);
}
#[test]
fn accepted_tool_suggest_defaults_to_enable_without_form_content() {
let (elicitation_action, user_decision) = parse_tool_suggest_elicitation_response(
ElicitationResponse {
action: ElicitationAction::Accept,
content: None,
meta: None,
},
ToolSuggestionType::Enable,
);
assert_eq!(elicitation_action, "accept");
assert_eq!(user_decision, TOOL_SUGGEST_DECISION_ENABLE);
}
#[test]
fn declined_tool_suggest_maps_to_not_now() {
let (elicitation_action, user_decision) = parse_tool_suggest_elicitation_response(
ElicitationResponse {
action: ElicitationAction::Decline,
content: None,
meta: None,
},
ToolSuggestionType::Install,
);
assert_eq!(elicitation_action, "decline");
assert_eq!(user_decision, TOOL_SUGGEST_DECISION_NOT_NOW);
}
#[test]
fn cancelled_tool_suggest_maps_to_not_now() {
let (elicitation_action, user_decision) = parse_tool_suggest_elicitation_response(
ElicitationResponse {
action: ElicitationAction::Cancel,
content: None,
meta: None,
},
ToolSuggestionType::Install,
);
assert_eq!(elicitation_action, "cancel");
assert_eq!(user_decision, TOOL_SUGGEST_DECISION_NOT_NOW);
}
}

View File

@@ -1,6 +1,7 @@
use crate::client_common::tools::ToolSpec;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::connectors::AppInfo;
use crate::function_tool::FunctionCallError;
use crate::mcp_connection_manager::ToolInfo;
use crate::sandboxing::SandboxPermissions;
@@ -11,6 +12,7 @@ use crate::tools::registry::ConfiguredToolSpec;
use crate::tools::registry::ToolRegistry;
use crate::tools::spec::ToolsConfig;
use crate::tools::spec::build_specs;
use crate::tools::spec::build_specs_with_connectors;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::LocalShellAction;
@@ -49,6 +51,20 @@ impl ToolRouter {
Self { registry, specs }
}
pub fn from_config_with_connectors(
config: &ToolsConfig,
mcp_tools: Option<HashMap<String, Tool>>,
app_tools: Option<HashMap<String, ToolInfo>>,
dynamic_tools: &[DynamicToolSpec],
connectors: Option<&[AppInfo]>,
) -> Self {
let builder =
build_specs_with_connectors(config, mcp_tools, app_tools, dynamic_tools, connectors);
let (specs, registry) = builder.build();
Self { registry, specs }
}
pub fn specs(&self) -> Vec<ToolSpec> {
self.specs
.iter()

View File

@@ -3,13 +3,16 @@ use crate::client_common::tools::FreeformToolFormat;
use crate::client_common::tools::ResponsesApiTool;
use crate::client_common::tools::ToolSpec;
use crate::config::AgentRoleConfig;
use crate::connectors::AppInfo;
use crate::features::Feature;
use crate::features::Features;
use crate::mcp_connection_manager::ToolInfo;
use crate::models_manager::collaboration_mode_presets::CollaborationModesConfig;
use crate::tools::handlers::INSTALLABLE_DISCOVERABLE_CONNECTOR_IDS;
use crate::tools::handlers::PLAN_TOOL;
use crate::tools::handlers::SEARCH_TOOL_BM25_DEFAULT_LIMIT;
use crate::tools::handlers::SEARCH_TOOL_BM25_TOOL_NAME;
use crate::tools::handlers::TOOL_SUGGEST_TOOL_NAME;
use crate::tools::handlers::agent_jobs::BatchJobHandler;
use crate::tools::handlers::apply_patch::create_apply_patch_freeform_tool;
use crate::tools::handlers::apply_patch::create_apply_patch_json_tool;
@@ -1269,7 +1272,10 @@ fn create_grep_files_tool() -> ToolSpec {
})
}
fn create_search_tool_bm25_tool(app_tools: &HashMap<String, ToolInfo>) -> ToolSpec {
fn create_search_tool_bm25_tool(
app_tools: &HashMap<String, ToolInfo>,
connectors: Option<&[AppInfo]>,
) -> ToolSpec {
let properties = BTreeMap::from([
(
"query".to_string(),
@@ -1285,6 +1291,15 @@ fn create_search_tool_bm25_tool(app_tools: &HashMap<String, ToolInfo>) -> ToolSp
)),
},
),
(
"mode".to_string(),
JsonSchema::String {
description: Some(
"Search mode: `enabled` searches currently enabled app tools; `discoverable` searches connectors that still need install or enable steps. Defaults to `enabled`."
.to_string(),
),
},
),
]);
let mut app_names = app_tools
.values()
@@ -1293,13 +1308,35 @@ fn create_search_tool_bm25_tool(app_tools: &HashMap<String, ToolInfo>) -> ToolSp
app_names.sort();
app_names.dedup();
let app_names = app_names.join(", ");
let mut discoverable_connector_names = connectors
.unwrap_or_default()
.iter()
.filter(|connector| INSTALLABLE_DISCOVERABLE_CONNECTOR_IDS.contains(connector.id.as_str()))
.map(|connector| connector.name.as_str())
.collect::<Vec<_>>();
discoverable_connector_names.sort_unstable();
discoverable_connector_names.dedup();
let discoverable_connector_names = if discoverable_connector_names.is_empty() {
"connector metadata unavailable".to_string()
} else {
discoverable_connector_names.join(", ")
};
let description = if app_names.is_empty() {
SEARCH_TOOL_BM25_DESCRIPTION_TEMPLATE
.replace("({{app_names}})", "(None currently enabled)")
.replace("{{app_names}}", "available apps")
.replace("{{app_names}}", "enabled apps")
.replace(
"{{discoverable_connector_names}}",
discoverable_connector_names.as_str(),
)
} else {
SEARCH_TOOL_BM25_DESCRIPTION_TEMPLATE.replace("{{app_names}}", app_names.as_str())
SEARCH_TOOL_BM25_DESCRIPTION_TEMPLATE
.replace("{{app_names}}", app_names.as_str())
.replace(
"{{discoverable_connector_names}}",
discoverable_connector_names.as_str(),
)
};
ToolSpec::Function(ResponsesApiTool {
@@ -1314,6 +1351,64 @@ fn create_search_tool_bm25_tool(app_tools: &HashMap<String, ToolInfo>) -> ToolSp
})
}
fn create_tool_suggest_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"connector_id".to_string(),
JsonSchema::String {
description: Some(
"Connector ID returned by `search_tool_bm25` in `discoverable` mode."
.to_string(),
),
},
),
(
"tool_type".to_string(),
JsonSchema::String {
description: Some(
"Tool type returned by `search_tool_bm25` in `discoverable` mode, for example `connector`."
.to_string(),
),
},
),
(
"suggestion_type".to_string(),
JsonSchema::String {
description: Some(
"Suggestion type returned by `search_tool_bm25` in `discoverable` mode: `install` or `enable`."
.to_string(),
),
},
),
(
"suggest_reason".to_string(),
JsonSchema::String {
description: Some(
"Optional brief, user-facing reason for why this connector should be installed or enabled now."
.to_string(),
),
},
),
]);
ToolSpec::Function(ResponsesApiTool {
name: TOOL_SUGGEST_TOOL_NAME.to_string(),
description:
"Prompt the user to install or enable a discoverable connector, optionally with a reason."
.to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec![
"connector_id".to_string(),
"tool_type".to_string(),
"suggestion_type".to_string(),
]),
additional_properties: Some(false.into()),
},
})
}
fn create_read_file_tool() -> ToolSpec {
let indentation_properties = BTreeMap::from([
(
@@ -1866,6 +1961,16 @@ pub(crate) fn build_specs(
mcp_tools: Option<HashMap<String, rmcp::model::Tool>>,
app_tools: Option<HashMap<String, ToolInfo>>,
dynamic_tools: &[DynamicToolSpec],
) -> ToolRegistryBuilder {
build_specs_with_connectors(config, mcp_tools, app_tools, dynamic_tools, None)
}
pub(crate) fn build_specs_with_connectors(
config: &ToolsConfig,
mcp_tools: Option<HashMap<String, rmcp::model::Tool>>,
app_tools: Option<HashMap<String, ToolInfo>>,
dynamic_tools: &[DynamicToolSpec],
connectors: Option<&[AppInfo]>,
) -> ToolRegistryBuilder {
use crate::tools::handlers::ApplyPatchHandler;
use crate::tools::handlers::ArtifactsHandler;
@@ -1886,6 +1991,7 @@ pub(crate) fn build_specs(
use crate::tools::handlers::ShellCommandHandler;
use crate::tools::handlers::ShellHandler;
use crate::tools::handlers::TestSyncHandler;
use crate::tools::handlers::ToolSuggestHandler;
use crate::tools::handlers::UnifiedExecHandler;
use crate::tools::handlers::ViewImageHandler;
use std::sync::Arc;
@@ -1906,6 +2012,7 @@ pub(crate) fn build_specs(
default_mode_request_user_input: config.default_mode_request_user_input,
});
let search_tool_handler = Arc::new(SearchToolBm25Handler);
let tool_suggest_handler = Arc::new(ToolSuggestHandler);
let code_mode_handler = Arc::new(CodeModeHandler);
let js_repl_handler = Arc::new(JsReplHandler);
let js_repl_reset_handler = Arc::new(JsReplResetHandler);
@@ -1914,11 +2021,12 @@ pub(crate) fn build_specs(
if config.code_mode_enabled {
let nested_config = config.for_code_mode_nested_tools();
let (nested_specs, _) = build_specs(
let (nested_specs, _) = build_specs_with_connectors(
&nested_config,
mcp_tools.clone(),
app_tools.clone(),
dynamic_tools,
connectors,
)
.build();
let mut enabled_tool_names = nested_specs
@@ -2003,8 +2111,13 @@ pub(crate) fn build_specs(
if config.search_tool {
let app_tools = app_tools.unwrap_or_default();
builder.push_spec_with_parallel_support(create_search_tool_bm25_tool(&app_tools), true);
builder.push_spec_with_parallel_support(
create_search_tool_bm25_tool(&app_tools, connectors),
true,
);
builder.push_spec_with_parallel_support(create_tool_suggest_tool(), true);
builder.register_handler(SEARCH_TOOL_BM25_TOOL_NAME, search_tool_handler);
builder.register_handler(TOOL_SUGGEST_TOOL_NAME, tool_suggest_handler);
}
if let Some(apply_patch_tool_type) = &config.apply_patch_tool_type {
@@ -3395,6 +3508,88 @@ mod tests {
};
assert!(description.contains("Calendar"));
assert!(!description.contains("mcp__rmcp__echo"));
assert!(description.contains("Always search `mode: \"enabled\"` first."));
assert!(description.contains("call `tool_suggest`"));
}
#[test]
fn search_tool_spec_includes_mode_and_tool_suggest_is_registered() {
let config = test_config();
let model_info =
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.enable(Feature::Apps);
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
});
let (tools, _) = build_specs(
&tools_config,
Some(HashMap::from([(
"mcp__codex_apps__calendar_create_event".to_string(),
mcp_tool(
"calendar_create_event",
"Create calendar event",
serde_json::json!({"type": "object"}),
),
)])),
Some(HashMap::from([(
"mcp__codex_apps__calendar_create_event".to_string(),
ToolInfo {
server_name: crate::mcp::CODEX_APPS_MCP_SERVER_NAME.to_string(),
tool_name: "calendar_create_event".to_string(),
tool: mcp_tool(
"calendar_create_event",
"Create calendar event",
serde_json::json!({"type": "object"}),
),
connector_id: Some("calendar".to_string()),
connector_name: Some("Calendar".to_string()),
plugin_display_names: Vec::new(),
},
)])),
&[],
)
.build();
let search_tool = find_tool(&tools, SEARCH_TOOL_BM25_TOOL_NAME);
let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &search_tool.spec else {
panic!("expected function tool");
};
let JsonSchema::Object { properties, .. } = parameters else {
panic!("expected object schema");
};
assert!(properties.contains_key("mode"));
let tool_suggest = find_tool(&tools, TOOL_SUGGEST_TOOL_NAME);
let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &tool_suggest.spec else {
panic!("expected function tool");
};
let JsonSchema::Object {
properties,
required,
..
} = parameters
else {
panic!("expected object schema");
};
assert!(properties.contains_key("connector_id"));
assert!(properties.contains_key("tool_type"));
assert!(properties.contains_key("suggestion_type"));
assert!(properties.contains_key("suggest_reason"));
assert_eq!(
required.as_deref(),
Some(
&[
"connector_id".to_string(),
"tool_type".to_string(),
"suggestion_type".to_string(),
][..]
)
);
}
#[test]
@@ -3454,15 +3649,38 @@ mod tests {
session_source: SessionSource::Cli,
});
let (tools, _) = build_specs(&tools_config, None, Some(HashMap::new()), &[]).build();
let discoverable_connectors = vec![AppInfo {
id: "connector_2128aebfecb84f64a069897515042a44".to_string(),
name: "Docs Connector".to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
install_url: None,
branding: None,
app_metadata: None,
labels: None,
is_accessible: false,
is_enabled: true,
plugin_display_names: Vec::new(),
}];
let (tools, _) = build_specs_with_connectors(
&tools_config,
None,
Some(HashMap::new()),
&[],
Some(&discoverable_connectors),
)
.build();
let search_tool = find_tool(&tools, SEARCH_TOOL_BM25_TOOL_NAME);
let ToolSpec::Function(ResponsesApiTool { description, .. }) = &search_tool.spec else {
panic!("expected function tool");
};
assert!(description.contains("(None currently enabled)"));
assert!(description.contains("available apps."));
assert!(description.contains("Docs Connector"));
assert!(!description.contains("{{app_names}}"));
assert!(!description.contains("{{discoverable_connector_names}}"));
}
#[test]

View File

@@ -444,7 +444,7 @@ mod tests {
"pause should block the unified exec yield timeout"
);
assert!(
response.output.contains("unified-exec-done"),
response.truncated_output().contains("unified-exec-done"),
"exec_command should wait for output after the pause lifts"
);
assert!(

View File

@@ -1,22 +1,29 @@
# Apps tool discovery
Searches over apps tool metadata with BM25 and exposes matching tools for the next model call.
Searches over apps metadata with BM25 and exposes matching enabled tools for the next model call, or discoverable apps when requested.
MCP tools of the apps ({{app_names}}) are hidden until you search for them with this tool (`search_tool_bm25`).
Known discoverable tools (connectors, plugins) via this tool: {{discoverable_connector_names}}.
Follow this workflow:
1. Call `search_tool_bm25` with:
- `query` (required): focused terms that describe the capability you need.
- `limit` (optional): maximum number of tools to return (default `8`).
2. Use the returned `tools` list to decide which Apps tools are relevant.
3. Matching tools are added to available `tools` and available for the remainder of the current session/thread.
4. Repeated searches in the same session/thread are additive: new matches are unioned into `tools`.
- `mode` (optional): `enabled` or `discoverable`. Default is `enabled`.
2. Always search `mode: "enabled"` first.
3. If `enabled` finds the right tool, use the returned `tools` list to decide which Apps tools are relevant.
4. Matching enabled tools are added to available `tools` for the remainder of the current session/thread.
5. If `enabled` does not find the right tool and the user strongly wants a specific app, search again with `mode: "discoverable"`.
6. `discoverable` results prefer:
- connectors that are not installed
- connectors that are installed but disabled
7. If `discoverable` finds the right app, call `tool_suggest` with the returned `connector_id`, `tool_type`, and `suggestion_type`, include a concise one-liner, user-facing `suggest_reason` for the `tool_suggest` tool.
Notes:
- Core tools remain available without searching.
- If you are unsure, start with `limit` between 5 and 10 to see a broader set of tools.
- `query` is matched against Apps tool metadata fields:
- In `enabled` mode, `query` is matched against Apps tool metadata fields:
- `name`
- `tool_name`
- `server_name`
@@ -24,5 +31,8 @@ Notes:
- `description`
- `connector_name`
- input schema property keys (`input_keys`)
- In `discoverable` mode, `query` is matched against various kinds of tool metadata such as connector id, name, description, labels, categories, and plugin display names.
- `discoverable` can also surface connectors that are already installed but currently disabled, even if they are not listed above.
- If the needed app is already explicit in the prompt (for example `[$app-name](app://{connector_id})`) or already present in the current `tools` list, you can call that tool directly.
- Do not use `search_tool_bm25` for non-apps/local tasks (filesystem, repo search, or shell-only workflows) or anything not related to {{app_names}}.
- Do not call app MCP tools for apps returned only by `discoverable` mode until the user installs or enables them.
- Do not use `search_tool_bm25` for non-apps/local tasks (filesystem, repo search, or shell-only workflows) or anything not related to the specific tool names mentioned above.

View File

@@ -5,6 +5,7 @@ use std::sync::Arc;
use std::time::Duration;
use anyhow::Result;
use base64::Engine as _;
use codex_core::CodexAuth;
use codex_core::CodexThread;
use codex_core::NewThread;
@@ -12,6 +13,8 @@ use codex_core::config::Config;
use codex_core::config::types::McpServerConfig;
use codex_core::config::types::McpServerTransportConfig;
use codex_core::features::Feature;
use codex_protocol::approvals::ElicitationAction;
use codex_protocol::approvals::ElicitationRequest;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
@@ -29,24 +32,38 @@ use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::stdio_server_bin;
use core_test_support::test_codex::TestCodex;
use core_test_support::test_codex::TestCodexBuilder;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use core_test_support::wait_for_event_match;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use wiremock::Mock;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
const SEARCH_TOOL_INSTRUCTION_SNIPPETS: [&str; 2] = [
"MCP tools of the apps (Calendar) are hidden until you search for them with this tool",
"Matching tools are added to available `tools` and available for the remainder of the current session/thread.",
"Always search `mode: \"enabled\"` first.",
"If `discoverable` finds the right app, call `tool_suggest` with the returned `connector_id`, `tool_type`, and `suggestion_type`, include a concise one-liner, user-facing `suggest_reason` for the `tool_suggest` tool.",
];
const SEARCH_TOOL_BM25_TOOL_NAME: &str = "search_tool_bm25";
const TOOL_SUGGEST_TOOL_NAME: &str = "tool_suggest";
const CALENDAR_CREATE_TOOL: &str = "mcp__codex_apps__calendar_create_event";
const CALENDAR_LIST_TOOL: &str = "mcp__codex_apps__calendar_list_events";
const RMCP_ECHO_TOOL: &str = "mcp__rmcp__echo";
const RMCP_IMAGE_TOOL: &str = "mcp__rmcp__image";
const CALENDAR_CREATE_QUERY: &str = "create calendar event";
const CALENDAR_LIST_QUERY: &str = "list calendar events";
const ALLOWLISTED_INSTALLABLE_CONNECTOR_ID: &str = "connector_2128aebfecb84f64a069897515042a44";
const ALLOWLISTED_INSTALLABLE_CONNECTOR_NAME: &str = "Docs Connector";
const ALLOWLISTED_INSTALLABLE_CONNECTOR_DESCRIPTION: &str = "Approved workspace docs";
const ALLOWLISTED_INSTALLABLE_QUERY: &str = "approved workspace docs";
const NOTION_CONNECTOR_ID: &str = "notion";
const NOTION_CONNECTOR_NAME: &str = "Notion";
const NOTION_CONNECTOR_DESCRIPTION: &str = "Workspace docs and notes";
fn tool_names(body: &Value) -> Vec<String> {
body.get("tools")
@@ -82,15 +99,20 @@ fn search_tool_description(body: &Value) -> Option<String> {
}
fn search_tool_output_payload(request: &ResponsesRequest, call_id: &str) -> Value {
function_tool_output_payload(request, call_id, SEARCH_TOOL_BM25_TOOL_NAME)
}
fn function_tool_output_payload(
request: &ResponsesRequest,
call_id: &str,
tool_name: &str,
) -> Value {
let (content, _success) = request
.function_call_output_content_and_success(call_id)
.unwrap_or_else(|| {
panic!("{SEARCH_TOOL_BM25_TOOL_NAME} function_call_output should be present")
});
let content = content
.unwrap_or_else(|| panic!("{SEARCH_TOOL_BM25_TOOL_NAME} output should include content"));
.unwrap_or_else(|| panic!("{tool_name} function_call_output should be present"));
let content = content.unwrap_or_else(|| panic!("{tool_name} output should include content"));
serde_json::from_str(&content)
.unwrap_or_else(|_| panic!("{SEARCH_TOOL_BM25_TOOL_NAME} content should be valid JSON"))
.unwrap_or_else(|_| panic!("{tool_name} content should be valid JSON"))
}
fn active_selected_tools(payload: &Value) -> Vec<String> {
@@ -118,6 +140,91 @@ fn search_result_tools(payload: &Value) -> Vec<&Value> {
.collect()
}
fn search_result_connectors(payload: &Value) -> Vec<&Value> {
payload
.get("connectors")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default()
.iter()
.collect()
}
async fn mount_directory_connectors(server: &wiremock::MockServer) {
Mock::given(method("GET"))
.and(path("/connectors/directory/list"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"apps": [
{
"id": "calendar",
"name": "Calendar",
"description": "Manage events",
"appMetadata": {
"categories": ["calendar"]
}
},
{
"id": NOTION_CONNECTOR_ID,
"name": NOTION_CONNECTOR_NAME,
"description": NOTION_CONNECTOR_DESCRIPTION,
"appMetadata": {
"categories": ["docs"]
}
},
{
"id": ALLOWLISTED_INSTALLABLE_CONNECTOR_ID,
"name": ALLOWLISTED_INSTALLABLE_CONNECTOR_NAME,
"description": ALLOWLISTED_INSTALLABLE_CONNECTOR_DESCRIPTION,
"appMetadata": {
"categories": ["docs"]
}
}
],
"nextToken": null
})))
.mount(server)
.await;
Mock::given(method("GET"))
.and(path("/connectors/directory/list_workspace"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"apps": [],
"nextToken": null
})))
.mount(server)
.await;
}
fn write_test_chatgpt_auth(home: &std::path::Path) {
let header = json!({ "alg": "none", "typ": "JWT" });
let payload = json!({
"email": "user@example.com",
"https://api.openai.com/auth": {
"chatgpt_plan_type": "plus",
"chatgpt_account_id": "account_id"
}
});
let encode = |value: &Value| {
base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(serde_json::to_vec(value).expect("serialize test JWT section"))
};
let fake_jwt = format!("{}.{}.{}", encode(&header), encode(&payload), "sig");
let auth_json = json!({
"tokens": {
"id_token": fake_jwt,
"access_token": "test-access-token",
"refresh_token": "test-refresh-token",
"account_id": "account_id"
},
"last_refresh": chrono::Utc::now()
});
std::fs::write(
home.join("auth.json"),
serde_json::to_string_pretty(&auth_json).expect("serialize test auth.json"),
)
.expect("write test auth.json");
}
fn rmcp_server_config(command: String) -> McpServerConfig {
McpServerConfig {
transport: McpServerTransportConfig::Stdio {
@@ -166,6 +273,9 @@ fn configure_apps_with_optional_rmcp(
fn configured_builder(apps_base_url: String, rmcp_server_bin: Option<String>) -> TestCodexBuilder {
test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_pre_build_hook(|home| {
write_test_chatgpt_auth(home);
})
.with_config(move |config| {
configure_apps_with_optional_rmcp(config, apps_base_url.as_str(), rmcp_server_bin);
})
@@ -185,6 +295,38 @@ async fn submit_user_input(thread: &Arc<CodexThread>, text: &str) -> Result<()>
Ok(())
}
async fn submit_turn_without_waiting(test: &TestCodex, text: &str) -> Result<()> {
test.codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: text.to_string(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: test.cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: test.session_configured.model.clone(),
effort: None,
summary: None,
service_tier: None,
collaboration_mode: None,
personality: None,
})
.await?;
Ok(())
}
async fn wait_for_elicitation_request(
thread: &Arc<CodexThread>,
) -> codex_protocol::approvals::ElicitationRequestEvent {
wait_for_event_match(thread, |event| match event {
EventMsg::ElicitationRequest(request) => Some(request.clone()),
_ => None,
})
.await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn search_tool_flag_adds_tool() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -217,6 +359,10 @@ async fn search_tool_flag_adds_tool() -> Result<()> {
tools.iter().any(|name| name == SEARCH_TOOL_BM25_TOOL_NAME),
"tools list should include {SEARCH_TOOL_BM25_TOOL_NAME} when enabled: {tools:?}"
);
assert!(
tools.iter().any(|name| name == TOOL_SUGGEST_TOOL_NAME),
"tools list should include {TOOL_SUGGEST_TOOL_NAME} when enabled: {tools:?}"
);
Ok(())
}
@@ -295,6 +441,10 @@ async fn search_tool_adds_discovery_instructions_to_tool_description() -> Result
.all(|snippet| description.contains(snippet)),
"search tool description should include search tool workflow: {description:?}"
);
assert!(
description.contains("connector metadata unavailable"),
"search tool description should explain missing discoverable connector metadata: {description:?}"
);
Ok(())
}
@@ -1215,3 +1365,613 @@ async fn search_tool_selection_drops_when_fork_excludes_search_turn() -> Result<
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn installable_search_returns_inaccessible_connectors_without_selecting_tools() -> Result<()>
{
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let apps_server = AppsTestServer::mount(&server).await?;
mount_directory_connectors(&server).await;
let call_id = "installable-search";
let args = json!({
"query": ALLOWLISTED_INSTALLABLE_QUERY,
"limit": 5,
"mode": "discoverable",
});
let mock = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(
call_id,
SEARCH_TOOL_BM25_TOOL_NAME,
&serde_json::to_string(&args)?,
),
ev_completed("resp-1"),
]),
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
],
)
.await;
let mut builder = configured_builder(apps_server.chatgpt_base_url.clone(), None);
let test = builder.build(&server).await?;
test.submit_turn_with_policies(
"find installable docs app",
AskForApproval::Never,
SandboxPolicy::DangerFullAccess,
)
.await?;
let requests = mock.requests();
assert_eq!(
requests.len(),
2,
"expected 2 requests, got {}",
requests.len()
);
let payload = search_tool_output_payload(&requests[1], call_id);
assert_eq!(
payload.get("mode").and_then(Value::as_str),
Some("discoverable")
);
assert_eq!(
payload.get("total_connectors").and_then(Value::as_u64),
Some(1),
"expected one installable connector: {payload:?}"
);
assert_eq!(
active_selected_tools(&payload),
Vec::<String>::new(),
"installable search should not select tools: {payload:?}"
);
let connectors = search_result_connectors(&payload);
assert_eq!(
connectors.len(),
1,
"expected one connector result: {payload:?}"
);
assert_eq!(
connectors[0].get("connector_id").and_then(Value::as_str),
Some(ALLOWLISTED_INSTALLABLE_CONNECTOR_ID),
);
assert_eq!(
connectors[0].get("tool_type").and_then(Value::as_str),
Some("connector"),
);
assert_eq!(
connectors[0].get("suggestion_type").and_then(Value::as_str),
Some("install"),
);
assert_eq!(
connectors[0].get("connector_name").and_then(Value::as_str),
Some(ALLOWLISTED_INSTALLABLE_CONNECTOR_NAME),
);
assert_eq!(
connectors[0]
.get("connector_description")
.and_then(Value::as_str),
Some(ALLOWLISTED_INSTALLABLE_CONNECTOR_DESCRIPTION),
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn installable_search_preserves_available_selection() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let apps_server = AppsTestServer::mount(&server).await?;
mount_directory_connectors(&server).await;
let available_call_id = "available-search";
let installable_call_id = "installable-search";
let responses = vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(
available_call_id,
SEARCH_TOOL_BM25_TOOL_NAME,
&serde_json::to_string(&json!({
"query": CALENDAR_CREATE_QUERY,
"limit": 1,
}))?,
),
ev_completed("resp-1"),
]),
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
sse(vec![
ev_response_created("resp-3"),
ev_function_call(
installable_call_id,
SEARCH_TOOL_BM25_TOOL_NAME,
&serde_json::to_string(&json!({
"query": ALLOWLISTED_INSTALLABLE_QUERY,
"limit": 5,
"mode": "discoverable",
}))?,
),
ev_completed("resp-3"),
]),
sse(vec![
ev_assistant_message("msg-2", "done"),
ev_completed("resp-4"),
]),
];
let mock = mount_sse_sequence(&server, responses).await;
let mut builder = configured_builder(apps_server.chatgpt_base_url.clone(), None);
let test = builder.build(&server).await?;
test.submit_turn_with_policies(
"find calendar tool",
AskForApproval::Never,
SandboxPolicy::DangerFullAccess,
)
.await?;
test.submit_turn_with_policies(
"find installable docs app",
AskForApproval::Never,
SandboxPolicy::DangerFullAccess,
)
.await?;
let requests = mock.requests();
assert_eq!(
requests.len(),
4,
"expected 4 requests, got {}",
requests.len()
);
let available_payload = search_tool_output_payload(&requests[1], available_call_id);
let selected_tools = active_selected_tools(&available_payload);
assert_eq!(
selected_tools,
vec![CALENDAR_CREATE_TOOL.to_string()],
"available search should select calendar create tool: {available_payload:?}"
);
let installable_payload = search_tool_output_payload(&requests[3], installable_call_id);
assert_eq!(
active_selected_tools(&installable_payload),
selected_tools,
"installable search should preserve active selected tools: {installable_payload:?}"
);
assert_eq!(
search_result_connectors(&installable_payload)
.first()
.and_then(|connector| connector.get("connector_id"))
.and_then(Value::as_str),
Some(ALLOWLISTED_INSTALLABLE_CONNECTOR_ID),
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn installable_search_reports_catalog_load_errors() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let apps_server = AppsTestServer::mount(&server).await?;
let call_id = "installable-search";
let args = json!({
"query": "notion docs",
"mode": "discoverable",
});
let mock = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(
call_id,
SEARCH_TOOL_BM25_TOOL_NAME,
&serde_json::to_string(&args)?,
),
ev_completed("resp-1"),
]),
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
],
)
.await;
let mut builder = test_codex()
.with_auth(CodexAuth::from_api_key("dummy"))
.with_config({
let apps_base_url = apps_server.chatgpt_base_url.clone();
move |config| {
configure_apps_with_optional_rmcp(config, apps_base_url.as_str(), None);
}
});
let test = builder.build(&server).await?;
test.submit_turn_with_policies(
"find installable notion app",
AskForApproval::Never,
SandboxPolicy::DangerFullAccess,
)
.await?;
let requests = mock.requests();
assert_eq!(
requests.len(),
2,
"expected 2 requests, got {}",
requests.len()
);
let error = requests[1]
.function_call_output_content_and_success(call_id)
.and_then(|(content, _)| content)
.expect("installable search output should contain an error");
assert!(
error.contains("failed to load discoverable apps"),
"unexpected installable search error: {error:?}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn tool_suggest_prompts_install_via_elicitation() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let apps_server = AppsTestServer::mount(&server).await?;
mount_directory_connectors(&server).await;
let call_id = "tool-suggest";
let args = json!({
"connector_id": NOTION_CONNECTOR_ID,
"tool_type": "connector",
"suggestion_type": "install",
"suggest_reason": "The user asked to access their workspace docs.",
});
let mock = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(
call_id,
TOOL_SUGGEST_TOOL_NAME,
&serde_json::to_string(&args)?,
),
ev_completed("resp-1"),
]),
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
],
)
.await;
let mut builder = configured_builder(apps_server.chatgpt_base_url.clone(), None);
let test = builder.build(&server).await?;
submit_turn_without_waiting(&test, "suggest notion install").await?;
let elicitation = wait_for_elicitation_request(&test.codex).await;
assert_eq!(elicitation.server_name, "codex_apps");
assert!(elicitation.turn_id.is_some());
let ElicitationRequest::Form {
meta,
message,
requested_schema,
} = elicitation.request
else {
panic!("tool_suggest should emit a form elicitation");
};
assert_eq!(
message,
format!(
"Install {NOTION_CONNECTOR_NAME} to continue? | Reason: The user asked to access their workspace docs. | {NOTION_CONNECTOR_DESCRIPTION} | Open URL: https://chatgpt.com/apps/notion/notion"
)
);
assert_eq!(
meta,
Some(json!({
"codex_approval_kind": "tool_suggestion",
"tool_type": "connector",
"suggestion_type": "install",
"connector_id": NOTION_CONNECTOR_ID,
"connector_name": NOTION_CONNECTOR_NAME,
"suggest_reason": "The user asked to access their workspace docs.",
"connector_description": NOTION_CONNECTOR_DESCRIPTION,
"install_url": "https://chatgpt.com/apps/notion/notion",
}))
);
assert_eq!(
requested_schema,
json!({
"type": "object",
"properties": {}
})
);
test.codex
.submit(Op::ResolveElicitation {
server_name: elicitation.server_name,
request_id: elicitation.id,
decision: ElicitationAction::Accept,
content: None,
meta: None,
})
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let requests = mock.requests();
assert_eq!(
requests.len(),
2,
"expected 2 requests, got {}",
requests.len()
);
let payload = function_tool_output_payload(&requests[1], call_id, TOOL_SUGGEST_TOOL_NAME);
assert_eq!(
payload.get("connector_id").and_then(Value::as_str),
Some(NOTION_CONNECTOR_ID),
);
assert_eq!(
payload.get("connector_name").and_then(Value::as_str),
Some(NOTION_CONNECTOR_NAME),
);
assert_eq!(
payload.get("suggest_reason").and_then(Value::as_str),
Some("The user asked to access their workspace docs."),
);
assert_eq!(
payload.get("connector_description").and_then(Value::as_str),
Some(NOTION_CONNECTOR_DESCRIPTION),
);
assert_eq!(
payload.get("install_url").and_then(Value::as_str),
Some("https://chatgpt.com/apps/notion/notion"),
);
assert_eq!(
payload.get("elicitation_action").and_then(Value::as_str),
Some("accept"),
);
assert_eq!(
payload.get("user_decision").and_then(Value::as_str),
Some("install"),
);
assert!(
payload
.get("assistant_instruction")
.and_then(Value::as_str)
.is_some_and(|instruction| instruction.contains("completed the install flow")),
"unexpected tool_suggest payload: {payload:?}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn tool_suggest_rejects_accessible_connector() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let apps_server = AppsTestServer::mount(&server).await?;
mount_directory_connectors(&server).await;
let call_id = "tool-suggest";
let args = json!({
"connector_id": "calendar",
"tool_type": "connector",
"suggestion_type": "install",
});
let mock = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(
call_id,
TOOL_SUGGEST_TOOL_NAME,
&serde_json::to_string(&args)?,
),
ev_completed("resp-1"),
]),
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
],
)
.await;
let mut builder = configured_builder(apps_server.chatgpt_base_url.clone(), None);
let test = builder.build(&server).await?;
test.submit_turn_with_policies(
"suggest calendar install",
AskForApproval::Never,
SandboxPolicy::DangerFullAccess,
)
.await?;
let requests = mock.requests();
assert_eq!(
requests.len(),
2,
"expected 2 requests, got {}",
requests.len()
);
let error = requests[1]
.function_call_output_content_and_success(call_id)
.and_then(|(content, _)| content)
.expect("tool_suggest should return an error");
assert!(
error.contains("already installed"),
"unexpected accessible connector rejection: {error:?}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn tool_suggest_reports_not_now_when_user_declines_install() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let apps_server = AppsTestServer::mount(&server).await?;
mount_directory_connectors(&server).await;
let call_id = "tool-suggest";
let args = json!({
"connector_id": NOTION_CONNECTOR_ID,
"tool_type": "connector",
"suggestion_type": "install",
});
let mock = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(
call_id,
TOOL_SUGGEST_TOOL_NAME,
&serde_json::to_string(&args)?,
),
ev_completed("resp-1"),
]),
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
],
)
.await;
let mut builder = configured_builder(apps_server.chatgpt_base_url.clone(), None);
let test = builder.build(&server).await?;
submit_turn_without_waiting(&test, "suggest notion install").await?;
let elicitation = wait_for_elicitation_request(&test.codex).await;
test.codex
.submit(Op::ResolveElicitation {
server_name: elicitation.server_name,
request_id: elicitation.id,
decision: ElicitationAction::Decline,
content: None,
meta: None,
})
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let requests = mock.requests();
assert_eq!(
requests.len(),
2,
"expected 2 requests, got {}",
requests.len()
);
let payload = function_tool_output_payload(&requests[1], call_id, TOOL_SUGGEST_TOOL_NAME);
assert_eq!(
payload.get("elicitation_action").and_then(Value::as_str),
Some("decline"),
);
assert_eq!(
payload.get("user_decision").and_then(Value::as_str),
Some("not_now"),
);
assert!(
payload
.get("assistant_instruction")
.and_then(Value::as_str)
.is_some_and(|instruction| instruction.contains("Do not ask them again")),
"unexpected tool_suggest payload: {payload:?}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn tool_suggest_rejects_unknown_connector() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let apps_server = AppsTestServer::mount(&server).await?;
mount_directory_connectors(&server).await;
let call_id = "tool-suggest";
let args = json!({
"connector_id": "unknown-app",
"tool_type": "connector",
"suggestion_type": "install",
});
let mock = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(
call_id,
TOOL_SUGGEST_TOOL_NAME,
&serde_json::to_string(&args)?,
),
ev_completed("resp-1"),
]),
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
],
)
.await;
let mut builder = configured_builder(apps_server.chatgpt_base_url.clone(), None);
let test = builder.build(&server).await?;
test.submit_turn_with_policies(
"suggest unknown install",
AskForApproval::Never,
SandboxPolicy::DangerFullAccess,
)
.await?;
let requests = mock.requests();
assert_eq!(
requests.len(),
2,
"expected 2 requests, got {}",
requests.len()
);
let error = requests[1]
.function_call_output_content_and_success(call_id)
.and_then(|(content, _)| content)
.expect("tool_suggest should return an error");
assert!(
error.contains("unknown connector_id `unknown-app`"),
"unexpected unknown connector rejection: {error:?}"
);
Ok(())
}

View File

@@ -2378,10 +2378,13 @@ impl App {
app_id,
title,
description,
suggest_reason: None,
instructions,
url,
is_installed,
is_enabled,
suggestion_type: None,
elicitation_resolution: None,
});
}
AppEvent::OpenUrlInBrowser { url } => {

View File

@@ -1,3 +1,9 @@
use codex_protocol::ThreadId;
use codex_protocol::approvals::ElicitationAction;
use codex_protocol::approvals::ElicitationRequest;
use codex_protocol::approvals::ElicitationRequestEvent;
use codex_protocol::mcp::RequestId as McpRequestId;
use codex_protocol::protocol::Op;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
@@ -11,6 +17,9 @@ use ratatui::widgets::Block;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use ratatui::widgets::Wrap;
use serde::Deserialize;
use serde_json::Value;
use serde_json::json;
use textwrap::wrap;
use super::CancellationEvent;
@@ -34,24 +43,121 @@ enum AppLinkScreen {
InstallConfirmation,
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
enum ToolSuggestionToolType {
Connector,
Plugin,
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub(crate) enum ToolSuggestionType {
Install,
Enable,
}
impl ToolSuggestionType {
fn decision(self) -> &'static str {
match self {
Self::Install => "install",
Self::Enable => "enable",
}
}
}
const TOOL_SUGGESTION_META_KIND_VALUE: &str = "tool_suggestion";
pub(crate) const APP_LINK_INSTALL_INSTRUCTIONS: &str =
"Install this app in your browser, then reload Codex.";
const APP_LINK_ENABLE_INSTRUCTIONS: &str =
"Enable this app in Codex to use its tools in this session.";
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct AppLinkElicitationResolution {
pub(crate) thread_id: ThreadId,
pub(crate) server_name: String,
pub(crate) request_id: McpRequestId,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct AppLinkViewParams {
pub(crate) app_id: String,
pub(crate) title: String,
pub(crate) description: Option<String>,
pub(crate) suggest_reason: Option<String>,
pub(crate) instructions: String,
pub(crate) url: String,
pub(crate) is_installed: bool,
pub(crate) is_enabled: bool,
pub(crate) suggestion_type: Option<ToolSuggestionType>,
pub(crate) elicitation_resolution: Option<AppLinkElicitationResolution>,
}
#[derive(Deserialize)]
struct ToolSuggestionMeta {
#[serde(rename = "codex_approval_kind")]
approval_kind: String,
tool_type: ToolSuggestionToolType,
suggestion_type: ToolSuggestionType,
connector_id: String,
connector_name: String,
suggest_reason: Option<String>,
connector_description: Option<String>,
install_url: String,
}
pub(crate) fn tool_suggestion_params_from_event(
thread_id: ThreadId,
request: &ElicitationRequestEvent,
) -> Option<AppLinkViewParams> {
let ElicitationRequest::Form {
meta: Some(meta), ..
} = &request.request
else {
return None;
};
let meta = serde_json::from_value::<ToolSuggestionMeta>(meta.clone()).ok()?;
if meta.approval_kind != TOOL_SUGGESTION_META_KIND_VALUE
|| meta.tool_type != ToolSuggestionToolType::Connector
{
return None;
}
let (instructions, is_installed, is_enabled) = match meta.suggestion_type {
ToolSuggestionType::Install => (APP_LINK_INSTALL_INSTRUCTIONS.to_string(), false, false),
ToolSuggestionType::Enable => (APP_LINK_ENABLE_INSTRUCTIONS.to_string(), true, false),
};
Some(AppLinkViewParams {
app_id: meta.connector_id,
title: meta.connector_name,
description: meta.connector_description,
suggest_reason: meta.suggest_reason,
instructions,
url: meta.install_url,
is_installed,
is_enabled,
suggestion_type: Some(meta.suggestion_type),
elicitation_resolution: Some(AppLinkElicitationResolution {
thread_id,
server_name: request.server_name.clone(),
request_id: request.id.clone(),
}),
})
}
pub(crate) struct AppLinkView {
app_id: String,
title: String,
description: Option<String>,
suggest_reason: Option<String>,
instructions: String,
url: String,
is_installed: bool,
is_enabled: bool,
suggestion_type: Option<ToolSuggestionType>,
elicitation_resolution: Option<AppLinkElicitationResolution>,
app_event_tx: AppEventSender,
screen: AppLinkScreen,
selected_action: usize,
@@ -64,19 +170,25 @@ impl AppLinkView {
app_id,
title,
description,
suggest_reason,
instructions,
url,
is_installed,
is_enabled,
suggestion_type,
elicitation_resolution,
} = params;
Self {
app_id,
title,
description,
suggest_reason,
instructions,
url,
is_installed,
is_enabled,
suggestion_type,
elicitation_resolution,
app_event_tx,
screen: AppLinkScreen::Link,
selected_action: 0,
@@ -127,6 +239,20 @@ impl AppLinkView {
self.app_event_tx.send(AppEvent::RefreshConnectors {
force_refetch: true,
});
self.resolve_elicitation(
ElicitationAction::Accept,
Some(json!({
"decision": self
.suggestion_type
.unwrap_or(ToolSuggestionType::Install)
.decision(),
})),
);
self.complete = true;
}
fn close_flow(&mut self) {
self.resolve_elicitation(ElicitationAction::Cancel, None);
self.complete = true;
}
@@ -141,6 +267,35 @@ impl AppLinkView {
id: self.app_id.clone(),
enabled: self.is_enabled,
});
if self.is_enabled
&& self.elicitation_resolution.is_some()
&& self.suggestion_type == Some(ToolSuggestionType::Enable)
{
self.resolve_elicitation(
ElicitationAction::Accept,
Some(json!({
"decision": ToolSuggestionType::Enable.decision(),
})),
);
self.complete = true;
}
}
fn resolve_elicitation(&self, decision: ElicitationAction, content: Option<Value>) {
let Some(resolution) = self.elicitation_resolution.as_ref() else {
return;
};
self.app_event_tx.send(AppEvent::SubmitThreadOp {
thread_id: resolution.thread_id,
op: Op::ResolveElicitation {
server_name: resolution.server_name.clone(),
request_id: resolution.request_id.clone(),
decision,
content,
meta: None,
},
});
}
fn activate_selected_action(&mut self) {
@@ -148,7 +303,7 @@ impl AppLinkView {
AppLinkScreen::Link => match self.selected_action {
0 => self.open_chatgpt_link(),
1 if self.is_installed => self.toggle_enabled(),
_ => self.complete = true,
_ => self.close_flow(),
},
AppLinkScreen::InstallConfirmation => match self.selected_action {
0 => self.refresh_connectors_and_close(),
@@ -179,6 +334,17 @@ impl AppLinkView {
lines.push(Line::from(line.into_owned().dim()));
}
}
if let Some(suggest_reason) = self
.suggest_reason
.as_deref()
.map(str::trim)
.filter(|suggest_reason| !suggest_reason.is_empty())
{
let suggest_reason = format!("Reason: {suggest_reason}");
for line in wrap(&suggest_reason, usable_width) {
lines.push(Line::from(line.into_owned()));
}
}
lines.push(Line::from(""));
if self.is_installed {
@@ -366,7 +532,7 @@ impl BottomPaneView for AppLinkView {
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
self.complete = true;
self.close_flow();
CancellationEvent::Handled
}
@@ -447,24 +613,43 @@ mod tests {
use super::*;
use crate::app_event::AppEvent;
use crate::render::renderable::Renderable;
use pretty_assertions::assert_eq;
use tokio::sync::mpsc::unbounded_channel;
fn base_params() -> AppLinkViewParams {
AppLinkViewParams {
app_id: "connector_1".to_string(),
title: "Notion".to_string(),
description: None,
suggest_reason: None,
instructions: "Manage app".to_string(),
url: "https://example.test/notion".to_string(),
is_installed: true,
is_enabled: true,
suggestion_type: None,
elicitation_resolution: None,
}
}
fn render_snapshot(view: &AppLinkView, area: Rect) -> String {
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
format!("{buf:?}")
}
fn elicitation_resolution(thread_id: ThreadId) -> AppLinkElicitationResolution {
AppLinkElicitationResolution {
thread_id,
server_name: "codex_apps".to_string(),
request_id: McpRequestId::String("request-1".to_string()),
}
}
#[test]
fn installed_app_has_toggle_action() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let view = AppLinkView::new(
AppLinkViewParams {
app_id: "connector_1".to_string(),
title: "Notion".to_string(),
description: None,
instructions: "Manage app".to_string(),
url: "https://example.test/notion".to_string(),
is_installed: true,
is_enabled: true,
},
tx,
);
let view = AppLinkView::new(base_params(), tx);
assert_eq!(
view.action_labels(),
@@ -476,18 +661,7 @@ mod tests {
fn toggle_action_sends_set_app_enabled_and_updates_label() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut view = AppLinkView::new(
AppLinkViewParams {
app_id: "connector_1".to_string(),
title: "Notion".to_string(),
description: None,
instructions: "Manage app".to_string(),
url: "https://example.test/notion".to_string(),
is_installed: true,
is_enabled: true,
},
tx,
);
let mut view = AppLinkView::new(base_params(), tx);
view.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE));
@@ -512,18 +686,9 @@ mod tests {
let tx = AppEventSender::new(tx_raw);
let url_like =
"example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890";
let mut view = AppLinkView::new(
AppLinkViewParams {
app_id: "connector_1".to_string(),
title: "Notion".to_string(),
description: None,
instructions: "Manage app".to_string(),
url: url_like.to_string(),
is_installed: true,
is_enabled: true,
},
tx,
);
let mut params = base_params();
params.url = url_like.to_string();
let mut view = AppLinkView::new(params, tx);
view.screen = AppLinkScreen::InstallConfirmation;
let rendered: Vec<String> = view
@@ -552,18 +717,9 @@ mod tests {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let url = "https://example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/with/a/very/long/path/tail42";
let mut view = AppLinkView::new(
AppLinkViewParams {
app_id: "connector_1".to_string(),
title: "Notion".to_string(),
description: None,
instructions: "Manage app".to_string(),
url: url.to_string(),
is_installed: true,
is_enabled: true,
},
tx,
);
let mut params = base_params();
params.url = url.to_string();
let mut view = AppLinkView::new(params, tx);
view.screen = AppLinkScreen::InstallConfirmation;
let width: u16 = 36;
@@ -593,4 +749,280 @@ mod tests {
"expected wrapped setup URL tail to remain visible in narrow pane, got:\n{rendered_blob}"
);
}
#[test]
fn tool_install_suggestion_event_builds_app_link_params() {
let thread_id = ThreadId::default();
let request = ElicitationRequestEvent {
turn_id: Some("turn-1".to_string()),
server_name: "codex_apps".to_string(),
id: McpRequestId::String("request-1".to_string()),
request: ElicitationRequest::Form {
meta: Some(json!({
"codex_approval_kind": "tool_suggestion",
"tool_type": "connector",
"suggestion_type": "install",
"connector_id": "connector_1",
"connector_name": "Notion",
"suggest_reason": "The user asked for workspace docs",
"connector_description": "Docs and notes",
"install_url": "https://example.test/notion",
})),
message: "Install Notion to continue?".to_string(),
requested_schema: json!({
"type": "object",
"properties": {},
}),
},
};
let params = tool_suggestion_params_from_event(thread_id, &request);
assert_eq!(
params,
Some(AppLinkViewParams {
app_id: "connector_1".to_string(),
title: "Notion".to_string(),
description: Some("Docs and notes".to_string()),
suggest_reason: Some("The user asked for workspace docs".to_string()),
instructions: APP_LINK_INSTALL_INSTRUCTIONS.to_string(),
url: "https://example.test/notion".to_string(),
is_installed: false,
is_enabled: false,
suggestion_type: Some(ToolSuggestionType::Install),
elicitation_resolution: Some(elicitation_resolution(thread_id)),
})
);
}
#[test]
fn tool_enable_suggestion_event_builds_app_link_params() {
let thread_id = ThreadId::default();
let request = ElicitationRequestEvent {
turn_id: Some("turn-1".to_string()),
server_name: "codex_apps".to_string(),
id: McpRequestId::String("request-1".to_string()),
request: ElicitationRequest::Form {
meta: Some(json!({
"codex_approval_kind": "tool_suggestion",
"tool_type": "connector",
"suggestion_type": "enable",
"connector_id": "connector_1",
"connector_name": "Notion",
"connector_description": "Docs and notes",
"install_url": "https://example.test/notion",
})),
message: "Enable Notion to continue?".to_string(),
requested_schema: json!({
"type": "object",
"properties": {},
}),
},
};
let params = tool_suggestion_params_from_event(thread_id, &request);
assert_eq!(
params,
Some(AppLinkViewParams {
app_id: "connector_1".to_string(),
title: "Notion".to_string(),
description: Some("Docs and notes".to_string()),
suggest_reason: None,
instructions: APP_LINK_ENABLE_INSTRUCTIONS.to_string(),
url: "https://example.test/notion".to_string(),
is_installed: true,
is_enabled: false,
suggestion_type: Some(ToolSuggestionType::Enable),
elicitation_resolution: Some(elicitation_resolution(thread_id)),
})
);
}
#[test]
fn enable_suggestion_snapshot() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut params = base_params();
params.is_enabled = false;
params.instructions = APP_LINK_ENABLE_INSTRUCTIONS.to_string();
params.suggestion_type = Some(ToolSuggestionType::Enable);
let view = AppLinkView::new(params, tx);
let area = Rect::new(0, 0, 80, view.desired_height(80));
insta::assert_snapshot!(
"app_link_view_enable_suggestion",
render_snapshot(&view, area)
);
}
#[test]
fn install_suggestion_with_reason_snapshot() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut params = base_params();
params.is_installed = false;
params.is_enabled = false;
params.suggest_reason = Some("The user asked to access their workspace docs.".to_string());
params.instructions = APP_LINK_INSTALL_INSTRUCTIONS.to_string();
params.suggestion_type = Some(ToolSuggestionType::Install);
let view = AppLinkView::new(params, tx);
let area = Rect::new(0, 0, 80, view.desired_height(80));
insta::assert_snapshot!(
"app_link_view_install_suggestion_with_reason",
render_snapshot(&view, area)
);
}
#[test]
fn install_confirmation_resolves_elicitation_after_refresh() {
let expected_thread_id = ThreadId::default();
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut params = base_params();
params.is_installed = false;
params.is_enabled = false;
params.instructions = APP_LINK_INSTALL_INSTRUCTIONS.to_string();
params.suggestion_type = Some(ToolSuggestionType::Install);
params.elicitation_resolution = Some(elicitation_resolution(expected_thread_id));
let mut view = AppLinkView::new(params, tx);
view.screen = AppLinkScreen::InstallConfirmation;
view.activate_selected_action();
match rx.try_recv() {
Ok(AppEvent::RefreshConnectors { force_refetch }) => {
assert!(force_refetch);
}
Ok(other) => panic!("unexpected app event: {other:?}"),
Err(err) => panic!("missing app event: {err}"),
}
match rx.try_recv() {
Ok(AppEvent::SubmitThreadOp {
thread_id,
op:
Op::ResolveElicitation {
server_name,
request_id,
decision,
content,
meta,
},
}) => {
assert_eq!(thread_id, expected_thread_id);
assert_eq!(server_name, "codex_apps");
assert_eq!(request_id, McpRequestId::String("request-1".to_string()));
assert_eq!(decision, ElicitationAction::Accept);
assert_eq!(
content,
Some(json!({
"decision": "install",
}))
);
assert_eq!(meta, None);
}
Ok(other) => panic!("unexpected app event: {other:?}"),
Err(err) => panic!("missing app event: {err}"),
}
assert!(view.is_complete());
}
#[test]
fn closing_link_view_cancels_elicitation_flow() {
let expected_thread_id = ThreadId::default();
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut params = base_params();
params.is_installed = false;
params.is_enabled = false;
params.instructions = APP_LINK_INSTALL_INSTRUCTIONS.to_string();
params.suggestion_type = Some(ToolSuggestionType::Install);
params.elicitation_resolution = Some(elicitation_resolution(expected_thread_id));
let mut view = AppLinkView::new(params, tx);
view.selected_action = 1;
view.activate_selected_action();
match rx.try_recv() {
Ok(AppEvent::SubmitThreadOp {
thread_id,
op:
Op::ResolveElicitation {
server_name,
request_id,
decision,
content,
meta,
},
}) => {
assert_eq!(thread_id, expected_thread_id);
assert_eq!(server_name, "codex_apps");
assert_eq!(request_id, McpRequestId::String("request-1".to_string()));
assert_eq!(decision, ElicitationAction::Cancel);
assert_eq!(content, None);
assert_eq!(meta, None);
}
Ok(other) => panic!("unexpected app event: {other:?}"),
Err(err) => panic!("missing app event: {err}"),
}
assert!(view.is_complete());
}
#[test]
fn enabling_suggested_app_resolves_elicitation() {
let expected_thread_id = ThreadId::default();
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut params = base_params();
params.is_enabled = false;
params.instructions = APP_LINK_ENABLE_INSTRUCTIONS.to_string();
params.suggestion_type = Some(ToolSuggestionType::Enable);
params.elicitation_resolution = Some(elicitation_resolution(expected_thread_id));
let mut view = AppLinkView::new(params, tx);
view.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE));
match rx.try_recv() {
Ok(AppEvent::SetAppEnabled { id, enabled }) => {
assert_eq!(id, "connector_1");
assert!(enabled);
}
Ok(other) => panic!("unexpected app event: {other:?}"),
Err(err) => panic!("missing app event: {err}"),
}
match rx.try_recv() {
Ok(AppEvent::SubmitThreadOp {
thread_id,
op:
Op::ResolveElicitation {
server_name,
request_id,
decision,
content,
meta,
},
}) => {
assert_eq!(thread_id, expected_thread_id);
assert_eq!(server_name, "codex_apps");
assert_eq!(request_id, McpRequestId::String("request-1".to_string()));
assert_eq!(decision, ElicitationAction::Accept);
assert_eq!(
content,
Some(json!({
"decision": "enable",
}))
);
assert_eq!(meta, None);
}
Ok(other) => panic!("unexpected app event: {other:?}"),
Err(err) => panic!("missing app event: {err}"),
}
assert!(view.is_complete());
}
}

View File

@@ -47,8 +47,10 @@ mod mcp_server_elicitation;
mod multi_select_picker;
mod request_user_input;
mod status_line_setup;
pub(crate) use app_link_view::APP_LINK_INSTALL_INSTRUCTIONS;
pub(crate) use app_link_view::AppLinkView;
pub(crate) use app_link_view::AppLinkViewParams;
pub(crate) use app_link_view::tool_suggestion_params_from_event;
pub(crate) use approval_overlay::ApprovalOverlay;
pub(crate) use approval_overlay::ApprovalRequest;
pub(crate) use approval_overlay::format_additional_permissions_rule;
@@ -898,6 +900,16 @@ impl BottomPane {
self.push_view(view);
}
pub(crate) fn push_app_link_view(&mut self, params: AppLinkViewParams) {
let view = AppLinkView::new(params, self.app_event_tx.clone());
self.pause_status_timer_for_modal();
self.set_composer_input_enabled(
false,
Some("Complete the app setup flow to continue.".to_string()),
);
self.push_view(Box::new(view));
}
/// Called when the agent requests user approval.
pub fn push_approval_request(&mut self, request: ApprovalRequest, features: &Features) {
let request = if let Some(view) = self.view_stack.last_mut() {

View File

@@ -0,0 +1,30 @@
---
source: tui/src/bottom_pane/app_link_view.rs
expression: "render_snapshot(&view, area)"
---
Buffer {
area: Rect { x: 0, y: 0, width: 80, height: 13 },
content: [
" ",
" Notion ",
" ",
" Use $ to insert this app into the prompt. ",
" ",
" Enable this app in Codex to use its tools in this session. ",
" Newly installed apps can take a few minutes to appear in /apps. ",
" ",
" ",
" 1. Manage on ChatGPT ",
" 2. Enable app ",
" 3. Back ",
" Use tab / ↑ ↓ to move, enter to select, esc to close ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 8, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 9, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD,
x: 24, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
]
}

View File

@@ -0,0 +1,29 @@
---
source: tui/src/bottom_pane/app_link_view.rs
expression: "render_snapshot(&view, area)"
---
Buffer {
area: Rect { x: 0, y: 0, width: 80, height: 12 },
content: [
" ",
" Notion ",
" Reason: The user asked to access their workspace docs. ",
" ",
" Install this app in your browser, then reload Codex. ",
" Newly installed apps can take a few minutes to appear in /apps. ",
" After installed, use $ to insert this app into the prompt. ",
" ",
" ",
" 1. Install on ChatGPT ",
" 2. Back ",
" Use tab / ↑ ↓ to move, enter to select, esc to close ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 8, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 9, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD,
x: 25, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
]
}

View File

@@ -2973,7 +2973,12 @@ impl ChatWidget {
});
let thread_id = self.thread_id.unwrap_or_default();
if let Some(request) = McpServerElicitationFormRequest::from_event(thread_id, ev.clone()) {
if let Some(params) = crate::bottom_pane::tool_suggestion_params_from_event(thread_id, &ev)
{
self.bottom_pane.push_app_link_view(params);
} else if let Some(request) =
McpServerElicitationFormRequest::from_event(thread_id, ev.clone())
{
self.bottom_pane
.push_mcp_server_elicitation_request(request);
} else {
@@ -7975,7 +7980,7 @@ impl ChatWidget {
let instructions = if connector.is_accessible {
"Manage this app in your browser."
} else {
"Install this app in your browser, then reload Codex."
crate::bottom_pane::APP_LINK_INSTALL_INSTRUCTIONS
};
if let Some(install_url) = connector.install_url.clone() {
let app_id = connector.id.clone();