mirror of
https://github.com/openai/codex.git
synced 2026-10-29 19:08:43 +00:00
annotate tools with plugin info, inject plugin info on @mention
This commit is contained in:
+110
-30
@@ -204,7 +204,10 @@ 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::annotate_tools_with_plugin_sources;
|
||||
use crate::plugins::render_explicit_plugin_instructions;
|
||||
use crate::project_doc::get_user_instructions;
|
||||
use crate::protocol::AgentMessageContentDeltaEvent;
|
||||
use crate::protocol::AgentReasoningSectionBreakEvent;
|
||||
@@ -4926,19 +4929,32 @@ pub(crate) async fn run_turn(
|
||||
.services
|
||||
.plugins_manager
|
||||
.plugins_for_config(&turn_context.config);
|
||||
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,
|
||||
// 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_index().plugins());
|
||||
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 connectors = connectors::merge_plugin_apps_with_accessible(
|
||||
loaded_plugins.effective_apps(),
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||
@@ -4953,18 +4969,6 @@ pub(crate) async fn run_turn(
|
||||
.map_or_else(HashMap::new, |outcome| {
|
||||
build_skill_name_counts(&outcome.skills, &outcome.disabled_paths).1
|
||||
});
|
||||
let mentioned_plugins =
|
||||
collect_explicit_plugin_mentions(&input, loaded_plugins.capability_index().plugins());
|
||||
if !mentioned_plugins.is_empty() {
|
||||
trace!(
|
||||
turn_id = %turn_context.sub_id,
|
||||
plugins = ?mentioned_plugins
|
||||
.iter()
|
||||
.map(|plugin| plugin.display_name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
"resolved explicit plugin mentions"
|
||||
);
|
||||
}
|
||||
let mentioned_skills = skills_outcome.as_ref().map_or_else(Vec::new, |outcome| {
|
||||
collect_explicit_skill_mentions(
|
||||
&input,
|
||||
@@ -5013,12 +5017,29 @@ 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()))
|
||||
@@ -5056,6 +5077,10 @@ 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;
|
||||
@@ -5124,7 +5149,7 @@ pub(crate) async fn run_turn(
|
||||
&mut client_session,
|
||||
turn_metadata_header.as_deref(),
|
||||
sampling_request_input,
|
||||
&explicitly_enabled_connectors,
|
||||
&turn_enabled_connectors,
|
||||
skills_outcome,
|
||||
&mut server_model_warning_emitted_for_turn,
|
||||
cancellation_token.child_token(),
|
||||
@@ -5670,17 +5695,21 @@ 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);
|
||||
// Annotate tool descriptions at runtime so prompt-visible tools and
|
||||
// search_tool_bm25 share the same plugin provenance without mutating the
|
||||
// cached MCP tool inventory.
|
||||
mcp_tools = annotate_tools_with_plugin_sources(mcp_tools, loaded_plugins.capability_index());
|
||||
|
||||
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(
|
||||
plugin_apps.effective_apps(),
|
||||
loaded_plugins.effective_apps(),
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||
);
|
||||
Some(connectors::with_app_enabled_state(
|
||||
@@ -5691,6 +5720,8 @@ 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)
|
||||
});
|
||||
@@ -5735,6 +5766,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)]
|
||||
struct SamplingRequestResult {
|
||||
needs_follow_up: bool,
|
||||
|
||||
@@ -53,7 +53,7 @@ pub(crate) fn collect_explicit_app_ids(input: &[UserInput]) -> HashSet<String> {
|
||||
///
|
||||
/// 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, using a conservative `[A-Za-z0-9_:-]` token set.
|
||||
/// 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
|
||||
@@ -71,43 +71,59 @@ pub(crate) fn collect_explicit_plugin_mentions(
|
||||
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_bytes = text.as_bytes();
|
||||
let text = text.to_lowercase();
|
||||
let mut index = 0;
|
||||
while index < text_bytes.len() {
|
||||
if text_bytes[index] != b'@' {
|
||||
index += 1;
|
||||
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;
|
||||
}
|
||||
|
||||
if index > 0 && is_plugin_mention_char(text_bytes[index - 1]) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let name_start = index + 1;
|
||||
let Some(first_name_byte) = text_bytes.get(name_start) else {
|
||||
index += 1;
|
||||
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 !is_plugin_mention_char(*first_name_byte) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut name_end = name_start + 1;
|
||||
while let Some(next_byte) = text_bytes.get(name_end)
|
||||
&& is_plugin_mention_char(*next_byte)
|
||||
if display_name_counts
|
||||
.get(matched_display_name)
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
== 1
|
||||
{
|
||||
name_end += 1;
|
||||
mentioned_display_names.insert(matched_display_name.clone());
|
||||
}
|
||||
|
||||
mentioned_display_names.insert(text[name_start..name_end].to_ascii_lowercase());
|
||||
index = name_end;
|
||||
index = at_sign + 1 + matched_len;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,23 +131,13 @@ pub(crate) fn collect_explicit_plugin_mentions(
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut display_name_counts = HashMap::new();
|
||||
for plugin in plugins {
|
||||
*display_name_counts
|
||||
.entry(plugin.display_name.to_ascii_lowercase())
|
||||
.or_insert(0) += 1;
|
||||
}
|
||||
|
||||
let mut selected = Vec::new();
|
||||
let mut seen_display_names = HashSet::new();
|
||||
for plugin in plugins {
|
||||
let display_name = plugin.display_name.to_ascii_lowercase();
|
||||
let display_name = plugin.display_name.to_lowercase();
|
||||
if !mentioned_display_names.contains(&display_name) {
|
||||
continue;
|
||||
}
|
||||
if display_name_counts.get(&display_name).copied().unwrap_or(0) != 1 {
|
||||
continue;
|
||||
}
|
||||
if seen_display_names.insert(display_name) {
|
||||
selected.push(plugin.clone());
|
||||
}
|
||||
@@ -169,8 +175,8 @@ pub(crate) fn build_connector_slug_counts(
|
||||
counts
|
||||
}
|
||||
|
||||
fn is_plugin_mention_char(byte: u8) -> bool {
|
||||
matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-' | b':')
|
||||
fn is_plugin_mention_body_char(ch: char) -> bool {
|
||||
ch.is_alphanumeric() || matches!(ch, '_' | '-' | ':')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -260,19 +266,46 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_explicit_plugin_mentions_skips_ambiguous_display_names() {
|
||||
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: "sample@test".to_string(),
|
||||
..plugin("sample")
|
||||
config_name: "calendar-1@test".to_string(),
|
||||
..plugin("Google Calendar")
|
||||
},
|
||||
PluginCapabilitySummary {
|
||||
config_name: "sample@prod".to_string(),
|
||||
..plugin("sample")
|
||||
config_name: "calendar-2@test".to_string(),
|
||||
..plugin("Google Calendar")
|
||||
},
|
||||
];
|
||||
|
||||
let mentioned = collect_explicit_plugin_mentions(&[text_input("use @sample")], &plugins);
|
||||
let mentioned =
|
||||
collect_explicit_plugin_mentions(&[text_input("use @Google Calendar")], &plugins);
|
||||
|
||||
assert_eq!(mentioned, Vec::<PluginCapabilitySummary>::new());
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
use super::load_plugin_manifest;
|
||||
use super::marketplace::MarketplaceError;
|
||||
use super::marketplace::resolve_marketplace_plugin;
|
||||
use super::plugin_manifest_name;
|
||||
use super::store::DEFAULT_PLUGIN_VERSION;
|
||||
use super::store::PluginId;
|
||||
use super::store::PluginInstallRequest;
|
||||
use super::store::PluginInstallResult;
|
||||
use super::store::PluginStore;
|
||||
use super::store::PluginStoreError;
|
||||
@@ -18,6 +17,7 @@ use crate::config_loader::ConfigLayerStack;
|
||||
use crate::features::Feature;
|
||||
use crate::features::FeatureOverrides;
|
||||
use crate::features::Features;
|
||||
use crate::mcp_connection_manager::ToolInfo;
|
||||
use codex_app_server_protocol::ConfigValueWriteParams;
|
||||
use codex_app_server_protocol::MergeStrategy;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
@@ -25,6 +25,7 @@ use serde::Deserialize;
|
||||
use serde_json::Map as JsonMap;
|
||||
use serde_json::Value as JsonValue;
|
||||
use serde_json::json;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
@@ -39,13 +40,6 @@ const DEFAULT_APP_CONFIG_FILE: &str = ".app.json";
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct AppConnectorId(pub String);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginInstallRequest {
|
||||
pub plugin_name: String,
|
||||
pub marketplace_name: String,
|
||||
pub cwd: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct LoadedPlugin {
|
||||
pub config_name: String,
|
||||
@@ -100,10 +94,146 @@ impl PluginCapabilitySummary {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct PluginCapabilityIndex {
|
||||
plugins: Vec<PluginCapabilitySummary>,
|
||||
plugin_index_by_config_name: HashMap<String, usize>,
|
||||
plugin_indexes_by_connector_id: HashMap<AppConnectorId, Vec<usize>>,
|
||||
plugin_indexes_by_mcp_server_name: HashMap<String, Vec<usize>>,
|
||||
}
|
||||
|
||||
impl PluginCapabilityIndex {
|
||||
fn from_plugins(plugins: &[LoadedPlugin]) -> Self {
|
||||
let mut capability_index = Self::default();
|
||||
|
||||
for plugin in plugins {
|
||||
let Some(summary) = PluginCapabilitySummary::from_plugin(plugin) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let plugin_index = capability_index.plugins.len();
|
||||
capability_index
|
||||
.plugin_index_by_config_name
|
||||
.insert(summary.config_name.clone(), plugin_index);
|
||||
|
||||
for connector_id in &summary.app_connector_ids {
|
||||
capability_index
|
||||
.plugin_indexes_by_connector_id
|
||||
.entry(connector_id.clone())
|
||||
.or_default()
|
||||
.push(plugin_index);
|
||||
}
|
||||
|
||||
for server_name in &summary.mcp_server_names {
|
||||
capability_index
|
||||
.plugin_indexes_by_mcp_server_name
|
||||
.entry(server_name.clone())
|
||||
.or_default()
|
||||
.push(plugin_index);
|
||||
}
|
||||
|
||||
capability_index.plugins.push(summary);
|
||||
}
|
||||
|
||||
capability_index
|
||||
}
|
||||
|
||||
pub fn plugins(&self) -> &[PluginCapabilitySummary] {
|
||||
&self.plugins
|
||||
}
|
||||
|
||||
pub fn plugin_for_config_name(&self, config_name: &str) -> Option<&PluginCapabilitySummary> {
|
||||
self.plugin_index_by_config_name
|
||||
.get(config_name)
|
||||
.map(|plugin_index| &self.plugins[*plugin_index])
|
||||
}
|
||||
|
||||
pub fn plugins_for_connector_id(
|
||||
&self,
|
||||
connector_id: &AppConnectorId,
|
||||
) -> Vec<&PluginCapabilitySummary> {
|
||||
self.plugin_indexes_by_connector_id
|
||||
.get(connector_id)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|plugin_index| &self.plugins[*plugin_index])
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn plugins_for_mcp_server_name(&self, server_name: &str) -> Vec<&PluginCapabilitySummary> {
|
||||
self.plugin_indexes_by_mcp_server_name
|
||||
.get(server_name)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|plugin_index| &self.plugins[*plugin_index])
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn annotate_tools_with_plugin_sources(
|
||||
mut tools: HashMap<String, ToolInfo>,
|
||||
capability_index: &PluginCapabilityIndex,
|
||||
) -> HashMap<String, ToolInfo> {
|
||||
for tool in tools.values_mut() {
|
||||
// Recover plugin provenance from the app connector or MCP server and
|
||||
// append it to the shared description text used by both prompt tools
|
||||
// and search_tool_bm25 indexing.
|
||||
let mut plugin_names = match tool.connector_id.as_ref() {
|
||||
Some(connector_id) => capability_index
|
||||
.plugins_for_connector_id(&AppConnectorId(connector_id.clone()))
|
||||
.into_iter()
|
||||
.map(|plugin| plugin.display_name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
None => capability_index
|
||||
.plugins_for_mcp_server_name(tool.server_name.as_str())
|
||||
.into_iter()
|
||||
.map(|plugin| plugin.display_name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
};
|
||||
|
||||
if plugin_names.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
plugin_names.sort_unstable();
|
||||
plugin_names.dedup();
|
||||
|
||||
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
|
||||
.into_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
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PluginLoadOutcome {
|
||||
plugins: Vec<LoadedPlugin>,
|
||||
capability_summaries: Vec<PluginCapabilitySummary>,
|
||||
capability_index: PluginCapabilityIndex,
|
||||
}
|
||||
|
||||
impl Default for PluginLoadOutcome {
|
||||
@@ -114,13 +244,10 @@ impl Default for PluginLoadOutcome {
|
||||
|
||||
impl PluginLoadOutcome {
|
||||
fn from_plugins(plugins: Vec<LoadedPlugin>) -> Self {
|
||||
let capability_summaries = plugins
|
||||
.iter()
|
||||
.filter_map(PluginCapabilitySummary::from_plugin)
|
||||
.collect::<Vec<_>>();
|
||||
let capability_index = PluginCapabilityIndex::from_plugins(&plugins);
|
||||
Self {
|
||||
plugins,
|
||||
capability_summaries,
|
||||
capability_index,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,8 +290,8 @@ impl PluginLoadOutcome {
|
||||
apps
|
||||
}
|
||||
|
||||
pub fn capability_summaries(&self) -> &[PluginCapabilitySummary] {
|
||||
&self.capability_summaries
|
||||
pub fn capability_index(&self) -> &PluginCapabilityIndex {
|
||||
&self.capability_index
|
||||
}
|
||||
|
||||
pub fn plugins(&self) -> &[LoadedPlugin] {
|
||||
@@ -239,17 +366,10 @@ impl PluginsManager {
|
||||
&self,
|
||||
request: PluginInstallRequest,
|
||||
) -> Result<PluginInstallResult, PluginInstallError> {
|
||||
let resolved = resolve_marketplace_plugin(
|
||||
&request.cwd,
|
||||
&request.plugin_name,
|
||||
&request.marketplace_name,
|
||||
)?;
|
||||
let store = self.store.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
store.install(resolved.source_path.into_path_buf(), resolved.plugin_id)
|
||||
})
|
||||
.await
|
||||
.map_err(PluginInstallError::join)??;
|
||||
let result = tokio::task::spawn_blocking(move || store.install(request))
|
||||
.await
|
||||
.map_err(PluginInstallError::join)??;
|
||||
|
||||
ConfigService::new_with_defaults(self.codex_home.clone())
|
||||
.write_value(ConfigValueWriteParams {
|
||||
@@ -271,9 +391,6 @@ impl PluginsManager {
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PluginInstallError {
|
||||
#[error("{0}")]
|
||||
Marketplace(#[from] MarketplaceError),
|
||||
|
||||
#[error("{0}")]
|
||||
Store(#[from] PluginStoreError),
|
||||
|
||||
@@ -288,18 +405,6 @@ impl PluginInstallError {
|
||||
fn join(source: tokio::task::JoinError) -> Self {
|
||||
Self::Join(source)
|
||||
}
|
||||
|
||||
pub fn is_invalid_request(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Marketplace(
|
||||
MarketplaceError::InvalidMarketplaceFile { .. }
|
||||
| MarketplaceError::PluginNotFound { .. }
|
||||
| MarketplaceError::DuplicatePlugin { .. }
|
||||
| MarketplaceError::InvalidPlugin(_)
|
||||
) | Self::Store(PluginStoreError::Invalid(_))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn plugins_feature_enabled_from_stack(config_layer_stack: &ConfigLayerStack) -> bool {
|
||||
@@ -888,6 +993,15 @@ mod tests {
|
||||
AppConnectorId("connector_gmail".to_string()),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
outcome
|
||||
.capability_index()
|
||||
.plugins_for_connector_id(&AppConnectorId("connector_example".to_string()))
|
||||
.into_iter()
|
||||
.map(|plugin| plugin.config_name.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["plugin-a@test".to_string(), "plugin-b@test".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -956,7 +1070,7 @@ mod tests {
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
outcome.capability_summaries(),
|
||||
outcome.capability_index().plugins(),
|
||||
&[
|
||||
PluginCapabilitySummary {
|
||||
has_skills: true,
|
||||
@@ -977,6 +1091,30 @@ mod tests {
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
outcome
|
||||
.capability_index()
|
||||
.plugins_for_connector_id(&connector("connector_example"))
|
||||
.into_iter()
|
||||
.map(|plugin| plugin.config_name.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["alpha@test".to_string(), "beta@test".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
outcome
|
||||
.capability_index()
|
||||
.plugins_for_mcp_server_name("alpha")
|
||||
.into_iter()
|
||||
.map(|plugin| plugin.config_name.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["alpha@test".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
outcome
|
||||
.capability_index()
|
||||
.plugin_for_config_name("empty@test"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1063,36 +1201,12 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn install_plugin_updates_config_with_relative_path_and_plugin_key() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
write_plugin(
|
||||
&repo_root.join(".agents/plugins"),
|
||||
"sample-plugin",
|
||||
"sample-plugin",
|
||||
);
|
||||
fs::write(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "debug",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "sample-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./sample-plugin"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
write_plugin(tmp.path(), "sample-plugin", "sample-plugin");
|
||||
|
||||
let result = PluginsManager::new(tmp.path().to_path_buf())
|
||||
.install_plugin(PluginInstallRequest {
|
||||
plugin_name: "sample-plugin".to_string(),
|
||||
marketplace_name: "debug".to_string(),
|
||||
cwd: repo_root.clone(),
|
||||
source_path: tmp.path().join("sample-plugin"),
|
||||
marketplace_name: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -11,9 +11,11 @@ pub use manager::PluginInstallError;
|
||||
pub use manager::PluginInstallRequest;
|
||||
pub use manager::PluginLoadOutcome;
|
||||
pub use manager::PluginsManager;
|
||||
pub(crate) use manager::annotate_tools_with_plugin_sources;
|
||||
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,6 +30,54 @@ 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::*;
|
||||
|
||||
@@ -13,6 +13,7 @@ use crate::connectors;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use crate::mcp_connection_manager::ToolInfo;
|
||||
use crate::plugins::annotate_tools_with_plugin_sources;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolOutput;
|
||||
use crate::tools::context::ToolPayload;
|
||||
@@ -125,6 +126,14 @@ impl ToolHandler for SearchToolBm25Handler {
|
||||
);
|
||||
let mcp_tools = filter_codex_apps_mcp_tools(mcp_tools, &connectors);
|
||||
let mcp_tools = connectors::filter_codex_apps_tools_by_policy(mcp_tools, &turn.config);
|
||||
let loaded_plugins = session
|
||||
.services
|
||||
.plugins_manager
|
||||
.plugins_for_config(&turn.config);
|
||||
// Reuse the same runtime annotation as prompt tool building so plugin
|
||||
// names become searchable through normal description indexing.
|
||||
let mcp_tools =
|
||||
annotate_tools_with_plugin_sources(mcp_tools, loaded_plugins.capability_index());
|
||||
|
||||
let mut entries: Vec<ToolEntry> = mcp_tools
|
||||
.into_iter()
|
||||
|
||||
@@ -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_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;
|
||||
@@ -22,41 +21,48 @@ 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;
|
||||
|
||||
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");
|
||||
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);
|
||||
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"}"#,
|
||||
format!(r#"{{"name":"{SAMPLE_PLUGIN_DISPLAY_NAME}"}}"#),
|
||||
)
|
||||
.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 = 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");
|
||||
let plugin_root = write_sample_plugin_manifest_and_config(home);
|
||||
std::fs::write(
|
||||
plugin_root.join(".mcp.json"),
|
||||
format!(
|
||||
@@ -70,21 +76,10 @@ 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 = 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");
|
||||
let plugin_root = write_sample_plugin_manifest_and_config(home);
|
||||
std::fs::write(
|
||||
plugin_root.join(".app.json"),
|
||||
r#"{
|
||||
@@ -96,14 +91,6 @@ 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(
|
||||
@@ -120,6 +107,32 @@ 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)
|
||||
@@ -137,6 +150,22 @@ 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(()));
|
||||
@@ -149,7 +178,7 @@ async fn plugin_skills_append_to_instructions() -> Result<()> {
|
||||
.await;
|
||||
|
||||
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?;
|
||||
|
||||
codex
|
||||
@@ -174,76 +203,48 @@ async fn plugin_skills_append_to_instructions() -> Result<()> {
|
||||
"expected plugins section present"
|
||||
);
|
||||
assert!(
|
||||
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"
|
||||
instructions_text.contains("`sample`"),
|
||||
"expected enabled plugin name in instructions"
|
||||
);
|
||||
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 plugin_apps_expose_tools_after_canonical_name_mention() -> Result<()> {
|
||||
async fn explicit_plugin_mentions_inject_plugin_guidance() -> 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_sequence(
|
||||
let mock = mount_sse_once(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
|
||||
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
|
||||
],
|
||||
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
|
||||
)
|
||||
.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());
|
||||
#[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;
|
||||
|
||||
let codex =
|
||||
build_apps_enabled_plugin_test_codex(&server, codex_home, apps_server.chatgpt_base_url)
|
||||
.await?;
|
||||
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![codex_protocol::user_input::UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text: "Use @sample for this task.".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
@@ -251,40 +252,46 @@ async fn plugin_apps_expose_tools_after_canonical_name_mention() -> Result<()> {
|
||||
.await?;
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
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());
|
||||
let request = mock.single_request();
|
||||
let developer_messages = request.message_input_texts("developer");
|
||||
assert!(
|
||||
!first_tools
|
||||
developer_messages
|
||||
.iter()
|
||||
.any(|name| name == "mcp__codex_apps__calendar_create_event"),
|
||||
"app tools should stay hidden before plugin app mention: {first_tools:?}"
|
||||
);
|
||||
|
||||
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:?}"
|
||||
.any(|text| text.contains("Skills from this plugin")),
|
||||
"expected plugin skills guidance: {developer_messages:?}"
|
||||
);
|
||||
assert!(
|
||||
second_tools
|
||||
developer_messages
|
||||
.iter()
|
||||
.any(|name| name == "mcp__codex_apps__calendar_list_events"),
|
||||
"calendar list tool should be available after plugin app mention: {second_tools:?}"
|
||||
.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
|
||||
.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(())
|
||||
|
||||
@@ -361,6 +361,110 @@ 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(()));
|
||||
|
||||
Reference in New Issue
Block a user