diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 48869483ff..e440661307 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -201,6 +201,7 @@ 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::PluginsManager; @@ -4921,6 +4922,10 @@ 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); let available_connectors = if turn_context.config.features.enabled(Feature::Apps) { let mcp_tools = match sess .services @@ -4934,12 +4939,8 @@ pub(crate) async fn run_turn( 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( - plugin_apps.effective_apps(), + loaded_plugins.effective_apps(), connectors::accessible_connectors_from_mcp_tools(&mcp_tools), ); connectors::with_app_enabled_state(connectors, &turn_context.config) @@ -4952,6 +4953,18 @@ 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::>(), + "resolved explicit plugin mentions" + ); + } let mentioned_skills = skills_outcome.as_ref().map_or_else(Vec::new, |outcome| { collect_explicit_skill_mentions( &input, diff --git a/codex-rs/core/src/mentions.rs b/codex-rs/core/src/mentions.rs index 008456d61a..49dcf52089 100644 --- a/codex-rs/core/src/mentions.rs +++ b/codex-rs/core/src/mentions.rs @@ -5,6 +5,7 @@ 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; @@ -48,6 +49,97 @@ pub(crate) fn collect_explicit_app_ids(input: &[UserInput]) -> HashSet { .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, using a conservative `[A-Za-z0-9_:-]` token set. +/// +/// 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 { + if plugins.is_empty() { + return Vec::new(); + } + + 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 mut index = 0; + while index < text_bytes.len() { + if text_bytes[index] != b'@' { + index += 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; + 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) + { + name_end += 1; + } + + mentioned_display_names.insert(text[name_start..name_end].to_ascii_lowercase()); + index = name_end; + } + } + + if mentioned_display_names.is_empty() { + 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(); + 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()); + } + } + + selected +} + pub(crate) fn build_skill_name_counts( skills: &[SkillMetadata], disabled_paths: &HashSet, @@ -77,6 +169,10 @@ 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':') +} + #[cfg(test)] mod tests { use std::collections::HashSet; @@ -85,6 +181,8 @@ 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 { @@ -93,6 +191,16 @@ mod tests { } } + fn plugin(display_name: &str) -> PluginCapabilitySummary { + PluginCapabilitySummary { + config_name: format!("{display_name}@test"), + display_name: display_name.to_string(), + has_skills: true, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + } + } + #[test] fn collect_explicit_app_ids_from_linked_text_mentions() { let input = vec![text_input("use [$calendar](app://calendar)")]; @@ -141,4 +249,43 @@ mod tests { assert_eq!(app_ids, HashSet::::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_skips_ambiguous_display_names() { + let plugins = vec![ + PluginCapabilitySummary { + config_name: "sample@test".to_string(), + ..plugin("sample") + }, + PluginCapabilitySummary { + config_name: "sample@prod".to_string(), + ..plugin("sample") + }, + ]; + + let mentioned = collect_explicit_plugin_mentions(&[text_input("use @sample")], &plugins); + + assert_eq!(mentioned, Vec::::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::::new()); + } }