feat: structured plugin parsing (#13711)

#### What

Add structured `@plugin` parsing and TUI support for plugin mentions.

- Core: switch from plain-text `@display_name` parsing to structured
`plugin://...` mentions via `UserInput::Mention` and
`[$...](plugin://...)` links in text, same pattern as apps/skills.
- TUI: add plugin mention popup, autocomplete, and chips when typing
`$`. Load plugin capability summaries and feed them into the composer;
plugin mentions appear alongside skills and apps.
- Generalize mention parsing to a sigil parameter, still defaults to `$`

<img width="797" height="119" alt="image"
src="https://github.com/user-attachments/assets/f0fe2658-d908-4927-9139-73f850805ceb"
/>

Builds on #13510. Currently clients have to build their own `id` via
`plugin@marketplace` and filter plugins to show by `enabled`, but we
will add `id` and `available` as fields returned from `plugin/list`
soon.

####Tests

Added tests, verified locally.
This commit is contained in:
sayan-oai
2026-03-06 11:08:36 -08:00
committed by GitHub
Unverified
parent 0e41a5c4a8
commit 8a54d3caaa
18 changed files with 468 additions and 181 deletions
+2 -2
View File
@@ -5129,7 +5129,7 @@ pub(crate) async fn run_turn(
.services
.plugins_manager
.plugins_for_config(&turn_context.config);
// Plain-text @plugin mentions are resolved from the current session's
// Structured 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());
@@ -5226,7 +5226,7 @@ pub(crate) async fn run_turn(
&available_connectors,
&skill_name_counts_lower,
));
// Explicit @plugin mentions can make a plugin's enabled apps callable for
// 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(
+80 -138
View File
@@ -10,6 +10,7 @@ use crate::skills::SkillMetadata;
use crate::skills::injection::ToolMentionKind;
use crate::skills::injection::app_id_from_path;
use crate::skills::injection::extract_tool_mentions;
use crate::skills::injection::plugin_config_name_from_path;
use crate::skills::injection::tool_kind_for_path;
pub(crate) struct CollectedToolMentions {
@@ -49,20 +50,7 @@ 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.
/// Collect explicit structured `plugin://...` mentions.
pub(crate) fn collect_explicit_plugin_mentions(
input: &[UserInput],
plugins: &[PluginCapabilitySummary],
@@ -71,79 +59,34 @@ 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 messages = input
.iter()
.filter_map(|item| match item {
UserInput::Text { text, .. } => Some(text.clone()),
_ => None,
})
.collect::<Vec<String>>();
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 mentioned_config_names: HashSet<String> = input
.iter()
.filter_map(|item| match item {
UserInput::Mention { path, .. } => Some(path.clone()),
_ => None,
})
.chain(collect_tool_mentions_from_messages(&messages).paths)
.filter(|path| tool_kind_for_path(path.as_str()) == ToolMentionKind::Plugin)
.filter_map(|path| plugin_config_name_from_path(path.as_str()).map(str::to_string))
.collect();
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() {
if mentioned_config_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
plugins
.iter()
.filter(|plugin| mentioned_config_names.contains(plugin.config_name.as_str()))
.cloned()
.collect()
}
pub(crate) fn build_skill_name_counts(
@@ -175,10 +118,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;
@@ -197,10 +136,11 @@ mod tests {
}
}
fn plugin(display_name: &str) -> PluginCapabilitySummary {
fn plugin(config_name: &str, display_name: &str) -> PluginCapabilitySummary {
PluginCapabilitySummary {
config_name: format!("{display_name}@test"),
config_name: config_name.to_string(),
display_name: display_name.to_string(),
description: None,
has_skills: true,
mcp_server_names: Vec::new(),
app_connector_ids: Vec::new(),
@@ -257,65 +197,67 @@ mod tests {
}
#[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() {
fn collect_explicit_plugin_mentions_from_structured_paths() {
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")
},
plugin("sample@test", "sample"),
plugin("other@test", "other"),
];
let mentioned =
collect_explicit_plugin_mentions(&[text_input("use @Google Calendar")], &plugins);
let mentioned = collect_explicit_plugin_mentions(
&[UserInput::Mention {
name: "sample".to_string(),
path: "plugin://sample@test".to_string(),
}],
&plugins,
);
assert_eq!(mentioned, Vec::<PluginCapabilitySummary>::new());
assert_eq!(mentioned, vec![plugin("sample@test", "sample")]);
}
#[test]
fn collect_explicit_plugin_mentions_ignores_embedded_at_signs() {
let plugins = vec![plugin("sample")];
fn collect_explicit_plugin_mentions_from_linked_text_mentions() {
let plugins = vec![
plugin("sample@test", "sample"),
plugin("other@test", "other"),
];
let mentioned = collect_explicit_plugin_mentions(
&[text_input("contact sample@openai.com, do not use plugins")],
&[text_input("use [$sample](plugin://sample@test)")],
&plugins,
);
assert_eq!(mentioned, vec![plugin("sample@test", "sample")]);
}
#[test]
fn collect_explicit_plugin_mentions_dedupes_structured_and_linked_mentions() {
let plugins = vec![
plugin("sample@test", "sample"),
plugin("other@test", "other"),
];
let mentioned = collect_explicit_plugin_mentions(
&[
text_input("use [$sample](plugin://sample@test)"),
UserInput::Mention {
name: "sample".to_string(),
path: "plugin://sample@test".to_string(),
},
],
&plugins,
);
assert_eq!(mentioned, vec![plugin("sample@test", "sample")]);
}
#[test]
fn collect_explicit_plugin_mentions_ignores_non_plugin_paths() {
let plugins = vec![plugin("sample@test", "sample")];
let mentioned = collect_explicit_plugin_mentions(
&[text_input(
"use [$app](app://calendar) and [$skill](skill://team/skill) and [$file](/tmp/file.txt)",
)],
&plugins,
);
+1 -1
View File
@@ -19,7 +19,7 @@ pub(crate) fn build_plugin_injections(
return Vec::new();
}
// Turn each explicit @plugin mention into a developer hint that points the
// 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()
+28 -1
View File
@@ -66,6 +66,7 @@ pub struct ConfiguredMarketplacePluginSummary {
pub struct LoadedPlugin {
pub config_name: String,
pub manifest_name: Option<String>,
pub manifest_description: Option<String>,
pub root: AbsolutePathBuf,
pub enabled: bool,
pub skill_roots: Vec<PathBuf>,
@@ -84,6 +85,7 @@ impl LoadedPlugin {
pub struct PluginCapabilitySummary {
pub config_name: String,
pub display_name: String,
pub description: Option<String>,
pub has_skills: bool,
pub mcp_server_names: Vec<String>,
pub app_connector_ids: Vec<AppConnectorId>,
@@ -104,6 +106,7 @@ impl PluginCapabilitySummary {
.manifest_name
.clone()
.unwrap_or_else(|| plugin.config_name.clone()),
description: plugin.manifest_description.clone(),
has_skills: !plugin.skill_roots.is_empty(),
mcp_server_names,
app_connector_ids: plugin.apps.clone(),
@@ -476,6 +479,7 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore)
let mut loaded_plugin = LoadedPlugin {
config_name,
manifest_name: None,
manifest_description: None,
root,
enabled: plugin.enabled,
skill_roots: Vec::new(),
@@ -507,6 +511,7 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore)
};
loaded_plugin.manifest_name = Some(plugin_manifest_name(&manifest, plugin_root.as_path()));
loaded_plugin.manifest_description = manifest.description;
loaded_plugin.skill_roots = default_skill_roots(plugin_root.as_path());
let mut mcp_servers = HashMap::new();
for mcp_config_path in default_mcp_config_paths(plugin_root.as_path()) {
@@ -752,7 +757,10 @@ mod tests {
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
r#"{
"name": "sample",
"description": "Plugin that includes the sample MCP server and Skills"
}"#,
);
write_file(
&plugin_root.join("skills/sample-search/SKILL.md"),
@@ -792,6 +800,9 @@ mod tests {
vec![LoadedPlugin {
config_name: "sample@test".to_string(),
manifest_name: Some("sample".to_string()),
manifest_description: Some(
"Plugin that includes the sample MCP server and Skills".to_string(),
),
root: AbsolutePathBuf::try_from(plugin_root.clone()).unwrap(),
enabled: true,
skill_roots: vec![plugin_root.join("skills")],
@@ -819,6 +830,19 @@ mod tests {
error: None,
}]
);
assert_eq!(
outcome.capability_summaries(),
&[PluginCapabilitySummary {
config_name: "sample@test".to_string(),
display_name: "sample".to_string(),
description: Some(
"Plugin that includes the sample MCP server and Skills".to_string(),
),
has_skills: true,
mcp_server_names: vec!["sample".to_string()],
app_connector_ids: vec![AppConnectorId("connector_example".to_string())],
}]
);
assert_eq!(
outcome.effective_skill_roots(),
vec![plugin_root.join("skills")]
@@ -862,6 +886,7 @@ mod tests {
vec![LoadedPlugin {
config_name: "sample@test".to_string(),
manifest_name: None,
manifest_description: None,
root: AbsolutePathBuf::try_from(plugin_root).unwrap(),
enabled: false,
skill_roots: Vec::new(),
@@ -972,6 +997,7 @@ mod tests {
let plugin = |config_name: &str, dir_name: &str, manifest_name: &str| LoadedPlugin {
config_name: config_name.to_string(),
manifest_name: Some(manifest_name.to_string()),
manifest_description: None,
root: AbsolutePathBuf::try_from(codex_home.path().join(dir_name)).unwrap(),
enabled: true,
skill_roots: Vec::new(),
@@ -982,6 +1008,7 @@ mod tests {
let summary = |config_name: &str, display_name: &str| PluginCapabilitySummary {
config_name: config_name.to_string(),
display_name: display_name.to_string(),
description: None,
..PluginCapabilitySummary::default()
};
let outcome = PluginLoadOutcome::from_plugins(vec![
+2 -1
View File
@@ -6,7 +6,8 @@ pub(crate) const PLUGIN_MANIFEST_PATH: &str = ".codex-plugin/plugin.json";
#[derive(Debug, Default, Deserialize)]
pub(crate) struct PluginManifest {
name: String,
pub(crate) name: String,
pub(crate) description: Option<String>,
}
pub(crate) fn load_plugin_manifest(plugin_root: &Path) -> Option<PluginManifest> {
+24 -8
View File
@@ -178,12 +178,14 @@ impl<'a> ToolMentions<'a> {
pub(crate) enum ToolMentionKind {
App,
Mcp,
Plugin,
Skill,
Other,
}
const APP_PATH_PREFIX: &str = "app://";
const MCP_PATH_PREFIX: &str = "mcp://";
const PLUGIN_PATH_PREFIX: &str = "plugin://";
const SKILL_PATH_PREFIX: &str = "skill://";
const SKILL_FILENAME: &str = "SKILL.md";
@@ -192,6 +194,8 @@ pub(crate) fn tool_kind_for_path(path: &str) -> ToolMentionKind {
ToolMentionKind::App
} else if path.starts_with(MCP_PATH_PREFIX) {
ToolMentionKind::Mcp
} else if path.starts_with(PLUGIN_PATH_PREFIX) {
ToolMentionKind::Plugin
} else if path.starts_with(SKILL_PATH_PREFIX) || is_skill_filename(path) {
ToolMentionKind::Skill
} else {
@@ -209,6 +213,11 @@ pub(crate) fn app_id_from_path(path: &str) -> Option<&str> {
.filter(|value| !value.is_empty())
}
pub(crate) fn plugin_config_name_from_path(path: &str) -> Option<&str> {
path.strip_prefix(PLUGIN_PATH_PREFIX)
.filter(|value| !value.is_empty())
}
pub(crate) fn normalize_skill_path(path: &str) -> &str {
path.strip_prefix(SKILL_PATH_PREFIX).unwrap_or(path)
}
@@ -219,6 +228,10 @@ pub(crate) fn normalize_skill_path(path: &str) -> &str {
/// resource path is present, it is captured for exact path matching while also tracking
/// the name for fallback matching.
pub(crate) fn extract_tool_mentions(text: &str) -> ToolMentions<'_> {
extract_tool_mentions_with_sigil(text, '$')
}
fn extract_tool_mentions_with_sigil(text: &str, sigil: char) -> ToolMentions<'_> {
let text_bytes = text.as_bytes();
let mut mentioned_names: HashSet<&str> = HashSet::new();
let mut mentioned_paths: HashSet<&str> = HashSet::new();
@@ -229,11 +242,13 @@ pub(crate) fn extract_tool_mentions(text: &str) -> ToolMentions<'_> {
let byte = text_bytes[index];
if byte == b'['
&& let Some((name, path, end_index)) =
parse_linked_tool_mention(text, text_bytes, index)
parse_linked_tool_mention(text, text_bytes, index, sigil)
{
if !is_common_env_var(name) {
let kind = tool_kind_for_path(path);
if !matches!(kind, ToolMentionKind::App | ToolMentionKind::Mcp) {
if !matches!(
tool_kind_for_path(path),
ToolMentionKind::App | ToolMentionKind::Mcp | ToolMentionKind::Plugin
) {
mentioned_names.insert(name);
}
mentioned_paths.insert(path);
@@ -242,7 +257,7 @@ pub(crate) fn extract_tool_mentions(text: &str) -> ToolMentions<'_> {
continue;
}
if byte != b'$' {
if byte != sigil as u8 {
index += 1;
continue;
}
@@ -297,7 +312,7 @@ fn select_skills_from_mentions(
.filter(|path| {
!matches!(
tool_kind_for_path(path),
ToolMentionKind::App | ToolMentionKind::Mcp
ToolMentionKind::App | ToolMentionKind::Mcp | ToolMentionKind::Plugin
)
})
.map(normalize_skill_path)
@@ -361,13 +376,14 @@ fn parse_linked_tool_mention<'a>(
text: &'a str,
text_bytes: &[u8],
start: usize,
sigil: char,
) -> Option<(&'a str, &'a str, usize)> {
let dollar_index = start + 1;
if text_bytes.get(dollar_index) != Some(&b'$') {
let sigil_index = start + 1;
if text_bytes.get(sigil_index) != Some(&(sigil as u8)) {
return None;
}
let name_start = dollar_index + 1;
let name_start = sigil_index + 1;
let first_name_byte = text_bytes.get(name_start)?;
if !is_mention_name_char(*first_name_byte) {
return None;
+1 -1
View File
@@ -684,7 +684,7 @@ fn create_collab_input_items_schema() -> JsonSchema {
"path".to_string(),
JsonSchema::String {
description: Some(
"Path when type is local_image/skill, or mention target such as app://<connector-id> when type is mention."
"Path when type is local_image/skill, or structured mention target such as app://<connector-id> or plugin://<plugin-name>@<marketplace-name> when type is mention."
.to_string(),
),
},