diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index bef7830fa..871322869 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -55,6 +55,7 @@ pub use mcp_connection_manager::SandboxState; pub use text_encoding::bytes_to_string_smart; mod mcp_tool_call; mod memories; +pub mod mention_syntax; mod mentions; mod message_history; mod model_provider_info; diff --git a/codex-rs/core/src/mention_syntax.rs b/codex-rs/core/src/mention_syntax.rs new file mode 100644 index 000000000..e58f419b5 --- /dev/null +++ b/codex-rs/core/src/mention_syntax.rs @@ -0,0 +1,4 @@ +// Default plaintext sigil for tools. +pub const TOOL_MENTION_SIGIL: char = '$'; +// Plugins use `@` in linked plaintext outside TUI. +pub const PLUGIN_TEXT_MENTION_SIGIL: char = '@'; diff --git a/codex-rs/core/src/mentions.rs b/codex-rs/core/src/mentions.rs index 0c7a4fff0..ceaced7fa 100644 --- a/codex-rs/core/src/mentions.rs +++ b/codex-rs/core/src/mentions.rs @@ -5,11 +5,13 @@ use std::path::PathBuf; use codex_protocol::user_input::UserInput; use crate::connectors; +use crate::mention_syntax::PLUGIN_TEXT_MENTION_SIGIL; +use crate::mention_syntax::TOOL_MENTION_SIGIL; use crate::plugins::PluginCapabilitySummary; 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::extract_tool_mentions_with_sigil; use crate::skills::injection::plugin_config_name_from_path; use crate::skills::injection::tool_kind_for_path; @@ -19,10 +21,17 @@ pub(crate) struct CollectedToolMentions { } pub(crate) fn collect_tool_mentions_from_messages(messages: &[String]) -> CollectedToolMentions { + collect_tool_mentions_from_messages_with_sigil(messages, TOOL_MENTION_SIGIL) +} + +fn collect_tool_mentions_from_messages_with_sigil( + messages: &[String], + sigil: char, +) -> CollectedToolMentions { let mut plain_names = HashSet::new(); let mut paths = HashSet::new(); for message in messages { - let mentions = extract_tool_mentions(message); + let mentions = extract_tool_mentions_with_sigil(message, sigil); plain_names.extend(mentions.plain_names().map(str::to_string)); paths.extend(mentions.paths().map(str::to_string)); } @@ -50,7 +59,7 @@ pub(crate) fn collect_explicit_app_ids(input: &[UserInput]) -> HashSet { .collect() } -/// Collect explicit structured `plugin://...` mentions. +/// Collect explicit structured or linked `plugin://...` mentions. pub(crate) fn collect_explicit_plugin_mentions( input: &[UserInput], plugins: &[PluginCapabilitySummary], @@ -73,7 +82,11 @@ pub(crate) fn collect_explicit_plugin_mentions( UserInput::Mention { path, .. } => Some(path.clone()), _ => None, }) - .chain(collect_tool_mentions_from_messages(&messages).paths) + .chain( + // Plugin plaintext links use `@`, not the default `$` tool sigil. + collect_tool_mentions_from_messages_with_sigil(&messages, PLUGIN_TEXT_MENTION_SIGIL) + .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(); @@ -222,7 +235,7 @@ mod tests { ]; let mentioned = collect_explicit_plugin_mentions( - &[text_input("use [$sample](plugin://sample@test)")], + &[text_input("use [@sample](plugin://sample@test)")], &plugins, ); @@ -238,7 +251,7 @@ mod tests { let mentioned = collect_explicit_plugin_mentions( &[ - text_input("use [$sample](plugin://sample@test)"), + text_input("use [@sample](plugin://sample@test)"), UserInput::Mention { name: "sample".to_string(), path: "plugin://sample@test".to_string(), @@ -263,4 +276,16 @@ mod tests { assert_eq!(mentioned, Vec::::new()); } + + #[test] + fn collect_explicit_plugin_mentions_ignores_dollar_linked_plugin_mentions() { + let plugins = vec![plugin("sample@test", "sample")]; + + let mentioned = collect_explicit_plugin_mentions( + &[text_input("use [$sample](plugin://sample@test)")], + &plugins, + ); + + assert_eq!(mentioned, Vec::::new()); + } } diff --git a/codex-rs/core/src/skills/injection.rs b/codex-rs/core/src/skills/injection.rs index ac55f3b8f..d40e1bed0 100644 --- a/codex-rs/core/src/skills/injection.rs +++ b/codex-rs/core/src/skills/injection.rs @@ -7,6 +7,7 @@ use crate::analytics_client::InvocationType; use crate::analytics_client::SkillInvocation; use crate::analytics_client::TrackEventsContext; use crate::instructions::SkillInstructions; +use crate::mention_syntax::TOOL_MENTION_SIGIL; use crate::mentions::build_skill_name_counts; use crate::skills::SkillMetadata; use codex_otel::SessionTelemetry; @@ -232,10 +233,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, '$') + extract_tool_mentions_with_sigil(text, TOOL_MENTION_SIGIL) } -fn extract_tool_mentions_with_sigil(text: &str, sigil: char) -> ToolMentions<'_> { +pub(crate) 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(); diff --git a/codex-rs/tui/src/chatwidget/skills.rs b/codex-rs/tui/src/chatwidget/skills.rs index 32dcb1ae8..6228efe4e 100644 --- a/codex-rs/tui/src/chatwidget/skills.rs +++ b/codex-rs/tui/src/chatwidget/skills.rs @@ -14,6 +14,7 @@ use crate::skills_helpers::skill_description; use crate::skills_helpers::skill_display_name; use codex_chatgpt::connectors::AppInfo; use codex_core::connectors::connector_mention_slug; +use codex_core::mention_syntax::TOOL_MENTION_SIGIL; use codex_core::skills::model::SkillDependencies; use codex_core::skills::model::SkillInterface; use codex_core::skills::model::SkillMetadata; @@ -296,8 +297,6 @@ pub(crate) struct ToolMentions { linked_paths: HashMap, } -const TOOL_MENTION_SIGIL: char = '$'; - fn extract_tool_mentions_from_text(text: &str) -> ToolMentions { extract_tool_mentions_from_text_with_sigil(text, TOOL_MENTION_SIGIL) } diff --git a/codex-rs/tui/src/mention_codec.rs b/codex-rs/tui/src/mention_codec.rs index a517896a9..bd051475a 100644 --- a/codex-rs/tui/src/mention_codec.rs +++ b/codex-rs/tui/src/mention_codec.rs @@ -1,14 +1,15 @@ use std::collections::HashMap; use std::collections::VecDeque; +use codex_core::mention_syntax::PLUGIN_TEXT_MENTION_SIGIL; +use codex_core::mention_syntax::TOOL_MENTION_SIGIL; + #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct LinkedMention { pub(crate) mention: String, pub(crate) path: String, } -const TOOL_MENTION_SIGIL: char = '$'; - #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct DecodedHistoryText { pub(crate) text: String, @@ -77,10 +78,7 @@ pub(crate) fn decode_history_mentions(text: &str) -> DecodedHistoryText { while index < bytes.len() { if bytes[index] == b'[' - && let Some((name, path, end_index)) = - parse_linked_tool_mention(text, bytes, index, TOOL_MENTION_SIGIL) - && !is_common_env_var(name) - && is_tool_path(path) + && let Some((name, path, end_index)) = parse_history_linked_mention(text, bytes, index) { out.push(TOOL_MENTION_SIGIL); out.push_str(name); @@ -105,6 +103,31 @@ pub(crate) fn decode_history_mentions(text: &str) -> DecodedHistoryText { } } +fn parse_history_linked_mention<'a>( + text: &'a str, + text_bytes: &[u8], + start: usize, +) -> Option<(&'a str, &'a str, usize)> { + // TUI writes `$name`, but may read plugin `[@name](plugin://...)` links from other clients. + if let Some(mention @ (name, path, _)) = + parse_linked_tool_mention(text, text_bytes, start, TOOL_MENTION_SIGIL) + && !is_common_env_var(name) + && is_tool_path(path) + { + return Some(mention); + } + + if let Some(mention @ (name, path, _)) = + parse_linked_tool_mention(text, text_bytes, start, PLUGIN_TEXT_MENTION_SIGIL) + && !is_common_env_var(name) + && path.starts_with("plugin://") + { + return Some(mention); + } + + None +} + fn parse_linked_tool_mention<'a>( text: &'a str, text_bytes: &[u8], @@ -225,6 +248,35 @@ mod tests { ); } + #[test] + fn decode_history_mentions_restores_plugin_links_with_at_sigil() { + let decoded = decode_history_mentions( + "Use [@sample](plugin://sample@test) and [$figma](app://figma-1).", + ); + assert_eq!(decoded.text, "Use $sample and $figma."); + assert_eq!( + decoded.mentions, + vec![ + LinkedMention { + mention: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }, + LinkedMention { + mention: "figma".to_string(), + path: "app://figma-1".to_string(), + }, + ] + ); + } + + #[test] + fn decode_history_mentions_ignores_at_sigil_for_non_plugin_paths() { + let decoded = decode_history_mentions("Use [@figma](app://figma-1)."); + + assert_eq!(decoded.text, "Use [@figma](app://figma-1)."); + assert_eq!(decoded.mentions, Vec::::new()); + } + #[test] fn encode_history_mentions_links_bound_mentions_in_order() { let text = "$figma then $sample then $figma then $other";