mirror of
https://github.com/openai/codex.git
synced 2026-04-24 00:11:51 +03:00
Compare commits
9 Commits
codex-debu
...
dev/sayan/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
464d4da7f6 | ||
|
|
81e641c6ea | ||
|
|
7fe4a43713 | ||
|
|
584919cd54 | ||
|
|
459766b4ad | ||
|
|
d8d86fb24f | ||
|
|
974d553983 | ||
|
|
e0b157279f | ||
|
|
3c9cb670ca |
@@ -201,9 +201,12 @@ use crate::memories;
|
|||||||
use crate::mentions::build_connector_slug_counts;
|
use crate::mentions::build_connector_slug_counts;
|
||||||
use crate::mentions::build_skill_name_counts;
|
use crate::mentions::build_skill_name_counts;
|
||||||
use crate::mentions::collect_explicit_app_ids;
|
use crate::mentions::collect_explicit_app_ids;
|
||||||
|
use crate::mentions::collect_explicit_plugin_mentions;
|
||||||
use crate::mentions::collect_tool_mentions_from_messages;
|
use crate::mentions::collect_tool_mentions_from_messages;
|
||||||
use crate::network_policy_decision::execpolicy_network_rule_amendment;
|
use crate::network_policy_decision::execpolicy_network_rule_amendment;
|
||||||
|
use crate::plugins::PluginCapabilitySummary;
|
||||||
use crate::plugins::PluginsManager;
|
use crate::plugins::PluginsManager;
|
||||||
|
use crate::plugins::render_explicit_plugin_instructions;
|
||||||
use crate::project_doc::get_user_instructions;
|
use crate::project_doc::get_user_instructions;
|
||||||
use crate::protocol::AgentMessageContentDeltaEvent;
|
use crate::protocol::AgentMessageContentDeltaEvent;
|
||||||
use crate::protocol::AgentReasoningSectionBreakEvent;
|
use crate::protocol::AgentReasoningSectionBreakEvent;
|
||||||
@@ -1524,7 +1527,7 @@ impl Session {
|
|||||||
tool_approvals: Mutex::new(ApprovalStore::default()),
|
tool_approvals: Mutex::new(ApprovalStore::default()),
|
||||||
execve_session_approvals: RwLock::new(HashMap::new()),
|
execve_session_approvals: RwLock::new(HashMap::new()),
|
||||||
skills_manager,
|
skills_manager,
|
||||||
plugins_manager,
|
plugins_manager: Arc::clone(&plugins_manager),
|
||||||
mcp_manager,
|
mcp_manager,
|
||||||
file_watcher,
|
file_watcher,
|
||||||
agent_control,
|
agent_control,
|
||||||
@@ -1609,6 +1612,10 @@ impl Session {
|
|||||||
.map(|(name, _)| name.clone())
|
.map(|(name, _)| name.clone())
|
||||||
.collect();
|
.collect();
|
||||||
required_mcp_servers.sort();
|
required_mcp_servers.sort();
|
||||||
|
let tool_plugin_provenance = plugins_manager
|
||||||
|
.plugins_for_config(config.as_ref())
|
||||||
|
.tool_plugin_provenance()
|
||||||
|
.clone();
|
||||||
{
|
{
|
||||||
let mut cancel_guard = sess.services.mcp_startup_cancellation_token.lock().await;
|
let mut cancel_guard = sess.services.mcp_startup_cancellation_token.lock().await;
|
||||||
cancel_guard.cancel();
|
cancel_guard.cancel();
|
||||||
@@ -1623,6 +1630,7 @@ impl Session {
|
|||||||
sandbox_state,
|
sandbox_state,
|
||||||
config.codex_home.clone(),
|
config.codex_home.clone(),
|
||||||
codex_apps_tools_cache_key(auth),
|
codex_apps_tools_cache_key(auth),
|
||||||
|
tool_plugin_provenance,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
{
|
{
|
||||||
@@ -2178,11 +2186,12 @@ impl Session {
|
|||||||
&per_turn_config,
|
&per_turn_config,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
// Reuse the session's resolved config for turn-time skills so plugin-backed
|
||||||
|
// skill/plugin availability stays aligned with the session-start view.
|
||||||
let skills_outcome = Arc::new(
|
let skills_outcome = Arc::new(
|
||||||
self.services
|
self.services
|
||||||
.skills_manager
|
.skills_manager
|
||||||
.skills_for_cwd(&session_configuration.cwd, false)
|
.skills_for_config(&per_turn_config),
|
||||||
.await,
|
|
||||||
);
|
);
|
||||||
let mut turn_context: TurnContext = Self::make_turn_context(
|
let mut turn_context: TurnContext = Self::make_turn_context(
|
||||||
Some(Arc::clone(&self.services.auth_manager)),
|
Some(Arc::clone(&self.services.auth_manager)),
|
||||||
@@ -3568,6 +3577,12 @@ impl Session {
|
|||||||
) {
|
) {
|
||||||
let auth = self.services.auth_manager.auth().await;
|
let auth = self.services.auth_manager.auth().await;
|
||||||
let config = self.get_config().await;
|
let config = self.get_config().await;
|
||||||
|
let tool_plugin_provenance = self
|
||||||
|
.services
|
||||||
|
.plugins_manager
|
||||||
|
.plugins_for_config(config.as_ref())
|
||||||
|
.tool_plugin_provenance()
|
||||||
|
.clone();
|
||||||
let mcp_servers = with_codex_apps_mcp(
|
let mcp_servers = with_codex_apps_mcp(
|
||||||
mcp_servers,
|
mcp_servers,
|
||||||
self.features.enabled(Feature::Apps),
|
self.features.enabled(Feature::Apps),
|
||||||
@@ -3595,6 +3610,7 @@ impl Session {
|
|||||||
sandbox_state,
|
sandbox_state,
|
||||||
config.codex_home.clone(),
|
config.codex_home.clone(),
|
||||||
codex_apps_tools_cache_key(auth.as_ref()),
|
codex_apps_tools_cache_key(auth.as_ref()),
|
||||||
|
tool_plugin_provenance,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
{
|
{
|
||||||
@@ -4921,25 +4937,57 @@ pub(crate) async fn run_turn(
|
|||||||
sess.record_context_updates_and_set_reference_context_item(turn_context.as_ref())
|
sess.record_context_updates_and_set_reference_context_item(turn_context.as_ref())
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let available_connectors = if turn_context.config.features.enabled(Feature::Apps) {
|
let loaded_plugins = sess
|
||||||
let mcp_tools = match sess
|
.services
|
||||||
.services
|
.plugins_manager
|
||||||
.mcp_connection_manager
|
.plugins_for_config(&turn_context.config);
|
||||||
.read()
|
// Plain-text @plugin mentions are resolved from the current session's
|
||||||
.await
|
// enabled plugins, then converted into turn-scoped guidance below.
|
||||||
.list_all_tools()
|
let mentioned_plugins =
|
||||||
.or_cancel(&cancellation_token)
|
collect_explicit_plugin_mentions(&input, loaded_plugins.capability_summaries());
|
||||||
.await
|
info!(
|
||||||
{
|
plugin_input_texts = ?input
|
||||||
Ok(mcp_tools) => mcp_tools,
|
.iter()
|
||||||
Err(_) => return None,
|
.filter_map(|item| match item {
|
||||||
|
UserInput::Text { text, .. } => Some(text.as_str()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
available_plugins = ?loaded_plugins
|
||||||
|
.capability_summaries()
|
||||||
|
.iter()
|
||||||
|
.map(|plugin| plugin.display_name.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
mentioned_plugins = ?mentioned_plugins
|
||||||
|
.iter()
|
||||||
|
.map(|plugin| plugin.display_name.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
"resolved explicit plugin mentions for turn"
|
||||||
|
);
|
||||||
|
let mcp_tools =
|
||||||
|
if turn_context.config.features.enabled(Feature::Apps) || !mentioned_plugins.is_empty() {
|
||||||
|
// Plugin mentions need raw MCP/app inventory even when app tools
|
||||||
|
// are normally hidden so we can describe the plugin's currently
|
||||||
|
// usable capabilities for this turn.
|
||||||
|
match sess
|
||||||
|
.services
|
||||||
|
.mcp_connection_manager
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.list_all_tools()
|
||||||
|
.or_cancel(&cancellation_token)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(mcp_tools) => mcp_tools,
|
||||||
|
Err(_) if turn_context.config.features.enabled(Feature::Apps) => return None,
|
||||||
|
Err(_) => HashMap::new(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HashMap::new()
|
||||||
};
|
};
|
||||||
let plugin_apps = sess
|
let available_connectors = if turn_context.config.features.enabled(Feature::Apps) {
|
||||||
.services
|
|
||||||
.plugins_manager
|
|
||||||
.plugins_for_config(&turn_context.config);
|
|
||||||
let connectors = connectors::merge_plugin_apps_with_accessible(
|
let connectors = connectors::merge_plugin_apps_with_accessible(
|
||||||
plugin_apps.effective_apps(),
|
loaded_plugins.effective_apps(),
|
||||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||||
);
|
);
|
||||||
connectors::with_app_enabled_state(connectors, &turn_context.config)
|
connectors::with_app_enabled_state(connectors, &turn_context.config)
|
||||||
@@ -5000,12 +5048,29 @@ pub(crate) async fn run_turn(
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let plugin_items =
|
||||||
|
build_plugin_injections(&mentioned_plugins, &mcp_tools, &available_connectors);
|
||||||
|
|
||||||
let mut explicitly_enabled_connectors = collect_explicit_app_ids(&input);
|
let mut explicitly_enabled_connectors = collect_explicit_app_ids(&input);
|
||||||
explicitly_enabled_connectors.extend(collect_explicit_app_ids_from_skill_items(
|
explicitly_enabled_connectors.extend(collect_explicit_app_ids_from_skill_items(
|
||||||
&skill_items,
|
&skill_items,
|
||||||
&available_connectors,
|
&available_connectors,
|
||||||
&skill_name_counts_lower,
|
&skill_name_counts_lower,
|
||||||
));
|
));
|
||||||
|
// Explicit @plugin mentions can make a plugin's enabled apps callable for
|
||||||
|
// this turn without persisting those connectors as sticky user selections.
|
||||||
|
let mut turn_enabled_connectors = explicitly_enabled_connectors.clone();
|
||||||
|
turn_enabled_connectors.extend(
|
||||||
|
mentioned_plugins
|
||||||
|
.iter()
|
||||||
|
.flat_map(|plugin| plugin.app_connector_ids.iter())
|
||||||
|
.map(|connector_id| connector_id.0.clone())
|
||||||
|
.filter(|connector_id| {
|
||||||
|
available_connectors
|
||||||
|
.iter()
|
||||||
|
.any(|connector| connector.is_enabled && connector.id == *connector_id)
|
||||||
|
}),
|
||||||
|
);
|
||||||
let connector_names_by_id = available_connectors
|
let connector_names_by_id = available_connectors
|
||||||
.iter()
|
.iter()
|
||||||
.map(|connector| (connector.id.as_str(), connector.name.as_str()))
|
.map(|connector| (connector.id.as_str(), connector.name.as_str()))
|
||||||
@@ -5043,6 +5108,10 @@ pub(crate) async fn run_turn(
|
|||||||
sess.record_conversation_items(&turn_context, &skill_items)
|
sess.record_conversation_items(&turn_context, &skill_items)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
if !plugin_items.is_empty() {
|
||||||
|
sess.record_conversation_items(&turn_context, &plugin_items)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token())
|
sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token())
|
||||||
.await;
|
.await;
|
||||||
@@ -5111,7 +5180,7 @@ pub(crate) async fn run_turn(
|
|||||||
&mut client_session,
|
&mut client_session,
|
||||||
turn_metadata_header.as_deref(),
|
turn_metadata_header.as_deref(),
|
||||||
sampling_request_input,
|
sampling_request_input,
|
||||||
&explicitly_enabled_connectors,
|
&turn_enabled_connectors,
|
||||||
skills_outcome,
|
skills_outcome,
|
||||||
&mut server_model_warning_emitted_for_turn,
|
&mut server_model_warning_emitted_for_turn,
|
||||||
cancellation_token.child_token(),
|
cancellation_token.child_token(),
|
||||||
@@ -5657,17 +5726,17 @@ async fn built_tools(
|
|||||||
.or_cancel(cancellation_token)
|
.or_cancel(cancellation_token)
|
||||||
.await?;
|
.await?;
|
||||||
drop(mcp_connection_manager);
|
drop(mcp_connection_manager);
|
||||||
|
let loaded_plugins = sess
|
||||||
|
.services
|
||||||
|
.plugins_manager
|
||||||
|
.plugins_for_config(&turn_context.config);
|
||||||
|
|
||||||
let mut effective_explicitly_enabled_connectors = explicitly_enabled_connectors.clone();
|
let mut effective_explicitly_enabled_connectors = explicitly_enabled_connectors.clone();
|
||||||
effective_explicitly_enabled_connectors.extend(sess.get_connector_selection().await);
|
effective_explicitly_enabled_connectors.extend(sess.get_connector_selection().await);
|
||||||
|
|
||||||
let connectors = if turn_context.features.enabled(Feature::Apps) {
|
let connectors = if turn_context.features.enabled(Feature::Apps) {
|
||||||
let plugin_apps = sess
|
|
||||||
.services
|
|
||||||
.plugins_manager
|
|
||||||
.plugins_for_config(&turn_context.config);
|
|
||||||
let connectors = connectors::merge_plugin_apps_with_accessible(
|
let connectors = connectors::merge_plugin_apps_with_accessible(
|
||||||
plugin_apps.effective_apps(),
|
loaded_plugins.effective_apps(),
|
||||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||||
);
|
);
|
||||||
Some(connectors::with_app_enabled_state(
|
Some(connectors::with_app_enabled_state(
|
||||||
@@ -5678,6 +5747,8 @@ async fn built_tools(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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| {
|
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)
|
||||||
});
|
});
|
||||||
@@ -5722,6 +5793,55 @@ async fn built_tools(
|
|||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_plugin_injections(
|
||||||
|
mentioned_plugins: &[PluginCapabilitySummary],
|
||||||
|
mcp_tools: &HashMap<String, crate::mcp_connection_manager::ToolInfo>,
|
||||||
|
available_connectors: &[connectors::AppInfo],
|
||||||
|
) -> Vec<ResponseItem> {
|
||||||
|
if mentioned_plugins.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let visible_mcp_server_names = mcp_tools
|
||||||
|
.values()
|
||||||
|
.filter(|tool| tool.server_name != CODEX_APPS_MCP_SERVER_NAME)
|
||||||
|
.map(|tool| tool.server_name.clone())
|
||||||
|
.collect::<HashSet<String>>();
|
||||||
|
let enabled_connectors_by_id = available_connectors
|
||||||
|
.iter()
|
||||||
|
.filter(|connector| connector.is_enabled)
|
||||||
|
.map(|connector| {
|
||||||
|
(
|
||||||
|
connector.id.as_str(),
|
||||||
|
connectors::connector_display_label(connector),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<HashMap<&str, String>>();
|
||||||
|
|
||||||
|
// Turn each explicit @plugin mention into a developer hint that points the
|
||||||
|
// model at the plugin's visible MCP servers, enabled apps, and skill prefix.
|
||||||
|
mentioned_plugins
|
||||||
|
.iter()
|
||||||
|
.filter_map(|plugin| {
|
||||||
|
let available_mcp_servers = plugin
|
||||||
|
.mcp_server_names
|
||||||
|
.iter()
|
||||||
|
.filter(|server_name| visible_mcp_server_names.contains(server_name.as_str()))
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let available_apps = plugin
|
||||||
|
.app_connector_ids
|
||||||
|
.iter()
|
||||||
|
.filter_map(|connector_id| enabled_connectors_by_id.get(connector_id.0.as_str()))
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
render_explicit_plugin_instructions(plugin, &available_mcp_servers, &available_apps)
|
||||||
|
.map(DeveloperInstructions::new)
|
||||||
|
.map(ResponseItem::from)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct SamplingRequestResult {
|
struct SamplingRequestResult {
|
||||||
needs_follow_up: bool,
|
needs_follow_up: bool,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ use crate::mcp::with_codex_apps_mcp;
|
|||||||
use crate::mcp_connection_manager::McpConnectionManager;
|
use crate::mcp_connection_manager::McpConnectionManager;
|
||||||
use crate::mcp_connection_manager::codex_apps_tools_cache_key;
|
use crate::mcp_connection_manager::codex_apps_tools_cache_key;
|
||||||
use crate::plugins::AppConnectorId;
|
use crate::plugins::AppConnectorId;
|
||||||
|
use crate::plugins::ToolPluginProvenance;
|
||||||
use crate::token_data::TokenData;
|
use crate::token_data::TokenData;
|
||||||
|
|
||||||
pub const CONNECTORS_CACHE_TTL: Duration = Duration::from_secs(3600);
|
pub const CONNECTORS_CACHE_TTL: Duration = Duration::from_secs(3600);
|
||||||
@@ -162,6 +163,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status(
|
|||||||
sandbox_state,
|
sandbox_state,
|
||||||
config.codex_home.clone(),
|
config.codex_home.clone(),
|
||||||
codex_apps_tools_cache_key(auth.as_ref()),
|
codex_apps_tools_cache_key(auth.as_ref()),
|
||||||
|
ToolPluginProvenance::default(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|||||||
@@ -217,7 +217,12 @@ pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent
|
|||||||
config.cli_auth_credentials_store_mode,
|
config.cli_auth_credentials_store_mode,
|
||||||
);
|
);
|
||||||
let auth = auth_manager.auth().await;
|
let auth = auth_manager.auth().await;
|
||||||
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(config.codex_home.clone())));
|
let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone()));
|
||||||
|
let tool_plugin_provenance = plugins_manager
|
||||||
|
.plugins_for_config(config)
|
||||||
|
.tool_plugin_provenance()
|
||||||
|
.clone();
|
||||||
|
let mcp_manager = McpManager::new(plugins_manager);
|
||||||
let mcp_servers = mcp_manager.effective_servers(config, auth.as_ref());
|
let mcp_servers = mcp_manager.effective_servers(config, auth.as_ref());
|
||||||
if mcp_servers.is_empty() {
|
if mcp_servers.is_empty() {
|
||||||
return McpListToolsResponseEvent {
|
return McpListToolsResponseEvent {
|
||||||
@@ -251,6 +256,7 @@ pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent
|
|||||||
sandbox_state,
|
sandbox_state,
|
||||||
config.codex_home.clone(),
|
config.codex_home.clone(),
|
||||||
codex_apps_tools_cache_key(auth.as_ref()),
|
codex_apps_tools_cache_key(auth.as_ref()),
|
||||||
|
tool_plugin_provenance,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//! in a single aggregated map using the fully-qualified tool name
|
//! in a single aggregated map using the fully-qualified tool name
|
||||||
//! `"<server><MCP_TOOL_NAME_DELIMITER><tool>"` as the key.
|
//! `"<server><MCP_TOOL_NAME_DELIMITER><tool>"` as the key.
|
||||||
|
|
||||||
|
use std::borrow::Cow;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::env;
|
use std::env;
|
||||||
@@ -79,6 +80,7 @@ use crate::codex::INITIAL_SUBMIT_ID;
|
|||||||
use crate::config::types::McpServerConfig;
|
use crate::config::types::McpServerConfig;
|
||||||
use crate::config::types::McpServerTransportConfig;
|
use crate::config::types::McpServerTransportConfig;
|
||||||
use crate::connectors::is_connector_id_allowed;
|
use crate::connectors::is_connector_id_allowed;
|
||||||
|
use crate::plugins::ToolPluginProvenance;
|
||||||
|
|
||||||
/// Delimiter used to separate the server name from the tool name in a fully
|
/// Delimiter used to separate the server name from the tool name in a fully
|
||||||
/// qualified tool name.
|
/// qualified tool name.
|
||||||
@@ -341,6 +343,7 @@ struct ManagedClient {
|
|||||||
tool_timeout: Option<Duration>,
|
tool_timeout: Option<Duration>,
|
||||||
server_supports_sandbox_state_capability: bool,
|
server_supports_sandbox_state_capability: bool,
|
||||||
codex_apps_tools_cache_context: Option<CodexAppsToolsCacheContext>,
|
codex_apps_tools_cache_context: Option<CodexAppsToolsCacheContext>,
|
||||||
|
tool_plugin_provenance: Arc<ToolPluginProvenance>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ManagedClient {
|
impl ManagedClient {
|
||||||
@@ -350,12 +353,17 @@ impl ManagedClient {
|
|||||||
&& let CachedCodexAppsToolsLoad::Hit(tools) =
|
&& let CachedCodexAppsToolsLoad::Hit(tools) =
|
||||||
load_cached_codex_apps_tools(cache_context)
|
load_cached_codex_apps_tools(cache_context)
|
||||||
{
|
{
|
||||||
|
// Keep the disk cache raw because plugin provenance is session/config-scoped,
|
||||||
|
// while the codex_apps cache is only keyed per user account.
|
||||||
emit_duration(
|
emit_duration(
|
||||||
MCP_TOOLS_LIST_DURATION_METRIC,
|
MCP_TOOLS_LIST_DURATION_METRIC,
|
||||||
total_start.elapsed(),
|
total_start.elapsed(),
|
||||||
&[("cache", "hit")],
|
&[("cache", "hit")],
|
||||||
);
|
);
|
||||||
return filter_tools(tools, &self.tool_filter);
|
return annotate_tools_with_plugin_sources(
|
||||||
|
filter_tools(tools, &self.tool_filter),
|
||||||
|
self.tool_plugin_provenance.as_ref(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.codex_apps_tools_cache_context.is_some() {
|
if self.codex_apps_tools_cache_context.is_some() {
|
||||||
@@ -394,6 +402,9 @@ struct AsyncManagedClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AsyncManagedClient {
|
impl AsyncManagedClient {
|
||||||
|
// Keep this constructor flat so the startup inputs remain readable at the
|
||||||
|
// single call site instead of introducing a one-off params wrapper.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn new(
|
fn new(
|
||||||
server_name: String,
|
server_name: String,
|
||||||
config: McpServerConfig,
|
config: McpServerConfig,
|
||||||
@@ -402,16 +413,23 @@ impl AsyncManagedClient {
|
|||||||
tx_event: Sender<Event>,
|
tx_event: Sender<Event>,
|
||||||
elicitation_requests: ElicitationRequestManager,
|
elicitation_requests: ElicitationRequestManager,
|
||||||
codex_apps_tools_cache_context: Option<CodexAppsToolsCacheContext>,
|
codex_apps_tools_cache_context: Option<CodexAppsToolsCacheContext>,
|
||||||
|
tool_plugin_provenance: Arc<ToolPluginProvenance>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let tool_filter = ToolFilter::from_config(&config);
|
let tool_filter = ToolFilter::from_config(&config);
|
||||||
let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot(
|
let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot(
|
||||||
&server_name,
|
&server_name,
|
||||||
codex_apps_tools_cache_context.as_ref(),
|
codex_apps_tools_cache_context.as_ref(),
|
||||||
)
|
)
|
||||||
.map(|tools| filter_tools(tools, &tool_filter));
|
.map(|tools| {
|
||||||
|
annotate_tools_with_plugin_sources(
|
||||||
|
filter_tools(tools, &tool_filter),
|
||||||
|
tool_plugin_provenance.as_ref(),
|
||||||
|
)
|
||||||
|
});
|
||||||
let startup_tool_filter = tool_filter;
|
let startup_tool_filter = tool_filter;
|
||||||
let startup_complete = Arc::new(AtomicBool::new(false));
|
let startup_complete = Arc::new(AtomicBool::new(false));
|
||||||
let startup_complete_for_fut = Arc::clone(&startup_complete);
|
let startup_complete_for_fut = Arc::clone(&startup_complete);
|
||||||
|
let startup_tool_plugin_provenance = Arc::clone(&tool_plugin_provenance);
|
||||||
let fut = async move {
|
let fut = async move {
|
||||||
let outcome = async {
|
let outcome = async {
|
||||||
if let Err(error) = validate_mcp_server_name(&server_name) {
|
if let Err(error) = validate_mcp_server_name(&server_name) {
|
||||||
@@ -432,6 +450,7 @@ impl AsyncManagedClient {
|
|||||||
tx_event,
|
tx_event,
|
||||||
elicitation_requests,
|
elicitation_requests,
|
||||||
codex_apps_tools_cache_context,
|
codex_apps_tools_cache_context,
|
||||||
|
tool_plugin_provenance: startup_tool_plugin_provenance,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.or_cancel(&cancel_token)
|
.or_cancel(&cancel_token)
|
||||||
@@ -552,12 +571,14 @@ impl McpConnectionManager {
|
|||||||
initial_sandbox_state: SandboxState,
|
initial_sandbox_state: SandboxState,
|
||||||
codex_home: PathBuf,
|
codex_home: PathBuf,
|
||||||
codex_apps_tools_cache_key: CodexAppsToolsCacheKey,
|
codex_apps_tools_cache_key: CodexAppsToolsCacheKey,
|
||||||
|
tool_plugin_provenance: ToolPluginProvenance,
|
||||||
) -> (Self, CancellationToken) {
|
) -> (Self, CancellationToken) {
|
||||||
let cancel_token = CancellationToken::new();
|
let cancel_token = CancellationToken::new();
|
||||||
let mut clients = HashMap::new();
|
let mut clients = HashMap::new();
|
||||||
let mut server_origins = HashMap::new();
|
let mut server_origins = HashMap::new();
|
||||||
let mut join_set = JoinSet::new();
|
let mut join_set = JoinSet::new();
|
||||||
let elicitation_requests = ElicitationRequestManager::new(approval_policy.value());
|
let elicitation_requests = ElicitationRequestManager::new(approval_policy.value());
|
||||||
|
let tool_plugin_provenance = Arc::new(tool_plugin_provenance);
|
||||||
let mcp_servers = mcp_servers.clone();
|
let mcp_servers = mcp_servers.clone();
|
||||||
for (server_name, cfg) in mcp_servers.into_iter().filter(|(_, cfg)| cfg.enabled) {
|
for (server_name, cfg) in mcp_servers.into_iter().filter(|(_, cfg)| cfg.enabled) {
|
||||||
if let Some(origin) = transport_origin(&cfg.transport) {
|
if let Some(origin) = transport_origin(&cfg.transport) {
|
||||||
@@ -588,6 +609,7 @@ impl McpConnectionManager {
|
|||||||
tx_event.clone(),
|
tx_event.clone(),
|
||||||
elicitation_requests.clone(),
|
elicitation_requests.clone(),
|
||||||
codex_apps_tools_cache_context,
|
codex_apps_tools_cache_context,
|
||||||
|
Arc::clone(&tool_plugin_provenance),
|
||||||
);
|
);
|
||||||
clients.insert(server_name.clone(), async_managed_client.clone());
|
clients.insert(server_name.clone(), async_managed_client.clone());
|
||||||
let tx_event = tx_event.clone();
|
let tx_event = tx_event.clone();
|
||||||
@@ -1135,6 +1157,55 @@ pub(crate) fn filter_mcp_tools_by_name(
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn annotate_tools_with_plugin_sources(
|
||||||
|
mut tools: Vec<ToolInfo>,
|
||||||
|
tool_plugin_provenance: &ToolPluginProvenance,
|
||||||
|
) -> Vec<ToolInfo> {
|
||||||
|
for tool in &mut tools {
|
||||||
|
let plugin_names = match tool.connector_id.as_deref() {
|
||||||
|
Some(connector_id) => {
|
||||||
|
tool_plugin_provenance.plugin_display_names_for_connector_id(connector_id)
|
||||||
|
}
|
||||||
|
None => tool_plugin_provenance
|
||||||
|
.plugin_display_names_for_mcp_server_name(tool.server_name.as_str()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if plugin_names.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let plugin_source_note = if plugin_names.len() == 1 {
|
||||||
|
format!("This tool is part of plugin `{}`.", plugin_names[0])
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"This tool is part of plugins {}.",
|
||||||
|
plugin_names
|
||||||
|
.iter()
|
||||||
|
.map(|plugin_name| format!("`{plugin_name}`"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let description = tool
|
||||||
|
.tool
|
||||||
|
.description
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.unwrap_or("");
|
||||||
|
let annotated_description = if description.is_empty() {
|
||||||
|
plugin_source_note
|
||||||
|
} else if matches!(description.chars().last(), Some('.' | '!' | '?')) {
|
||||||
|
format!("{description} {plugin_source_note}")
|
||||||
|
} else {
|
||||||
|
format!("{description}. {plugin_source_note}")
|
||||||
|
};
|
||||||
|
tool.tool.description = Some(Cow::Owned(annotated_description));
|
||||||
|
}
|
||||||
|
|
||||||
|
tools
|
||||||
|
}
|
||||||
|
|
||||||
fn normalize_codex_apps_tool_title(
|
fn normalize_codex_apps_tool_title(
|
||||||
server_name: &str,
|
server_name: &str,
|
||||||
connector_name: Option<&str>,
|
connector_name: Option<&str>,
|
||||||
@@ -1233,6 +1304,7 @@ async fn start_server_task(
|
|||||||
tx_event,
|
tx_event,
|
||||||
elicitation_requests,
|
elicitation_requests,
|
||||||
codex_apps_tools_cache_context,
|
codex_apps_tools_cache_context,
|
||||||
|
tool_plugin_provenance,
|
||||||
} = params;
|
} = params;
|
||||||
let elicitation = elicitation_capability_for_server(&server_name);
|
let elicitation = elicitation_capability_for_server(&server_name);
|
||||||
let params = InitializeRequestParams {
|
let params = InitializeRequestParams {
|
||||||
@@ -1285,7 +1357,10 @@ async fn start_server_task(
|
|||||||
&[("cache", "miss")],
|
&[("cache", "miss")],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let tools = filter_tools(tools, &tool_filter);
|
let tools = annotate_tools_with_plugin_sources(
|
||||||
|
filter_tools(tools, &tool_filter),
|
||||||
|
tool_plugin_provenance.as_ref(),
|
||||||
|
);
|
||||||
|
|
||||||
let server_supports_sandbox_state_capability = initialize_result
|
let server_supports_sandbox_state_capability = initialize_result
|
||||||
.capabilities
|
.capabilities
|
||||||
@@ -1301,6 +1376,7 @@ async fn start_server_task(
|
|||||||
tool_filter,
|
tool_filter,
|
||||||
server_supports_sandbox_state_capability,
|
server_supports_sandbox_state_capability,
|
||||||
codex_apps_tools_cache_context,
|
codex_apps_tools_cache_context,
|
||||||
|
tool_plugin_provenance,
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(managed)
|
Ok(managed)
|
||||||
@@ -1313,6 +1389,7 @@ struct StartServerTaskParams {
|
|||||||
tx_event: Sender<Event>,
|
tx_event: Sender<Event>,
|
||||||
elicitation_requests: ElicitationRequestManager,
|
elicitation_requests: ElicitationRequestManager,
|
||||||
codex_apps_tools_cache_context: Option<CodexAppsToolsCacheContext>,
|
codex_apps_tools_cache_context: Option<CodexAppsToolsCacheContext>,
|
||||||
|
tool_plugin_provenance: Arc<ToolPluginProvenance>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn make_rmcp_client(
|
async fn make_rmcp_client(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use std::path::PathBuf;
|
|||||||
use codex_protocol::user_input::UserInput;
|
use codex_protocol::user_input::UserInput;
|
||||||
|
|
||||||
use crate::connectors;
|
use crate::connectors;
|
||||||
|
use crate::plugins::PluginCapabilitySummary;
|
||||||
use crate::skills::SkillMetadata;
|
use crate::skills::SkillMetadata;
|
||||||
use crate::skills::injection::ToolMentionKind;
|
use crate::skills::injection::ToolMentionKind;
|
||||||
use crate::skills::injection::app_id_from_path;
|
use crate::skills::injection::app_id_from_path;
|
||||||
@@ -48,6 +49,103 @@ pub(crate) fn collect_explicit_app_ids(input: &[UserInput]) -> HashSet<String> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Collect explicit plain-text `@plugin` mentions from user text.
|
||||||
|
///
|
||||||
|
/// This is currently the core-side fallback path for plugin mentions. It
|
||||||
|
/// matches unambiguous plugin `display_name`s from the filtered capability
|
||||||
|
/// index, case-insensitively, by scanning for exact `@display name` matches.
|
||||||
|
///
|
||||||
|
/// It is hand-rolled because core only has a `$...` / `[$...](...)` mention
|
||||||
|
/// parser today, and the existing TUI `@...` logic is file-autocomplete, not
|
||||||
|
/// turn-time parsing.
|
||||||
|
///
|
||||||
|
/// Long term, explicit plugin picks should come through structured
|
||||||
|
/// `plugin://...` mentions, likely via `UserInput::Mention`, once clients can list
|
||||||
|
/// plugins and the UI has plugin-mention support (likely a plugins/list app-server
|
||||||
|
/// endpoint). Even then, this may stay as a text fallback, similar to skills/apps.
|
||||||
|
pub(crate) fn collect_explicit_plugin_mentions(
|
||||||
|
input: &[UserInput],
|
||||||
|
plugins: &[PluginCapabilitySummary],
|
||||||
|
) -> Vec<PluginCapabilitySummary> {
|
||||||
|
if plugins.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut display_name_counts = HashMap::new();
|
||||||
|
for plugin in plugins {
|
||||||
|
*display_name_counts
|
||||||
|
.entry(plugin.display_name.to_lowercase())
|
||||||
|
.or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut display_names = display_name_counts.keys().cloned().collect::<Vec<_>>();
|
||||||
|
display_names.sort_by_key(|display_name| std::cmp::Reverse(display_name.len()));
|
||||||
|
|
||||||
|
let mut mentioned_display_names = HashSet::new();
|
||||||
|
for text in input.iter().filter_map(|item| match item {
|
||||||
|
UserInput::Text { text, .. } => Some(text.as_str()),
|
||||||
|
_ => None,
|
||||||
|
}) {
|
||||||
|
let text = text.to_lowercase();
|
||||||
|
let mut index = 0;
|
||||||
|
while let Some(relative_at_sign) = text[index..].find('@') {
|
||||||
|
let at_sign = index + relative_at_sign;
|
||||||
|
if text[..at_sign]
|
||||||
|
.chars()
|
||||||
|
.next_back()
|
||||||
|
.is_some_and(is_plugin_mention_body_char)
|
||||||
|
{
|
||||||
|
index = at_sign + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some((matched_display_name, matched_len)) =
|
||||||
|
display_names.iter().find_map(|display_name| {
|
||||||
|
text[at_sign + 1..].starts_with(display_name).then(|| {
|
||||||
|
let end = at_sign + 1 + display_name.len();
|
||||||
|
text[end..]
|
||||||
|
.chars()
|
||||||
|
.next()
|
||||||
|
.is_none_or(|ch| !is_plugin_mention_body_char(ch))
|
||||||
|
.then_some((display_name, display_name.len()))
|
||||||
|
})?
|
||||||
|
})
|
||||||
|
else {
|
||||||
|
index = at_sign + 1;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if display_name_counts
|
||||||
|
.get(matched_display_name)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(0)
|
||||||
|
== 1
|
||||||
|
{
|
||||||
|
mentioned_display_names.insert(matched_display_name.clone());
|
||||||
|
}
|
||||||
|
index = at_sign + 1 + matched_len;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if mentioned_display_names.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut selected = Vec::new();
|
||||||
|
let mut seen_display_names = HashSet::new();
|
||||||
|
for plugin in plugins {
|
||||||
|
let display_name = plugin.display_name.to_lowercase();
|
||||||
|
if !mentioned_display_names.contains(&display_name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if seen_display_names.insert(display_name) {
|
||||||
|
selected.push(plugin.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selected
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn build_skill_name_counts(
|
pub(crate) fn build_skill_name_counts(
|
||||||
skills: &[SkillMetadata],
|
skills: &[SkillMetadata],
|
||||||
disabled_paths: &HashSet<PathBuf>,
|
disabled_paths: &HashSet<PathBuf>,
|
||||||
@@ -77,6 +175,10 @@ pub(crate) fn build_connector_slug_counts(
|
|||||||
counts
|
counts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_plugin_mention_body_char(ch: char) -> bool {
|
||||||
|
ch.is_alphanumeric() || matches!(ch, '_' | '-' | ':')
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
@@ -85,6 +187,8 @@ mod tests {
|
|||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
|
|
||||||
use super::collect_explicit_app_ids;
|
use super::collect_explicit_app_ids;
|
||||||
|
use super::collect_explicit_plugin_mentions;
|
||||||
|
use crate::plugins::PluginCapabilitySummary;
|
||||||
|
|
||||||
fn text_input(text: &str) -> UserInput {
|
fn text_input(text: &str) -> UserInput {
|
||||||
UserInput::Text {
|
UserInput::Text {
|
||||||
@@ -93,6 +197,16 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn plugin(display_name: &str) -> PluginCapabilitySummary {
|
||||||
|
PluginCapabilitySummary {
|
||||||
|
config_name: format!("{display_name}@test"),
|
||||||
|
display_name: display_name.to_string(),
|
||||||
|
has_skills: true,
|
||||||
|
mcp_server_names: Vec::new(),
|
||||||
|
app_connector_ids: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn collect_explicit_app_ids_from_linked_text_mentions() {
|
fn collect_explicit_app_ids_from_linked_text_mentions() {
|
||||||
let input = vec")];
|
let input = vec")];
|
||||||
@@ -141,4 +255,70 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(app_ids, HashSet::<String>::new());
|
assert_eq!(app_ids, HashSet::<String>::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collect_explicit_plugin_mentions_resolves_unique_display_names() {
|
||||||
|
let plugins = vec![plugin("sample"), plugin("other")];
|
||||||
|
|
||||||
|
let mentioned = collect_explicit_plugin_mentions(&[text_input("use @sample")], &plugins);
|
||||||
|
|
||||||
|
assert_eq!(mentioned, vec![plugin("sample")]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collect_explicit_plugin_mentions_resolves_non_slug_display_names() {
|
||||||
|
let spaced_plugins = vec![plugin("Google Calendar")];
|
||||||
|
let spaced_mentioned = collect_explicit_plugin_mentions(
|
||||||
|
&[text_input("use @Google Calendar")],
|
||||||
|
&spaced_plugins,
|
||||||
|
);
|
||||||
|
assert_eq!(spaced_mentioned, vec![plugin("Google Calendar")]);
|
||||||
|
|
||||||
|
let unicode_plugins = vec![plugin("Café")];
|
||||||
|
let unicode_mentioned =
|
||||||
|
collect_explicit_plugin_mentions(&[text_input("use @Café")], &unicode_plugins);
|
||||||
|
assert_eq!(unicode_mentioned, vec![plugin("Café")]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collect_explicit_plugin_mentions_prefers_longer_display_names() {
|
||||||
|
let plugins = vec![plugin("Google"), plugin("Google Calendar")];
|
||||||
|
|
||||||
|
let mentioned =
|
||||||
|
collect_explicit_plugin_mentions(&[text_input("use @Google Calendar")], &plugins);
|
||||||
|
|
||||||
|
assert_eq!(mentioned, vec![plugin("Google Calendar")]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collect_explicit_plugin_mentions_does_not_fall_back_from_ambiguous_longer_name() {
|
||||||
|
let plugins = vec![
|
||||||
|
plugin("Google"),
|
||||||
|
PluginCapabilitySummary {
|
||||||
|
config_name: "calendar-1@test".to_string(),
|
||||||
|
..plugin("Google Calendar")
|
||||||
|
},
|
||||||
|
PluginCapabilitySummary {
|
||||||
|
config_name: "calendar-2@test".to_string(),
|
||||||
|
..plugin("Google Calendar")
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let mentioned =
|
||||||
|
collect_explicit_plugin_mentions(&[text_input("use @Google Calendar")], &plugins);
|
||||||
|
|
||||||
|
assert_eq!(mentioned, Vec::<PluginCapabilitySummary>::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collect_explicit_plugin_mentions_ignores_embedded_at_signs() {
|
||||||
|
let plugins = vec![plugin("sample")];
|
||||||
|
|
||||||
|
let mentioned = collect_explicit_plugin_mentions(
|
||||||
|
&[text_input("contact sample@openai.com, do not use plugins")],
|
||||||
|
&plugins,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(mentioned, Vec::<PluginCapabilitySummary>::new());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ use std::fs;
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::RwLock;
|
use std::sync::RwLock;
|
||||||
|
use tracing::info;
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
const DEFAULT_SKILLS_DIR_NAME: &str = "skills";
|
const DEFAULT_SKILLS_DIR_NAME: &str = "skills";
|
||||||
@@ -100,10 +101,33 @@ impl PluginCapabilitySummary {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct ToolPluginProvenance {
|
||||||
|
plugin_display_names_by_connector_id: HashMap<String, Vec<String>>,
|
||||||
|
plugin_display_names_by_mcp_server_name: HashMap<String, Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToolPluginProvenance {
|
||||||
|
pub fn plugin_display_names_for_connector_id(&self, connector_id: &str) -> &[String] {
|
||||||
|
self.plugin_display_names_by_connector_id
|
||||||
|
.get(connector_id)
|
||||||
|
.map(Vec::as_slice)
|
||||||
|
.unwrap_or(&[])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn plugin_display_names_for_mcp_server_name(&self, server_name: &str) -> &[String] {
|
||||||
|
self.plugin_display_names_by_mcp_server_name
|
||||||
|
.get(server_name)
|
||||||
|
.map(Vec::as_slice)
|
||||||
|
.unwrap_or(&[])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct PluginLoadOutcome {
|
pub struct PluginLoadOutcome {
|
||||||
plugins: Vec<LoadedPlugin>,
|
plugins: Vec<LoadedPlugin>,
|
||||||
capability_summaries: Vec<PluginCapabilitySummary>,
|
capability_summaries: Vec<PluginCapabilitySummary>,
|
||||||
|
tool_plugin_provenance: ToolPluginProvenance,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for PluginLoadOutcome {
|
impl Default for PluginLoadOutcome {
|
||||||
@@ -118,9 +142,42 @@ impl PluginLoadOutcome {
|
|||||||
.iter()
|
.iter()
|
||||||
.filter_map(PluginCapabilitySummary::from_plugin)
|
.filter_map(PluginCapabilitySummary::from_plugin)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
let mut tool_plugin_provenance = ToolPluginProvenance::default();
|
||||||
|
for plugin in &capability_summaries {
|
||||||
|
for connector_id in &plugin.app_connector_ids {
|
||||||
|
tool_plugin_provenance
|
||||||
|
.plugin_display_names_by_connector_id
|
||||||
|
.entry(connector_id.0.clone())
|
||||||
|
.or_default()
|
||||||
|
.push(plugin.display_name.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
for server_name in &plugin.mcp_server_names {
|
||||||
|
tool_plugin_provenance
|
||||||
|
.plugin_display_names_by_mcp_server_name
|
||||||
|
.entry(server_name.clone())
|
||||||
|
.or_default()
|
||||||
|
.push(plugin.display_name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for plugin_names in tool_plugin_provenance
|
||||||
|
.plugin_display_names_by_connector_id
|
||||||
|
.values_mut()
|
||||||
|
.chain(
|
||||||
|
tool_plugin_provenance
|
||||||
|
.plugin_display_names_by_mcp_server_name
|
||||||
|
.values_mut(),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
plugin_names.sort_unstable();
|
||||||
|
plugin_names.dedup();
|
||||||
|
}
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
plugins,
|
plugins,
|
||||||
capability_summaries,
|
capability_summaries,
|
||||||
|
tool_plugin_provenance,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +224,10 @@ impl PluginLoadOutcome {
|
|||||||
&self.capability_summaries
|
&self.capability_summaries
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn tool_plugin_provenance(&self) -> &ToolPluginProvenance {
|
||||||
|
&self.tool_plugin_provenance
|
||||||
|
}
|
||||||
|
|
||||||
pub fn plugins(&self) -> &[LoadedPlugin] {
|
pub fn plugins(&self) -> &[LoadedPlugin] {
|
||||||
&self.plugins
|
&self.plugins
|
||||||
}
|
}
|
||||||
@@ -198,20 +259,34 @@ impl PluginsManager {
|
|||||||
force_reload: bool,
|
force_reload: bool,
|
||||||
) -> PluginLoadOutcome {
|
) -> PluginLoadOutcome {
|
||||||
if !plugins_feature_enabled_from_stack(config_layer_stack) {
|
if !plugins_feature_enabled_from_stack(config_layer_stack) {
|
||||||
let mut cache = match self.cache_by_cwd.write() {
|
info!(cwd = ?cwd, "plugins disabled in config layer stack");
|
||||||
Ok(cache) => cache,
|
|
||||||
Err(err) => err.into_inner(),
|
|
||||||
};
|
|
||||||
cache.insert(cwd.to_path_buf(), PluginLoadOutcome::default());
|
|
||||||
return PluginLoadOutcome::default();
|
return PluginLoadOutcome::default();
|
||||||
}
|
}
|
||||||
|
|
||||||
if !force_reload && let Some(outcome) = self.cached_outcome_for_cwd(cwd) {
|
if !force_reload && let Some(outcome) = self.cached_outcome_for_cwd(cwd) {
|
||||||
|
info!(
|
||||||
|
cwd = ?cwd,
|
||||||
|
plugin_display_names = ?outcome
|
||||||
|
.capability_summaries()
|
||||||
|
.iter()
|
||||||
|
.map(|plugin| plugin.display_name.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
"plugins cache hit"
|
||||||
|
);
|
||||||
return outcome;
|
return outcome;
|
||||||
}
|
}
|
||||||
|
|
||||||
let outcome = load_plugins_from_layer_stack(config_layer_stack, &self.store);
|
let outcome = load_plugins_from_layer_stack(config_layer_stack, &self.store);
|
||||||
log_plugin_load_errors(&outcome);
|
log_plugin_load_errors(&outcome);
|
||||||
|
info!(
|
||||||
|
cwd = ?cwd,
|
||||||
|
plugin_display_names = ?outcome
|
||||||
|
.capability_summaries()
|
||||||
|
.iter()
|
||||||
|
.map(|plugin| plugin.display_name.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
"plugins loaded from config layer stack"
|
||||||
|
);
|
||||||
let mut cache = match self.cache_by_cwd.write() {
|
let mut cache = match self.cache_by_cwd.write() {
|
||||||
Ok(cache) => cache,
|
Ok(cache) => cache,
|
||||||
Err(err) => err.into_inner(),
|
Err(err) => err.into_inner(),
|
||||||
@@ -888,6 +963,19 @@ mod tests {
|
|||||||
AppConnectorId("connector_gmail".to_string()),
|
AppConnectorId("connector_gmail".to_string()),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
outcome.tool_plugin_provenance(),
|
||||||
|
&ToolPluginProvenance {
|
||||||
|
plugin_display_names_by_connector_id: HashMap::from([
|
||||||
|
(
|
||||||
|
"connector_example".to_string(),
|
||||||
|
vec!["plugin-a".to_string(), "plugin-b".to_string()],
|
||||||
|
),
|
||||||
|
("connector_gmail".to_string(), vec!["plugin-b".to_string()],),
|
||||||
|
]),
|
||||||
|
plugin_display_names_by_mcp_server_name: HashMap::new(),
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -977,6 +1065,25 @@ mod tests {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
outcome.tool_plugin_provenance(),
|
||||||
|
&ToolPluginProvenance {
|
||||||
|
plugin_display_names_by_connector_id: HashMap::from([
|
||||||
|
(
|
||||||
|
"connector_example".to_string(),
|
||||||
|
vec!["alpha-plugin".to_string(), "beta-plugin".to_string()],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"connector_gmail".to_string(),
|
||||||
|
vec!["beta-plugin".to_string()],
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
plugin_display_names_by_mcp_server_name: HashMap::from([
|
||||||
|
("alpha".to_string(), vec!["alpha-plugin".to_string()]),
|
||||||
|
("beta".to_string(), vec!["beta-plugin".to_string()]),
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ pub use manager::PluginInstallError;
|
|||||||
pub use manager::PluginInstallRequest;
|
pub use manager::PluginInstallRequest;
|
||||||
pub use manager::PluginLoadOutcome;
|
pub use manager::PluginLoadOutcome;
|
||||||
pub use manager::PluginsManager;
|
pub use manager::PluginsManager;
|
||||||
|
pub use manager::ToolPluginProvenance;
|
||||||
pub(crate) use manager::plugin_namespace_for_skill_path;
|
pub(crate) use manager::plugin_namespace_for_skill_path;
|
||||||
pub(crate) use manifest::load_plugin_manifest;
|
pub(crate) use manifest::load_plugin_manifest;
|
||||||
pub(crate) use manifest::plugin_manifest_name;
|
pub(crate) use manifest::plugin_manifest_name;
|
||||||
|
pub(crate) use render::render_explicit_plugin_instructions;
|
||||||
pub(crate) use render::render_plugins_section;
|
pub(crate) use render::render_plugins_section;
|
||||||
pub use store::PluginId;
|
pub use store::PluginId;
|
||||||
pub use store::PluginInstallResult;
|
pub use store::PluginInstallResult;
|
||||||
|
|||||||
@@ -30,6 +30,54 @@ pub(crate) fn render_plugins_section(plugins: &[PluginCapabilitySummary]) -> Opt
|
|||||||
Some(lines.join("\n"))
|
Some(lines.join("\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn render_explicit_plugin_instructions(
|
||||||
|
plugin: &PluginCapabilitySummary,
|
||||||
|
available_mcp_servers: &[String],
|
||||||
|
available_apps: &[String],
|
||||||
|
) -> Option<String> {
|
||||||
|
let mut lines = vec![format!(
|
||||||
|
"Capabilities from the `{}` plugin:",
|
||||||
|
plugin.display_name
|
||||||
|
)];
|
||||||
|
|
||||||
|
if plugin.has_skills {
|
||||||
|
lines.push(format!(
|
||||||
|
"- Skills from this plugin are prefixed with `{}:`.",
|
||||||
|
plugin.display_name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !available_mcp_servers.is_empty() {
|
||||||
|
lines.push(format!(
|
||||||
|
"- MCP servers from this plugin available in this session: {}.",
|
||||||
|
available_mcp_servers
|
||||||
|
.iter()
|
||||||
|
.map(|server| format!("`{server}`"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !available_apps.is_empty() {
|
||||||
|
lines.push(format!(
|
||||||
|
"- Apps from this plugin available in this session: {}.",
|
||||||
|
available_apps
|
||||||
|
.iter()
|
||||||
|
.map(|app| format!("`{app}`"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if lines.len() == 1 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("Use these plugin-associated capabilities to help solve the task.".to_string());
|
||||||
|
|
||||||
|
Some(lines.join("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ use core_test_support::apps_test_server::AppsTestServer;
|
|||||||
use core_test_support::responses::ev_completed;
|
use core_test_support::responses::ev_completed;
|
||||||
use core_test_support::responses::ev_response_created;
|
use core_test_support::responses::ev_response_created;
|
||||||
use core_test_support::responses::mount_sse_once;
|
use core_test_support::responses::mount_sse_once;
|
||||||
use core_test_support::responses::mount_sse_sequence;
|
|
||||||
use core_test_support::responses::sse;
|
use core_test_support::responses::sse;
|
||||||
use core_test_support::responses::start_mock_server;
|
use core_test_support::responses::start_mock_server;
|
||||||
use core_test_support::skip_if_no_network;
|
use core_test_support::skip_if_no_network;
|
||||||
@@ -22,41 +21,48 @@ use core_test_support::stdio_server_bin;
|
|||||||
use core_test_support::test_codex::test_codex;
|
use core_test_support::test_codex::test_codex;
|
||||||
use core_test_support::wait_for_event;
|
use core_test_support::wait_for_event;
|
||||||
use core_test_support::wait_for_event_with_timeout;
|
use core_test_support::wait_for_event_with_timeout;
|
||||||
use dunce::canonicalize as normalize_path;
|
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
use wiremock::MockServer;
|
use wiremock::MockServer;
|
||||||
|
|
||||||
fn write_plugin_skill_plugin(home: &TempDir) -> std::path::PathBuf {
|
const SAMPLE_PLUGIN_CONFIG_NAME: &str = "sample@test";
|
||||||
let plugin_root = home.path().join("plugins/cache/test/sample/local");
|
const SAMPLE_PLUGIN_DISPLAY_NAME: &str = "sample";
|
||||||
let skill_dir = plugin_root.join("skills/sample-search");
|
|
||||||
std::fs::create_dir_all(skill_dir.as_path()).expect("create plugin skill dir");
|
fn sample_plugin_root(home: &TempDir) -> std::path::PathBuf {
|
||||||
|
home.path().join("plugins/cache/test/sample/local")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_sample_plugin_manifest_and_config(home: &TempDir) -> std::path::PathBuf {
|
||||||
|
let plugin_root = sample_plugin_root(home);
|
||||||
std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
|
std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
plugin_root.join(".codex-plugin/plugin.json"),
|
plugin_root.join(".codex-plugin/plugin.json"),
|
||||||
r#"{"name":"sample"}"#,
|
format!(r#"{{"name":"{SAMPLE_PLUGIN_DISPLAY_NAME}"}}"#),
|
||||||
)
|
)
|
||||||
.expect("write plugin manifest");
|
.expect("write plugin manifest");
|
||||||
|
std::fs::write(
|
||||||
|
home.path().join("config.toml"),
|
||||||
|
format!(
|
||||||
|
"[features]\nplugins = true\n\n[plugins.\"{SAMPLE_PLUGIN_CONFIG_NAME}\"]\nenabled = true\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.expect("write config");
|
||||||
|
plugin_root
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_plugin_skill_plugin(home: &TempDir) -> std::path::PathBuf {
|
||||||
|
let plugin_root = write_sample_plugin_manifest_and_config(home);
|
||||||
|
let skill_dir = plugin_root.join("skills/sample-search");
|
||||||
|
std::fs::create_dir_all(skill_dir.as_path()).expect("create plugin skill dir");
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
skill_dir.join("SKILL.md"),
|
skill_dir.join("SKILL.md"),
|
||||||
"---\ndescription: inspect sample data\n---\n\n# body\n",
|
"---\ndescription: inspect sample data\n---\n\n# body\n",
|
||||||
)
|
)
|
||||||
.expect("write plugin skill");
|
.expect("write plugin skill");
|
||||||
std::fs::write(
|
|
||||||
home.path().join("config.toml"),
|
|
||||||
"[features]\nplugins = true\n\n[plugins.\"sample@test\"]\nenabled = true\n",
|
|
||||||
)
|
|
||||||
.expect("write config");
|
|
||||||
skill_dir.join("SKILL.md")
|
skill_dir.join("SKILL.md")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_plugin_mcp_plugin(home: &TempDir, command: &str) {
|
fn write_plugin_mcp_plugin(home: &TempDir, command: &str) {
|
||||||
let plugin_root = home.path().join("plugins/cache/test/sample/local");
|
let plugin_root = write_sample_plugin_manifest_and_config(home);
|
||||||
std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
|
|
||||||
std::fs::write(
|
|
||||||
plugin_root.join(".codex-plugin/plugin.json"),
|
|
||||||
r#"{"name":"sample"}"#,
|
|
||||||
)
|
|
||||||
.expect("write plugin manifest");
|
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
plugin_root.join(".mcp.json"),
|
plugin_root.join(".mcp.json"),
|
||||||
format!(
|
format!(
|
||||||
@@ -70,21 +76,10 @@ fn write_plugin_mcp_plugin(home: &TempDir, command: &str) {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.expect("write plugin mcp config");
|
.expect("write plugin mcp config");
|
||||||
std::fs::write(
|
|
||||||
home.path().join("config.toml"),
|
|
||||||
"[features]\nplugins = true\n\n[plugins.\"sample@test\"]\nenabled = true\n",
|
|
||||||
)
|
|
||||||
.expect("write config");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_plugin_app_plugin(home: &TempDir) {
|
fn write_plugin_app_plugin(home: &TempDir) {
|
||||||
let plugin_root = home.path().join("plugins/sample");
|
let plugin_root = write_sample_plugin_manifest_and_config(home);
|
||||||
std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
|
|
||||||
std::fs::write(
|
|
||||||
plugin_root.join(".codex-plugin/plugin.json"),
|
|
||||||
r#"{"name":"sample"}"#,
|
|
||||||
)
|
|
||||||
.expect("write plugin manifest");
|
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
plugin_root.join(".app.json"),
|
plugin_root.join(".app.json"),
|
||||||
r#"{
|
r#"{
|
||||||
@@ -96,14 +91,6 @@ fn write_plugin_app_plugin(home: &TempDir) {
|
|||||||
}"#,
|
}"#,
|
||||||
)
|
)
|
||||||
.expect("write plugin app config");
|
.expect("write plugin app config");
|
||||||
std::fs::write(
|
|
||||||
home.path().join("config.toml"),
|
|
||||||
format!(
|
|
||||||
"[features]\nplugins = true\n\n[plugins.sample]\nenabled = true\npath = \"{}\"\n",
|
|
||||||
plugin_root.display()
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.expect("write config");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn build_plugin_test_codex(
|
async fn build_plugin_test_codex(
|
||||||
@@ -120,6 +107,32 @@ async fn build_plugin_test_codex(
|
|||||||
.codex)
|
.codex)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn build_apps_enabled_plugin_test_codex(
|
||||||
|
server: &MockServer,
|
||||||
|
codex_home: Arc<TempDir>,
|
||||||
|
chatgpt_base_url: String,
|
||||||
|
) -> Result<Arc<codex_core::CodexThread>> {
|
||||||
|
let mut builder = test_codex()
|
||||||
|
.with_home(codex_home)
|
||||||
|
.with_auth(CodexAuth::from_api_key("Test API Key"))
|
||||||
|
.with_config(move |config| {
|
||||||
|
config
|
||||||
|
.features
|
||||||
|
.enable(Feature::Apps)
|
||||||
|
.expect("test config should allow feature update");
|
||||||
|
config
|
||||||
|
.features
|
||||||
|
.disable(Feature::AppsMcpGateway)
|
||||||
|
.expect("test config should allow feature update");
|
||||||
|
config.chatgpt_base_url = chatgpt_base_url;
|
||||||
|
});
|
||||||
|
Ok(builder
|
||||||
|
.build(server)
|
||||||
|
.await
|
||||||
|
.expect("create new conversation")
|
||||||
|
.codex)
|
||||||
|
}
|
||||||
|
|
||||||
fn tool_names(body: &serde_json::Value) -> Vec<String> {
|
fn tool_names(body: &serde_json::Value) -> Vec<String> {
|
||||||
body.get("tools")
|
body.get("tools")
|
||||||
.and_then(serde_json::Value::as_array)
|
.and_then(serde_json::Value::as_array)
|
||||||
@@ -137,6 +150,22 @@ fn tool_names(body: &serde_json::Value) -> Vec<String> {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn tool_description(body: &serde_json::Value, tool_name: &str) -> Option<String> {
|
||||||
|
body.get("tools")
|
||||||
|
.and_then(serde_json::Value::as_array)
|
||||||
|
.and_then(|tools| {
|
||||||
|
tools.iter().find_map(|tool| {
|
||||||
|
if tool.get("name").and_then(serde_json::Value::as_str) == Some(tool_name) {
|
||||||
|
tool.get("description")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.map(str::to_string)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
async fn plugin_skills_append_to_instructions() -> Result<()> {
|
async fn plugin_skills_append_to_instructions() -> Result<()> {
|
||||||
skip_if_no_network!(Ok(()));
|
skip_if_no_network!(Ok(()));
|
||||||
@@ -149,7 +178,7 @@ async fn plugin_skills_append_to_instructions() -> Result<()> {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
let codex_home = Arc::new(TempDir::new()?);
|
let codex_home = Arc::new(TempDir::new()?);
|
||||||
let skill_path = write_plugin_skill_plugin(codex_home.as_ref());
|
write_plugin_skill_plugin(codex_home.as_ref());
|
||||||
let codex = build_plugin_test_codex(&server, Arc::clone(&codex_home)).await?;
|
let codex = build_plugin_test_codex(&server, Arc::clone(&codex_home)).await?;
|
||||||
|
|
||||||
codex
|
codex
|
||||||
@@ -174,76 +203,48 @@ async fn plugin_skills_append_to_instructions() -> Result<()> {
|
|||||||
"expected plugins section present"
|
"expected plugins section present"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
instructions_text.contains("### Available plugins\n- `sample`"),
|
instructions_text.contains("`sample`"),
|
||||||
"expected enabled plugin list in instructions"
|
"expected enabled plugin name in instructions"
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
instructions_text.contains("### How to use plugins"),
|
|
||||||
"expected plugin usage guidance heading"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
instructions_text.contains("## Skills"),
|
|
||||||
"expected skills section present"
|
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
instructions_text.contains("sample:sample-search: inspect sample data"),
|
instructions_text.contains("sample:sample-search: inspect sample data"),
|
||||||
"expected namespaced plugin skill summary"
|
"expected namespaced plugin skill summary"
|
||||||
);
|
);
|
||||||
let expected_path = normalize_path(skill_path)?;
|
|
||||||
let expected_path_str = expected_path.to_string_lossy().replace('\\', "/");
|
|
||||||
assert!(
|
|
||||||
instructions_text.contains(&expected_path_str),
|
|
||||||
"expected path {expected_path_str} in instructions"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
instructions_text.find("## Plugins") < instructions_text.find("## Skills"),
|
|
||||||
"expected plugins section before skills section"
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
async fn plugin_apps_expose_tools_after_canonical_name_mention() -> Result<()> {
|
async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> {
|
||||||
skip_if_no_network!(Ok(()));
|
skip_if_no_network!(Ok(()));
|
||||||
let server = start_mock_server().await;
|
let server = start_mock_server().await;
|
||||||
let apps_server = AppsTestServer::mount_with_connector_name(&server, "Google Calendar").await?;
|
let apps_server = AppsTestServer::mount_with_connector_name(&server, "Google Calendar").await?;
|
||||||
let mock = mount_sse_sequence(
|
let mock = mount_sse_once(
|
||||||
&server,
|
&server,
|
||||||
vec![
|
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
|
||||||
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
|
|
||||||
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let codex_home = Arc::new(TempDir::new()?);
|
let codex_home = Arc::new(TempDir::new()?);
|
||||||
|
let rmcp_test_server_bin = match stdio_server_bin() {
|
||||||
|
Ok(bin) => bin,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("test_stdio_server binary not available, skipping test: {err}");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
write_plugin_skill_plugin(codex_home.as_ref());
|
||||||
|
write_plugin_mcp_plugin(codex_home.as_ref(), &rmcp_test_server_bin);
|
||||||
write_plugin_app_plugin(codex_home.as_ref());
|
write_plugin_app_plugin(codex_home.as_ref());
|
||||||
#[allow(clippy::expect_used)]
|
|
||||||
let mut builder = test_codex()
|
let codex =
|
||||||
.with_home(codex_home)
|
build_apps_enabled_plugin_test_codex(&server, codex_home, apps_server.chatgpt_base_url)
|
||||||
.with_auth(CodexAuth::from_api_key("Test API Key"))
|
.await?;
|
||||||
.with_config(move |config| {
|
|
||||||
config
|
|
||||||
.features
|
|
||||||
.enable(Feature::Apps)
|
|
||||||
.expect("test config should allow feature update");
|
|
||||||
config
|
|
||||||
.features
|
|
||||||
.disable(Feature::AppsMcpGateway)
|
|
||||||
.expect("test config should allow feature update");
|
|
||||||
config.chatgpt_base_url = apps_server.chatgpt_base_url;
|
|
||||||
});
|
|
||||||
let codex = builder
|
|
||||||
.build(&server)
|
|
||||||
.await
|
|
||||||
.expect("create new conversation")
|
|
||||||
.codex;
|
|
||||||
|
|
||||||
codex
|
codex
|
||||||
.submit(Op::UserInput {
|
.submit(Op::UserInput {
|
||||||
items: vec![codex_protocol::user_input::UserInput::Text {
|
items: vec![codex_protocol::user_input::UserInput::Text {
|
||||||
text: "hello".into(),
|
text: "Use @sample for this task.".into(),
|
||||||
text_elements: Vec::new(),
|
text_elements: Vec::new(),
|
||||||
}],
|
}],
|
||||||
final_output_json_schema: None,
|
final_output_json_schema: None,
|
||||||
@@ -251,40 +252,46 @@ async fn plugin_apps_expose_tools_after_canonical_name_mention() -> Result<()> {
|
|||||||
.await?;
|
.await?;
|
||||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||||
|
|
||||||
codex
|
let request = mock.single_request();
|
||||||
.submit(Op::UserInput {
|
let developer_messages = request.message_input_texts("developer");
|
||||||
items: vec![codex_protocol::user_input::UserInput::Text {
|
|
||||||
text: "Use $google-calendar and then call tools.".into(),
|
|
||||||
text_elements: Vec::new(),
|
|
||||||
}],
|
|
||||||
final_output_json_schema: None,
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
|
||||||
|
|
||||||
let requests = mock.requests();
|
|
||||||
assert_eq!(requests.len(), 2, "expected two model requests");
|
|
||||||
|
|
||||||
let first_tools = tool_names(&requests[0].body_json());
|
|
||||||
assert!(
|
assert!(
|
||||||
!first_tools
|
developer_messages
|
||||||
.iter()
|
.iter()
|
||||||
.any(|name| name == "mcp__codex_apps__calendar_create_event"),
|
.any(|text| text.contains("Skills from this plugin")),
|
||||||
"app tools should stay hidden before plugin app mention: {first_tools:?}"
|
"expected plugin skills guidance: {developer_messages:?}"
|
||||||
);
|
|
||||||
|
|
||||||
let second_tools = tool_names(&requests[1].body_json());
|
|
||||||
assert!(
|
|
||||||
second_tools
|
|
||||||
.iter()
|
|
||||||
.any(|name| name == "mcp__codex_apps__calendar_create_event"),
|
|
||||||
"calendar create tool should be available after plugin app mention: {second_tools:?}"
|
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
second_tools
|
developer_messages
|
||||||
.iter()
|
.iter()
|
||||||
.any(|name| name == "mcp__codex_apps__calendar_list_events"),
|
.any(|text| text.contains("MCP servers from this plugin")),
|
||||||
"calendar list tool should be available after plugin app mention: {second_tools:?}"
|
"expected visible plugin MCP guidance: {developer_messages:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
developer_messages
|
||||||
|
.iter()
|
||||||
|
.any(|text| text.contains("Apps from this plugin")),
|
||||||
|
"expected visible plugin app guidance: {developer_messages:?}"
|
||||||
|
);
|
||||||
|
let request_body = request.body_json();
|
||||||
|
let request_tools = tool_names(&request_body);
|
||||||
|
assert!(
|
||||||
|
request_tools
|
||||||
|
.iter()
|
||||||
|
.any(|name| name == "mcp__codex_apps__calendar_create_event"),
|
||||||
|
"expected plugin app tools to become visible for this turn: {request_tools:?}"
|
||||||
|
);
|
||||||
|
let echo_description = tool_description(&request_body, "mcp__sample__echo")
|
||||||
|
.expect("plugin MCP tool description should be present");
|
||||||
|
assert!(
|
||||||
|
echo_description.contains("This tool is part of plugin `sample`."),
|
||||||
|
"expected plugin MCP provenance in tool description: {echo_description:?}"
|
||||||
|
);
|
||||||
|
let calendar_description =
|
||||||
|
tool_description(&request_body, "mcp__codex_apps__calendar_create_event")
|
||||||
|
.expect("plugin app tool description should be present");
|
||||||
|
assert!(
|
||||||
|
calendar_description.contains("This tool is part of plugin `sample`."),
|
||||||
|
"expected plugin app provenance in tool description: {calendar_description:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -361,6 +361,110 @@ async fn explicit_app_mentions_expose_apps_tools_without_search() -> Result<()>
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn search_tool_results_match_plugin_names_and_annotate_descriptions() -> Result<()> {
|
||||||
|
skip_if_no_network!(Ok(()));
|
||||||
|
|
||||||
|
let server = start_mock_server().await;
|
||||||
|
let apps_server = AppsTestServer::mount_with_connector_name(&server, "Google Calendar").await?;
|
||||||
|
let call_id = "tool-search";
|
||||||
|
let args = json!({
|
||||||
|
"query": "sample",
|
||||||
|
"limit": 2,
|
||||||
|
});
|
||||||
|
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 codex_home = Arc::new(tempfile::TempDir::new()?);
|
||||||
|
let plugin_root = codex_home.path().join("plugins/cache/test/sample/local");
|
||||||
|
std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
|
||||||
|
std::fs::write(
|
||||||
|
plugin_root.join(".codex-plugin/plugin.json"),
|
||||||
|
r#"{"name":"sample"}"#,
|
||||||
|
)
|
||||||
|
.expect("write plugin manifest");
|
||||||
|
std::fs::write(
|
||||||
|
plugin_root.join(".app.json"),
|
||||||
|
r#"{
|
||||||
|
"apps": {
|
||||||
|
"calendar": {
|
||||||
|
"id": "calendar"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.expect("write plugin app config");
|
||||||
|
std::fs::write(
|
||||||
|
codex_home.path().join("config.toml"),
|
||||||
|
"[features]\nplugins = true\n\n[plugins.\"sample@test\"]\nenabled = true\n",
|
||||||
|
)
|
||||||
|
.expect("write config");
|
||||||
|
|
||||||
|
let mut builder =
|
||||||
|
configured_builder(apps_server.chatgpt_base_url.clone(), None).with_home(codex_home);
|
||||||
|
let test = builder.build(&server).await?;
|
||||||
|
|
||||||
|
test.submit_turn_with_policies(
|
||||||
|
"find sample plugin tools",
|
||||||
|
AskForApproval::Never,
|
||||||
|
SandboxPolicy::DangerFullAccess,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let requests = mock.requests();
|
||||||
|
assert_eq!(
|
||||||
|
requests.len(),
|
||||||
|
2,
|
||||||
|
"expected 2 requests, got {}",
|
||||||
|
requests.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
let search_output_payload = search_tool_output_payload(&requests[1], call_id);
|
||||||
|
let result_tools = search_result_tools(&search_output_payload);
|
||||||
|
assert_eq!(result_tools.len(), 2, "expected 2 search results");
|
||||||
|
assert!(
|
||||||
|
result_tools.iter().all(|tool| {
|
||||||
|
tool.get("description")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(|description| {
|
||||||
|
description.contains("This tool is part of plugin `sample`.")
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
"expected plugin provenance in search result descriptions: {search_output_payload:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result_tools
|
||||||
|
.iter()
|
||||||
|
.any(|tool| { tool.get("name").and_then(Value::as_str) == Some(CALENDAR_CREATE_TOOL) }),
|
||||||
|
"expected calendar create tool in search results: {search_output_payload:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result_tools
|
||||||
|
.iter()
|
||||||
|
.any(|tool| { tool.get("name").and_then(Value::as_str) == Some(CALENDAR_LIST_TOOL) }),
|
||||||
|
"expected calendar list tool in search results: {search_output_payload:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
async fn search_tool_selection_persists_across_turns() -> Result<()> {
|
async fn search_tool_selection_persists_across_turns() -> Result<()> {
|
||||||
skip_if_no_network!(Ok(()));
|
skip_if_no_network!(Ok(()));
|
||||||
|
|||||||
Reference in New Issue
Block a user