mirror of
https://github.com/openai/codex.git
synced 2026-03-05 06:03:20 +00:00
Compare commits
3 Commits
dev/sayan/
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3eb9115cef | ||
|
|
3284bde48e | ||
|
|
394e538640 |
@@ -201,12 +201,9 @@ use crate::memories;
|
||||
use crate::mentions::build_connector_slug_counts;
|
||||
use crate::mentions::build_skill_name_counts;
|
||||
use crate::mentions::collect_explicit_app_ids;
|
||||
use crate::mentions::collect_explicit_plugin_mentions;
|
||||
use crate::mentions::collect_tool_mentions_from_messages;
|
||||
use crate::network_policy_decision::execpolicy_network_rule_amendment;
|
||||
use crate::plugins::PluginCapabilitySummary;
|
||||
use crate::plugins::PluginsManager;
|
||||
use crate::plugins::render_explicit_plugin_instructions;
|
||||
use crate::project_doc::get_user_instructions;
|
||||
use crate::protocol::AgentMessageContentDeltaEvent;
|
||||
use crate::protocol::AgentReasoningSectionBreakEvent;
|
||||
@@ -1527,7 +1524,7 @@ impl Session {
|
||||
tool_approvals: Mutex::new(ApprovalStore::default()),
|
||||
execve_session_approvals: RwLock::new(HashMap::new()),
|
||||
skills_manager,
|
||||
plugins_manager: Arc::clone(&plugins_manager),
|
||||
plugins_manager,
|
||||
mcp_manager,
|
||||
file_watcher,
|
||||
agent_control,
|
||||
@@ -1612,10 +1609,6 @@ impl Session {
|
||||
.map(|(name, _)| name.clone())
|
||||
.collect();
|
||||
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;
|
||||
cancel_guard.cancel();
|
||||
@@ -1630,7 +1623,6 @@ impl Session {
|
||||
sandbox_state,
|
||||
config.codex_home.clone(),
|
||||
codex_apps_tools_cache_key(auth),
|
||||
tool_plugin_provenance,
|
||||
)
|
||||
.await;
|
||||
{
|
||||
@@ -2186,12 +2178,11 @@ impl Session {
|
||||
&per_turn_config,
|
||||
)
|
||||
.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(
|
||||
self.services
|
||||
.skills_manager
|
||||
.skills_for_config(&per_turn_config),
|
||||
.skills_for_cwd(&session_configuration.cwd, false)
|
||||
.await,
|
||||
);
|
||||
let mut turn_context: TurnContext = Self::make_turn_context(
|
||||
Some(Arc::clone(&self.services.auth_manager)),
|
||||
@@ -3577,12 +3568,6 @@ impl Session {
|
||||
) {
|
||||
let auth = self.services.auth_manager.auth().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(
|
||||
mcp_servers,
|
||||
self.features.enabled(Feature::Apps),
|
||||
@@ -3610,7 +3595,6 @@ impl Session {
|
||||
sandbox_state,
|
||||
config.codex_home.clone(),
|
||||
codex_apps_tools_cache_key(auth.as_ref()),
|
||||
tool_plugin_provenance,
|
||||
)
|
||||
.await;
|
||||
{
|
||||
@@ -4937,57 +4921,25 @@ pub(crate) async fn run_turn(
|
||||
sess.record_context_updates_and_set_reference_context_item(turn_context.as_ref())
|
||||
.await;
|
||||
|
||||
let loaded_plugins = sess
|
||||
.services
|
||||
.plugins_manager
|
||||
.plugins_for_config(&turn_context.config);
|
||||
// Plain-text @plugin mentions are resolved from the current session's
|
||||
// enabled plugins, then converted into turn-scoped guidance below.
|
||||
let mentioned_plugins =
|
||||
collect_explicit_plugin_mentions(&input, loaded_plugins.capability_summaries());
|
||||
info!(
|
||||
plugin_input_texts = ?input
|
||||
.iter()
|
||||
.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 available_connectors = if turn_context.config.features.enabled(Feature::Apps) {
|
||||
let mcp_tools = match sess
|
||||
.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.list_all_tools()
|
||||
.or_cancel(&cancellation_token)
|
||||
.await
|
||||
{
|
||||
Ok(mcp_tools) => mcp_tools,
|
||||
Err(_) => return None,
|
||||
};
|
||||
let plugin_apps = sess
|
||||
.services
|
||||
.plugins_manager
|
||||
.plugins_for_config(&turn_context.config);
|
||||
let connectors = connectors::merge_plugin_apps_with_accessible(
|
||||
loaded_plugins.effective_apps(),
|
||||
plugin_apps.effective_apps(),
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||
);
|
||||
connectors::with_app_enabled_state(connectors, &turn_context.config)
|
||||
@@ -5048,29 +5000,12 @@ pub(crate) async fn run_turn(
|
||||
.await;
|
||||
}
|
||||
|
||||
let plugin_items =
|
||||
build_plugin_injections(&mentioned_plugins, &mcp_tools, &available_connectors);
|
||||
|
||||
let mut explicitly_enabled_connectors = collect_explicit_app_ids(&input);
|
||||
explicitly_enabled_connectors.extend(collect_explicit_app_ids_from_skill_items(
|
||||
&skill_items,
|
||||
&available_connectors,
|
||||
&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
|
||||
.iter()
|
||||
.map(|connector| (connector.id.as_str(), connector.name.as_str()))
|
||||
@@ -5108,10 +5043,6 @@ pub(crate) async fn run_turn(
|
||||
sess.record_conversation_items(&turn_context, &skill_items)
|
||||
.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())
|
||||
.await;
|
||||
@@ -5180,7 +5111,7 @@ pub(crate) async fn run_turn(
|
||||
&mut client_session,
|
||||
turn_metadata_header.as_deref(),
|
||||
sampling_request_input,
|
||||
&turn_enabled_connectors,
|
||||
&explicitly_enabled_connectors,
|
||||
skills_outcome,
|
||||
&mut server_model_warning_emitted_for_turn,
|
||||
cancellation_token.child_token(),
|
||||
@@ -5726,17 +5657,17 @@ async fn built_tools(
|
||||
.or_cancel(cancellation_token)
|
||||
.await?;
|
||||
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();
|
||||
effective_explicitly_enabled_connectors.extend(sess.get_connector_selection().await);
|
||||
|
||||
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(
|
||||
loaded_plugins.effective_apps(),
|
||||
plugin_apps.effective_apps(),
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||
);
|
||||
Some(connectors::with_app_enabled_state(
|
||||
@@ -5747,8 +5678,6 @@ async fn built_tools(
|
||||
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| {
|
||||
filter_codex_apps_mcp_tools(&mcp_tools, connectors, &turn_context.config)
|
||||
});
|
||||
@@ -5793,55 +5722,6 @@ 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)]
|
||||
struct SamplingRequestResult {
|
||||
needs_follow_up: bool,
|
||||
|
||||
@@ -31,7 +31,6 @@ use crate::mcp::with_codex_apps_mcp;
|
||||
use crate::mcp_connection_manager::McpConnectionManager;
|
||||
use crate::mcp_connection_manager::codex_apps_tools_cache_key;
|
||||
use crate::plugins::AppConnectorId;
|
||||
use crate::plugins::ToolPluginProvenance;
|
||||
use crate::token_data::TokenData;
|
||||
|
||||
pub const CONNECTORS_CACHE_TTL: Duration = Duration::from_secs(3600);
|
||||
@@ -163,7 +162,6 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status(
|
||||
sandbox_state,
|
||||
config.codex_home.clone(),
|
||||
codex_apps_tools_cache_key(auth.as_ref()),
|
||||
ToolPluginProvenance::default(),
|
||||
)
|
||||
.await;
|
||||
|
||||
|
||||
@@ -708,8 +708,8 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
FeatureSpec {
|
||||
id: Feature::FastMode,
|
||||
key: "fast_mode",
|
||||
stage: Stage::UnderDevelopment,
|
||||
default_enabled: false,
|
||||
stage: Stage::Stable,
|
||||
default_enabled: true,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::VoiceTranscription,
|
||||
|
||||
@@ -217,12 +217,7 @@ pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent
|
||||
config.cli_auth_credentials_store_mode,
|
||||
);
|
||||
let auth = auth_manager.auth().await;
|
||||
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_manager = McpManager::new(Arc::new(PluginsManager::new(config.codex_home.clone())));
|
||||
let mcp_servers = mcp_manager.effective_servers(config, auth.as_ref());
|
||||
if mcp_servers.is_empty() {
|
||||
return McpListToolsResponseEvent {
|
||||
@@ -256,7 +251,6 @@ pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent
|
||||
sandbox_state,
|
||||
config.codex_home.clone(),
|
||||
codex_apps_tools_cache_key(auth.as_ref()),
|
||||
tool_plugin_provenance,
|
||||
)
|
||||
.await;
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
//! in a single aggregated map using the fully-qualified tool name
|
||||
//! `"<server><MCP_TOOL_NAME_DELIMITER><tool>"` as the key.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
@@ -80,7 +79,6 @@ use crate::codex::INITIAL_SUBMIT_ID;
|
||||
use crate::config::types::McpServerConfig;
|
||||
use crate::config::types::McpServerTransportConfig;
|
||||
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
|
||||
/// qualified tool name.
|
||||
@@ -343,7 +341,6 @@ struct ManagedClient {
|
||||
tool_timeout: Option<Duration>,
|
||||
server_supports_sandbox_state_capability: bool,
|
||||
codex_apps_tools_cache_context: Option<CodexAppsToolsCacheContext>,
|
||||
tool_plugin_provenance: Arc<ToolPluginProvenance>,
|
||||
}
|
||||
|
||||
impl ManagedClient {
|
||||
@@ -353,17 +350,12 @@ impl ManagedClient {
|
||||
&& let CachedCodexAppsToolsLoad::Hit(tools) =
|
||||
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(
|
||||
MCP_TOOLS_LIST_DURATION_METRIC,
|
||||
total_start.elapsed(),
|
||||
&[("cache", "hit")],
|
||||
);
|
||||
return annotate_tools_with_plugin_sources(
|
||||
filter_tools(tools, &self.tool_filter),
|
||||
self.tool_plugin_provenance.as_ref(),
|
||||
);
|
||||
return filter_tools(tools, &self.tool_filter);
|
||||
}
|
||||
|
||||
if self.codex_apps_tools_cache_context.is_some() {
|
||||
@@ -402,9 +394,6 @@ struct 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(
|
||||
server_name: String,
|
||||
config: McpServerConfig,
|
||||
@@ -413,23 +402,16 @@ impl AsyncManagedClient {
|
||||
tx_event: Sender<Event>,
|
||||
elicitation_requests: ElicitationRequestManager,
|
||||
codex_apps_tools_cache_context: Option<CodexAppsToolsCacheContext>,
|
||||
tool_plugin_provenance: Arc<ToolPluginProvenance>,
|
||||
) -> Self {
|
||||
let tool_filter = ToolFilter::from_config(&config);
|
||||
let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot(
|
||||
&server_name,
|
||||
codex_apps_tools_cache_context.as_ref(),
|
||||
)
|
||||
.map(|tools| {
|
||||
annotate_tools_with_plugin_sources(
|
||||
filter_tools(tools, &tool_filter),
|
||||
tool_plugin_provenance.as_ref(),
|
||||
)
|
||||
});
|
||||
.map(|tools| filter_tools(tools, &tool_filter));
|
||||
let startup_tool_filter = tool_filter;
|
||||
let startup_complete = Arc::new(AtomicBool::new(false));
|
||||
let startup_complete_for_fut = Arc::clone(&startup_complete);
|
||||
let startup_tool_plugin_provenance = Arc::clone(&tool_plugin_provenance);
|
||||
let fut = async move {
|
||||
let outcome = async {
|
||||
if let Err(error) = validate_mcp_server_name(&server_name) {
|
||||
@@ -450,7 +432,6 @@ impl AsyncManagedClient {
|
||||
tx_event,
|
||||
elicitation_requests,
|
||||
codex_apps_tools_cache_context,
|
||||
tool_plugin_provenance: startup_tool_plugin_provenance,
|
||||
},
|
||||
)
|
||||
.or_cancel(&cancel_token)
|
||||
@@ -571,14 +552,12 @@ impl McpConnectionManager {
|
||||
initial_sandbox_state: SandboxState,
|
||||
codex_home: PathBuf,
|
||||
codex_apps_tools_cache_key: CodexAppsToolsCacheKey,
|
||||
tool_plugin_provenance: ToolPluginProvenance,
|
||||
) -> (Self, CancellationToken) {
|
||||
let cancel_token = CancellationToken::new();
|
||||
let mut clients = HashMap::new();
|
||||
let mut server_origins = HashMap::new();
|
||||
let mut join_set = JoinSet::new();
|
||||
let elicitation_requests = ElicitationRequestManager::new(approval_policy.value());
|
||||
let tool_plugin_provenance = Arc::new(tool_plugin_provenance);
|
||||
let mcp_servers = mcp_servers.clone();
|
||||
for (server_name, cfg) in mcp_servers.into_iter().filter(|(_, cfg)| cfg.enabled) {
|
||||
if let Some(origin) = transport_origin(&cfg.transport) {
|
||||
@@ -609,7 +588,6 @@ impl McpConnectionManager {
|
||||
tx_event.clone(),
|
||||
elicitation_requests.clone(),
|
||||
codex_apps_tools_cache_context,
|
||||
Arc::clone(&tool_plugin_provenance),
|
||||
);
|
||||
clients.insert(server_name.clone(), async_managed_client.clone());
|
||||
let tx_event = tx_event.clone();
|
||||
@@ -1157,55 +1135,6 @@ pub(crate) fn filter_mcp_tools_by_name(
|
||||
.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(
|
||||
server_name: &str,
|
||||
connector_name: Option<&str>,
|
||||
@@ -1304,7 +1233,6 @@ async fn start_server_task(
|
||||
tx_event,
|
||||
elicitation_requests,
|
||||
codex_apps_tools_cache_context,
|
||||
tool_plugin_provenance,
|
||||
} = params;
|
||||
let elicitation = elicitation_capability_for_server(&server_name);
|
||||
let params = InitializeRequestParams {
|
||||
@@ -1357,10 +1285,7 @@ async fn start_server_task(
|
||||
&[("cache", "miss")],
|
||||
);
|
||||
}
|
||||
let tools = annotate_tools_with_plugin_sources(
|
||||
filter_tools(tools, &tool_filter),
|
||||
tool_plugin_provenance.as_ref(),
|
||||
);
|
||||
let tools = filter_tools(tools, &tool_filter);
|
||||
|
||||
let server_supports_sandbox_state_capability = initialize_result
|
||||
.capabilities
|
||||
@@ -1376,7 +1301,6 @@ async fn start_server_task(
|
||||
tool_filter,
|
||||
server_supports_sandbox_state_capability,
|
||||
codex_apps_tools_cache_context,
|
||||
tool_plugin_provenance,
|
||||
};
|
||||
|
||||
Ok(managed)
|
||||
@@ -1389,7 +1313,6 @@ struct StartServerTaskParams {
|
||||
tx_event: Sender<Event>,
|
||||
elicitation_requests: ElicitationRequestManager,
|
||||
codex_apps_tools_cache_context: Option<CodexAppsToolsCacheContext>,
|
||||
tool_plugin_provenance: Arc<ToolPluginProvenance>,
|
||||
}
|
||||
|
||||
async fn make_rmcp_client(
|
||||
|
||||
@@ -5,7 +5,6 @@ use std::path::PathBuf;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
|
||||
use crate::connectors;
|
||||
use crate::plugins::PluginCapabilitySummary;
|
||||
use crate::skills::SkillMetadata;
|
||||
use crate::skills::injection::ToolMentionKind;
|
||||
use crate::skills::injection::app_id_from_path;
|
||||
@@ -49,103 +48,6 @@ pub(crate) fn collect_explicit_app_ids(input: &[UserInput]) -> HashSet<String> {
|
||||
.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(
|
||||
skills: &[SkillMetadata],
|
||||
disabled_paths: &HashSet<PathBuf>,
|
||||
@@ -175,10 +77,6 @@ pub(crate) fn build_connector_slug_counts(
|
||||
counts
|
||||
}
|
||||
|
||||
fn is_plugin_mention_body_char(ch: char) -> bool {
|
||||
ch.is_alphanumeric() || matches!(ch, '_' | '-' | ':')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashSet;
|
||||
@@ -187,8 +85,6 @@ mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::collect_explicit_app_ids;
|
||||
use super::collect_explicit_plugin_mentions;
|
||||
use crate::plugins::PluginCapabilitySummary;
|
||||
|
||||
fn text_input(text: &str) -> UserInput {
|
||||
UserInput::Text {
|
||||
@@ -197,16 +93,6 @@ 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]
|
||||
fn collect_explicit_app_ids_from_linked_text_mentions() {
|
||||
let input = vec")];
|
||||
@@ -255,70 +141,4 @@ mod tests {
|
||||
|
||||
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,7 +30,6 @@ use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::RwLock;
|
||||
use tracing::info;
|
||||
use tracing::warn;
|
||||
|
||||
const DEFAULT_SKILLS_DIR_NAME: &str = "skills";
|
||||
@@ -101,33 +100,10 @@ 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)]
|
||||
pub struct PluginLoadOutcome {
|
||||
plugins: Vec<LoadedPlugin>,
|
||||
capability_summaries: Vec<PluginCapabilitySummary>,
|
||||
tool_plugin_provenance: ToolPluginProvenance,
|
||||
}
|
||||
|
||||
impl Default for PluginLoadOutcome {
|
||||
@@ -142,42 +118,9 @@ impl PluginLoadOutcome {
|
||||
.iter()
|
||||
.filter_map(PluginCapabilitySummary::from_plugin)
|
||||
.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 {
|
||||
plugins,
|
||||
capability_summaries,
|
||||
tool_plugin_provenance,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,10 +167,6 @@ impl PluginLoadOutcome {
|
||||
&self.capability_summaries
|
||||
}
|
||||
|
||||
pub fn tool_plugin_provenance(&self) -> &ToolPluginProvenance {
|
||||
&self.tool_plugin_provenance
|
||||
}
|
||||
|
||||
pub fn plugins(&self) -> &[LoadedPlugin] {
|
||||
&self.plugins
|
||||
}
|
||||
@@ -259,7 +198,6 @@ impl PluginsManager {
|
||||
force_reload: bool,
|
||||
) -> PluginLoadOutcome {
|
||||
if !plugins_feature_enabled_from_stack(config_layer_stack) {
|
||||
info!(cwd = ?cwd, "plugins disabled in config layer stack");
|
||||
let mut cache = match self.cache_by_cwd.write() {
|
||||
Ok(cache) => cache,
|
||||
Err(err) => err.into_inner(),
|
||||
@@ -269,29 +207,11 @@ impl PluginsManager {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
let outcome = load_plugins_from_layer_stack(config_layer_stack, &self.store);
|
||||
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() {
|
||||
Ok(cache) => cache,
|
||||
Err(err) => err.into_inner(),
|
||||
@@ -968,19 +888,6 @@ mod tests {
|
||||
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]
|
||||
@@ -1070,25 +977,6 @@ 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]
|
||||
|
||||
@@ -11,11 +11,9 @@ pub use manager::PluginInstallError;
|
||||
pub use manager::PluginInstallRequest;
|
||||
pub use manager::PluginLoadOutcome;
|
||||
pub use manager::PluginsManager;
|
||||
pub use manager::ToolPluginProvenance;
|
||||
pub(crate) use manager::plugin_namespace_for_skill_path;
|
||||
pub(crate) use manifest::load_plugin_manifest;
|
||||
pub(crate) use manifest::plugin_manifest_name;
|
||||
pub(crate) use render::render_explicit_plugin_instructions;
|
||||
pub(crate) use render::render_plugins_section;
|
||||
pub use store::PluginId;
|
||||
pub use store::PluginInstallResult;
|
||||
|
||||
@@ -30,54 +30,6 @@ pub(crate) fn render_plugins_section(plugins: &[PluginCapabilitySummary]) -> Opt
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -14,6 +14,7 @@ use core_test_support::apps_test_server::AppsTestServer;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
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::start_mock_server;
|
||||
use core_test_support::skip_if_no_network;
|
||||
@@ -21,48 +22,41 @@ use core_test_support::stdio_server_bin;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_event_with_timeout;
|
||||
use dunce::canonicalize as normalize_path;
|
||||
use tempfile::TempDir;
|
||||
use wiremock::MockServer;
|
||||
|
||||
const SAMPLE_PLUGIN_CONFIG_NAME: &str = "sample@test";
|
||||
const SAMPLE_PLUGIN_DISPLAY_NAME: &str = "sample";
|
||||
|
||||
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);
|
||||
fn write_plugin_skill_plugin(home: &TempDir) -> std::path::PathBuf {
|
||||
let plugin_root = home.path().join("plugins/cache/test/sample/local");
|
||||
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::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
|
||||
std::fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
format!(r#"{{"name":"{SAMPLE_PLUGIN_DISPLAY_NAME}"}}"#),
|
||||
r#"{"name":"sample"}"#,
|
||||
)
|
||||
.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(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"---\ndescription: inspect sample data\n---\n\n# body\n",
|
||||
)
|
||||
.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")
|
||||
}
|
||||
|
||||
fn write_plugin_mcp_plugin(home: &TempDir, command: &str) {
|
||||
let plugin_root = write_sample_plugin_manifest_and_config(home);
|
||||
let plugin_root = 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(".mcp.json"),
|
||||
format!(
|
||||
@@ -76,10 +70,21 @@ fn write_plugin_mcp_plugin(home: &TempDir, command: &str) {
|
||||
),
|
||||
)
|
||||
.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) {
|
||||
let plugin_root = write_sample_plugin_manifest_and_config(home);
|
||||
let plugin_root = home.path().join("plugins/sample");
|
||||
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#"{
|
||||
@@ -91,6 +96,14 @@ fn write_plugin_app_plugin(home: &TempDir) {
|
||||
}"#,
|
||||
)
|
||||
.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(
|
||||
@@ -107,32 +120,6 @@ async fn build_plugin_test_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> {
|
||||
body.get("tools")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
@@ -150,22 +137,6 @@ fn tool_names(body: &serde_json::Value) -> Vec<String> {
|
||||
.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)]
|
||||
async fn plugin_skills_append_to_instructions() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
@@ -178,7 +149,7 @@ async fn plugin_skills_append_to_instructions() -> Result<()> {
|
||||
.await;
|
||||
|
||||
let codex_home = Arc::new(TempDir::new()?);
|
||||
write_plugin_skill_plugin(codex_home.as_ref());
|
||||
let skill_path = write_plugin_skill_plugin(codex_home.as_ref());
|
||||
let codex = build_plugin_test_codex(&server, Arc::clone(&codex_home)).await?;
|
||||
|
||||
codex
|
||||
@@ -203,48 +174,76 @@ async fn plugin_skills_append_to_instructions() -> Result<()> {
|
||||
"expected plugins section present"
|
||||
);
|
||||
assert!(
|
||||
instructions_text.contains("`sample`"),
|
||||
"expected enabled plugin name in instructions"
|
||||
instructions_text.contains("### Available plugins\n- `sample`"),
|
||||
"expected enabled plugin list 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!(
|
||||
instructions_text.contains("sample:sample-search: inspect sample data"),
|
||||
"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(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> {
|
||||
async fn plugin_apps_expose_tools_after_canonical_name_mention() -> 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 mock = mount_sse_once(
|
||||
let mock = mount_sse_sequence(
|
||||
&server,
|
||||
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
|
||||
vec![
|
||||
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
|
||||
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
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());
|
||||
|
||||
let codex =
|
||||
build_apps_enabled_plugin_test_codex(&server, codex_home, apps_server.chatgpt_base_url)
|
||||
.await?;
|
||||
#[allow(clippy::expect_used)]
|
||||
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 = apps_server.chatgpt_base_url;
|
||||
});
|
||||
let codex = builder
|
||||
.build(&server)
|
||||
.await
|
||||
.expect("create new conversation")
|
||||
.codex;
|
||||
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![codex_protocol::user_input::UserInput::Text {
|
||||
text: "Use @sample for this task.".into(),
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
@@ -252,46 +251,40 @@ async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> {
|
||||
.await?;
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let request = mock.single_request();
|
||||
let developer_messages = request.message_input_texts("developer");
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
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!(
|
||||
developer_messages
|
||||
.iter()
|
||||
.any(|text| text.contains("Skills from this plugin")),
|
||||
"expected plugin skills guidance: {developer_messages:?}"
|
||||
);
|
||||
assert!(
|
||||
developer_messages
|
||||
.iter()
|
||||
.any(|text| text.contains("MCP servers from this plugin")),
|
||||
"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
|
||||
!first_tools
|
||||
.iter()
|
||||
.any(|name| name == "mcp__codex_apps__calendar_create_event"),
|
||||
"expected plugin app tools to become visible for this turn: {request_tools:?}"
|
||||
"app tools should stay hidden before plugin app mention: {first_tools:?}"
|
||||
);
|
||||
let echo_description = tool_description(&request_body, "mcp__sample__echo")
|
||||
.expect("plugin MCP tool description should be present");
|
||||
|
||||
let second_tools = tool_names(&requests[1].body_json());
|
||||
assert!(
|
||||
echo_description.contains("This tool is part of plugin `sample`."),
|
||||
"expected plugin MCP provenance in tool description: {echo_description:?}"
|
||||
second_tools
|
||||
.iter()
|
||||
.any(|name| name == "mcp__codex_apps__calendar_create_event"),
|
||||
"calendar create tool should be available after plugin app mention: {second_tools:?}"
|
||||
);
|
||||
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:?}"
|
||||
second_tools
|
||||
.iter()
|
||||
.any(|name| name == "mcp__codex_apps__calendar_list_events"),
|
||||
"calendar list tool should be available after plugin app mention: {second_tools:?}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -361,110 +361,6 @@ async fn explicit_app_mentions_expose_apps_tools_without_search() -> Result<()>
|
||||
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)]
|
||||
async fn search_tool_selection_persists_across_turns() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -44,6 +44,7 @@ use codex_core::plugins::PluginsManager;
|
||||
use codex_core::web_search::web_search_detail;
|
||||
use codex_otel::RuntimeMetricsSummary;
|
||||
use codex_protocol::account::PlanType;
|
||||
use codex_protocol::config_types::ServiceTier;
|
||||
use codex_protocol::mcp::Resource;
|
||||
use codex_protocol::mcp::ResourceTemplate;
|
||||
use codex_protocol::models::WebSearchAction;
|
||||
@@ -1099,7 +1100,12 @@ pub(crate) fn new_session_info(
|
||||
} else {
|
||||
if config.show_tooltips
|
||||
&& let Some(tooltips) = tooltip_override
|
||||
.or_else(|| tooltips::get_tooltip(auth_plan))
|
||||
.or_else(|| {
|
||||
tooltips::get_tooltip(
|
||||
auth_plan,
|
||||
matches!(config.service_tier, Some(ServiceTier::Fast)),
|
||||
)
|
||||
})
|
||||
.map(TooltipHistoryCell::new)
|
||||
{
|
||||
parts.push(Box::new(tooltips));
|
||||
|
||||
@@ -90,7 +90,7 @@ impl SlashCommand {
|
||||
SlashCommand::MemoryDrop => "DO NOT USE",
|
||||
SlashCommand::MemoryUpdate => "DO NOT USE",
|
||||
SlashCommand::Model => "choose what model and reasoning effort to use",
|
||||
SlashCommand::Fast => "toggle Fast mode to enable fastest inference at 3X plan usage",
|
||||
SlashCommand::Fast => "toggle Fast mode to enable fastest inference at 2X plan usage",
|
||||
SlashCommand::Personality => "choose a communication style for Codex",
|
||||
SlashCommand::Realtime => "toggle realtime voice mode (experimental)",
|
||||
SlashCommand::Settings => "configure realtime microphone/speaker",
|
||||
|
||||
@@ -7,9 +7,12 @@ const ANNOUNCEMENT_TIP_URL: &str =
|
||||
"https://raw.githubusercontent.com/openai/codex/main/announcement_tip.toml";
|
||||
|
||||
const IS_MACOS: bool = cfg!(target_os = "macos");
|
||||
const IS_WINDOWS: bool = cfg!(target_os = "windows");
|
||||
|
||||
const PAID_TOOLTIP: &str = "*New* Try the **Codex App** with 2x rate limits until *April 2nd*. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true";
|
||||
const PAID_TOOLTIP_WINDOWS: &str = "*New* Try the **Codex App**, now available on **Windows**, with 2x rate limits until *April 2nd*. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true";
|
||||
const PAID_TOOLTIP_NON_MAC: &str = "*New* 2x rate limits until *April 2nd*.";
|
||||
const FAST_TOOLTIP: &str = "*New* Use **/fast** to enable our fastest inference at 2X plan usage.";
|
||||
const OTHER_TOOLTIP: &str = "*New* Build faster with the **Codex App**. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true";
|
||||
const OTHER_TOOLTIP_NON_MAC: &str = "*New* Build faster with Codex.";
|
||||
const FREE_GO_TOOLTIP: &str =
|
||||
@@ -25,7 +28,7 @@ lazy_static! {
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
return false;
|
||||
}
|
||||
if !IS_MACOS && line.contains("codex app") {
|
||||
if !IS_MACOS && !IS_WINDOWS && line.contains("codex app") {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
@@ -47,7 +50,7 @@ fn experimental_tooltips() -> Vec<&'static str> {
|
||||
}
|
||||
|
||||
/// Pick a random tooltip to show to the user when starting Codex.
|
||||
pub(crate) fn get_tooltip(plan: Option<PlanType>) -> Option<String> {
|
||||
pub(crate) fn get_tooltip(plan: Option<PlanType>, fast_mode_enabled: bool) -> Option<String> {
|
||||
let mut rng = rand::rng();
|
||||
|
||||
if let Some(announcement) = announcement::fetch_announcement_tip() {
|
||||
@@ -62,12 +65,7 @@ pub(crate) fn get_tooltip(plan: Option<PlanType>) -> Option<String> {
|
||||
| Some(PlanType::Team)
|
||||
| Some(PlanType::Enterprise)
|
||||
| Some(PlanType::Pro) => {
|
||||
let tooltip = if IS_MACOS {
|
||||
PAID_TOOLTIP
|
||||
} else {
|
||||
PAID_TOOLTIP_NON_MAC
|
||||
};
|
||||
return Some(tooltip.to_string());
|
||||
return Some(pick_paid_tooltip(&mut rng, fast_mode_enabled).to_string());
|
||||
}
|
||||
Some(PlanType::Go) | Some(PlanType::Free) => {
|
||||
return Some(FREE_GO_TOOLTIP.to_string());
|
||||
@@ -86,6 +84,28 @@ pub(crate) fn get_tooltip(plan: Option<PlanType>) -> Option<String> {
|
||||
pick_tooltip(&mut rng).map(str::to_string)
|
||||
}
|
||||
|
||||
fn paid_app_tooltip() -> &'static str {
|
||||
if IS_MACOS {
|
||||
PAID_TOOLTIP
|
||||
} else if IS_WINDOWS {
|
||||
PAID_TOOLTIP_WINDOWS
|
||||
} else {
|
||||
PAID_TOOLTIP_NON_MAC
|
||||
}
|
||||
}
|
||||
|
||||
/// Paid users spend most startup sessions in a dedicated promo slot rather than the
|
||||
/// generic random tip pool. Keep this business logic explicit: we currently split
|
||||
/// that slot between the app promo and Fast mode, but suppress the Fast promo once
|
||||
/// the user already has Fast mode enabled.
|
||||
fn pick_paid_tooltip<R: Rng + ?Sized>(rng: &mut R, fast_mode_enabled: bool) -> &'static str {
|
||||
if fast_mode_enabled || rng.random_bool(0.5) {
|
||||
paid_app_tooltip()
|
||||
} else {
|
||||
FAST_TOOLTIP
|
||||
}
|
||||
}
|
||||
|
||||
fn pick_tooltip<R: Rng + ?Sized>(rng: &mut R) -> Option<&'static str> {
|
||||
if ALL_TOOLTIPS.is_empty() {
|
||||
None
|
||||
@@ -264,6 +284,31 @@ mod tests {
|
||||
assert_eq!(expected, pick_tooltip(&mut rng));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paid_tooltip_pool_rotates_between_promos() {
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for seed in 0..32 {
|
||||
let mut rng = StdRng::seed_from_u64(seed);
|
||||
seen.insert(pick_paid_tooltip(&mut rng, false));
|
||||
}
|
||||
|
||||
let expected = std::collections::BTreeSet::from([paid_app_tooltip(), FAST_TOOLTIP]);
|
||||
assert_eq!(seen, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paid_tooltip_pool_skips_fast_when_fast_mode_is_enabled() {
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for seed in 0..8 {
|
||||
let mut rng = StdRng::seed_from_u64(seed);
|
||||
seen.insert(pick_paid_tooltip(&mut rng, true));
|
||||
}
|
||||
|
||||
let expected = std::collections::BTreeSet::from([paid_app_tooltip()]);
|
||||
assert_eq!(seen, expected);
|
||||
assert!(!seen.contains(&FAST_TOOLTIP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn announcement_tip_toml_picks_last_matching() {
|
||||
let toml = r#"
|
||||
|
||||
Reference in New Issue
Block a user