From aef7d276a4518ad9cb2805a8ddc634eef6f3e7dc Mon Sep 17 00:00:00 2001 From: goodman-oai Date: Mon, 2 Nov 2026 13:54:50 +0000 Subject: [PATCH] Support model catalog overrides for plugin guidance (#58894) ## What changed - Add `model_messages.plugins` overrides for generic usage guidance, selected-plugin hints, and recommendation introductions, with independent fallback to bundled wording for missing or invalid fields. - Validate text limits and required template placeholders while keeping plugin identities, available capabilities, and eligibility controlled by the client. - Track generic guidance changes across model switches and session resume, emitting replacement or retirement notices and supporting legacy snapshots and retained history. ## Testing Add unit and scenario coverage for sparse overrides, literal text preservation, template validation, UTF-8 size bounds, capability filtering, recommendation fallback, and guidance recovery after resume. GitOrigin-RevId: 148b658c2d8490c9d0dc208addbee1be5342a5f5 --- .../app-server/tests/common/models_cache.rs | 1 + .../context/available_plugins_instructions.rs | 36 +- .../recommended_plugins_instructions.rs | 20 +- .../recommended_plugins_instructions_tests.rs | 48 ++ codex-rs/core/src/context/world_state/mod.rs | 45 +- .../world_state/plugins_instructions.rs | 138 ++++- .../world_state/plugins_instructions_tests.rs | 535 ++++++++++++++++- ...lugins_instructions__tests__snapshots.snap | 10 +- codex-rs/core/src/plugins/injection.rs | 3 + codex-rs/core/src/plugins/render.rs | 83 +-- codex-rs/core/src/plugins/render_tests.rs | 59 +- codex-rs/core/src/session/mod.rs | 8 +- codex-rs/core/src/session/turn.rs | 1 + codex-rs/core/src/session/world_state.rs | 12 +- .../suite/catalog_permission_messages.rs | 1 + .../tests/suite/collaboration_instructions.rs | 1 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../tests/suite/request_plugin_install.rs | 137 +++++ codex-rs/core/tests/suite/scenarios.rs | 544 ++++++++++++++++++ ...catalog_plugin_recommendations_absent.snap | 53 ++ ..._catalog_plugin_recommendations_blank.snap | 53 ++ ...catalog_plugin_recommendations_custom.snap | 54 ++ ...talog_plugin_recommendations_disabled.snap | 48 ++ ..._catalog_plugin_recommendations_empty.snap | 53 ++ ...__catalog_plugin_recommendations_null.snap | 53 ++ ...alog_plugin_recommendations_oversized.snap | 53 ++ ...nce_follows_model_and_survives_resume.snap | 229 ++++++++ ..._omission_bounds_runtime_capabilities.snap | 92 +++ ...n_wording_apps_false_simplified_false.snap | 167 ++++++ ...in_wording_apps_true_simplified_false.snap | 191 ++++++ ...gin_wording_apps_true_simplified_true.snap | 176 ++++++ .../models-manager/src/model_info_tests.rs | 1 + codex-rs/prompts/src/lib.rs | 3 + codex-rs/prompts/src/model_messages.rs | 8 + .../prompts/src/model_messages/plugins.rs | 192 +++++++ .../src/model_messages/plugins_tests.rs | 215 +++++++ codex-rs/protocol/src/openai_models.rs | 9 + .../protocol/src/openai_models/plugins.rs | 66 +++ .../src/openai_models/plugins_tests.rs | 34 ++ 39 files changed, 3326 insertions(+), 107 deletions(-) create mode 100644 codex-rs/core/src/context/recommended_plugins_instructions_tests.rs create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__request_plugin_install__catalog_plugin_recommendations_absent.snap create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__request_plugin_install__catalog_plugin_recommendations_blank.snap create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__request_plugin_install__catalog_plugin_recommendations_custom.snap create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__request_plugin_install__catalog_plugin_recommendations_disabled.snap create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__request_plugin_install__catalog_plugin_recommendations_empty.snap create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__request_plugin_install__catalog_plugin_recommendations_null.snap create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__request_plugin_install__catalog_plugin_recommendations_oversized.snap create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__scenarios__catalog_plugin_guidance_follows_model_and_survives_resume.snap create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__scenarios__catalog_selected_plugin_omission_bounds_runtime_capabilities.snap create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__scenarios__catalog_selected_plugin_wording_apps_false_simplified_false.snap create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__scenarios__catalog_selected_plugin_wording_apps_true_simplified_false.snap create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__scenarios__catalog_selected_plugin_wording_apps_true_simplified_true.snap create mode 100644 codex-rs/prompts/src/model_messages/plugins.rs create mode 100644 codex-rs/prompts/src/model_messages/plugins_tests.rs create mode 100644 codex-rs/protocol/src/openai_models/plugins.rs create mode 100644 codex-rs/protocol/src/openai_models/plugins_tests.rs diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index 582692b76c..411ebabc4b 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -32,6 +32,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { upgrade: preset.upgrade.as_ref().map(Into::into), model_messages: Some(ModelMessages { apps: None, + plugins: None, content_filter_guidance: None, persistent_instructions: None, tools: None, diff --git a/codex-rs/core/src/context/available_plugins_instructions.rs b/codex-rs/core/src/context/available_plugins_instructions.rs index 29f09113b2..7e10b12d28 100644 --- a/codex-rs/core/src/context/available_plugins_instructions.rs +++ b/codex-rs/core/src/context/available_plugins_instructions.rs @@ -7,7 +7,23 @@ use codex_protocol::models::ContentItemKind; use codex_protocol::models::input_text; #[derive(Debug, Clone, PartialEq)] -pub(crate) struct AvailablePluginsInstructions; +pub(crate) struct AvailablePluginsInstructions(pub(crate) String); + +impl From for AvailablePluginsInstructions { + fn from(messages: codex_prompts::ResolvedGenericPluginMessages) -> Self { + let intro = messages.intro; + let usage = messages.usage_instructions; + Self(format!( + "\n## Plugins\n{intro}\n### How to use plugins\n{usage}\n" + )) + } +} + +impl Default for AvailablePluginsInstructions { + fn default() -> Self { + Self::from(codex_prompts::ResolvedModelMessages::bundled().generic_plugin()) + } +} impl ContextualUserFragment for AvailablePluginsInstructions { fn content_kind(&self) -> ContentItemKind { @@ -30,22 +46,6 @@ impl ContextualUserFragment for AvailablePluginsInstructions { } fn body(&self) -> ContentItem { - let mut lines = vec![ - "## Plugins".to_string(), - "A plugin is a local bundle of skills, MCP servers, and apps.".to_string(), - ]; - - lines.push("### How to use plugins".to_string()); - lines.push( - r###"- Skill naming: If a plugin contributes skills, those skill entries are prefixed with `plugin_name:` in the Skills list. -- MCP naming: Plugin-provided MCP tools keep standard MCP identifiers such as `mcp__server__tool`; use tool provenance to tell which plugin they come from. -- Trigger rules: If the user explicitly names a plugin, prefer capabilities associated with that plugin for that turn. -- Relationship to capabilities: Plugins are not invoked directly. Use their underlying skills, MCP tools, and app tools to help solve the task. -- Relevance: Determine what a plugin can help with from explicit user mention or from the plugin-associated skills, MCP tools, and apps exposed elsewhere in this turn. -- Missing/blocked: If the user requests a plugin that does not have relevant callable capabilities for the task, say so briefly and continue with the best fallback."### - .to_string(), - ); - - input_text(format!("\n{}\n", lines.join("\n"))) + input_text(self.0.clone()) } } diff --git a/codex-rs/core/src/context/recommended_plugins_instructions.rs b/codex-rs/core/src/context/recommended_plugins_instructions.rs index 05ba981ec2..8a259f9234 100644 --- a/codex-rs/core/src/context/recommended_plugins_instructions.rs +++ b/codex-rs/core/src/context/recommended_plugins_instructions.rs @@ -4,21 +4,28 @@ use codex_protocol::models::ContentItemKind; use codex_protocol::models::input_text; use codex_tools::DiscoverableTool; -const RECOMMENDED_PLUGINS_INTRO: &str = - "Here is a list of plugins that are available but not installed."; +// Endpoint candidates cap display names at 64 Unicode characters and slugs at 64 ASCII +// characters. With the curated marketplace suffix, 50 rows, a 500-byte introduction and +// fragment markers, the final item is at most 17,946 UTF-8 bytes. Revisit this bound if +// upstream identity limits or the marketplace suffix change; never truncate these facts. const MAX_RECOMMENDED_PLUGINS: usize = 50; #[derive(Debug, Clone, PartialEq)] pub(crate) struct RecommendedPluginsInstructions { plugins: Vec, + intro: String, } impl RecommendedPluginsInstructions { - pub(crate) fn from_plugins(plugins: &[DiscoverableTool]) -> Option { + pub(crate) fn from_plugins( + plugins: &[DiscoverableTool], + messages: &codex_prompts::ResolvedPluginRecommendationMessages, + ) -> Option { if plugins.is_empty() { return None; } Some(Self { + intro: messages.intro.clone(), plugins: plugins .iter() .take(MAX_RECOMMENDED_PLUGINS) @@ -52,6 +59,11 @@ impl ContextualUserFragment for RecommendedPluginsInstructions { .map(|plugin| format!("- {} ({})", plugin.name(), plugin.id())) .collect::>() .join("\n"); - input_text(format!("\n{RECOMMENDED_PLUGINS_INTRO}\n\n{plugins}\n")) + let intro = &self.intro; + input_text(format!("\n{intro}\n\n{plugins}\n")) } } + +#[cfg(test)] +#[path = "recommended_plugins_instructions_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/context/recommended_plugins_instructions_tests.rs b/codex-rs/core/src/context/recommended_plugins_instructions_tests.rs new file mode 100644 index 0000000000..d8956be334 --- /dev/null +++ b/codex-rs/core/src/context/recommended_plugins_instructions_tests.rs @@ -0,0 +1,48 @@ +#![cfg(test)] + +//! Checks final recommendation bounds without truncating runtime identities. + +use super::*; +use codex_protocol_test_support::ContentItemTestExt; +use codex_tools::DiscoverablePluginInfo; +use pretty_assertions::assert_eq; + +#[test] +fn recommendation_wording_keeps_runtime_identities_within_the_derived_final_bound() { + // Endpoint recommendations validate names at 64 chars. Display names permit four-byte + // characters; config IDs use the ASCII slug and the curated marketplace suffix. + let name = "🦀".repeat(64); + let id = format!("{}@openai-curated-remote", "a".repeat(64)); + let plugins = vec![ + DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo { + id: id.clone(), + remote_plugin_id: None, + name: name.clone(), + description: None, + has_skills: true, + mcp_server_names: vec![], + app_connector_ids: vec![], + })); + 51 + ]; + let messages = codex_prompts::ResolvedPluginRecommendationMessages { + intro: "é".repeat(250), + }; + let fragment = RecommendedPluginsInstructions::from_plugins(&plugins, &messages).unwrap(); + let rendered = fragment.render().expect_text(); + let rows = vec![format!("- {name} ({id})"); 50].join("\n"); + assert_eq!( + rendered, + format!( + "\n{}\n\n{rows}\n", + messages.intro + ) + ); + assert_eq!(rendered.len(), 17_946); + assert_eq!(fragment.role(), "developer"); + assert_eq!( + fragment.content_kind(), + ContentItemKind("plugins.recommendations".into()) + ); + assert!(RecommendedPluginsInstructions::from_plugins(&[], &messages).is_none()); +} diff --git a/codex-rs/core/src/context/world_state/mod.rs b/codex-rs/core/src/context/world_state/mod.rs index 49c6e9ed50..a444bc981f 100644 --- a/codex-rs/core/src/context/world_state/mod.rs +++ b/codex-rs/core/src/context/world_state/mod.rs @@ -426,6 +426,34 @@ impl WorldStateSnapshot { self.sections.into_iter().collect() } + fn section(&self, id: &str) -> Option<&Value> { + let snapshot = self.sections.get(id)?; + if id == PluginsInstructionsState::ID + && snapshot == &Value::Bool(false) + && let Some(retired) = self + .sections + .get(plugins_instructions::RETIRED_CHECKPOINT_ID) + && plugins_instructions::is_retired_checkpoint(retired) + { + return Some(retired); + } + Some(snapshot) + } + + fn insert_section(&mut self, id: &str, mut snapshot: Value) { + if id == PluginsInstructionsState::ID + && plugins_instructions::is_retired_checkpoint(&snapshot) + { + // Keep the original section readable by clients whose snapshot type is bool. + self.sections.insert( + plugins_instructions::RETIRED_CHECKPOINT_ID.to_string(), + snapshot, + ); + snapshot = Value::Bool(false); + } + self.sections.insert(id.to_string(), snapshot); + } + /// Returns the RFC 7386 merge patch that advances `previous` to `self`. pub(crate) fn merge_patch_from(&self, previous: &Self) -> Option> { let mut patch = Map::new(); @@ -475,7 +503,7 @@ impl WorldState { pub(crate) fn add_section(&mut self, section: S) { let id = S::ID; assert!( - !self.sections.contains_key(id), + id != plugins_instructions::RETIRED_CHECKPOINT_ID && !self.sections.contains_key(id), "duplicate world-state section ID: {id}" ); self.sections.insert(id, Box::new(section)); @@ -484,7 +512,7 @@ impl WorldState { pub(crate) fn add_extension_section(&mut self, section: WorldStateSectionContribution) { let id = section.id(); assert!( - !self.sections.contains_key(id), + id != plugins_instructions::RETIRED_CHECKPOINT_ID && !self.sections.contains_key(id), "duplicate world-state section ID: {id}" ); let section = Box::new(ExtensionWorldStateSection(section)); @@ -509,7 +537,7 @@ impl WorldState { items: impl IntoIterator + Clone, ) -> (WorldStateSnapshot, Vec) { self.render_with(previous, |id, section| { - if let Some(previous) = previous.and_then(|previous| previous.sections.get(id)) { + if let Some(previous) = previous.and_then(|previous| previous.section(id)) { if section.has_retained_fragment_matcher() && !has_retained_fragment(items.clone(), section) { @@ -536,17 +564,12 @@ impl WorldState { let prior = previous(id, section.as_ref()); let (section_snapshot, section_updates) = section.render_diff(prior); // A skipped snapshot retains stored state even if history required a fresh render. - let section_snapshot = section_snapshot.or_else(|| { - stored - .and_then(|snapshot| snapshot.sections.get(*id)) - .cloned() - }); + let section_snapshot = section_snapshot + .or_else(|| stored.and_then(|snapshot| snapshot.section(id)).cloned()); if let Some(section_snapshot) = section_snapshot && !section_snapshot.is_null() { - snapshot - .sections - .insert((*id).to_string(), section_snapshot); + snapshot.insert_section(id, section_snapshot); } updates.extend(section_updates); } diff --git a/codex-rs/core/src/context/world_state/plugins_instructions.rs b/codex-rs/core/src/context/world_state/plugins_instructions.rs index 1de7ceee15..84dc85b311 100644 --- a/codex-rs/core/src/context/world_state/plugins_instructions.rs +++ b/codex-rs/core/src/context/world_state/plugins_instructions.rs @@ -1,25 +1,82 @@ +//! Tracks literal generic plugin wording across catalog changes, legacy snapshots, and retained history. +//! Bundled-only availability changes retain their previous silent-removal behavior. + use super::PreviousSectionState; use super::SectionTransition; +use super::WorldStateHash; use super::WorldStateSection; use super::WorldStateUpdate; use crate::context::AvailablePluginsInstructions; use crate::context::ContextualUserFragment; +use codex_prompts::ResolvedGenericPluginMessages; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value; -/// Whether generic plugin usage guidance should be visible to the model. -#[derive(Clone, Copy, Debug, Default)] +const REPLACEMENT_PREFIX: &str = "\nThese plugin usage instructions replace all previously provided plugin usage instructions.\n\n"; +const REMOVAL_BODY: &str = "\nThe previously provided plugin usage instructions no longer apply.\n"; + +// Only a catalog retirement needs this extra checkpoint to distinguish it from the +// legacy behavior of silently disabling bundled guidance. Older clients read the primary bool. +pub(super) const RETIRED_CHECKPOINT_ID: &str = "plugins_instructions_catalog_retired"; + +pub(super) fn is_retired_checkpoint(snapshot: &Value) -> bool { + snapshot.get("instructions").is_none() && snapshot.get("catalog") == Some(&Value::Bool(true)) +} + +#[derive(Clone, Debug)] pub(crate) struct PluginsInstructionsState { available: bool, + resolved: ResolvedGenericPluginMessages, + retained_body: Option, +} + +/// Older clients read only availability. Keep writing false when guidance is disabled. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub(crate) enum PluginsInstructionsSnapshot { + Current { + instructions: Option, + catalog: bool, + }, + Legacy(bool), } impl PluginsInstructionsState { - pub(crate) fn new(available: bool) -> Self { - Self { available } + pub(crate) fn new<'a>( + available: bool, + resolved: ResolvedGenericPluginMessages, + history: impl DoubleEndedIterator, + ) -> Self { + let (open, close) = AvailablePluginsInstructions::type_markers(); + let retained_body = history.rev().find_map(|item| match item { + ResponseItem::Message { role, content, .. } if role == "developer" => { + content.iter().rev().find_map(|content| match content { + ContentItem::InputText { text } + if AvailablePluginsInstructions::matches_text(text) => + { + let text = text.trim(); + text.get(open.len()..text.len() - close.len()) + .map(str::to_owned) + } + _ => None, + }) + } + _ => None, + }); + Self { + available, + resolved, + retained_body, + } } } impl WorldStateSection for PluginsInstructionsState { const ID: &'static str = "plugins_instructions"; - type Snapshot = bool; + type Snapshot = PluginsInstructionsSnapshot; fn matches_legacy_fragment(role: &str, text: &str) -> bool { role == "developer" && AvailablePluginsInstructions::matches_text(text) @@ -37,17 +94,76 @@ impl WorldStateSection for PluginsInstructionsState { &self, previous: PreviousSectionState<'_, Self::Snapshot>, ) -> SectionTransition { - let current = self.available; - if !self.available - || matches!(previous, PreviousSectionState::Known(previous) if *previous) - || matches!(previous, PreviousSectionState::Unknown) + use PluginsInstructionsSnapshot::Current; + use PluginsInstructionsSnapshot::Legacy; + + // JSON hashing preserves CRLF and all other literal bytes, unlike fragment hashing. + let fingerprint = + |text: &str| WorldStateHash::from_json(&serde_json::Value::String(text.to_string())); + let rendered = self + .available + .then(|| AvailablePluginsInstructions::from(self.resolved.clone()).0); + let instructions = rendered.as_deref(); + let current_hash = instructions.map(fingerprint); + // Catalog selection can retire retained guidance even while availability is gated off. + let catalog = self.resolved.catalog_override; + let bundled = AvailablePluginsInstructions::default().0; + let (mut previous_hash, mut previous_catalog) = match previous { + PreviousSectionState::Absent | PreviousSectionState::Known(Legacy(false)) => { + (None, false) + } + PreviousSectionState::Unknown | PreviousSectionState::Known(Legacy(true)) => { + (Some(fingerprint(&bundled)), false) + } + PreviousSectionState::Known(Current { + instructions, + catalog, + }) => (instructions.clone(), *catalog), + }; + if !matches!(previous, PreviousSectionState::Absent) + && let Some(body) = self.retained_body.as_deref() { + // Catalog fields occur inside the "## Plugins" body, after any transition framing. + let retained = (body != REMOVAL_BODY) + .then(|| body.strip_prefix(REPLACEMENT_PREFIX).unwrap_or(body)); + let retained_catalog = retained != Some(bundled.as_str()); + // A legacy false snapshot can coexist with retained catalog guidance, while a + // bundled-only disablement deliberately leaves the old bundled text in history. + if previous_hash.is_some() || previous_catalog || catalog || retained_catalog { + previous_hash = retained.map(fingerprint); + previous_catalog |= retained_catalog; + } + } + let current = if self.available { + Current { + instructions: current_hash.clone(), + catalog, + } + } else if previous_catalog || (catalog && previous_hash.is_some()) { + Current { + instructions: None, + catalog: true, + } + } else { + Legacy(false) + }; + if previous_hash == current_hash { return (Some(current), Vec::new()); } - + let body = match instructions { + Some(text) if previous_hash.is_some() => format!("{REPLACEMENT_PREFIX}{text}"), + Some(text) => text.to_string(), + None if previous_hash.is_some() && (previous_catalog || catalog) => { + REMOVAL_BODY.to_string() + } + None => return (Some(current), Vec::new()), + }; + // The 500/2,000-byte field allowances leave room for all framing below 4 KiB. ( Some(current), - vec![WorldStateUpdate::fragment(AvailablePluginsInstructions)], + vec![WorldStateUpdate::fragment(AvailablePluginsInstructions( + body, + ))], ) } } diff --git a/codex-rs/core/src/context/world_state/plugins_instructions_tests.rs b/codex-rs/core/src/context/world_state/plugins_instructions_tests.rs index eef760767e..a353fe726c 100644 --- a/codex-rs/core/src/context/world_state/plugins_instructions_tests.rs +++ b/codex-rs/core/src/context/world_state/plugins_instructions_tests.rs @@ -1,9 +1,34 @@ +//! Covers generic plugin wording transitions and recovery from retained developer history. +#![cfg(test)] + use super::*; use crate::context::ContextualUserFragment; use crate::context::world_state::PreviousSectionState; +use crate::context::world_state::WorldState; +use crate::context::world_state::WorldStateSnapshot; use crate::context::world_state::test_support::render_section_cases; +use codex_prompts::ResolvedGenericPluginMessages; +use codex_prompts::ResolvedModelMessages; +use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use pretty_assertions::assert_eq; +use serde_json::json; + +fn resolve(intro: Option<&str>, usage: Option<&str>) -> ResolvedGenericPluginMessages { + let mut model = codex_models_manager::model_info::model_info_from_slug("gpt-5.5"); + model.model_messages = Some( + serde_json::from_value(json!({"plugins": {"generic": { + "intro": intro, + "usage_instructions": usage, + }}})) + .unwrap(), + ); + ResolvedModelMessages::from_model(&model).generic_plugin() +} + +fn expected_body(intro: &str, usage: &str) -> String { + format!("\n## Plugins\n{intro}\n### How to use plugins\n{usage}\n") +} #[test] fn snapshots() { @@ -11,8 +36,14 @@ fn snapshots() { use PreviousSectionState::Known; use PreviousSectionState::Unknown; - let unavailable = PluginsInstructionsState::new(/*available*/ false); - let available = PluginsInstructionsState::new(/*available*/ true); + let bundled = ResolvedModelMessages::bundled().generic_plugin(); + let unavailable = PluginsInstructionsState::new( + /*available*/ false, + bundled.clone(), + std::iter::empty(), + ); + let available = + PluginsInstructionsState::new(/*available*/ true, bundled, std::iter::empty()); insta::assert_snapshot!(render_section_cases(&[ (Absent, Absent), @@ -28,9 +59,14 @@ fn snapshots() { #[test] fn legacy_guidance_is_not_injected_again() { - let mut world_state = super::super::WorldState::default(); - world_state.add_section(PluginsInstructionsState::new(/*available*/ true)); - let legacy: ResponseItem = ContextualUserFragment::into(AvailablePluginsInstructions); + let legacy: ResponseItem = + ContextualUserFragment::into(AvailablePluginsInstructions::default()); + let mut world_state = WorldState::default(); + world_state.add_section(PluginsInstructionsState::new( + /*available*/ true, + ResolvedModelMessages::bundled().generic_plugin(), + std::iter::once(&legacy), + )); assert!( world_state @@ -42,22 +78,487 @@ fn legacy_guidance_is_not_injected_again() { #[test] fn persisted_guidance_is_restored_only_when_missing_from_history() { - let mut world_state = super::super::WorldState::default(); - world_state.add_section(PluginsInstructionsState::new(/*available*/ true)); - let snapshot = world_state.render_full().0; - let retained: ResponseItem = ContextualUserFragment::into(AvailablePluginsInstructions); + let retained: ResponseItem = + ContextualUserFragment::into(AvailablePluginsInstructions::default()); + for history in [vec![], vec![retained]] { + let mut world_state = WorldState::default(); + world_state.add_section(PluginsInstructionsState::new( + /*available*/ true, + ResolvedModelMessages::bundled().generic_plugin(), + history.iter(), + )); + let snapshot = world_state.render_full().0; + assert_eq!( + world_state + .render_history_fragment_diff(Some(&snapshot), &history) + .1 + .len(), + usize::from(history.is_empty()) + ); + } +} +#[test] +fn catalog_text_equal_to_bundled_changes_provenance_without_appending_guidance() { + let bundled = ResolvedModelMessages::bundled().generic_plugin(); + let mut world_state = WorldState::default(); + world_state.add_section(PluginsInstructionsState::new( + /*available*/ true, + bundled.clone(), + std::iter::empty(), + )); + let (mut previous, initial) = + world_state.render_history_fragment_diff(/*previous*/ None, &[]); + let history = initial + .into_iter() + .map(ContextualUserFragment::into_boxed_response_item) + .collect::>(); assert_eq!( - world_state - .render_history_fragment_diff(Some(&snapshot), &[]) + history, + vec![ContextualUserFragment::into( + AvailablePluginsInstructions::default() + )] + ); + let fingerprint = + serde_json::to_value(&previous).unwrap()["plugins_instructions"]["instructions"].clone(); + assert!(!fingerprint.is_null()); + for (resolved, expected_catalog) in [ + ( + resolve(Some(&bundled.intro), Some(&bundled.usage_instructions)), + true, + ), + ( + resolve(Some(&bundled.intro), Some(&bundled.usage_instructions)), + true, + ), + (resolve(/*intro*/ None, /*usage*/ None), false), + ] { + assert_eq!(resolved.catalog_override, expected_catalog); + let mut current = WorldState::default(); + current.add_section(PluginsInstructionsState::new( + /*available*/ true, + resolved, + history.iter(), + )); + let (snapshot, updates) = current.render_history_fragment_diff(Some(&previous), &history); + assert_eq!( + updates + .into_iter() + .map(ContextualUserFragment::into_boxed_response_item) + .collect::>(), + Vec::::new() + ); + assert_eq!( + serde_json::to_value(&snapshot).unwrap(), + json!({"plugins_instructions": {"instructions": fingerprint, "catalog": expected_catalog}}) + ); + previous = snapshot; + } +} + +#[test] +fn catalog_guidance_changes_falls_back_and_retires_without_rewriting_history() { + let bundled = AvailablePluginsInstructions::default().0; + let body_a = expected_body("Intro A", "Usage A"); + let body_b = expected_body("Intro B", "Usage B"); + let replacement_a = format!("{REPLACEMENT_PREFIX}{body_a}"); + let replacement_b = format!("{REPLACEMENT_PREFIX}{body_b}"); + let fallback = format!("{REPLACEMENT_PREFIX}{bundled}"); + let oversized_intro = "x".repeat(501); + let oversized_usage = "y".repeat(2_001); + let mut history = Vec::new(); + let mut previous = None; + for (available, intro, usage, expected) in [ + ( + true, + Some("Intro A"), + Some("Usage A"), + Some(body_a.as_str()), + ), + (true, Some("Intro A"), Some("Usage A"), None), + ( + true, + Some("Intro B"), + Some("Usage B"), + Some(replacement_b.as_str()), + ), + (true, Some(""), Some(" \n"), Some(fallback.as_str())), + ( + true, + Some(oversized_intro.as_str()), + Some(oversized_usage.as_str()), + None, + ), + (true, None, None, None), + // Bundled-only availability changes retain the legacy quiet removal. + (false, None, None, None), + (true, None, None, Some(bundled.as_str())), + // Valid catalog intent still retires bundled text when availability is off. + (false, Some("Intro B"), Some("Usage B"), Some(REMOVAL_BODY)), + (false, Some("Intro B"), Some("Usage B"), None), + ( + true, + Some("Intro B"), + Some("Usage B"), + Some(body_b.as_str()), + ), + (false, Some("Intro B"), Some("Usage B"), Some(REMOVAL_BODY)), + // Empty plugin fields return to the bundled body, rather than clearing it. + (true, Some(""), Some(""), Some(bundled.as_str())), + ( + true, + Some("Intro A"), + Some("Usage A"), + Some(replacement_a.as_str()), + ), + ] { + let mut world_state = WorldState::default(); + world_state.add_section(PluginsInstructionsState::new( + available, + resolve(intro, usage), + history.iter(), + )); + let (snapshot, fragments) = + world_state.render_history_fragment_diff(previous.as_ref(), &history); + let updates = fragments + .into_iter() + .map(ContextualUserFragment::into_boxed_response_item) + .collect::>(); + assert_eq!( + updates, + expected + .map( + |body| ContextualUserFragment::into(AvailablePluginsInstructions( + body.to_string() + )) + ) + .into_iter() + .collect::>() + ); + if !available { + // A previous client's boolean reader must restore bundled guidance once plugins + // become available again, including after a catalog retirement notice. + let mut replayed = previous.clone().unwrap_or_default(); + if let Some(patch) = snapshot.merge_patch_from(&replayed) { + replayed.apply_merge_patch(&patch); + } + let availability: bool = serde_json::from_value( + replayed.into_object()[PluginsInstructionsState::ID].clone(), + ) + .expect("a previous client can read the persisted plugin availability"); + assert!(!availability); + } + history.extend(updates); + previous = Some(snapshot); + } +} + +#[test] +fn a_lost_catalog_retirement_is_repaired_after_switching_to_a_bundled_model() { + fn render( + available: bool, + intro: Option<&str>, + history: &[ResponseItem], + previous: &WorldStateSnapshot, + ) -> (WorldStateSnapshot, Vec) { + let mut world = WorldState::default(); + world.add_section(PluginsInstructionsState::new( + available, + resolve(intro, /*usage*/ None), + history.iter(), + )); + let (snapshot, updates) = world.render_history_fragment_diff(Some(previous), history); + ( + snapshot, + updates + .into_iter() + .map(ContextualUserFragment::into_boxed_response_item) + .collect(), + ) + } + + let bundled = ContextualUserFragment::into(AvailablePluginsInstructions::default()); + let stale_history = std::slice::from_ref(&bundled); + let previous: WorldStateSnapshot = + serde_json::from_value(json!({"plugins_instructions": true})).unwrap(); + let (retired, updates) = render( + /*available*/ false, + Some("Catalog intro"), + stale_history, + &previous, + ); + let notice = + ContextualUserFragment::into(AvailablePluginsInstructions(REMOVAL_BODY.to_string())); + assert_eq!(updates.as_slice(), std::slice::from_ref(¬ice)); + + // A boolean reader can still restore its bundled guidance, and a reader with the + // original object snapshot can repair the same lost retirement. + let mut saved = previous; + saved.apply_merge_patch(&retired.merge_patch_from(&saved).unwrap()); + assert_eq!( + serde_json::to_value(&saved).unwrap(), + json!({ + "plugins_instructions": false, + "plugins_instructions_catalog_retired": {"catalog": true} + }) + ); + let previous_format: WorldStateSnapshot = + serde_json::from_value(json!({"plugins_instructions": {"catalog": true}})).unwrap(); + for checkpoint in [&saved, &previous_format] { + let (repaired, updates) = render( + /*available*/ false, + /*intro*/ None, + stale_history, + checkpoint, + ); + assert_eq!(repaired, retired); + assert_eq!(updates.as_slice(), std::slice::from_ref(¬ice)); + } + + let repaired_history = [bundled.clone(), notice]; + let (restored, updates) = render( + /*available*/ true, + /*intro*/ None, + &repaired_history, + &saved, + ); + assert_eq!(updates, [bundled]); + saved.apply_merge_patch(&restored.merge_patch_from(&saved).unwrap()); + assert!(!saved.into_object().contains_key(RETIRED_CHECKPOINT_ID)); +} + +#[test] +fn legacy_false_snapshots_do_not_hide_retained_catalog_guidance() { + let bundled = AvailablePluginsInstructions::default().0; + let custom = expected_body("Catalog intro", "Catalog usage"); + let replaced_custom = format!("{REPLACEMENT_PREFIX}{custom}"); + let replaced_bundled = format!("{REPLACEMENT_PREFIX}{bundled}"); + let snapshot: WorldStateSnapshot = + serde_json::from_value(json!({"plugins_instructions": false})).unwrap(); + for (retained_body, available, intro, usage, expected) in [ + ( + custom.as_str(), + true, + Some("Catalog intro"), + Some("Catalog usage"), + None, + ), + ( + replaced_custom.as_str(), + true, + Some("Catalog intro"), + Some("Catalog usage"), + None, + ), + ( + custom.as_str(), + true, + None, + None, + Some(replaced_bundled.as_str()), + ), + ( + replaced_custom.as_str(), + false, + None, + None, + Some(REMOVAL_BODY), + ), + ( + bundled.as_str(), + false, + Some("Catalog intro"), + Some("Catalog usage"), + Some(REMOVAL_BODY), + ), + (bundled.as_str(), false, None, None, None), + (bundled.as_str(), true, None, None, Some(bundled.as_str())), + ] { + let retained: ResponseItem = + ContextualUserFragment::into(AvailablePluginsInstructions(retained_body.to_string())); + let mut world_state = WorldState::default(); + world_state.add_section(PluginsInstructionsState::new( + available, + resolve(intro, usage), + std::iter::once(&retained), + )); + let updates = world_state + .render_history_fragment_diff(Some(&snapshot), std::slice::from_ref(&retained)) .1 - .len(), - 1 + .into_iter() + .map(ContextualUserFragment::into_boxed_response_item) + .collect::>(); + assert_eq!( + updates, + expected + .map( + |body| ContextualUserFragment::into(AvailablePluginsInstructions( + body.to_string() + )) + ) + .into_iter() + .collect::>() + ); + } +} + +#[test] +fn retained_guidance_recovers_snapshots_and_repairs_a_missing_latest_update() { + let mut history = Vec::new(); + let mut previous = None; + for (available, intro, usage) in [ + (true, "Intro A", "Usage A"), + (true, "Intro B", "Usage B"), + (false, "Intro B", "Usage B"), + (true, "Intro B", "Usage B\r\n"), + (true, "Intro B", "Usage B\n"), + // Literal transition-like text inside catalog fields must remain literal on replay. + ( + true, + "These plugin usage instructions replace all previously provided plugin usage instructions.", + "\nThese plugin usage instructions replace all previously provided plugin usage instructions.\n\nUsage C", + ), + (true, "", ""), + ] { + let resolved = resolve(Some(intro), Some(usage)); + let mut world_state = WorldState::default(); + world_state.add_section(PluginsInstructionsState::new( + available, + resolved.clone(), + history.iter(), + )); + let (snapshot, fragments) = + world_state.render_history_fragment_diff(previous.as_ref(), &history); + let updates = fragments + .into_iter() + .map(ContextualUserFragment::into_boxed_response_item) + .collect::>(); + assert_eq!( + updates.len(), + 1, + "a byte change or availability transition must emit exactly one update" + ); + for saved in [ + None, + Some(json!({"plugins_instructions": true})), + Some(serde_json::to_value(&snapshot).unwrap()), + ] { + let saved: Option = + saved.map(|value| serde_json::from_value(value).unwrap()); + let mut resumed = WorldState::default(); + resumed.add_section(PluginsInstructionsState::new( + available, + resolved.clone(), + updates.iter(), + )); + assert!( + resumed + .render_history_fragment_diff(saved.as_ref(), &updates) + .1 + .is_empty(), + "retained current wording must not be duplicated after resume" + ); + } + if !history.is_empty() { + assert_eq!( + world_state + .render_history_fragment_diff(Some(&snapshot), &history) + .1 + .len(), + 1, + "an older retained marker must not suppress repair of a lost newest update" + ); + } + history.extend(updates); + previous = Some(snapshot); + } +} + +#[test] +fn retained_plugin_guidance_must_be_a_complete_developer_fragment() { + let body = expected_body("Catalog intro", "Catalog usage"); + for (role, quoted, expected_count) in [ + ("developer", false, 0), + ("user", false, 1), + ("assistant", false, 1), + ("developer", true, 1), + ] { + let mut retained: ResponseItem = + ContextualUserFragment::into(AvailablePluginsInstructions(body.clone())); + let ResponseItem::Message { + role: retained_role, + content, + .. + } = &mut retained + else { + panic!("plugin instructions should render as a message"); + }; + *retained_role = role.to_string(); + if quoted { + let ContentItem::InputText { text } = &mut content[0] else { + panic!("plugin instructions should render as input text"); + }; + *text = format!("Quoted example: {text}"); + } + let mut world_state = WorldState::default(); + world_state.add_section(PluginsInstructionsState::new( + /*available*/ true, + resolve(Some("Catalog intro"), Some("Catalog usage")), + std::iter::once(&retained), + )); + let snapshot = world_state.render_full().0; + let updates = world_state + .render_history_fragment_diff(Some(&snapshot), std::slice::from_ref(&retained)) + .1; + assert_eq!( + updates.len(), + expected_count, + "role={role}, quoted={quoted}" + ); + for update in updates { + assert_eq!( + (update.role(), update.content_kind().0), + ("developer", "plugins.usage_instructions".to_string()) + ); + assert_eq!( + update.into_boxed_response_item(), + ContextualUserFragment::into(AvailablePluginsInstructions(body.clone())) + ); + } + } +} + +#[test] +fn catalog_fields_at_utf8_limits_fit_final_framing_without_truncation() { + let intro = "é".repeat(250); + let usage = "\u{10ffff}".repeat(500); + let body = expected_body(&intro, &usage); + let retained: ResponseItem = ContextualUserFragment::into(AvailablePluginsInstructions( + expected_body("Old intro", "Old usage"), + )); + let mut world_state = WorldState::default(); + world_state.add_section(PluginsInstructionsState::new( + /*available*/ true, + resolve(Some(&intro), Some(&usage)), + std::iter::once(&retained), + )); + let (_, updates) = world_state + .render_history_fragment_diff(/*previous*/ None, std::slice::from_ref(&retained)); + assert_eq!(updates.len(), 1); + let update = &updates[0]; + assert_eq!( + (update.role(), update.content_kind().0), + ("developer", "plugins.usage_instructions".to_string()) + ); + let ContentItem::InputText { text } = update.render() else { + panic!("plugin instructions should render as input text"); + }; + assert_eq!( + text, + format!("{REPLACEMENT_PREFIX}{body}") ); assert!( - world_state - .render_history_fragment_diff(Some(&snapshot), &[retained]) - .1 - .is_empty() + text.len() <= 4096, + "the final bound includes markers and transition framing" ); } diff --git a/codex-rs/core/src/context/world_state/snapshots/codex_core__context__world_state__plugins_instructions__tests__snapshots.snap b/codex-rs/core/src/context/world_state/snapshots/codex_core__context__world_state__plugins_instructions__tests__snapshots.snap index 851917d2bd..94867693e2 100644 --- a/codex-rs/core/src/context/world_state/snapshots/codex_core__context__world_state__plugins_instructions__tests__snapshots.snap +++ b/codex-rs/core/src/context/world_state/snapshots/codex_core__context__world_state__plugins_instructions__tests__snapshots.snap @@ -8,7 +8,7 @@ None Absent -> false None -Absent -> true (role - developer) +Absent -> {"catalog":false,"instructions":"d3f660b06d428f262fffddce3d2b11f390d8b1da"} (role - developer) ## Plugins A plugin is a local bundle of skills, MCP servers, and apps. @@ -21,7 +21,7 @@ A plugin is a local bundle of skills, MCP servers, and apps. - Missing/blocked: If the user requests a plugin that does not have relevant callable capabilities for the task, say so briefly and continue with the best fallback. -false -> true (role - developer) +false -> {"catalog":false,"instructions":"d3f660b06d428f262fffddce3d2b11f390d8b1da"} (role - developer) ## Plugins A plugin is a local bundle of skills, MCP servers, and apps. @@ -34,14 +34,14 @@ A plugin is a local bundle of skills, MCP servers, and apps. - Missing/blocked: If the user requests a plugin that does not have relevant callable capabilities for the task, say so briefly and continue with the best fallback. -true -> true +{"catalog":false,"instructions":"d3f660b06d428f262fffddce3d2b11f390d8b1da"} -> {"catalog":false,"instructions":"d3f660b06d428f262fffddce3d2b11f390d8b1da"} None -true -> false +{"catalog":false,"instructions":"d3f660b06d428f262fffddce3d2b11f390d8b1da"} -> false None Unknown -> false None -Unknown -> true +Unknown -> {"catalog":false,"instructions":"d3f660b06d428f262fffddce3d2b11f390d8b1da"} None diff --git a/codex-rs/core/src/plugins/injection.rs b/codex-rs/core/src/plugins/injection.rs index f2ef1cacdc..10225525e6 100644 --- a/codex-rs/core/src/plugins/injection.rs +++ b/codex-rs/core/src/plugins/injection.rs @@ -17,11 +17,13 @@ pub(crate) fn build_plugin_injections( mcp_tools: &[ToolInfo], available_connectors: &[connectors::AppInfo], features: &Features, + model_messages: codex_prompts::ResolvedModelMessages<'_>, ) -> Vec { if mentioned_plugins.is_empty() { return Vec::new(); } + let messages = model_messages.selected_plugin(); // Turn each explicit plugin mention with available capabilities into a developer hint. mentioned_plugins .iter() @@ -57,6 +59,7 @@ pub(crate) fn build_plugin_injections( &available_mcp_servers, &available_apps, features, + &messages, ) .map(PluginInstructions::new) .map(ContextualUserFragment::into) diff --git a/codex-rs/core/src/plugins/render.rs b/codex-rs/core/src/plugins/render.rs index 8b9dd204c8..f721eb35e1 100644 --- a/codex-rs/core/src/plugins/render.rs +++ b/codex-rs/core/src/plugins/render.rs @@ -5,17 +5,20 @@ use crate::context::ContextualUserFragment; use crate::plugins::PluginCapabilitySummary; use codex_features::Feature; use codex_features::Features; +use codex_prompts::ResolvedSelectedPluginMessages; #[cfg(test)] use codex_protocol_test_support::ContentItemTestExt; use codex_utils_string::take_bytes_at_char_boundary; const MAX_EXPLICIT_PLUGIN_INSTRUCTIONS_BYTES: usize = 4 * 1024; -const TRUNCATED_PLUGIN_INSTRUCTIONS_SUFFIX: &str = - "\n- Additional plugin capabilities omitted to fit the context limit."; #[cfg(test)] pub(crate) fn render_plugins_section(plugins: &[PluginCapabilitySummary]) -> Option { - (!plugins.is_empty()).then(|| AvailablePluginsInstructions.render().expect_text()) + (!plugins.is_empty()).then(|| { + AvailablePluginsInstructions::default() + .render() + .expect_text() + }) } pub(crate) fn render_explicit_plugin_instructions( @@ -23,6 +26,7 @@ pub(crate) fn render_explicit_plugin_instructions( available_mcp_servers: &[String], available_apps: &[String], features: &Features, + messages: &ResolvedSelectedPluginMessages, ) -> Option { if features.enabled(Feature::SimplifiedSelectionHints) { return (plugin.has_skills || !available_apps.is_empty() || !available_mcp_servers.is_empty()) @@ -37,20 +41,14 @@ pub(crate) fn render_explicit_plugin_instructions( }); } - let mut lines = vec![format!( - "Capabilities from the `{}` plugin:", - plugin.display_name - )]; + let mut lines = vec![ + messages + .intro + .replace("{{ plugin_name }}", &plugin.display_name), + ]; if !available_apps.is_empty() { - lines.push( - concat!( - "- For the user request that explicitly selected this plugin, and only for that ", - "request, if `tool_search` is available and an app from this plugin may help, ", - "search for its tools before falling back to unrelated or built-in tools." - ) - .to_string(), - ); + lines.push(messages.search_tools.clone()); } if plugin.has_skills { @@ -58,42 +56,55 @@ pub(crate) fn render_explicit_plugin_instructions( .plugin_namespace .as_deref() .unwrap_or(plugin.display_name.as_str()); - lines.push(format!( - "- Skills from this plugin are prefixed with `{skill_namespace}:`." - )); + lines.push( + messages + .skill_namespace + .replace("{{ skill_namespace }}", skill_namespace), + ); } if !available_apps.is_empty() { - lines.push(format!( - "- Apps from this plugin available in this session: {}.", - available_apps - .iter() - .map(|app| format!("`{app}`")) - .collect::>() - .join(", ") - )); + lines.push( + messages.apps.replace( + "{{ apps }}", + &available_apps + .iter() + .map(|app| format!("`{app}`")) + .collect::>() + .join(", "), + ), + ); } 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::>() - .join(", ") - )); + lines.push( + messages.mcp_servers.replace( + "{{ mcp_servers }}", + &available_mcp_servers + .iter() + .map(|server| format!("`{server}`")) + .collect::>() + .join(", "), + ), + ); } if lines.len() == 1 { return None; } - lines.push("Use these plugin-associated capabilities to help solve the task.".to_string()); + lines.push(messages.closing.clone()); + + // The separator belongs to the harness; preserve the catalog's exact bytes after it. + let omitted = if messages.omitted.starts_with('\n') || messages.omitted.starts_with("\r\n") { + messages.omitted.clone() + } else { + format!("\n{}", messages.omitted) + }; Some(bound_explicit_plugin_instructions( lines.join("\n"), - TRUNCATED_PLUGIN_INSTRUCTIONS_SUFFIX, + &omitted, )) } diff --git a/codex-rs/core/src/plugins/render_tests.rs b/codex-rs/core/src/plugins/render_tests.rs index 8ddbaf7b5c..09b67a3547 100644 --- a/codex-rs/core/src/plugins/render_tests.rs +++ b/codex-rs/core/src/plugins/render_tests.rs @@ -36,6 +36,7 @@ fn explicit_plugin_instructions_use_manifest_namespace_for_skills() { &[], &[], &Features::default(), + &codex_prompts::ResolvedModelMessages::bundled().selected_plugin(), ) .expect("skill capability should render"); @@ -55,6 +56,7 @@ fn explicit_plugin_instructions_search_available_apps_before_fallback() { &[], &["Adobe".to_string()], &Features::default(), + &codex_prompts::ResolvedModelMessages::bundled().selected_plugin(), ) .expect("app capability should render"); @@ -88,6 +90,7 @@ fn explicit_plugin_instructions_are_bounded() { &servers, &apps, &Features::default(), + &codex_prompts::ResolvedModelMessages::bundled().selected_plugin(), ) .expect("MCP capability should render"); @@ -96,5 +99,59 @@ fn explicit_plugin_instructions_are_bounded() { assert!(rendered.contains("if `tool_search` is available")); assert!(rendered.contains("Skills from this plugin")); assert!(rendered.contains("`app-0`")); - assert!(rendered.ends_with(TRUNCATED_PLUGIN_INSTRUCTIONS_SUFFIX)); + assert!( + rendered.ends_with( + &codex_prompts::ResolvedModelMessages::bundled() + .selected_plugin() + .omitted + ) + ); +} + +#[test] +fn selected_plugin_templates_insert_runtime_facts_once() { + let mut messages = codex_prompts::ResolvedModelMessages::bundled().selected_plugin(); + messages.intro = "Selected {{ plugin_name }}.".into(); + messages.search_tools = "Search wording {{literal}}.\r\n".into(); + messages.skill_namespace = "Skills {{ skill_namespace }}.".into(); + messages.apps = "Apps {{ apps }}.".into(); + messages.mcp_servers = "Servers {{ mcp_servers }}.".into(); + messages.closing = "Closing {{literal}}.".into(); + let rendered = render_explicit_plugin_instructions( + &PluginCapabilitySummary { + display_name: "Name {{ apps }}".into(), + plugin_namespace: Some("namespace{{ plugin_name }}".into()), + has_skills: true, + ..PluginCapabilitySummary::default() + }, + &["server{{ apps }}".into()], + &["app{{ mcp_servers }}".into()], + &Features::default(), + &messages, + ) + .unwrap(); + assert_eq!( + rendered, + "Selected Name {{ apps }}.\nSearch wording {{literal}}.\r\n\nSkills namespace{{ plugin_name }}.\nApps `app{{ mcp_servers }}`.\nServers `server{{ apps }}`.\nClosing {{literal}}." + ); +} + +#[test] +fn selected_plugin_omission_keeps_utf8_and_the_final_item_bound() { + let mut messages = codex_prompts::ResolvedModelMessages::bundled().selected_plugin(); + messages.omitted = "é".repeat(250); + let rendered = render_explicit_plugin_instructions( + &PluginCapabilitySummary { + display_name: "plugin".into(), + ..Default::default() + }, + &[], + &["🦀".repeat(2_000)], + &Features::default(), + &messages, + ) + .unwrap(); + assert!(rendered.len() <= MAX_EXPLICIT_PLUGIN_INSTRUCTIONS_BYTES); + assert!(rendered.ends_with(&format!("\n{}", messages.omitted))); + assert!(rendered.contains("🦀")); } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index f365c4e2e8..667618102c 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -4983,7 +4983,13 @@ impl Session { if next_item.is_none() && let Some(recommended_plugins) = recommended_plugin_candidates .as_deref() - .and_then(RecommendedPluginsInstructions::from_plugins) + .and_then(|plugins| { + RecommendedPluginsInstructions::from_plugins( + plugins, + &ResolvedModelMessages::from_model(&step_context.settings.model_info) + .plugin_recommendations(), + ) + }) { developer_sections.push(recommended_plugins.render_fragment()); } diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index b25f718d47..6da4a09a3d 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1271,6 +1271,7 @@ async fn build_skills_and_plugins( mcp_tools, &available_connectors, &turn_context.config.features, + codex_prompts::ResolvedModelMessages::from_model(&step_context.settings.model_info), ); let mut explicitly_enabled_connectors = collect_explicit_app_ids(user_input); explicitly_enabled_connectors.extend(skill_connector_ids); diff --git a/codex-rs/core/src/session/world_state.rs b/codex-rs/core/src/session/world_state.rs index e2027dd10c..09fa3434d9 100644 --- a/codex-rs/core/src/session/world_state.rs +++ b/codex-rs/core/src/session/world_state.rs @@ -342,9 +342,15 @@ impl Session { )); let plugins_usage_instructions_available = step_context.mcp.plugins_available() && model_info.include_plugin_usage_instructions; - world_state.add_section(PluginsInstructionsState::new( - plugins_usage_instructions_available, - )); + let plugins_state = { + let state = self.state.lock().await; + PluginsInstructionsState::new( + plugins_usage_instructions_available, + model_messages.generic_plugin(), + state.history.raw_items(), + ) + }; + world_state.add_section(plugins_state); if turn_context .config .features diff --git a/codex-rs/core/tests/suite/catalog_permission_messages.rs b/codex-rs/core/tests/suite/catalog_permission_messages.rs index 67c3cbd804..55f6aa324a 100644 --- a/codex-rs/core/tests/suite/catalog_permission_messages.rs +++ b/codex-rs/core/tests/suite/catalog_permission_messages.rs @@ -41,6 +41,7 @@ async fn catalog_permission_message_loaded_from_remote_models_is_sent( let mut model = model_info_from_slug(model_slug); model.model_messages = Some(ModelMessages { apps: None, + plugins: None, content_filter_guidance: None, persistent_instructions: None, tools: None, diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index 3cf3874144..d1b53e41a3 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -75,6 +75,7 @@ fn model_with_collaboration_messages( let mut model = model_info_from_slug(slug); let model_messages = model.model_messages.get_or_insert(ModelMessages { apps: None, + plugins: None, content_filter_guidance: None, persistent_instructions: None, tools: None, diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index a160daf644..fd8c8560b2 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -644,6 +644,7 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo { upgrade: None, model_messages: Some(ModelMessages { apps: None, + plugins: None, content_filter_guidance: None, persistent_instructions: None, tools: None, diff --git a/codex-rs/core/tests/suite/request_plugin_install.rs b/codex-rs/core/tests/suite/request_plugin_install.rs index 8bb2650766..38cb627ed5 100644 --- a/codex-rs/core/tests/suite/request_plugin_install.rs +++ b/codex-rs/core/tests/suite/request_plugin_install.rs @@ -609,6 +609,143 @@ async fn startup_recommendations_use_developer_message( Ok(()) } +#[test_case("custom"; "custom")] +#[test_case("absent"; "absent")] +#[test_case("null"; "null")] +#[test_case("empty"; "empty")] +#[test_case("blank"; "blank")] +#[test_case("oversized"; "oversized")] +#[test_case("disabled"; "disabled")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn catalog_plugin_recommendations_use_receiving_model(case: &str) -> Result<()> { + skip_if_no_network!(Ok(())); + let server = start_mock_server().await; + let apps = AppsTestServer::mount(&server).await?; + mount_recommendations( + &server, + ResponseTemplate::new(200).set_body_json(json!({ + "enabled": true, + "plugins": [{"id": "plugin_github", "name": "github", "display_name": "GitHub"}], + })), + ) + .await; + let custom = " Catalog recommendations: {{literal}}.\r\n"; + let recommendations = match case { + "custom" | "disabled" => json!({"intro": custom}), + "absent" => json!({}), + "null" => json!({"intro": null}), + "empty" => json!({"intro": ""}), + "blank" => json!({"intro": " \n"}), + "oversized" => json!({"intro": "x".repeat(501)}), + _ => unreachable!("declared test case"), + }; + let enabled = case != "disabled"; + let test = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(move |config| { + configure_apps_without_search_tool(config, &apps.chatgpt_base_url); + config.features.disable(Feature::ToolSuggest).unwrap(); + config + .features + .set_enabled(Feature::RecommendedPlugins, enabled) + .unwrap(); + let catalog = config.model_catalog.as_mut().unwrap(); + let startup_model = catalog + .models + .iter_mut() + .find(|model| model.slug == "gpt-5.5") + .unwrap(); + startup_model.model_messages = Some(serde_json::from_value(json!({ + "plugins": {"recommendations": {"intro": "Stale startup model recommendations."}}, + })).unwrap()); + let mut receiving_model = startup_model.clone(); + receiving_model.slug = "recommendations-receiving".to_string(); + receiving_model.model_messages = Some( + serde_json::from_value(json!({ + "plugins": {"recommendations": recommendations}, + })) + .unwrap(), + ); + catalog.models.push(receiving_model); + }) + .build_with_auto_env(&server) + .await?; + let mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ev_completed("recommendations-first")]), + sse(vec![ev_completed("recommendations-followup")]), + ], + ) + .await; + test.codex + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { + text: "Suggest a plugin for this task.".to_string(), + text_elements: Vec::new(), + }]) + .with_thread_settings(ThreadSettingsOverrides { + model: Some("recommendations-receiving".to_string()), + ..Default::default() + }), + ) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + test.submit_text_turn("Continue with the same recommendations.") + .await?; + let requests = mock.requests(); + assert_eq!(requests.len(), 2); + let expected_intro = if case == "custom" { + custom + } else { + "Here is a list of plugins that are available but not installed." + }; + let expected = if enabled { + vec![format!( + "\n{expected_intro}\n\n- GitHub (github@openai-curated-remote)\n" + )] + } else { + Vec::new() + }; + for request in &requests { + assert_eq!( + request.body_json()["model"], + json!("recommendations-receiving") + ); + let actual = request + .message_input_texts("developer") + .into_iter() + .filter(|text| text.starts_with("")) + .collect::>(); + assert_eq!( + actual, expected, + "the receiving model owns wording; runtime recommendation identities remain unchanged and appear once" + ); + assert!(!request.body_contains_text("Stale startup model recommendations.")); + assert!( + !request + .message_input_texts("user") + .iter() + .any(|text| text.contains("")) + ); + } + insta::assert_snapshot!( + format!("catalog_plugin_recommendations_{case}"), + context_snapshot::format_request_history_snapshot( + "The first receiving model controls recommendation wording without changing feature eligibility, developer role, runtime plugin identities or retained-history deduplication.", + &requests, + &ContextSnapshotOptions::default() + .rewrite_known_segments() + .include_request_settings(), + ) + ); + test.codex.shutdown_and_wait().await?; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn mcp_discovery_overlaps_endpoint_plugin_recommendations() -> Result<()> { skip_if_wine_exec!( diff --git a/codex-rs/core/tests/suite/scenarios.rs b/codex-rs/core/tests/suite/scenarios.rs index 29ddc61096..530e508ea9 100644 --- a/codex-rs/core/tests/suite/scenarios.rs +++ b/codex-rs/core/tests/suite/scenarios.rs @@ -19,6 +19,7 @@ use anyhow::Result; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_analytics::CompactionTrigger; +use codex_config::ConfigLayerEntry; use codex_config::ConfigLayerSource; use codex_config::ConfigLayerStack; use codex_config::types::McpServerConfig; @@ -350,6 +351,27 @@ fn configure_scenario_catalog(config: &mut Config) { config.cloud_skill_enabled = false; } +fn configure_catalog_plugin_scenario(config: &mut Config) { + configure_scenario_catalog(config); + config.workspace_roots = vec![config.cwd.clone()]; + let stack = &config.config_layer_stack; + let fixture_config = stack.effective_config(); + // Keep the fixture's plugin and App policy while avoiding discovery of the real + // $HOME/.agents/skills, which a User layer adds alongside the fixture's skills. + config.config_layer_stack = ConfigLayerStack::new( + vec![ConfigLayerEntry::new( + ConfigLayerSource::System { + file: config.codex_home.join("config.toml"), + }, + fixture_config, + )], + stack.requirements().clone(), + stack.requirements_toml().clone(), + ) + .expect("isolated plugin scenario config") + .with_cloud_config_binding(stack.cloud_config_binding().cloned()); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn admitted_prompt_id_reaches_responses() -> Result<()> { skip_if_no_network!(Ok(())); @@ -3901,3 +3923,525 @@ async fn unshared_apps_catalog_across_turns() -> Result<()> { insta::assert_snapshot!("unshared_apps_catalog_across_turns", snapshot); Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn catalog_plugin_guidance_follows_model_and_survives_resume() -> Result<()> { + skip_if_no_network!(Ok(())); + let server = start_mock_server().await; + let home = Arc::new(TempDir::new()?); + write_scenario_capabilities(&home)?; + let configure = |config: &mut Config| { + configure_catalog_plugin_scenario(config); + let catalog = config.model_catalog.as_mut().unwrap(); + let base = catalog + .models + .iter() + .find(|model| model.slug == "gpt-5.5") + .unwrap() + .clone(); + for (slug, generic, enabled) in [ + ("plugins-default", json!({}), true), + ( + "plugins-a", + json!({"intro": "Catalog intro A: {{literal}}.", "usage_instructions": "Catalog usage A.\nKeep this line."}), + true, + ), + ( + "plugins-b", + json!({"intro": "Catalog intro B.", "usage_instructions": "Catalog usage B."}), + true, + ), + ( + "plugins-empty", + json!({"intro": "", "usage_instructions": " \n"}), + true, + ), + ( + "plugins-invalid", + json!({"intro": "x".repeat(501), "usage_instructions": "y".repeat(2001)}), + true, + ), + ( + "plugins-null", + json!({"intro": null, "usage_instructions": null}), + true, + ), + ( + "plugins-off", + json!({"intro": "Disabled catalog intro.", "usage_instructions": "Disabled catalog usage."}), + false, + ), + ] { + let mut model = base.clone(); + model.slug = slug.to_string(); + model.include_plugin_usage_instructions = enabled; + model.model_messages = + Some(serde_json::from_value(json!({"plugins": {"generic": generic}})).unwrap()); + catalog.models.push(model); + } + }; + let mut builder = test_codex() + .with_model("plugins-default") + .with_home(home) + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_extensions(skills_extensions()) + .with_config(configure); + let test = builder.build_with_auto_env(&server).await?; + let mock = mount_sse_sequence( + &server, + (0..10) + .map(|i| sse(vec![ev_completed(&format!("plugins-{i}"))])) + .collect(), + ) + .await; + test.submit_text_turn("Use the bundled plugin guidance.") + .await?; + for model in ["plugins-a", "plugins-a", "plugins-b"] { + test.codex + .start_or_steer_turn( + TurnInputRequest::user_input(vec![text("Continue with the selected model.")]) + .with_thread_settings(codex_protocol::protocol::ThreadSettingsOverrides { + model: Some(model.to_string()), + ..Default::default() + }), + ) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + } + builder = builder.with_config(configure).with_model("plugins-b"); + let resumed = builder.restart_with_auto_env(&server, &test).await?; + resumed + .submit_text_turn("Continue after restarting with the same guidance.") + .await?; + for model in [ + "plugins-empty", + "plugins-invalid", + "plugins-null", + "plugins-off", + "plugins-a", + ] { + resumed + .codex + .start_or_steer_turn( + TurnInputRequest::user_input(vec![text("Continue with the selected model.")]) + .with_thread_settings(codex_protocol::protocol::ThreadSettingsOverrides { + model: Some(model.to_string()), + ..Default::default() + }), + ) + .await?; + wait_for_event(&resumed.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + } + let requests = mock.requests(); + assert_eq!(requests.len(), 10); + assert_eq!( + requests + .iter() + .map(|request| request.body_json()["model"].clone()) + .collect::>(), + vec![ + json!("plugins-default"), + json!("plugins-a"), + json!("plugins-a"), + json!("plugins-b"), + json!("plugins-b"), + json!("plugins-empty"), + json!("plugins-invalid"), + json!("plugins-null"), + json!("plugins-off"), + json!("plugins-a"), + ] + ); + let guidance = requests + .iter() + .map(|request| { + request + .message_input_texts("developer") + .into_iter() + .filter(|text| text.starts_with("")) + .collect::>() + }) + .collect::>(); + let bundled = concat!( + "\n## Plugins\n", + "A plugin is a local bundle of skills, MCP servers, and apps.\n", + "### How to use plugins\n", + "- Skill naming: If a plugin contributes skills, those skill entries are prefixed with `plugin_name:` in the Skills list.\n", + "- MCP naming: Plugin-provided MCP tools keep standard MCP identifiers such as `mcp__server__tool`; use tool provenance to tell which plugin they come from.\n", + "- Trigger rules: If the user explicitly names a plugin, prefer capabilities associated with that plugin for that turn.\n", + "- Relationship to capabilities: Plugins are not invoked directly. Use their underlying skills, MCP tools, and app tools to help solve the task.\n", + "- Relevance: Determine what a plugin can help with from explicit user mention or from the plugin-associated skills, MCP tools, and apps exposed elsewhere in this turn.\n", + "- Missing/blocked: If the user requests a plugin that does not have relevant callable capabilities for the task, say so briefly and continue with the best fallback.\n", + "", + ); + let catalog_a = "\n## Plugins\nCatalog intro A: {{literal}}.\n### How to use plugins\nCatalog usage A.\nKeep this line.\n"; + let replacement_prefix = "\nThese plugin usage instructions replace all previously provided plugin usage instructions.\n\n\n"; + let replaced_a = catalog_a.replacen("\n", replacement_prefix, 1); + let replaced_bundled = bundled.replacen("\n", replacement_prefix, 1); + assert_eq!(guidance[0], vec![bundled]); + assert_eq!(guidance[1].last(), Some(&replaced_a)); + assert_eq!(guidance[2], guidance[1]); + assert_eq!( + guidance[3].last().map(String::as_str), + Some(concat!( + "\n", + "These plugin usage instructions replace all previously provided plugin usage instructions.\n\n\n", + "## Plugins\nCatalog intro B.\n### How to use plugins\nCatalog usage B.\n", + )) + ); + assert_eq!( + guidance[4], guidance[3], + "resume must not duplicate retained custom guidance" + ); + assert_eq!(guidance[5].last(), Some(&replaced_bundled)); + assert_eq!( + guidance[6], guidance[5], + "oversized fields fall back without appending unchanged guidance" + ); + assert_eq!( + guidance[7], guidance[5], + "null fields preserve bundled guidance" + ); + assert_eq!( + guidance[8].last().map(String::as_str), + Some( + "\nThe previously provided plugin usage instructions no longer apply.\n" + ) + ); + assert_eq!(guidance[9].last().map(String::as_str), Some(catalog_a)); + for pair in guidance.windows(2) { + assert!( + pair[1].starts_with(&pair[0]), + "prior plugin guidance must remain verbatim in history" + ); + } + assert_eq!( + guidance[9] + .iter() + .filter(|text| text.contains("Catalog intro A: {{literal}}.")) + .count(), + 2 + ); + assert!(guidance.iter().flatten().all(|text| text.len() <= 4096)); + for request in &requests { + assert!(!request.body_contains_text("Disabled catalog intro.")); + assert!( + !request + .message_input_texts("user") + .iter() + .any(|text| text.contains("")) + ); + } + insta::assert_snapshot!( + "catalog_plugin_guidance_follows_model_and_survives_resume", + context_snapshot::format_labeled_requests_snapshot( + "First and final requests show generic plugin guidance and the complete append-only history. All ten intermediate model, wording, fallback, availability and resume states are asserted in the test.", + &[ + ("Initial bundled guidance", &requests[0]), + ("Final retained history", &requests[9]) + ], + &ContextSnapshotOptions::default().include_request_settings(), + ) + ); + Ok(()) +} + +#[test_case::test_case(true, false; "apps_enabled")] +#[test_case::test_case(false, false; "apps_disabled")] +#[test_case::test_case(true, true; "simplified_selection")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn catalog_selected_plugin_wording_preserves_capability_policy( + apps_enabled: bool, + simplified_selection: bool, +) -> Result<()> { + skip_if_no_network!(Ok(())); + core_test_support::skip_if_remote!(Ok(()), "plugin MCP fixture uses a host-local executable"); + let server = start_mock_server().await; + let apps = core_test_support::apps_test_server::AppsTestServer::mount(&server).await?; + let home = Arc::new(TempDir::new()?); + write_scenario_capabilities(&home)?; + let plugin_root = home.path().join("plugins/cache/test/notes/local"); + fs::write( + plugin_root.join(".mcp.json"), + json!({"mcpServers": {"notes": {"command": stdio_server_bin()?, "cwd": "."}}}).to_string(), + )?; + fs::write( + plugin_root.join(".app.json"), + json!({"apps": {"notes_app": {"id": "calendar"}}}).to_string(), + )?; + let config_path = home.path().join("config.toml"); + fs::write( + &config_path, + format!( + "{}\n[apps.calendar]\nenabled = {apps_enabled}\n", + fs::read_to_string(&config_path)? + ), + )?; + let test = test_codex() + .with_model("selected-custom") + .with_home(home) + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_extensions(skills_extensions()) + .with_config(move |config| { + configure_catalog_plugin_scenario(config); + config.chatgpt_base_url = apps.chatgpt_base_url; + config + .features + .enable(Feature::Apps) + .expect("enable Apps for selected-plugin scenario"); + config + .features + .set_enabled(Feature::SimplifiedSelectionHints, simplified_selection) + .expect("configure selected-plugin hint mode"); + let catalog = config + .model_catalog + .as_mut() + .expect("scenario model catalog"); + let base = catalog + .models + .iter() + .find(|model| model.slug == "gpt-5.5") + .expect("bundled scenario base model") + .clone(); + for (slug, selected) in [ + ( + "selected-custom", + json!({ + "intro": "Catalog selected {{ plugin_name }}.", + "search_tools": "Catalog search for this explicit request.", + "skill_namespace": "Catalog skills {{ skill_namespace }}.", + "apps": "Catalog apps {{ apps }}.", + "mcp_servers": "Catalog MCP {{ mcp_servers }}.", + "closing": "Catalog closing.", + "omitted": "Catalog omitted.", + }), + ), + ( + "selected-fallback", + json!({ + "intro": "Invalid intro without the required name.", + "search_tools": "Catalog search from the receiving model.", + "skill_namespace": "Invalid {{ unknown }}.", + "apps": " \n", + "mcp_servers": "{{ mcp_servers }} and {{ mcp_servers }}", + "closing": "x".repeat(501), + "omitted": null, + }), + ), + ] { + let mut model = base.clone(); + model.slug = slug.to_string(); + model.include_plugin_usage_instructions = false; + model.supports_search_tool = false; + model.model_messages = Some( + serde_json::from_value(json!({"plugins": {"selected": selected}})) + .expect("selected-plugin model messages"), + ); + catalog.models.push(model); + } + }) + .build_with_auto_env(&server) + .await?; + wait_for_mcp_server(&test.codex, "notes").await?; + let mock = mount_sse_sequence( + &server, + (0..4) + .map(|i| sse(vec![ev_completed(&format!("selected-{i}"))])) + .collect(), + ) + .await; + test.submit_text_turn("List the available capabilities.") + .await?; + for (model, input) in [ + ( + "selected-custom", + vec![text("Use the selected plugin."), plugin("notes")], + ), + ( + "selected-custom", + vec![text("Continue without a plugin selection.")], + ), + ( + "selected-fallback", + vec![text("Use the selected plugin again."), plugin("notes")], + ), + ] { + test.codex + .start_or_steer_turn(TurnInputRequest::user_input(input).with_thread_settings( + codex_protocol::protocol::ThreadSettingsOverrides { + model: Some(model.to_string()), + ..Default::default() + }, + )) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + } + let requests = mock.requests(); + assert_eq!(requests.len(), 4); + assert_eq!(requests[3].body_json()["model"], json!("selected-fallback")); + let selected = requests + .iter() + .map(|request| { + request + .message_input_texts("developer") + .into_iter() + .filter(|text| { + text.starts_with("Catalog selected ") + || text.starts_with("Capabilities from ") + || text.starts_with("The user selected ") + }) + .collect::>() + }) + .collect::>(); + let custom = if simplified_selection { + "The user selected the `notes` plugin. Use its available skills and tools to complete this request." + } else if apps_enabled { + "Catalog selected notes.\nCatalog search for this explicit request.\nCatalog skills notes.\nCatalog apps `Calendar`.\nCatalog MCP `notes`.\nCatalog closing." + } else { + "Catalog selected notes.\nCatalog skills notes.\nCatalog MCP `notes`.\nCatalog closing." + }; + let fallback = if simplified_selection { + custom + } else if apps_enabled { + concat!( + "Capabilities from the `notes` plugin:\n", + "Catalog search from the receiving model.\n", + "- Skills from this plugin are prefixed with `notes:`.\n", + "- Apps from this plugin available in this session: `Calendar`.\n", + "- MCP servers from this plugin available in this session: `notes`.\n", + "Use these plugin-associated capabilities to help solve the task.", + ) + } else { + concat!( + "Capabilities from the `notes` plugin:\n", + "- Skills from this plugin are prefixed with `notes:`.\n", + "- MCP servers from this plugin available in this session: `notes`.\n", + "Use these plugin-associated capabilities to help solve the task.", + ) + }; + assert!( + selected[0].is_empty(), + "catalog wording must not imply an explicit selection" + ); + assert_eq!(selected[1], vec![custom]); + assert_eq!( + selected[2], selected[1], + "an implicit follow-up must not repeat the selected-plugin hint" + ); + assert_eq!(selected[3], vec![custom, fallback]); + assert!( + requests[1].body_contains_text("This tool is part of plugin `notes`."), + "catalog wording must preserve tool provenance" + ); + for request in &requests { + assert!( + !request + .message_input_texts("user") + .iter() + .any(|text| text.contains("Catalog selected notes.")) + ); + } + insta::assert_snapshot!( + format!( + "catalog_selected_plugin_wording_apps_{apps_enabled}_simplified_{simplified_selection}" + ), + context_snapshot::format_request_history_snapshot( + "Selected-plugin wording follows the receiving model while live skills, MCP, App policy, explicit selection eligibility and simplified selection dispatch remain authoritative.", + &requests, + &ContextSnapshotOptions::default().include_request_settings(), + ) + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn catalog_selected_plugin_omission_bounds_runtime_capabilities() -> Result<()> { + skip_if_no_network!(Ok(())); + let server = start_mock_server().await; + let apps = core_test_support::apps_test_server::AppsTestServer::mount_with_connector_name( + &server, + &"Calendar café ".repeat(400), + ) + .await?; + let home = Arc::new(TempDir::new()?); + write_scenario_capabilities(&home)?; + fs::write( + home.path().join("plugins/cache/test/notes/local/.app.json"), + json!({"apps": {"notes_app": {"id": "calendar"}}}).to_string(), + )?; + let test = test_codex() + .with_model("gpt-5.5") + .with_home(home) + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_extensions(skills_extensions()) + .with_config(move |config| { + configure_catalog_plugin_scenario(config); + config.chatgpt_base_url = apps.chatgpt_base_url; + config.features.enable(Feature::Apps).unwrap(); + config + .features + .disable(Feature::SimplifiedSelectionHints) + .unwrap(); + let model = config + .model_catalog + .as_mut() + .unwrap() + .models + .iter_mut() + .find(|model| model.slug == "gpt-5.5") + .unwrap(); + model.include_plugin_usage_instructions = false; + model.model_messages = Some( + serde_json::from_value(json!({"plugins": {"selected": { + "intro": "Catalog selected {{ plugin_name }}.", + "apps": "Catalog apps {{ apps }}.", + "omitted": "Catalog capability list omitted here.", + }}})) + .unwrap(), + ); + }) + .build_with_auto_env(&server) + .await?; + let mock = mount_sse_sequence(&server, vec![sse(vec![ev_completed("bounded-plugin")])]).await; + test.codex + .start_or_steer_turn(TurnInputRequest::user_input(vec![ + text("Use the selected plugin."), + plugin("notes"), + ])) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + let request = mock.single_request(); + let selected = request + .message_input_texts("developer") + .into_iter() + .filter(|text| text.starts_with("Catalog selected notes.")) + .collect::>(); + assert_eq!(selected.len(), 1); + assert!(selected[0].contains("Catalog apps `Calendar café ")); + assert!(selected[0].ends_with("\nCatalog capability list omitted here.")); + assert!( + selected[0].len() <= 4096, + "the bound applies after runtime capability substitution" + ); + insta::assert_snapshot!( + "catalog_selected_plugin_omission_bounds_runtime_capabilities", + context_snapshot::format_request_history_snapshot( + "A catalog omission suffix is reserved within the final selected-plugin bound after substituting a long Unicode runtime capability name.", + &mock.requests(), + &ContextSnapshotOptions::default().include_request_settings(), + ) + ); + Ok(()) +} diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__request_plugin_install__catalog_plugin_recommendations_absent.snap b/codex-rs/core/tests/suite/snapshots/all__suite__request_plugin_install__catalog_plugin_recommendations_absent.snap new file mode 100644 index 0000000000..fd3de969ef --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__request_plugin_install__catalog_plugin_recommendations_absent.snap @@ -0,0 +1,53 @@ +--- +source: core/tests/suite/request_plugin_install.rs +expression: "context_snapshot::format_request_history_snapshot(\"The first receiving model controls recommendation wording without changing feature eligibility, developer role, runtime plugin identities or retained-history deduplication.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)" +--- +Scenario: The first receiving model controls recommendation wording without changing feature eligibility, developer role, runtime plugin identities or retained-history deduplication. + +## Window 1 +Settings: + include: ["reasoning.encrypted_content"] + model: "recommendations-receiving" + parallel_tool_calls: true + prompt_cache_key: "" + reasoning: {"effort":"medium"} + store: false + stream: true + text: {"verbosity":"low"} + tool_choice: "auto" + tools (11; hash=62414ae13aabdb67): + - function/exec_command: Runs a command in a PTY, returning output or a session ID for ongoing interaction.; args=[cmd, justification, login, max_output_tokens, prefix_rule, sandbox_permissions, shell, tty, workdir, yield_time_ms]; hash=c04108e51a8548c5 + - function/write_stdin: Writes characters to an existing unified exec session and returns recent output.; args=[chars, max_output_tokens, session_id, yield_time_ms]; hash=7fb62d8a3e2df4e2 + - function/list_mcp_resources: Lists resources provided by MCP servers. Resources allow servers to share data that provides cont...; args=[cursor, server]; hash=6792edaff13561bd + - function/list_mcp_resource_templates: Lists resource templates provided by MCP servers. Parameterized resource templates allow servers ...; args=[cursor, server]; hash=60f17c90ef7a4ebb + - function/read_mcp_resource: Read a specific resource from an MCP server given the server name and resource URI.; args=[server, uri]; hash=24246896b39008ae + - function/request_user_input: Request user input for one to three short questions and wait for the response. This tool is only ...; args=[questions]; hash=64a729b25c424fc6 + - custom/apply_patch: The `apply_patch` tool can be used to edit files. This is a FREEFORM tool, so do not wrap the pat...; hash=dea9f12b0111a4ba + - function/view_image: View a local image file from the filesystem when visual inspection is needed. Use this for images...; args=[detail, path]; hash=3b766d5bdec5d50e + - namespace/multi_agent_v1: Tools for spawning and managing sub-agents.; hash=2327142bda177c50 + - function/close_agent + - function/resume_agent + - function/send_input + - function/spawn_agent + - function/wait_agent + - namespace/mcp__codex_apps__calendar: Plan events and manage your calendar.; hash=9aecf0b528e2fee4 + - function/_create_event + - function/_extract_text + - function/_list_events + - web_search; hash=f103e327c6870a46 +-- request 1 (turn) -- +00:message/developer[3]: + [01] + [02] + [03] + Here is a list of plugins that are available but not installed. + + - GitHub (github@openai-curated-remote) + +01:message/user: + +02:message/user: + Suggest a plugin for this task. +-- request 2 (turn) -- +03:message/user: + Continue with the same recommendations. diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__request_plugin_install__catalog_plugin_recommendations_blank.snap b/codex-rs/core/tests/suite/snapshots/all__suite__request_plugin_install__catalog_plugin_recommendations_blank.snap new file mode 100644 index 0000000000..fd3de969ef --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__request_plugin_install__catalog_plugin_recommendations_blank.snap @@ -0,0 +1,53 @@ +--- +source: core/tests/suite/request_plugin_install.rs +expression: "context_snapshot::format_request_history_snapshot(\"The first receiving model controls recommendation wording without changing feature eligibility, developer role, runtime plugin identities or retained-history deduplication.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)" +--- +Scenario: The first receiving model controls recommendation wording without changing feature eligibility, developer role, runtime plugin identities or retained-history deduplication. + +## Window 1 +Settings: + include: ["reasoning.encrypted_content"] + model: "recommendations-receiving" + parallel_tool_calls: true + prompt_cache_key: "" + reasoning: {"effort":"medium"} + store: false + stream: true + text: {"verbosity":"low"} + tool_choice: "auto" + tools (11; hash=62414ae13aabdb67): + - function/exec_command: Runs a command in a PTY, returning output or a session ID for ongoing interaction.; args=[cmd, justification, login, max_output_tokens, prefix_rule, sandbox_permissions, shell, tty, workdir, yield_time_ms]; hash=c04108e51a8548c5 + - function/write_stdin: Writes characters to an existing unified exec session and returns recent output.; args=[chars, max_output_tokens, session_id, yield_time_ms]; hash=7fb62d8a3e2df4e2 + - function/list_mcp_resources: Lists resources provided by MCP servers. Resources allow servers to share data that provides cont...; args=[cursor, server]; hash=6792edaff13561bd + - function/list_mcp_resource_templates: Lists resource templates provided by MCP servers. Parameterized resource templates allow servers ...; args=[cursor, server]; hash=60f17c90ef7a4ebb + - function/read_mcp_resource: Read a specific resource from an MCP server given the server name and resource URI.; args=[server, uri]; hash=24246896b39008ae + - function/request_user_input: Request user input for one to three short questions and wait for the response. This tool is only ...; args=[questions]; hash=64a729b25c424fc6 + - custom/apply_patch: The `apply_patch` tool can be used to edit files. This is a FREEFORM tool, so do not wrap the pat...; hash=dea9f12b0111a4ba + - function/view_image: View a local image file from the filesystem when visual inspection is needed. Use this for images...; args=[detail, path]; hash=3b766d5bdec5d50e + - namespace/multi_agent_v1: Tools for spawning and managing sub-agents.; hash=2327142bda177c50 + - function/close_agent + - function/resume_agent + - function/send_input + - function/spawn_agent + - function/wait_agent + - namespace/mcp__codex_apps__calendar: Plan events and manage your calendar.; hash=9aecf0b528e2fee4 + - function/_create_event + - function/_extract_text + - function/_list_events + - web_search; hash=f103e327c6870a46 +-- request 1 (turn) -- +00:message/developer[3]: + [01] + [02] + [03] + Here is a list of plugins that are available but not installed. + + - GitHub (github@openai-curated-remote) + +01:message/user: + +02:message/user: + Suggest a plugin for this task. +-- request 2 (turn) -- +03:message/user: + Continue with the same recommendations. diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__request_plugin_install__catalog_plugin_recommendations_custom.snap b/codex-rs/core/tests/suite/snapshots/all__suite__request_plugin_install__catalog_plugin_recommendations_custom.snap new file mode 100644 index 0000000000..d9fef113ce --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__request_plugin_install__catalog_plugin_recommendations_custom.snap @@ -0,0 +1,54 @@ +--- +source: core/tests/suite/request_plugin_install.rs +expression: "context_snapshot::format_request_history_snapshot(\"The first receiving model controls recommendation wording without changing feature eligibility, developer role, runtime plugin identities or retained-history deduplication.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)" +--- +Scenario: The first receiving model controls recommendation wording without changing feature eligibility, developer role, runtime plugin identities or retained-history deduplication. + +## Window 1 +Settings: + include: ["reasoning.encrypted_content"] + model: "recommendations-receiving" + parallel_tool_calls: true + prompt_cache_key: "" + reasoning: {"effort":"medium"} + store: false + stream: true + text: {"verbosity":"low"} + tool_choice: "auto" + tools (11; hash=62414ae13aabdb67): + - function/exec_command: Runs a command in a PTY, returning output or a session ID for ongoing interaction.; args=[cmd, justification, login, max_output_tokens, prefix_rule, sandbox_permissions, shell, tty, workdir, yield_time_ms]; hash=c04108e51a8548c5 + - function/write_stdin: Writes characters to an existing unified exec session and returns recent output.; args=[chars, max_output_tokens, session_id, yield_time_ms]; hash=7fb62d8a3e2df4e2 + - function/list_mcp_resources: Lists resources provided by MCP servers. Resources allow servers to share data that provides cont...; args=[cursor, server]; hash=6792edaff13561bd + - function/list_mcp_resource_templates: Lists resource templates provided by MCP servers. Parameterized resource templates allow servers ...; args=[cursor, server]; hash=60f17c90ef7a4ebb + - function/read_mcp_resource: Read a specific resource from an MCP server given the server name and resource URI.; args=[server, uri]; hash=24246896b39008ae + - function/request_user_input: Request user input for one to three short questions and wait for the response. This tool is only ...; args=[questions]; hash=64a729b25c424fc6 + - custom/apply_patch: The `apply_patch` tool can be used to edit files. This is a FREEFORM tool, so do not wrap the pat...; hash=dea9f12b0111a4ba + - function/view_image: View a local image file from the filesystem when visual inspection is needed. Use this for images...; args=[detail, path]; hash=3b766d5bdec5d50e + - namespace/multi_agent_v1: Tools for spawning and managing sub-agents.; hash=2327142bda177c50 + - function/close_agent + - function/resume_agent + - function/send_input + - function/spawn_agent + - function/wait_agent + - namespace/mcp__codex_apps__calendar: Plan events and manage your calendar.; hash=9aecf0b528e2fee4 + - function/_create_event + - function/_extract_text + - function/_list_events + - web_search; hash=f103e327c6870a46 +-- request 1 (turn) -- +00:message/developer[3]: + [01] + [02] + [03] + Catalog recommendations: {{literal}}. + + + - GitHub (github@openai-curated-remote) + +01:message/user: + +02:message/user: + Suggest a plugin for this task. +-- request 2 (turn) -- +03:message/user: + Continue with the same recommendations. diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__request_plugin_install__catalog_plugin_recommendations_disabled.snap b/codex-rs/core/tests/suite/snapshots/all__suite__request_plugin_install__catalog_plugin_recommendations_disabled.snap new file mode 100644 index 0000000000..7ae8e27f5b --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__request_plugin_install__catalog_plugin_recommendations_disabled.snap @@ -0,0 +1,48 @@ +--- +source: core/tests/suite/request_plugin_install.rs +expression: "context_snapshot::format_request_history_snapshot(\"The first receiving model controls recommendation wording without changing feature eligibility, developer role, runtime plugin identities or retained-history deduplication.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)" +--- +Scenario: The first receiving model controls recommendation wording without changing feature eligibility, developer role, runtime plugin identities or retained-history deduplication. + +## Window 1 +Settings: + include: ["reasoning.encrypted_content"] + model: "recommendations-receiving" + parallel_tool_calls: true + prompt_cache_key: "" + reasoning: {"effort":"medium"} + store: false + stream: true + text: {"verbosity":"low"} + tool_choice: "auto" + tools (11; hash=62414ae13aabdb67): + - function/exec_command: Runs a command in a PTY, returning output or a session ID for ongoing interaction.; args=[cmd, justification, login, max_output_tokens, prefix_rule, sandbox_permissions, shell, tty, workdir, yield_time_ms]; hash=c04108e51a8548c5 + - function/write_stdin: Writes characters to an existing unified exec session and returns recent output.; args=[chars, max_output_tokens, session_id, yield_time_ms]; hash=7fb62d8a3e2df4e2 + - function/list_mcp_resources: Lists resources provided by MCP servers. Resources allow servers to share data that provides cont...; args=[cursor, server]; hash=6792edaff13561bd + - function/list_mcp_resource_templates: Lists resource templates provided by MCP servers. Parameterized resource templates allow servers ...; args=[cursor, server]; hash=60f17c90ef7a4ebb + - function/read_mcp_resource: Read a specific resource from an MCP server given the server name and resource URI.; args=[server, uri]; hash=24246896b39008ae + - function/request_user_input: Request user input for one to three short questions and wait for the response. This tool is only ...; args=[questions]; hash=64a729b25c424fc6 + - custom/apply_patch: The `apply_patch` tool can be used to edit files. This is a FREEFORM tool, so do not wrap the pat...; hash=dea9f12b0111a4ba + - function/view_image: View a local image file from the filesystem when visual inspection is needed. Use this for images...; args=[detail, path]; hash=3b766d5bdec5d50e + - namespace/multi_agent_v1: Tools for spawning and managing sub-agents.; hash=2327142bda177c50 + - function/close_agent + - function/resume_agent + - function/send_input + - function/spawn_agent + - function/wait_agent + - namespace/mcp__codex_apps__calendar: Plan events and manage your calendar.; hash=9aecf0b528e2fee4 + - function/_create_event + - function/_extract_text + - function/_list_events + - web_search; hash=f103e327c6870a46 +-- request 1 (turn) -- +00:message/developer[2]: + [01] + [02] +01:message/user: + +02:message/user: + Suggest a plugin for this task. +-- request 2 (turn) -- +03:message/user: + Continue with the same recommendations. diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__request_plugin_install__catalog_plugin_recommendations_empty.snap b/codex-rs/core/tests/suite/snapshots/all__suite__request_plugin_install__catalog_plugin_recommendations_empty.snap new file mode 100644 index 0000000000..fd3de969ef --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__request_plugin_install__catalog_plugin_recommendations_empty.snap @@ -0,0 +1,53 @@ +--- +source: core/tests/suite/request_plugin_install.rs +expression: "context_snapshot::format_request_history_snapshot(\"The first receiving model controls recommendation wording without changing feature eligibility, developer role, runtime plugin identities or retained-history deduplication.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)" +--- +Scenario: The first receiving model controls recommendation wording without changing feature eligibility, developer role, runtime plugin identities or retained-history deduplication. + +## Window 1 +Settings: + include: ["reasoning.encrypted_content"] + model: "recommendations-receiving" + parallel_tool_calls: true + prompt_cache_key: "" + reasoning: {"effort":"medium"} + store: false + stream: true + text: {"verbosity":"low"} + tool_choice: "auto" + tools (11; hash=62414ae13aabdb67): + - function/exec_command: Runs a command in a PTY, returning output or a session ID for ongoing interaction.; args=[cmd, justification, login, max_output_tokens, prefix_rule, sandbox_permissions, shell, tty, workdir, yield_time_ms]; hash=c04108e51a8548c5 + - function/write_stdin: Writes characters to an existing unified exec session and returns recent output.; args=[chars, max_output_tokens, session_id, yield_time_ms]; hash=7fb62d8a3e2df4e2 + - function/list_mcp_resources: Lists resources provided by MCP servers. Resources allow servers to share data that provides cont...; args=[cursor, server]; hash=6792edaff13561bd + - function/list_mcp_resource_templates: Lists resource templates provided by MCP servers. Parameterized resource templates allow servers ...; args=[cursor, server]; hash=60f17c90ef7a4ebb + - function/read_mcp_resource: Read a specific resource from an MCP server given the server name and resource URI.; args=[server, uri]; hash=24246896b39008ae + - function/request_user_input: Request user input for one to three short questions and wait for the response. This tool is only ...; args=[questions]; hash=64a729b25c424fc6 + - custom/apply_patch: The `apply_patch` tool can be used to edit files. This is a FREEFORM tool, so do not wrap the pat...; hash=dea9f12b0111a4ba + - function/view_image: View a local image file from the filesystem when visual inspection is needed. Use this for images...; args=[detail, path]; hash=3b766d5bdec5d50e + - namespace/multi_agent_v1: Tools for spawning and managing sub-agents.; hash=2327142bda177c50 + - function/close_agent + - function/resume_agent + - function/send_input + - function/spawn_agent + - function/wait_agent + - namespace/mcp__codex_apps__calendar: Plan events and manage your calendar.; hash=9aecf0b528e2fee4 + - function/_create_event + - function/_extract_text + - function/_list_events + - web_search; hash=f103e327c6870a46 +-- request 1 (turn) -- +00:message/developer[3]: + [01] + [02] + [03] + Here is a list of plugins that are available but not installed. + + - GitHub (github@openai-curated-remote) + +01:message/user: + +02:message/user: + Suggest a plugin for this task. +-- request 2 (turn) -- +03:message/user: + Continue with the same recommendations. diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__request_plugin_install__catalog_plugin_recommendations_null.snap b/codex-rs/core/tests/suite/snapshots/all__suite__request_plugin_install__catalog_plugin_recommendations_null.snap new file mode 100644 index 0000000000..fd3de969ef --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__request_plugin_install__catalog_plugin_recommendations_null.snap @@ -0,0 +1,53 @@ +--- +source: core/tests/suite/request_plugin_install.rs +expression: "context_snapshot::format_request_history_snapshot(\"The first receiving model controls recommendation wording without changing feature eligibility, developer role, runtime plugin identities or retained-history deduplication.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)" +--- +Scenario: The first receiving model controls recommendation wording without changing feature eligibility, developer role, runtime plugin identities or retained-history deduplication. + +## Window 1 +Settings: + include: ["reasoning.encrypted_content"] + model: "recommendations-receiving" + parallel_tool_calls: true + prompt_cache_key: "" + reasoning: {"effort":"medium"} + store: false + stream: true + text: {"verbosity":"low"} + tool_choice: "auto" + tools (11; hash=62414ae13aabdb67): + - function/exec_command: Runs a command in a PTY, returning output or a session ID for ongoing interaction.; args=[cmd, justification, login, max_output_tokens, prefix_rule, sandbox_permissions, shell, tty, workdir, yield_time_ms]; hash=c04108e51a8548c5 + - function/write_stdin: Writes characters to an existing unified exec session and returns recent output.; args=[chars, max_output_tokens, session_id, yield_time_ms]; hash=7fb62d8a3e2df4e2 + - function/list_mcp_resources: Lists resources provided by MCP servers. Resources allow servers to share data that provides cont...; args=[cursor, server]; hash=6792edaff13561bd + - function/list_mcp_resource_templates: Lists resource templates provided by MCP servers. Parameterized resource templates allow servers ...; args=[cursor, server]; hash=60f17c90ef7a4ebb + - function/read_mcp_resource: Read a specific resource from an MCP server given the server name and resource URI.; args=[server, uri]; hash=24246896b39008ae + - function/request_user_input: Request user input for one to three short questions and wait for the response. This tool is only ...; args=[questions]; hash=64a729b25c424fc6 + - custom/apply_patch: The `apply_patch` tool can be used to edit files. This is a FREEFORM tool, so do not wrap the pat...; hash=dea9f12b0111a4ba + - function/view_image: View a local image file from the filesystem when visual inspection is needed. Use this for images...; args=[detail, path]; hash=3b766d5bdec5d50e + - namespace/multi_agent_v1: Tools for spawning and managing sub-agents.; hash=2327142bda177c50 + - function/close_agent + - function/resume_agent + - function/send_input + - function/spawn_agent + - function/wait_agent + - namespace/mcp__codex_apps__calendar: Plan events and manage your calendar.; hash=9aecf0b528e2fee4 + - function/_create_event + - function/_extract_text + - function/_list_events + - web_search; hash=f103e327c6870a46 +-- request 1 (turn) -- +00:message/developer[3]: + [01] + [02] + [03] + Here is a list of plugins that are available but not installed. + + - GitHub (github@openai-curated-remote) + +01:message/user: + +02:message/user: + Suggest a plugin for this task. +-- request 2 (turn) -- +03:message/user: + Continue with the same recommendations. diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__request_plugin_install__catalog_plugin_recommendations_oversized.snap b/codex-rs/core/tests/suite/snapshots/all__suite__request_plugin_install__catalog_plugin_recommendations_oversized.snap new file mode 100644 index 0000000000..fd3de969ef --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__request_plugin_install__catalog_plugin_recommendations_oversized.snap @@ -0,0 +1,53 @@ +--- +source: core/tests/suite/request_plugin_install.rs +expression: "context_snapshot::format_request_history_snapshot(\"The first receiving model controls recommendation wording without changing feature eligibility, developer role, runtime plugin identities or retained-history deduplication.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)" +--- +Scenario: The first receiving model controls recommendation wording without changing feature eligibility, developer role, runtime plugin identities or retained-history deduplication. + +## Window 1 +Settings: + include: ["reasoning.encrypted_content"] + model: "recommendations-receiving" + parallel_tool_calls: true + prompt_cache_key: "" + reasoning: {"effort":"medium"} + store: false + stream: true + text: {"verbosity":"low"} + tool_choice: "auto" + tools (11; hash=62414ae13aabdb67): + - function/exec_command: Runs a command in a PTY, returning output or a session ID for ongoing interaction.; args=[cmd, justification, login, max_output_tokens, prefix_rule, sandbox_permissions, shell, tty, workdir, yield_time_ms]; hash=c04108e51a8548c5 + - function/write_stdin: Writes characters to an existing unified exec session and returns recent output.; args=[chars, max_output_tokens, session_id, yield_time_ms]; hash=7fb62d8a3e2df4e2 + - function/list_mcp_resources: Lists resources provided by MCP servers. Resources allow servers to share data that provides cont...; args=[cursor, server]; hash=6792edaff13561bd + - function/list_mcp_resource_templates: Lists resource templates provided by MCP servers. Parameterized resource templates allow servers ...; args=[cursor, server]; hash=60f17c90ef7a4ebb + - function/read_mcp_resource: Read a specific resource from an MCP server given the server name and resource URI.; args=[server, uri]; hash=24246896b39008ae + - function/request_user_input: Request user input for one to three short questions and wait for the response. This tool is only ...; args=[questions]; hash=64a729b25c424fc6 + - custom/apply_patch: The `apply_patch` tool can be used to edit files. This is a FREEFORM tool, so do not wrap the pat...; hash=dea9f12b0111a4ba + - function/view_image: View a local image file from the filesystem when visual inspection is needed. Use this for images...; args=[detail, path]; hash=3b766d5bdec5d50e + - namespace/multi_agent_v1: Tools for spawning and managing sub-agents.; hash=2327142bda177c50 + - function/close_agent + - function/resume_agent + - function/send_input + - function/spawn_agent + - function/wait_agent + - namespace/mcp__codex_apps__calendar: Plan events and manage your calendar.; hash=9aecf0b528e2fee4 + - function/_create_event + - function/_extract_text + - function/_list_events + - web_search; hash=f103e327c6870a46 +-- request 1 (turn) -- +00:message/developer[3]: + [01] + [02] + [03] + Here is a list of plugins that are available but not installed. + + - GitHub (github@openai-curated-remote) + +01:message/user: + +02:message/user: + Suggest a plugin for this task. +-- request 2 (turn) -- +03:message/user: + Continue with the same recommendations. diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__catalog_plugin_guidance_follows_model_and_survives_resume.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__catalog_plugin_guidance_follows_model_and_survives_resume.snap new file mode 100644 index 0000000000..3303e02413 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__catalog_plugin_guidance_follows_model_and_survives_resume.snap @@ -0,0 +1,229 @@ +--- +source: core/tests/suite/scenarios.rs +expression: "context_snapshot::format_labeled_requests_snapshot(\"First and final requests show generic plugin guidance and the complete append-only history. All ten intermediate model, wording, fallback, availability and resume states are asserted in the test.\",\n&[(\"Initial bundled guidance\", &requests[0]),\n(\"Final retained history\", &requests[9])],\n&ContextSnapshotOptions::default().include_request_settings(),)" +--- +Scenario: First and final requests show generic plugin guidance and the complete append-only history. All ten intermediate model, wording, fallback, availability and resume states are asserted in the test. + +## Window 1 +Settings: + include: ["reasoning.encrypted_content"] + model: "plugins-default" + parallel_tool_calls: true + prompt_cache_key: "" + reasoning: {"effort":"medium"} + store: false + stream: true + text: {"verbosity":"low"} + tool_choice: "auto" + tools (7; hash=994c9baf5d9a36a0): + - function/exec_command: Runs a command in a PTY, returning output or a session ID for ongoing interaction.; args=[cmd, justification, login, max_output_tokens, prefix_rule, sandbox_permissions, shell, tty, workdir, yield_time_ms]; hash=c04108e51a8548c5 + - function/write_stdin: Writes characters to an existing unified exec session and returns recent output.; args=[chars, max_output_tokens, session_id, yield_time_ms]; hash=7fb62d8a3e2df4e2 + - function/request_user_input: Request user input for one to three short questions and wait for the response. This tool is only ...; args=[questions]; hash=64a729b25c424fc6 + - custom/apply_patch: The `apply_patch` tool can be used to edit files. This is a FREEFORM tool, so do not wrap the pat...; hash=dea9f12b0111a4ba + - function/view_image: View a local image file from the filesystem when visual inspection is needed. Use this for images...; args=[detail, path]; hash=3b766d5bdec5d50e + - tool_search: # Tool discovery Searches over deferred tool metadata with BM25 and exposes matching tools for t...; args=[limit, query]; hash=b573ed69c0b0e6c7 + - web_search; hash=f103e327c6870a46 +-- request 1 (turn; Initial bundled guidance) -- +00:message/developer[4]: + [01] + ## Skills + A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list ...ed into an absolute path using the skill roots table. [hash=ca27c3957f42502b] + ### Skill roots + - `r0` = `` + - `r1` = `/test` + ### Available skills + - final-check: Review a final brief (file: r0/final-check/SKILL.md) + + - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them. + - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why. + - Context hygiene: + - Progressive disclosure applies to selecting relevant files, not partially reading a selected instruc...Do not load unrelated references, scripts, or assets. [hash=d6eeb13522175402] + - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked. + - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice. + - Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue. + + [02] + Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `read-only`: The san... permits reading files. Network access is restricted. [hash=be7da4425f89053c] + # Escalation Requests + + Commands are run outside the sandbox if they are approved by the user, or match an existing rule that al...hell control operators, including but not limited to: [hash=d24ebf5fa4e4b9bb] + + - Pipes: | + - Logical operators: &&, || + + NEVER provide a prefix_rule if your command uses a heredoc or herestring. + + ### Examples + Good examples of prefixes: + - ["npm", "run", "dev"] + - ["gh", "pr", "check"] + - ["cargo", "test"] + + [03] + ## Plugins + A plugin is a local bundle of skills, MCP servers, and apps. + ### How to use plugins + - Skill naming: If a plugin contributes skills, those skill entries are prefixed with `plugin_name:` in the Skills list. + - MCP naming: Plugin-provided MCP tools keep standard MCP identifiers such as `mcp__server__tool`; use tool provenance to tell which plugin they come from. + - Trigger rules: If the user explicitly names a plugin, prefer capabilities associated with that plugin for that turn. + - Relationship to capabilities: Plugins are not invoked directly. Use their underlying skills, MCP tools, and app tools to help solve the task. + - Relevance: Determine what a plugin can help with from explicit user mention or from the plugin-associa..., MCP tools, and apps exposed elsewhere in this turn. [hash=620459a4e7b590f0] + - Missing/blocked: If the user requests a plugin that does not have relevant callable capabilities for t..., say so briefly and continue with the best fallback. [hash=746b0469a8f6cb3e] + + [04] + Deferred tool namespaces: + - multi_agent_v1: Tools for spawning and managing sub-agents. + +01:message/user: + + + + + + [hash=e8c1de60307d0a3d] + +02:message/user: + Use the bundled plugin guidance. + +## Window 2 (after request 1) +Settings: relative to window 1 + model: "plugins-a" + tools (10; hash=df5c5997ff843305): + + function/list_mcp_resources: Lists resources provided by MCP servers. Resources allow servers to share data that provides cont...; args=[cursor, server]; hash=6792edaff13561bd + + function/list_mcp_resource_templates: Lists resource templates provided by MCP servers. Parameterized resource templates allow servers ...; args=[cursor, server]; hash=60f17c90ef7a4ebb + + function/read_mcp_resource: Read a specific resource from an MCP server given the server name and resource URI.; args=[server, uri]; hash=24246896b39008ae +-- request 2 (turn; Final retained history) -- +00:message/developer[4]: + [01] + ## Skills + A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list ...ed into an absolute path using the skill roots table. [hash=ca27c3957f42502b] + ### Skill roots + - `r0` = `` + - `r1` = `/test` + ### Available skills + - final-check: Review a final brief (file: r0/final-check/SKILL.md) + + - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them. + - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why. + - Context hygiene: + - Progressive disclosure applies to selecting relevant files, not partially reading a selected instruc...Do not load unrelated references, scripts, or assets. [hash=d6eeb13522175402] + - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked. + - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice. + - Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue. + + [02] + Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `read-only`: The san... permits reading files. Network access is restricted. [hash=be7da4425f89053c] + # Escalation Requests + + Commands are run outside the sandbox if they are approved by the user, or match an existing rule that al...hell control operators, including but not limited to: [hash=d24ebf5fa4e4b9bb] + + - Pipes: | + - Logical operators: &&, || + + NEVER provide a prefix_rule if your command uses a heredoc or herestring. + + ### Examples + Good examples of prefixes: + - ["npm", "run", "dev"] + - ["gh", "pr", "check"] + - ["cargo", "test"] + + [03] + ## Plugins + A plugin is a local bundle of skills, MCP servers, and apps. + ### How to use plugins + - Skill naming: If a plugin contributes skills, those skill entries are prefixed with `plugin_name:` in the Skills list. + - MCP naming: Plugin-provided MCP tools keep standard MCP identifiers such as `mcp__server__tool`; use tool provenance to tell which plugin they come from. + - Trigger rules: If the user explicitly names a plugin, prefer capabilities associated with that plugin for that turn. + - Relationship to capabilities: Plugins are not invoked directly. Use their underlying skills, MCP tools, and app tools to help solve the task. + - Relevance: Determine what a plugin can help with from explicit user mention or from the plugin-associa..., MCP tools, and apps exposed elsewhere in this turn. [hash=620459a4e7b590f0] + - Missing/blocked: If the user requests a plugin that does not have relevant callable capabilities for t..., say so briefly and continue with the best fallback. [hash=746b0469a8f6cb3e] + + [04] + Deferred tool namespaces: + - multi_agent_v1: Tools for spawning and managing sub-agents. + +01:message/user: + + + + + + [hash=e8c1de60307d0a3d] + +02:message/user: + Use the bundled plugin guidance. +03:message/developer: + + These plugin usage instructions replace all previously provided plugin usage instructions. + + + ## Plugins + Catalog intro A: {{literal}}. + ### How to use plugins + Catalog usage A. + Keep this line. + +04:message/user: + Continue with the selected model. +05:message/user: + Continue with the selected model. +06:message/developer: + + These plugin usage instructions replace all previously provided plugin usage instructions. + + + ## Plugins + Catalog intro B. + ### How to use plugins + Catalog usage B. + +07:message/user: + Continue with the selected model. +08:message/user: + + + + + + ...ntry> [hash=8fc9d186c1133657] + +09:message/user: + Continue after restarting with the same guidance. +10:message/developer: + + These plugin usage instructions replace all previously provided plugin usage instructions. + + + ## Plugins + A plugin is a local bundle of skills, MCP servers, and apps. + ### How to use plugins + - Skill naming: If a plugin contributes skills, those skill entries are prefixed with `plugin_name:` in the Skills list. + - MCP naming: Plugin-provided MCP tools keep standard MCP identifiers such as `mcp__server__tool`; use tool provenance to tell which plugin they come from. + - Trigger rules: If the user explicitly names a plugin, prefer capabilities associated with that plugin for that turn. + - Relationship to capabilities: Plugins are not invoked directly. Use their underlying skills, MCP tools, and app tools to help solve the task. + - Relevance: Determine what a plugin can help with from explicit user mention or from the plugin-associa..., MCP tools, and apps exposed elsewhere in this turn. [hash=620459a4e7b590f0] + - Missing/blocked: If the user requests a plugin that does not have relevant callable capabilities for t..., say so briefly and continue with the best fallback. [hash=746b0469a8f6cb3e] + +11:message/user: + Continue with the selected model. +12:message/user: + Continue with the selected model. +13:message/user: + Continue with the selected model. +14:message/developer: + + The previously provided plugin usage instructions no longer apply. + +15:message/user: + Continue with the selected model. +16:message/developer: + + ## Plugins + Catalog intro A: {{literal}}. + ### How to use plugins + Catalog usage A. + Keep this line. + +17:message/user: + Continue with the selected model. diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__catalog_selected_plugin_omission_bounds_runtime_capabilities.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__catalog_selected_plugin_omission_bounds_runtime_capabilities.snap new file mode 100644 index 0000000000..d76357726f --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__catalog_selected_plugin_omission_bounds_runtime_capabilities.snap @@ -0,0 +1,92 @@ +--- +source: core/tests/suite/scenarios.rs +expression: "context_snapshot::format_request_history_snapshot(\"A catalog omission suffix is reserved within the final selected-plugin bound after substituting a long Unicode runtime capability name.\",\n&mock.requests(),\n&ContextSnapshotOptions::default().include_request_settings(),)" +--- +Scenario: A catalog omission suffix is reserved within the final selected-plugin bound after substituting a long Unicode runtime capability name. + +## Window 1 +Settings: + include: ["reasoning.encrypted_content"] + model: "gpt-5.5" + parallel_tool_calls: true + prompt_cache_key: "" + reasoning: {"effort":"medium"} + store: false + stream: true + text: {"verbosity":"low"} + tool_choice: "auto" + tools (10; hash=df5c5997ff843305): + - function/exec_command: Runs a command in a PTY, returning output or a session ID for ongoing interaction.; args=[cmd, justification, login, max_output_tokens, prefix_rule, sandbox_permissions, shell, tty, workdir, yield_time_ms]; hash=c04108e51a8548c5 + - function/write_stdin: Writes characters to an existing unified exec session and returns recent output.; args=[chars, max_output_tokens, session_id, yield_time_ms]; hash=7fb62d8a3e2df4e2 + - function/list_mcp_resources: Lists resources provided by MCP servers. Resources allow servers to share data that provides cont...; args=[cursor, server]; hash=6792edaff13561bd + - function/list_mcp_resource_templates: Lists resource templates provided by MCP servers. Parameterized resource templates allow servers ...; args=[cursor, server]; hash=60f17c90ef7a4ebb + - function/read_mcp_resource: Read a specific resource from an MCP server given the server name and resource URI.; args=[server, uri]; hash=24246896b39008ae + - function/request_user_input: Request user input for one to three short questions and wait for the response. This tool is only ...; args=[questions]; hash=64a729b25c424fc6 + - custom/apply_patch: The `apply_patch` tool can be used to edit files. This is a FREEFORM tool, so do not wrap the pat...; hash=dea9f12b0111a4ba + - function/view_image: View a local image file from the filesystem when visual inspection is needed. Use this for images...; args=[detail, path]; hash=3b766d5bdec5d50e + - tool_search: # Tool discovery Searches over deferred tool metadata with BM25 and exposes matching tools for t...; args=[limit, query]; hash=b573ed69c0b0e6c7 + - web_search; hash=f103e327c6870a46 +-- request 1 (turn) -- +00:message/developer[4]: + [01] + ## Skills + A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list ...ed into an absolute path using the skill roots table. [hash=ca27c3957f42502b] + ### Skill roots + - `r0` = `` + - `r1` = `/test` + ### Available skills + - final-check: Review a final brief (file: r0/final-check/SKILL.md) + + - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them. + - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why. + - Context hygiene: + - Progressive disclosure applies to selecting relevant files, not partially reading a selected instruc...Do not load unrelated references, scripts, or assets. [hash=d6eeb13522175402] + - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked. + - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice. + - Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue. + + [02] + Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `read-only`: The san... permits reading files. Network access is restricted. [hash=be7da4425f89053c] + # Escalation Requests + + Commands are run outside the sandbox if they are approved by the user, or match an existing rule that al...hell control operators, including but not limited to: [hash=d24ebf5fa4e4b9bb] + + - Pipes: | + - Logical operators: &&, || + + NEVER provide a prefix_rule if your command uses a heredoc or herestring. + + ### Examples + Good examples of prefixes: + - ["npm", "run", "dev"] + - ["gh", "pr", "check"] + - ["cargo", "test"] + + [03] + ## Apps (Connectors) + Apps (Connectors) can be explicitly triggered in user messages in the format `[$app-name](app://{connect...long as the context suggests usage of available apps. [hash=8c2ddeb1adaf185d] + An app is equivalent to a set of MCP tools within the `codex_apps` MCP. + An installed app's MCP tools are either provided to you already, or can be lazy-loaded through the `tool...re searchable by `tools_search` will be listed by it. [hash=4f9c0d35da3968a5] + Do not additionally call list_mcp_resources or list_mcp_resource_templates for apps. + + [04] + Deferred tool namespaces: + - mcp__codex_apps__calendar_caf__calendar_caf__calendar_caf__calendar_caf__calendar_caf__calendar_caf__calendar_caf: Plan events and manage your calendar. + - multi_agent_v1: Tools for spawning and managing sub-agents. + +01:message/user: + + + + + + [hash=e8c1de60307d0a3d] + +02:message/user: + Use the selected plugin. +03:message/developer: + Catalog selected notes. + - For the user request that explicitly selected this plugin, and only for that request, if `tool_search`...s before falling back to unrelated or built-in tools. [hash=a8711ebc98aa4e05] + - Skills from this plugin are prefixed with `notes:`. + Catalog apps `Calendar café Calendar café Calendar café Calendar café Calendar café Calendar café Calend...dar café Calendar café Calendar café Calendar café Ca [hash=b7ebb1f4aabcf30e] + Catalog capability list omitted here. diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__catalog_selected_plugin_wording_apps_false_simplified_false.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__catalog_selected_plugin_wording_apps_false_simplified_false.snap new file mode 100644 index 0000000000..0f1dc063c9 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__catalog_selected_plugin_wording_apps_false_simplified_false.snap @@ -0,0 +1,167 @@ +--- +source: core/tests/suite/scenarios.rs +expression: "context_snapshot::format_request_history_snapshot(\"Selected-plugin wording follows the receiving model while live skills, MCP, App policy, explicit selection eligibility and simplified selection dispatch remain authoritative.\",\n&requests, &ContextSnapshotOptions::default().include_request_settings(),)" +--- +Scenario: Selected-plugin wording follows the receiving model while live skills, MCP, App policy, explicit selection eligibility and simplified selection dispatch remain authoritative. + +## Window 1 +Settings: + include: ["reasoning.encrypted_content"] + model: "selected-custom" + parallel_tool_calls: true + prompt_cache_key: "" + reasoning: {"effort":"medium"} + store: false + stream: true + text: {"verbosity":"low"} + tool_choice: "auto" + tools (11; hash=70f020489861129c): + - function/exec_command: Runs a command in a PTY, returning output or a session ID for ongoing interaction.; args=[cmd, justification, login, max_output_tokens, prefix_rule, sandbox_permissions, shell, tty, workdir, yield_time_ms]; hash=c04108e51a8548c5 + - function/write_stdin: Writes characters to an existing unified exec session and returns recent output.; args=[chars, max_output_tokens, session_id, yield_time_ms]; hash=7fb62d8a3e2df4e2 + - function/list_mcp_resources: Lists resources provided by MCP servers. Resources allow servers to share data that provides cont...; args=[cursor, server]; hash=6792edaff13561bd + - function/list_mcp_resource_templates: Lists resource templates provided by MCP servers. Parameterized resource templates allow servers ...; args=[cursor, server]; hash=60f17c90ef7a4ebb + - function/read_mcp_resource: Read a specific resource from an MCP server given the server name and resource URI.; args=[server, uri]; hash=24246896b39008ae + - function/request_user_input: Request user input for one to three short questions and wait for the response. This tool is only ...; args=[questions]; hash=64a729b25c424fc6 + - custom/apply_patch: The `apply_patch` tool can be used to edit files. This is a FREEFORM tool, so do not wrap the pat...; hash=dea9f12b0111a4ba + - function/view_image: View a local image file from the filesystem when visual inspection is needed. Use this for images...; args=[detail, path]; hash=3b766d5bdec5d50e + - namespace/multi_agent_v1: Tools for spawning and managing sub-agents.; hash=2327142bda177c50 + - function/close_agent + - function/resume_agent + - function/send_input + - function/spawn_agent + - function/wait_agent + - namespace/mcp__notes: Use these tools to exercise the rmcp test server.; hash=508dce78d8cc5d3b + - function/client_capabilities + - function/cwd + - function/echo + - function/echo_tool + - function/encrypted_output + - function/image + - function/image_scenario + - function/sandbox_meta + - function/sync + - function/sync_readonly + - web_search; hash=f103e327c6870a46 +-- request 1 (turn) -- +00:message/developer[2]: + [01] + ## Skills + A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list ...ed into an absolute path using the skill roots table. [hash=ca27c3957f42502b] + ### Skill roots + - `r0` = `` + - `r1` = `/test` + ### Available skills + - final-check: Review a final brief (file: r0/final-check/SKILL.md) + + - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them. + - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why. + - Context hygiene: + - Progressive disclosure applies to selecting relevant files, not partially reading a selected instruc...Do not load unrelated references, scripts, or assets. [hash=d6eeb13522175402] + - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked. + - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice. + - Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue. + + [02] + Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `read-only`: The san... permits reading files. Network access is restricted. [hash=be7da4425f89053c] + # Escalation Requests + + Commands are run outside the sandbox if they are approved by the user, or match an existing rule that al...hell control operators, including but not limited to: [hash=d24ebf5fa4e4b9bb] + + - Pipes: | + - Logical operators: &&, || + + NEVER provide a prefix_rule if your command uses a heredoc or herestring. + + ### Examples + Good examples of prefixes: + - ["npm", "run", "dev"] + - ["gh", "pr", "check"] + - ["cargo", "test"] + +01:message/user: + + + + + + [hash=e8c1de60307d0a3d] + +02:message/user: + List the available capabilities. +-- request 2 (turn) -- +03:message/user: + Use the selected plugin. +04:message/developer: + Catalog selected notes. + Catalog skills notes. + Catalog MCP `notes`. + Catalog closing. +-- request 3 (turn) -- +05:message/user: + Continue without a plugin selection. + +## Window 2 (after request 3) +Settings: relative to window 1 + model: "selected-fallback" +-- request 4 (turn) -- +00:message/developer[2]: + [01] + ## Skills + A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list ...ed into an absolute path using the skill roots table. [hash=ca27c3957f42502b] + ### Skill roots + - `r0` = `` + - `r1` = `/test` + ### Available skills + - final-check: Review a final brief (file: r0/final-check/SKILL.md) + + - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them. + - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why. + - Context hygiene: + - Progressive disclosure applies to selecting relevant files, not partially reading a selected instruc...Do not load unrelated references, scripts, or assets. [hash=d6eeb13522175402] + - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked. + - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice. + - Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue. + + [02] + Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `read-only`: The san... permits reading files. Network access is restricted. [hash=be7da4425f89053c] + # Escalation Requests + + Commands are run outside the sandbox if they are approved by the user, or match an existing rule that al...hell control operators, including but not limited to: [hash=d24ebf5fa4e4b9bb] + + - Pipes: | + - Logical operators: &&, || + + NEVER provide a prefix_rule if your command uses a heredoc or herestring. + + ### Examples + Good examples of prefixes: + - ["npm", "run", "dev"] + - ["gh", "pr", "check"] + - ["cargo", "test"] + +01:message/user: + + + + + + [hash=e8c1de60307d0a3d] + +02:message/user: + List the available capabilities. +03:message/user: + Use the selected plugin. +04:message/developer: + Catalog selected notes. + Catalog skills notes. + Catalog MCP `notes`. + Catalog closing. +05:message/user: + Continue without a plugin selection. +06:message/user: + Use the selected plugin again. +07:message/developer: + Capabilities from the `notes` plugin: + - Skills from this plugin are prefixed with `notes:`. + - MCP servers from this plugin available in this session: `notes`. + Use these plugin-associated capabilities to help solve the task. diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__catalog_selected_plugin_wording_apps_true_simplified_false.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__catalog_selected_plugin_wording_apps_true_simplified_false.snap new file mode 100644 index 0000000000..c924bff461 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__catalog_selected_plugin_wording_apps_true_simplified_false.snap @@ -0,0 +1,191 @@ +--- +source: core/tests/suite/scenarios.rs +expression: "context_snapshot::format_request_history_snapshot(\"Selected-plugin wording follows the receiving model while live skills, MCP, App policy, explicit selection eligibility and simplified selection dispatch remain authoritative.\",\n&requests, &ContextSnapshotOptions::default().include_request_settings(),)" +--- +Scenario: Selected-plugin wording follows the receiving model while live skills, MCP, App policy, explicit selection eligibility and simplified selection dispatch remain authoritative. + +## Window 1 +Settings: + include: ["reasoning.encrypted_content"] + model: "selected-custom" + parallel_tool_calls: true + prompt_cache_key: "" + reasoning: {"effort":"medium"} + store: false + stream: true + text: {"verbosity":"low"} + tool_choice: "auto" + tools (12; hash=222ce6d65d94fe37): + - function/exec_command: Runs a command in a PTY, returning output or a session ID for ongoing interaction.; args=[cmd, justification, login, max_output_tokens, prefix_rule, sandbox_permissions, shell, tty, workdir, yield_time_ms]; hash=c04108e51a8548c5 + - function/write_stdin: Writes characters to an existing unified exec session and returns recent output.; args=[chars, max_output_tokens, session_id, yield_time_ms]; hash=7fb62d8a3e2df4e2 + - function/list_mcp_resources: Lists resources provided by MCP servers. Resources allow servers to share data that provides cont...; args=[cursor, server]; hash=6792edaff13561bd + - function/list_mcp_resource_templates: Lists resource templates provided by MCP servers. Parameterized resource templates allow servers ...; args=[cursor, server]; hash=60f17c90ef7a4ebb + - function/read_mcp_resource: Read a specific resource from an MCP server given the server name and resource URI.; args=[server, uri]; hash=24246896b39008ae + - function/request_user_input: Request user input for one to three short questions and wait for the response. This tool is only ...; args=[questions]; hash=64a729b25c424fc6 + - custom/apply_patch: The `apply_patch` tool can be used to edit files. This is a FREEFORM tool, so do not wrap the pat...; hash=dea9f12b0111a4ba + - function/view_image: View a local image file from the filesystem when visual inspection is needed. Use this for images...; args=[detail, path]; hash=3b766d5bdec5d50e + - namespace/multi_agent_v1: Tools for spawning and managing sub-agents.; hash=2327142bda177c50 + - function/close_agent + - function/resume_agent + - function/send_input + - function/spawn_agent + - function/wait_agent + - namespace/mcp__notes: Use these tools to exercise the rmcp test server.; hash=508dce78d8cc5d3b + - function/client_capabilities + - function/cwd + - function/echo + - function/echo_tool + - function/encrypted_output + - function/image + - function/image_scenario + - function/sandbox_meta + - function/sync + - function/sync_readonly + - namespace/mcp__codex_apps__calendar: Plan events and manage your calendar.; hash=1ab035994e88edae + - function/_create_event + - function/_extract_text + - function/_list_events + - web_search; hash=f103e327c6870a46 +-- request 1 (turn) -- +00:message/developer[3]: + [01] + ## Skills + A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list ...ed into an absolute path using the skill roots table. [hash=ca27c3957f42502b] + ### Skill roots + - `r0` = `` + - `r1` = `/test` + ### Available skills + - final-check: Review a final brief (file: r0/final-check/SKILL.md) + + - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them. + - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why. + - Context hygiene: + - Progressive disclosure applies to selecting relevant files, not partially reading a selected instruc...Do not load unrelated references, scripts, or assets. [hash=d6eeb13522175402] + - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked. + - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice. + - Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue. + + [02] + Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `read-only`: The san... permits reading files. Network access is restricted. [hash=be7da4425f89053c] + # Escalation Requests + + Commands are run outside the sandbox if they are approved by the user, or match an existing rule that al...hell control operators, including but not limited to: [hash=d24ebf5fa4e4b9bb] + + - Pipes: | + - Logical operators: &&, || + + NEVER provide a prefix_rule if your command uses a heredoc or herestring. + + ### Examples + Good examples of prefixes: + - ["npm", "run", "dev"] + - ["gh", "pr", "check"] + - ["cargo", "test"] + + [03] + ## Apps (Connectors) + Apps (Connectors) can be explicitly triggered in user messages in the format `[$app-name](app://{connect...long as the context suggests usage of available apps. [hash=8c2ddeb1adaf185d] + An app is equivalent to a set of MCP tools within the `codex_apps` MCP. + An installed app's MCP tools are either provided to you already, or can be lazy-loaded through the `tool...re searchable by `tools_search` will be listed by it. [hash=4f9c0d35da3968a5] + Do not additionally call list_mcp_resources or list_mcp_resource_templates for apps. + +01:message/user: + + + + + + [hash=e8c1de60307d0a3d] + +02:message/user: + List the available capabilities. +-- request 2 (turn) -- +03:message/user: + Use the selected plugin. +04:message/developer: + Catalog selected notes. + Catalog search for this explicit request. + Catalog skills notes. + Catalog apps `Calendar`. + Catalog MCP `notes`. + Catalog closing. +-- request 3 (turn) -- +05:message/user: + Continue without a plugin selection. + +## Window 2 (after request 3) +Settings: relative to window 1 + model: "selected-fallback" +-- request 4 (turn) -- +00:message/developer[3]: + [01] + ## Skills + A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list ...ed into an absolute path using the skill roots table. [hash=ca27c3957f42502b] + ### Skill roots + - `r0` = `` + - `r1` = `/test` + ### Available skills + - final-check: Review a final brief (file: r0/final-check/SKILL.md) + + - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them. + - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why. + - Context hygiene: + - Progressive disclosure applies to selecting relevant files, not partially reading a selected instruc...Do not load unrelated references, scripts, or assets. [hash=d6eeb13522175402] + - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked. + - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice. + - Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue. + + [02] + Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `read-only`: The san... permits reading files. Network access is restricted. [hash=be7da4425f89053c] + # Escalation Requests + + Commands are run outside the sandbox if they are approved by the user, or match an existing rule that al...hell control operators, including but not limited to: [hash=d24ebf5fa4e4b9bb] + + - Pipes: | + - Logical operators: &&, || + + NEVER provide a prefix_rule if your command uses a heredoc or herestring. + + ### Examples + Good examples of prefixes: + - ["npm", "run", "dev"] + - ["gh", "pr", "check"] + - ["cargo", "test"] + + [03] + ## Apps (Connectors) + Apps (Connectors) can be explicitly triggered in user messages in the format `[$app-name](app://{connect...long as the context suggests usage of available apps. [hash=8c2ddeb1adaf185d] + An app is equivalent to a set of MCP tools within the `codex_apps` MCP. + An installed app's MCP tools are either provided to you already, or can be lazy-loaded through the `tool...re searchable by `tools_search` will be listed by it. [hash=4f9c0d35da3968a5] + Do not additionally call list_mcp_resources or list_mcp_resource_templates for apps. + +01:message/user: + + + + + + [hash=e8c1de60307d0a3d] + +02:message/user: + List the available capabilities. +03:message/user: + Use the selected plugin. +04:message/developer: + Catalog selected notes. + Catalog search for this explicit request. + Catalog skills notes. + Catalog apps `Calendar`. + Catalog MCP `notes`. + Catalog closing. +05:message/user: + Continue without a plugin selection. +06:message/user: + Use the selected plugin again. +07:message/developer: + Capabilities from the `notes` plugin: + Catalog search from the receiving model. + - Skills from this plugin are prefixed with `notes:`. + - Apps from this plugin available in this session: `Calendar`. + - MCP servers from this plugin available in this session: `notes`. + Use these plugin-associated capabilities to help solve the task. diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__catalog_selected_plugin_wording_apps_true_simplified_true.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__catalog_selected_plugin_wording_apps_true_simplified_true.snap new file mode 100644 index 0000000000..0a6fd8ca66 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__catalog_selected_plugin_wording_apps_true_simplified_true.snap @@ -0,0 +1,176 @@ +--- +source: core/tests/suite/scenarios.rs +expression: "context_snapshot::format_request_history_snapshot(\"Selected-plugin wording follows the receiving model while live skills, MCP, App policy, explicit selection eligibility and simplified selection dispatch remain authoritative.\",\n&requests, &ContextSnapshotOptions::default().include_request_settings(),)" +--- +Scenario: Selected-plugin wording follows the receiving model while live skills, MCP, App policy, explicit selection eligibility and simplified selection dispatch remain authoritative. + +## Window 1 +Settings: + include: ["reasoning.encrypted_content"] + model: "selected-custom" + parallel_tool_calls: true + prompt_cache_key: "" + reasoning: {"effort":"medium"} + store: false + stream: true + text: {"verbosity":"low"} + tool_choice: "auto" + tools (12; hash=222ce6d65d94fe37): + - function/exec_command: Runs a command in a PTY, returning output or a session ID for ongoing interaction.; args=[cmd, justification, login, max_output_tokens, prefix_rule, sandbox_permissions, shell, tty, workdir, yield_time_ms]; hash=c04108e51a8548c5 + - function/write_stdin: Writes characters to an existing unified exec session and returns recent output.; args=[chars, max_output_tokens, session_id, yield_time_ms]; hash=7fb62d8a3e2df4e2 + - function/list_mcp_resources: Lists resources provided by MCP servers. Resources allow servers to share data that provides cont...; args=[cursor, server]; hash=6792edaff13561bd + - function/list_mcp_resource_templates: Lists resource templates provided by MCP servers. Parameterized resource templates allow servers ...; args=[cursor, server]; hash=60f17c90ef7a4ebb + - function/read_mcp_resource: Read a specific resource from an MCP server given the server name and resource URI.; args=[server, uri]; hash=24246896b39008ae + - function/request_user_input: Request user input for one to three short questions and wait for the response. This tool is only ...; args=[questions]; hash=64a729b25c424fc6 + - custom/apply_patch: The `apply_patch` tool can be used to edit files. This is a FREEFORM tool, so do not wrap the pat...; hash=dea9f12b0111a4ba + - function/view_image: View a local image file from the filesystem when visual inspection is needed. Use this for images...; args=[detail, path]; hash=3b766d5bdec5d50e + - namespace/multi_agent_v1: Tools for spawning and managing sub-agents.; hash=2327142bda177c50 + - function/close_agent + - function/resume_agent + - function/send_input + - function/spawn_agent + - function/wait_agent + - namespace/mcp__notes: Use these tools to exercise the rmcp test server.; hash=508dce78d8cc5d3b + - function/client_capabilities + - function/cwd + - function/echo + - function/echo_tool + - function/encrypted_output + - function/image + - function/image_scenario + - function/sandbox_meta + - function/sync + - function/sync_readonly + - namespace/mcp__codex_apps__calendar: Plan events and manage your calendar.; hash=1ab035994e88edae + - function/_create_event + - function/_extract_text + - function/_list_events + - web_search; hash=f103e327c6870a46 +-- request 1 (turn) -- +00:message/developer[3]: + [01] + ## Skills + A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list ...ed into an absolute path using the skill roots table. [hash=ca27c3957f42502b] + ### Skill roots + - `r0` = `` + - `r1` = `/test` + ### Available skills + - final-check: Review a final brief (file: r0/final-check/SKILL.md) + + - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them. + - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why. + - Context hygiene: + - Progressive disclosure applies to selecting relevant files, not partially reading a selected instruc...Do not load unrelated references, scripts, or assets. [hash=d6eeb13522175402] + - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked. + - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice. + - Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue. + + [02] + Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `read-only`: The san... permits reading files. Network access is restricted. [hash=be7da4425f89053c] + # Escalation Requests + + Commands are run outside the sandbox if they are approved by the user, or match an existing rule that al...hell control operators, including but not limited to: [hash=d24ebf5fa4e4b9bb] + + - Pipes: | + - Logical operators: &&, || + + NEVER provide a prefix_rule if your command uses a heredoc or herestring. + + ### Examples + Good examples of prefixes: + - ["npm", "run", "dev"] + - ["gh", "pr", "check"] + - ["cargo", "test"] + + [03] + ## Apps (Connectors) + Apps (Connectors) can be explicitly triggered in user messages in the format `[$app-name](app://{connect...long as the context suggests usage of available apps. [hash=8c2ddeb1adaf185d] + An app is equivalent to a set of MCP tools within the `codex_apps` MCP. + An installed app's MCP tools are either provided to you already, or can be lazy-loaded through the `tool...re searchable by `tools_search` will be listed by it. [hash=4f9c0d35da3968a5] + Do not additionally call list_mcp_resources or list_mcp_resource_templates for apps. + +01:message/user: + + + + + + [hash=e8c1de60307d0a3d] + +02:message/user: + List the available capabilities. +-- request 2 (turn) -- +03:message/user: + Use the selected plugin. +04:message/developer: + The user selected the `notes` plugin. Use its available skills and tools to complete this request. +-- request 3 (turn) -- +05:message/user: + Continue without a plugin selection. + +## Window 2 (after request 3) +Settings: relative to window 1 + model: "selected-fallback" +-- request 4 (turn) -- +00:message/developer[3]: + [01] + ## Skills + A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list ...ed into an absolute path using the skill roots table. [hash=ca27c3957f42502b] + ### Skill roots + - `r0` = `` + - `r1` = `/test` + ### Available skills + - final-check: Review a final brief (file: r0/final-check/SKILL.md) + + - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them. + - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why. + - Context hygiene: + - Progressive disclosure applies to selecting relevant files, not partially reading a selected instruc...Do not load unrelated references, scripts, or assets. [hash=d6eeb13522175402] + - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked. + - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice. + - Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue. + + [02] + Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `read-only`: The san... permits reading files. Network access is restricted. [hash=be7da4425f89053c] + # Escalation Requests + + Commands are run outside the sandbox if they are approved by the user, or match an existing rule that al...hell control operators, including but not limited to: [hash=d24ebf5fa4e4b9bb] + + - Pipes: | + - Logical operators: &&, || + + NEVER provide a prefix_rule if your command uses a heredoc or herestring. + + ### Examples + Good examples of prefixes: + - ["npm", "run", "dev"] + - ["gh", "pr", "check"] + - ["cargo", "test"] + + [03] + ## Apps (Connectors) + Apps (Connectors) can be explicitly triggered in user messages in the format `[$app-name](app://{connect...long as the context suggests usage of available apps. [hash=8c2ddeb1adaf185d] + An app is equivalent to a set of MCP tools within the `codex_apps` MCP. + An installed app's MCP tools are either provided to you already, or can be lazy-loaded through the `tool...re searchable by `tools_search` will be listed by it. [hash=4f9c0d35da3968a5] + Do not additionally call list_mcp_resources or list_mcp_resource_templates for apps. + +01:message/user: + + + + + + [hash=e8c1de60307d0a3d] + +02:message/user: + List the available capabilities. +03:message/user: + Use the selected plugin. +04:message/developer: + The user selected the `notes` plugin. Use its available skills and tools to complete this request. +05:message/user: + Continue without a plugin selection. +06:message/user: + Use the selected plugin again. +07:message/developer: + The user selected the `notes` plugin. Use its available skills and tools to complete this request. diff --git a/codex-rs/models-manager/src/model_info_tests.rs b/codex-rs/models-manager/src/model_info_tests.rs index dc061ccffe..a4a84ec23e 100644 --- a/codex-rs/models-manager/src/model_info_tests.rs +++ b/codex-rs/models-manager/src/model_info_tests.rs @@ -94,6 +94,7 @@ fn base_instruction_override_is_literal_and_preserves_catalog_messages() { apps: Some(AppsMessages { usage_instructions: Some("Catalog Apps instructions.".to_string()), }), + plugins: None, content_filter_guidance: None, persistent_instructions: Some(persistent_instructions.to_string()), tools: Some(ToolMessages { diff --git a/codex-rs/prompts/src/lib.rs b/codex-rs/prompts/src/lib.rs index c6ba31a387..7690ceac6b 100644 --- a/codex-rs/prompts/src/lib.rs +++ b/codex-rs/prompts/src/lib.rs @@ -17,10 +17,13 @@ pub use guardian_instructions::render_guardian_rejection; pub use model_instructions::render_model_instructions; pub use model_messages::ResolvedAutoReviewMessages; pub use model_messages::ResolvedCollaborationModeMessages; +pub use model_messages::ResolvedGenericPluginMessages; pub use model_messages::ResolvedIncrementalToolMessages; pub use model_messages::ResolvedMessage; pub use model_messages::ResolvedModelMessages; pub use model_messages::ResolvedMultiAgentMessages; +pub use model_messages::ResolvedPluginRecommendationMessages; +pub use model_messages::ResolvedSelectedPluginMessages; pub use multi_agent_instructions::MultiAgentRoleInstructions; pub use permissions_instructions::ApprovalPromptContext; pub use permissions_instructions::PermissionsInstructions; diff --git a/codex-rs/prompts/src/model_messages.rs b/codex-rs/prompts/src/model_messages.rs index c3f7e43c3f..ab295566fa 100644 --- a/codex-rs/prompts/src/model_messages.rs +++ b/codex-rs/prompts/src/model_messages.rs @@ -18,11 +18,15 @@ mod guardian; mod incremental_tools; mod multi_agent; pub(crate) mod permissions; +mod plugins; pub use collaboration::ResolvedCollaborationModeMessages; pub use guardian::ResolvedAutoReviewMessages; pub use incremental_tools::ResolvedIncrementalToolMessages; pub use multi_agent::ResolvedMultiAgentMessages; +pub use plugins::ResolvedGenericPluginMessages; +pub use plugins::ResolvedPluginRecommendationMessages; +pub use plugins::ResolvedSelectedPluginMessages; /// Text together with whether it was supplied by the catalog, even when it equals the default. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -279,3 +283,7 @@ impl<'a> ResolvedModelMessages<'a> { .unwrap_or(CONTENT_FILTER_GUIDANCE) } } + +#[cfg(test)] +#[path = "model_messages/plugins_tests.rs"] +mod plugins_tests; diff --git a/codex-rs/prompts/src/model_messages/plugins.rs b/codex-rs/prompts/src/model_messages/plugins.rs new file mode 100644 index 0000000000..2ed1b2d0de --- /dev/null +++ b/codex-rs/prompts/src/model_messages/plugins.rs @@ -0,0 +1,192 @@ +//! Resolves bounded plugin wording while leaving capability facts and eligibility to callers. +//! Missing, blank, oversized, or invalid fields independently retain their bundled wording. + +use super::ResolvedMessage; +use super::ResolvedModelMessages; +use codex_protocol::openai_models::PluginMessages; + +const MAX_WORDING_BYTES: usize = 500; +const MAX_USAGE_BYTES: usize = 2_000; +const GENERIC_INTRO: &str = "A plugin is a local bundle of skills, MCP servers, and apps."; +const GENERIC_USAGE: &str = r###"- Skill naming: If a plugin contributes skills, those skill entries are prefixed with `plugin_name:` in the Skills list. +- MCP naming: Plugin-provided MCP tools keep standard MCP identifiers such as `mcp__server__tool`; use tool provenance to tell which plugin they come from. +- Trigger rules: If the user explicitly names a plugin, prefer capabilities associated with that plugin for that turn. +- Relationship to capabilities: Plugins are not invoked directly. Use their underlying skills, MCP tools, and app tools to help solve the task. +- Relevance: Determine what a plugin can help with from explicit user mention or from the plugin-associated skills, MCP tools, and apps exposed elsewhere in this turn. +- Missing/blocked: If the user requests a plugin that does not have relevant callable capabilities for the task, say so briefly and continue with the best fallback."###; +const SELECTED_INTRO: &str = "Capabilities from the `{{ plugin_name }}` plugin:"; +const SEARCH_TOOLS: &str = concat!( + "- For the user request that explicitly selected this plugin, and only for that ", + "request, if `tool_search` is available and an app from this plugin may help, ", + "search for its tools before falling back to unrelated or built-in tools." +); +const SKILL_NAMESPACE: &str = + "- Skills from this plugin are prefixed with `{{ skill_namespace }}:`."; +const APPS: &str = "- Apps from this plugin available in this session: {{ apps }}."; +const MCP_SERVERS: &str = + "- MCP servers from this plugin available in this session: {{ mcp_servers }}."; +const CLOSING: &str = "Use these plugin-associated capabilities to help solve the task."; +const OMITTED: &str = "\n- Additional plugin capabilities omitted to fit the context limit."; +const RECOMMENDATIONS_INTRO: &str = + "Here is a list of plugins that are available but not installed."; + +/// Generic wording and its source, retained by the usage-guidance lifecycle. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResolvedGenericPluginMessages { + pub intro: String, + pub usage_instructions: String, + pub catalog_override: bool, +} + +/// Validated templates retain one required placeholder for each harness-owned fact. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResolvedSelectedPluginMessages { + pub intro: String, + pub search_tools: String, + pub skill_namespace: String, + pub apps: String, + pub mcp_servers: String, + pub closing: String, + pub omitted: String, +} + +/// Bounded introduction independent of recommendation eligibility and plugin identities. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResolvedPluginRecommendationMessages { + pub intro: String, +} + +impl<'a> ResolvedModelMessages<'a> { + fn plugin_messages(self) -> Option<&'a PluginMessages> { + self.catalog_messages?.plugins.as_ref() + } + + /// Resolves the generic plugin guidance used in the conversation's world state. + pub fn generic_plugin(self) -> ResolvedGenericPluginMessages { + let generic = self + .plugin_messages() + .and_then(|messages| messages.generic.as_ref()); + let intro = literal( + generic.and_then(|messages| messages.intro.as_deref()), + GENERIC_INTRO, + MAX_WORDING_BYTES, + ); + let usage = literal( + generic.and_then(|messages| messages.usage_instructions.as_deref()), + GENERIC_USAGE, + MAX_USAGE_BYTES, + ); + ResolvedGenericPluginMessages { + intro: intro.text().to_owned(), + usage_instructions: usage.text().to_owned(), + catalog_override: intro.catalog_override().is_some() + || usage.catalog_override().is_some(), + } + } + + /// Resolves the wording surrounding an explicitly selected plugin's capabilities. + pub fn selected_plugin(self) -> ResolvedSelectedPluginMessages { + let selected = self + .plugin_messages() + .and_then(|messages| messages.selected.as_ref()); + ResolvedSelectedPluginMessages { + intro: template( + selected.and_then(|messages| messages.intro.as_deref()), + SELECTED_INTRO, + "{{ plugin_name }}", + ), + search_tools: literal( + selected.and_then(|messages| messages.search_tools.as_deref()), + SEARCH_TOOLS, + MAX_WORDING_BYTES, + ) + .text() + .to_owned(), + skill_namespace: template( + selected.and_then(|messages| messages.skill_namespace.as_deref()), + SKILL_NAMESPACE, + "{{ skill_namespace }}", + ), + apps: template( + selected.and_then(|messages| messages.apps.as_deref()), + APPS, + "{{ apps }}", + ), + mcp_servers: template( + selected.and_then(|messages| messages.mcp_servers.as_deref()), + MCP_SERVERS, + "{{ mcp_servers }}", + ), + closing: literal( + selected.and_then(|messages| messages.closing.as_deref()), + CLOSING, + MAX_WORDING_BYTES, + ) + .text() + .to_owned(), + omitted: literal( + selected.and_then(|messages| messages.omitted.as_deref()), + OMITTED, + MAX_WORDING_BYTES, + ) + .text() + .to_owned(), + } + } + + /// Resolves the introduction to the list of recommended plugins. + pub fn plugin_recommendations(self) -> ResolvedPluginRecommendationMessages { + let recommendations = self + .plugin_messages() + .and_then(|messages| messages.recommendations.as_ref()); + ResolvedPluginRecommendationMessages { + intro: literal( + recommendations.and_then(|messages| messages.intro.as_deref()), + RECOMMENDATIONS_INTRO, + MAX_WORDING_BYTES, + ) + .text() + .to_owned(), + } + } +} + +fn literal<'a>( + catalog: Option<&'a str>, + bundled: &'static str, + max_bytes: usize, +) -> ResolvedMessage<'a> { + ResolvedMessage::new( + catalog.filter(|text| { + text.len() <= max_bytes + && !text.trim().is_empty() + && !text + .chars() + .any(|c| c.is_control() && !matches!(c, '\n' | '\r' | '\t')) + && ![ + "", + "", + "", + "", + ] + .iter() + .any(|marker| text.contains(marker)) + }), + bundled, + ) +} + +fn template(catalog: Option<&str>, bundled: &'static str, placeholder: &str) -> String { + literal(catalog, bundled, MAX_WORDING_BYTES) + .catalog_override() + .filter(|text| { + !text + .chars() + .any(|c| c.is_control() || matches!(c, '\u{2028}' | '\u{2029}')) + && text.split_once(placeholder).is_some_and(|(before, after)| { + ![before, after].iter().any(|part| part.contains(['{', '}'])) + }) + }) + .unwrap_or(bundled) + .to_owned() +} diff --git a/codex-rs/prompts/src/model_messages/plugins_tests.rs b/codex-rs/prompts/src/model_messages/plugins_tests.rs new file mode 100644 index 0000000000..147f1e4e9c --- /dev/null +++ b/codex-rs/prompts/src/model_messages/plugins_tests.rs @@ -0,0 +1,215 @@ +#![cfg(test)] + +//! Verifies sparse plugin wording, bounded literal bytes, and required runtime fact contracts. + +use super::ResolvedModelMessages; +use codex_protocol::openai_models::GenericPluginMessages; +use codex_protocol::openai_models::ModelMessages; +use codex_protocol::openai_models::PluginMessages; +use codex_protocol::openai_models::PluginRecommendationMessages; +use codex_protocol::openai_models::SelectedPluginMessages; +use pretty_assertions::assert_eq; + +#[test] +fn plugin_fields_fall_back_independently_without_losing_valid_siblings() { + let bundled = ResolvedModelMessages::bundled(); + for invalid in [ + None, + Some(String::new()), + Some(" \t\r\n".into()), + Some("é".repeat(251)), + ] { + let messages = ModelMessages { + plugins: Some(PluginMessages { + generic: Some(GenericPluginMessages { + intro: invalid.clone(), + usage_instructions: Some(" literal {{braces}}\r\n".into()), + }), + selected: Some(SelectedPluginMessages { + search_tools: invalid.clone(), + closing: Some("closing".into()), + ..Default::default() + }), + recommendations: Some(PluginRecommendationMessages { intro: invalid }), + }), + ..Default::default() + }; + let resolved = ResolvedModelMessages { + catalog_messages: Some(&messages), + }; + let generic = resolved.generic_plugin(); + assert_eq!(generic.intro, bundled.generic_plugin().intro); + assert_eq!(generic.usage_instructions, " literal {{braces}}\r\n"); + assert!(generic.catalog_override); + let selected = resolved.selected_plugin(); + assert_eq!( + selected.search_tools, + bundled.selected_plugin().search_tools + ); + assert_eq!(selected.closing, "closing"); + assert_eq!( + resolved.plugin_recommendations(), + bundled.plugin_recommendations() + ); + } +} + +#[test] +fn plugin_templates_require_exactly_one_harness_fact_placeholder() { + let bundled = ResolvedModelMessages::bundled().selected_plugin(); + for invalid in [ + "pre-expanded invented fact", + "{{plugin_name}}", + "{{ plugin_name }} {{ plugin_name }}", + "{{ plugin_name }} {{ other }}", + "{{ plugin_name }} {broken}", + "{{ plugin_name }}\nsecond line", + "{{ plugin_name }}\u{2028}second line", + ] { + let messages = ModelMessages { + plugins: Some(PluginMessages { + selected: Some(SelectedPluginMessages { + intro: Some(invalid.into()), + closing: Some("valid sibling".into()), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }; + let resolved = ResolvedModelMessages { + catalog_messages: Some(&messages), + } + .selected_plugin(); + assert_eq!(resolved.intro, bundled.intro); + assert_eq!(resolved.closing, "valid sibling"); + } + let selected = SelectedPluginMessages { + intro: Some("Selected {{ plugin_name }}.".into()), + skill_namespace: Some("Namespace {{ skill_namespace }}.".into()), + apps: Some("Apps: {{ apps }}.".into()), + mcp_servers: Some("Servers: {{ mcp_servers }}.".into()), + ..Default::default() + }; + let messages = ModelMessages { + plugins: Some(PluginMessages { + selected: Some(selected.clone()), + ..Default::default() + }), + ..Default::default() + }; + let resolved = ResolvedModelMessages { + catalog_messages: Some(&messages), + } + .selected_plugin(); + assert_eq!(resolved.intro, selected.intro.unwrap()); + assert_eq!(resolved.skill_namespace, selected.skill_namespace.unwrap()); + assert_eq!(resolved.apps, selected.apps.unwrap()); + assert_eq!(resolved.mcp_servers, selected.mcp_servers.unwrap()); +} + +#[test] +fn plugin_wording_limits_count_utf8_bytes_and_keep_boundary_values_literal() { + let bundled = ResolvedModelMessages::bundled(); + for (intro, usage, expected_override) in [ + ("é".repeat(250), "é".repeat(1_000), true), + ("é".repeat(251), "é".repeat(1_001), false), + ] { + let messages = ModelMessages { + plugins: Some(PluginMessages { + generic: Some(GenericPluginMessages { + intro: Some(intro.clone()), + usage_instructions: Some(usage.clone()), + }), + selected: Some(SelectedPluginMessages { + omitted: Some(intro.clone()), + ..Default::default() + }), + recommendations: Some(PluginRecommendationMessages { + intro: Some(intro.clone()), + }), + }), + ..Default::default() + }; + let resolved = ResolvedModelMessages { + catalog_messages: Some(&messages), + }; + let generic = resolved.generic_plugin(); + let selected = resolved.selected_plugin(); + let recommendations = resolved.plugin_recommendations(); + if expected_override { + assert_eq!(generic.intro, intro); + assert_eq!(generic.usage_instructions, usage); + assert_eq!(selected.omitted, intro); + assert_eq!(recommendations.intro, intro); + } else { + assert_eq!(generic, bundled.generic_plugin()); + assert_eq!(selected, bundled.selected_plugin()); + assert_eq!(recommendations, bundled.plugin_recommendations()); + } + assert_eq!(generic.catalog_override, expected_override); + } +} + +#[test] +fn blank_plugin_omission_notice_keeps_the_disclosure() { + for omitted in ["", " \r\n\t"] { + let messages = ModelMessages { + plugins: Some(PluginMessages { + selected: Some(SelectedPluginMessages { + omitted: Some(omitted.into()), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }; + assert_eq!( + ResolvedModelMessages { + catalog_messages: Some(&messages) + } + .selected_plugin(), + ResolvedModelMessages::bundled().selected_plugin() + ); + } +} + +#[test] +fn plugin_wording_cannot_close_harness_fragments_or_insert_control_characters() { + let bundled = ResolvedModelMessages::bundled(); + for invalid in [ + "", + "", + "", + "", + "hidden\u{0}text", + "hidden\u{1b}text", + ] { + let messages = ModelMessages { + plugins: Some(PluginMessages { + generic: Some(GenericPluginMessages { + intro: Some(invalid.into()), + usage_instructions: Some(invalid.into()), + }), + selected: Some(SelectedPluginMessages { + closing: Some(invalid.into()), + omitted: Some(invalid.into()), + ..Default::default() + }), + recommendations: Some(PluginRecommendationMessages { + intro: Some(invalid.into()), + }), + }), + ..Default::default() + }; + let resolved = ResolvedModelMessages { + catalog_messages: Some(&messages), + }; + assert_eq!(resolved.generic_plugin(), bundled.generic_plugin()); + assert_eq!(resolved.selected_plugin(), bundled.selected_plugin()); + assert_eq!( + resolved.plugin_recommendations(), + bundled.plugin_recommendations() + ); + } +} diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index ade3654968..1f0275ea7b 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -41,6 +41,11 @@ mod memento_v2; pub use memento_v2::ContextManagementToolMessages; pub use memento_v2::HistoryToolMessages; pub use memento_v2::NotesToolMessages; +mod plugins; +pub use plugins::GenericPluginMessages; +pub use plugins::PluginMessages; +pub use plugins::PluginRecommendationMessages; +pub use plugins::SelectedPluginMessages; #[path = "openai_models/guardian.rs"] mod guardian; pub use guardian::GuardianModelPolicy; @@ -583,6 +588,9 @@ pub struct ModelMessages { /// Optional model-owned Apps wording, transported without runtime interpretation. #[serde(default, skip_serializing_if = "Option::is_none")] pub apps: Option, + /// Model-owned plugin wording; runtime capability facts and eligibility remain client-owned. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugins: Option, /// Developer guidance after a content-filter block. Missing, null, blank, or values over /// 512 UTF-8 bytes use the bundled guidance. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1764,6 +1772,7 @@ mod tests { apps: Some(AppsMessages { usage_instructions: Some("Literal Apps {{guidance}}".to_string()), }), + plugins: None, content_filter_guidance: Some("Offer a permitted alternative.".to_string()), persistent_instructions: Some("Persistent catalog instructions".to_string()), tools: Some(ToolMessages { diff --git a/codex-rs/protocol/src/openai_models/plugins.rs b/codex-rs/protocol/src/openai_models/plugins.rs new file mode 100644 index 0000000000..b3ec67bb08 --- /dev/null +++ b/codex-rs/protocol/src/openai_models/plugins.rs @@ -0,0 +1,66 @@ +//! Sparse model-catalog wording for generic, selected, and recommended plugins. + +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +/// Sparse plugin wording families, independent of runtime availability and authorization. +#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] +pub struct PluginMessages { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub generic: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selected: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub recommendations: Option, +} + +/// Literal generic plugin wording. Missing, null, blank, or invalid values use bundled text. +#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] +pub struct GenericPluginMessages { + /// Literal text, up to 500 UTF-8 bytes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub intro: Option, + /// Up to 2,000 UTF-8 bytes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usage_instructions: Option, +} + +/// Selected-plugin wording. Fact-bearing fields require exactly one documented placeholder. +#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] +pub struct SelectedPluginMessages { + /// Up to 500 UTF-8 bytes. Selected intro requires `{{ plugin_name }}`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub intro: Option, + /// Up to 500 UTF-8 bytes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub search_tools: Option, + /// Up to 500 UTF-8 bytes; requires `{{ skill_namespace }}`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub skill_namespace: Option, + /// Up to 500 UTF-8 bytes; requires `{{ apps }}`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub apps: Option, + /// Up to 500 UTF-8 bytes; requires `{{ mcp_servers }}`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mcp_servers: Option, + /// Up to 500 UTF-8 bytes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub closing: Option, + /// Nonblank omission notice, up to 500 UTF-8 bytes, including any desired separator. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub omitted: Option, +} + +/// Literal recommendation introduction; plugin identities remain supplied by the harness. +#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] +pub struct PluginRecommendationMessages { + /// Literal text, up to 500 UTF-8 bytes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub intro: Option, +} + +#[cfg(test)] +#[path = "plugins_tests.rs"] +mod tests; diff --git a/codex-rs/protocol/src/openai_models/plugins_tests.rs b/codex-rs/protocol/src/openai_models/plugins_tests.rs new file mode 100644 index 0000000000..6774a87bc1 --- /dev/null +++ b/codex-rs/protocol/src/openai_models/plugins_tests.rs @@ -0,0 +1,34 @@ +#![cfg(test)] + +use crate::openai_models::ModelMessages; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[test] +fn plugin_messages_preserve_sparse_nested_and_literal_wire_values() { + for plugins in [ + serde_json::Value::Null, + json!({}), + json!({"generic": {}, "selected": {}, "recommendations": {}}), + json!({ + "generic": {"intro": "", "usage_instructions": " \r\n\t"}, + "selected": { + "intro": " {{ plugin_name }}\r\n", + "search_tools": "literal {{braces}}", + "skill_namespace": "{{ skill_namespace }}", + "apps": "{{ apps }}", + "mcp_servers": "{{ mcp_servers }}", + "closing": "é".repeat(251), + "omitted": "omitted" + }, + "recommendations": {"intro": "recommendations"} + }), + ] { + let messages: ModelMessages = serde_json::from_value(json!({"plugins": plugins})).unwrap(); + let mut expected = serde_json::to_value(ModelMessages::default()).unwrap(); + if !plugins.is_null() { + expected["plugins"] = plugins; + } + assert_eq!(serde_json::to_value(messages).unwrap(), expected); + } +}