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
This commit is contained in:
goodman-oai
2026-11-02 14:06:17 +00:00
committed by copyberry
parent 1e8b536dc7
commit aef7d276a4
39 changed files with 3326 additions and 107 deletions
@@ -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,
@@ -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<codex_prompts::ResolvedGenericPluginMessages> 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())
}
}
@@ -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<DiscoverableTool>,
intro: String,
}
impl RecommendedPluginsInstructions {
pub(crate) fn from_plugins(plugins: &[DiscoverableTool]) -> Option<Self> {
pub(crate) fn from_plugins(
plugins: &[DiscoverableTool],
messages: &codex_prompts::ResolvedPluginRecommendationMessages,
) -> Option<Self> {
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::<Vec<_>>()
.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;
@@ -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!(
"<recommended_plugins>\n{}\n\n{rows}\n</recommended_plugins>",
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());
}
+34 -11
View File
@@ -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<Map<String, Value>> {
let mut patch = Map::new();
@@ -475,7 +503,7 @@ impl WorldState {
pub(crate) fn add_section<S: WorldStateSection>(&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<Item = &'a ResponseItem> + Clone,
) -> (WorldStateSnapshot, Vec<WorldStateUpdate>) {
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);
}
@@ -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<String>,
}
/// 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<WorldStateHash>,
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<Item = &'a ResponseItem>,
) -> 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<Self::Snapshot> {
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,
))],
)
}
}
@@ -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::<Vec<_>>();
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<_>>(),
Vec::<ResponseItem>::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::<Vec<_>>();
assert_eq!(
updates,
expected
.map(
|body| ContextualUserFragment::into(AvailablePluginsInstructions(
body.to_string()
))
)
.into_iter()
.collect::<Vec<_>>()
);
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<ResponseItem>) {
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(&notice));
// 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(&notice));
}
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::<Vec<_>>();
assert_eq!(
updates,
expected
.map(
|body| ContextualUserFragment::into(AvailablePluginsInstructions(
body.to_string()
))
)
.into_iter()
.collect::<Vec<_>>()
);
}
}
#[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::<Vec<_>>();
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<WorldStateSnapshot> =
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!("<plugins_instructions>{REPLACEMENT_PREFIX}{body}</plugins_instructions>")
);
assert!(
world_state
.render_history_fragment_diff(Some(&snapshot), &[retained])
.1
.is_empty()
text.len() <= 4096,
"the final bound includes markers and transition framing"
);
}
@@ -8,7 +8,7 @@ None
Absent -> false
None
Absent -> true (role - developer)
Absent -> {"catalog":false,"instructions":"d3f660b06d428f262fffddce3d2b11f390d8b1da"} (role - developer)
<plugins_instructions>
## 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.
</plugins_instructions>
false -> true (role - developer)
false -> {"catalog":false,"instructions":"d3f660b06d428f262fffddce3d2b11f390d8b1da"} (role - developer)
<plugins_instructions>
## 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.
</plugins_instructions>
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
+3
View File
@@ -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<ResponseItem> {
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)
+47 -36
View File
@@ -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<String> {
(!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<String> {
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::<Vec<_>>()
.join(", ")
));
lines.push(
messages.apps.replace(
"{{ apps }}",
&available_apps
.iter()
.map(|app| format!("`{app}`"))
.collect::<Vec<_>>()
.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::<Vec<_>>()
.join(", ")
));
lines.push(
messages.mcp_servers.replace(
"{{ mcp_servers }}",
&available_mcp_servers
.iter()
.map(|server| format!("`{server}`"))
.collect::<Vec<_>>()
.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,
))
}
+58 -1
View File
@@ -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("🦀"));
}
+7 -1
View File
@@ -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());
}
+1
View File
@@ -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);
+9 -3
View File
@@ -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
@@ -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,
@@ -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,
@@ -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,
@@ -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!(
"<recommended_plugins>\n{expected_intro}\n\n- GitHub (github@openai-curated-remote)\n</recommended_plugins>"
)]
} 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("<recommended_plugins>"))
.collect::<Vec<_>>();
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("<recommended_plugins>"))
);
}
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!(
+544
View File
@@ -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<_>>(),
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("<plugins_instructions>"))
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
let bundled = concat!(
"<plugins_instructions>\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",
"</plugins_instructions>",
);
let catalog_a = "<plugins_instructions>\n## Plugins\nCatalog intro A: {{literal}}.\n### How to use plugins\nCatalog usage A.\nKeep this line.\n</plugins_instructions>";
let replacement_prefix = "<plugins_instructions>\nThese plugin usage instructions replace all previously provided plugin usage instructions.\n\n\n";
let replaced_a = catalog_a.replacen("<plugins_instructions>\n", replacement_prefix, 1);
let replaced_bundled = bundled.replacen("<plugins_instructions>\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!(
"<plugins_instructions>\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</plugins_instructions>",
))
);
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(
"<plugins_instructions>\nThe previously provided plugin usage instructions no longer apply.\n</plugins_instructions>"
)
);
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("<plugins_instructions>"))
);
}
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::<Vec<_>>()
})
.collect::<Vec<_>>();
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::<Vec<_>>();
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(())
}
@@ -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: "<PROMPT_CACHE_KEY 1>"
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] <PERMISSIONS_INSTRUCTIONS>
[02] <APPS_INSTRUCTIONS>
[03] <recommended_plugins>
Here is a list of plugins that are available but not installed.
- GitHub (github@openai-curated-remote)
</recommended_plugins>
01:message/user:
<ENVIRONMENT_CONTEXT>
02:message/user:
Suggest a plugin for this task.
-- request 2 (turn) --
03:message/user:
Continue with the same recommendations.
@@ -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: "<PROMPT_CACHE_KEY 1>"
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] <PERMISSIONS_INSTRUCTIONS>
[02] <APPS_INSTRUCTIONS>
[03] <recommended_plugins>
Here is a list of plugins that are available but not installed.
- GitHub (github@openai-curated-remote)
</recommended_plugins>
01:message/user:
<ENVIRONMENT_CONTEXT>
02:message/user:
Suggest a plugin for this task.
-- request 2 (turn) --
03:message/user:
Continue with the same recommendations.
@@ -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: "<PROMPT_CACHE_KEY 1>"
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] <PERMISSIONS_INSTRUCTIONS>
[02] <APPS_INSTRUCTIONS>
[03] <recommended_plugins>
Catalog recommendations: {{literal}}.
- GitHub (github@openai-curated-remote)
</recommended_plugins>
01:message/user:
<ENVIRONMENT_CONTEXT>
02:message/user:
Suggest a plugin for this task.
-- request 2 (turn) --
03:message/user:
Continue with the same recommendations.
@@ -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: "<PROMPT_CACHE_KEY 1>"
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] <PERMISSIONS_INSTRUCTIONS>
[02] <APPS_INSTRUCTIONS>
01:message/user:
<ENVIRONMENT_CONTEXT>
02:message/user:
Suggest a plugin for this task.
-- request 2 (turn) --
03:message/user:
Continue with the same recommendations.
@@ -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: "<PROMPT_CACHE_KEY 1>"
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] <PERMISSIONS_INSTRUCTIONS>
[02] <APPS_INSTRUCTIONS>
[03] <recommended_plugins>
Here is a list of plugins that are available but not installed.
- GitHub (github@openai-curated-remote)
</recommended_plugins>
01:message/user:
<ENVIRONMENT_CONTEXT>
02:message/user:
Suggest a plugin for this task.
-- request 2 (turn) --
03:message/user:
Continue with the same recommendations.
@@ -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: "<PROMPT_CACHE_KEY 1>"
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] <PERMISSIONS_INSTRUCTIONS>
[02] <APPS_INSTRUCTIONS>
[03] <recommended_plugins>
Here is a list of plugins that are available but not installed.
- GitHub (github@openai-curated-remote)
</recommended_plugins>
01:message/user:
<ENVIRONMENT_CONTEXT>
02:message/user:
Suggest a plugin for this task.
-- request 2 (turn) --
03:message/user:
Continue with the same recommendations.
@@ -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: "<PROMPT_CACHE_KEY 1>"
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] <PERMISSIONS_INSTRUCTIONS>
[02] <APPS_INSTRUCTIONS>
[03] <recommended_plugins>
Here is a list of plugins that are available but not installed.
- GitHub (github@openai-curated-remote)
</recommended_plugins>
01:message/user:
<ENVIRONMENT_CONTEXT>
02:message/user:
Suggest a plugin for this task.
-- request 2 (turn) --
03:message/user:
Continue with the same recommendations.
@@ -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: "<PROMPT_CACHE_KEY 1>"
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_instructions>
## 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` = `<SKILLS_ROOT>`
- `r1` = `<PLUGINS_CACHE>/test`
### Available skills
- final-check: Review a final brief (file: r0/final-check/SKILL.md)
<OMITTED 14 LINES; ~497 TOKENS; hash=1553c68bf4173825>
- 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.
</skills_instructions>
[02] <permissions instructions>
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: &&, ||
<OMITTED 44 LINES; ~790 TOKENS; hash=3a8900343e968d61>
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"]
</permissions instructions>
[03] <plugins_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]
</plugins_instructions>
[04] <tools>
Deferred tool namespaces:
- multi_agent_v1: Tools for spawning and managing sub-agents.
</tools>
01:message/user:
<environment_context>
<cwd><CWD></cwd>
<shell><HOST_SHELL></shell>
<current_date><CURRENT_DATE></current_date>
<timezone><HOST_TIMEZONE></timezone>
<filesystem><workspace_roots><root><CWD></root></workspace_roots><permission_profile type="managed"><f...ntry></file_system></permission_profile></filesystem> [hash=e8c1de60307d0a3d]
</environment_context>
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_instructions>
## 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` = `<SKILLS_ROOT>`
- `r1` = `<PLUGINS_CACHE>/test`
### Available skills
- final-check: Review a final brief (file: r0/final-check/SKILL.md)
<OMITTED 14 LINES; ~497 TOKENS; hash=1553c68bf4173825>
- 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.
</skills_instructions>
[02] <permissions instructions>
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: &&, ||
<OMITTED 44 LINES; ~790 TOKENS; hash=3a8900343e968d61>
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"]
</permissions instructions>
[03] <plugins_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]
</plugins_instructions>
[04] <tools>
Deferred tool namespaces:
- multi_agent_v1: Tools for spawning and managing sub-agents.
</tools>
01:message/user:
<environment_context>
<cwd><CWD></cwd>
<shell><HOST_SHELL></shell>
<current_date><CURRENT_DATE></current_date>
<timezone><HOST_TIMEZONE></timezone>
<filesystem><workspace_roots><root><CWD></root></workspace_roots><permission_profile type="managed"><f...ntry></file_system></permission_profile></filesystem> [hash=e8c1de60307d0a3d]
</environment_context>
02:message/user:
Use the bundled plugin guidance.
03:message/developer:
<plugins_instructions>
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.
</plugins_instructions>
04:message/user:
Continue with the selected model.
05:message/user:
Continue with the selected model.
06:message/developer:
<plugins_instructions>
These plugin usage instructions replace all previously provided plugin usage instructions.
## Plugins
Catalog intro B.
### How to use plugins
Catalog usage B.
</plugins_instructions>
07:message/user:
Continue with the selected model.
08:message/user:
<environment_context>
<cwd><CWD 2></cwd>
<shell><HOST_SHELL></shell>
<current_date><CURRENT_DATE></current_date>
<timezone><HOST_TIMEZONE></timezone>
<filesystem><workspace_roots><root><CWD 2></root></workspace_roots><permission_profile type="managed">...ntry></file_system></permission_profile></filesystem> [hash=8fc9d186c1133657]
</environment_context>
09:message/user:
Continue after restarting with the same guidance.
10:message/developer:
<plugins_instructions>
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]
</plugins_instructions>
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:
<plugins_instructions>
The previously provided plugin usage instructions no longer apply.
</plugins_instructions>
15:message/user:
Continue with the selected model.
16:message/developer:
<plugins_instructions>
## Plugins
Catalog intro A: {{literal}}.
### How to use plugins
Catalog usage A.
Keep this line.
</plugins_instructions>
17:message/user:
Continue with the selected model.
@@ -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: "<PROMPT_CACHE_KEY 1>"
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_instructions>
## 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` = `<SKILLS_ROOT>`
- `r1` = `<PLUGINS_CACHE>/test`
### Available skills
- final-check: Review a final brief (file: r0/final-check/SKILL.md)
<OMITTED 14 LINES; ~497 TOKENS; hash=1553c68bf4173825>
- 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.
</skills_instructions>
[02] <permissions instructions>
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: &&, ||
<OMITTED 44 LINES; ~790 TOKENS; hash=3a8900343e968d61>
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"]
</permissions instructions>
[03] <apps_instructions>
## 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.
</apps_instructions>
[04] <tools>
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.
</tools>
01:message/user:
<environment_context>
<cwd><CWD></cwd>
<shell><HOST_SHELL></shell>
<current_date><CURRENT_DATE></current_date>
<timezone><HOST_TIMEZONE></timezone>
<filesystem><workspace_roots><root><CWD></root></workspace_roots><permission_profile type="managed"><f...ntry></file_system></permission_profile></filesystem> [hash=e8c1de60307d0a3d]
</environment_context>
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.
@@ -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: "<PROMPT_CACHE_KEY 1>"
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_instructions>
## 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` = `<SKILLS_ROOT>`
- `r1` = `<PLUGINS_CACHE>/test`
### Available skills
- final-check: Review a final brief (file: r0/final-check/SKILL.md)
<OMITTED 14 LINES; ~497 TOKENS; hash=1553c68bf4173825>
- 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.
</skills_instructions>
[02] <permissions instructions>
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: &&, ||
<OMITTED 44 LINES; ~790 TOKENS; hash=3a8900343e968d61>
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"]
</permissions instructions>
01:message/user:
<environment_context>
<cwd><CWD></cwd>
<shell><HOST_SHELL></shell>
<current_date><CURRENT_DATE></current_date>
<timezone><HOST_TIMEZONE></timezone>
<filesystem><workspace_roots><root><CWD></root></workspace_roots><permission_profile type="managed"><f...ntry></file_system></permission_profile></filesystem> [hash=e8c1de60307d0a3d]
</environment_context>
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_instructions>
## 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` = `<SKILLS_ROOT>`
- `r1` = `<PLUGINS_CACHE>/test`
### Available skills
- final-check: Review a final brief (file: r0/final-check/SKILL.md)
<OMITTED 14 LINES; ~497 TOKENS; hash=1553c68bf4173825>
- 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.
</skills_instructions>
[02] <permissions instructions>
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: &&, ||
<OMITTED 44 LINES; ~790 TOKENS; hash=3a8900343e968d61>
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"]
</permissions instructions>
01:message/user:
<environment_context>
<cwd><CWD></cwd>
<shell><HOST_SHELL></shell>
<current_date><CURRENT_DATE></current_date>
<timezone><HOST_TIMEZONE></timezone>
<filesystem><workspace_roots><root><CWD></root></workspace_roots><permission_profile type="managed"><f...ntry></file_system></permission_profile></filesystem> [hash=e8c1de60307d0a3d]
</environment_context>
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.
@@ -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: "<PROMPT_CACHE_KEY 1>"
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_instructions>
## 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` = `<SKILLS_ROOT>`
- `r1` = `<PLUGINS_CACHE>/test`
### Available skills
- final-check: Review a final brief (file: r0/final-check/SKILL.md)
<OMITTED 14 LINES; ~497 TOKENS; hash=1553c68bf4173825>
- 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.
</skills_instructions>
[02] <permissions instructions>
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: &&, ||
<OMITTED 44 LINES; ~790 TOKENS; hash=3a8900343e968d61>
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"]
</permissions instructions>
[03] <apps_instructions>
## 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.
</apps_instructions>
01:message/user:
<environment_context>
<cwd><CWD></cwd>
<shell><HOST_SHELL></shell>
<current_date><CURRENT_DATE></current_date>
<timezone><HOST_TIMEZONE></timezone>
<filesystem><workspace_roots><root><CWD></root></workspace_roots><permission_profile type="managed"><f...ntry></file_system></permission_profile></filesystem> [hash=e8c1de60307d0a3d]
</environment_context>
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_instructions>
## 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` = `<SKILLS_ROOT>`
- `r1` = `<PLUGINS_CACHE>/test`
### Available skills
- final-check: Review a final brief (file: r0/final-check/SKILL.md)
<OMITTED 14 LINES; ~497 TOKENS; hash=1553c68bf4173825>
- 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.
</skills_instructions>
[02] <permissions instructions>
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: &&, ||
<OMITTED 44 LINES; ~790 TOKENS; hash=3a8900343e968d61>
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"]
</permissions instructions>
[03] <apps_instructions>
## 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.
</apps_instructions>
01:message/user:
<environment_context>
<cwd><CWD></cwd>
<shell><HOST_SHELL></shell>
<current_date><CURRENT_DATE></current_date>
<timezone><HOST_TIMEZONE></timezone>
<filesystem><workspace_roots><root><CWD></root></workspace_roots><permission_profile type="managed"><f...ntry></file_system></permission_profile></filesystem> [hash=e8c1de60307d0a3d]
</environment_context>
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.
@@ -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: "<PROMPT_CACHE_KEY 1>"
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_instructions>
## 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` = `<SKILLS_ROOT>`
- `r1` = `<PLUGINS_CACHE>/test`
### Available skills
- final-check: Review a final brief (file: r0/final-check/SKILL.md)
<OMITTED 14 LINES; ~497 TOKENS; hash=1553c68bf4173825>
- 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.
</skills_instructions>
[02] <permissions instructions>
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: &&, ||
<OMITTED 44 LINES; ~790 TOKENS; hash=3a8900343e968d61>
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"]
</permissions instructions>
[03] <apps_instructions>
## 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.
</apps_instructions>
01:message/user:
<environment_context>
<cwd><CWD></cwd>
<shell><HOST_SHELL></shell>
<current_date><CURRENT_DATE></current_date>
<timezone><HOST_TIMEZONE></timezone>
<filesystem><workspace_roots><root><CWD></root></workspace_roots><permission_profile type="managed"><f...ntry></file_system></permission_profile></filesystem> [hash=e8c1de60307d0a3d]
</environment_context>
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_instructions>
## 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` = `<SKILLS_ROOT>`
- `r1` = `<PLUGINS_CACHE>/test`
### Available skills
- final-check: Review a final brief (file: r0/final-check/SKILL.md)
<OMITTED 14 LINES; ~497 TOKENS; hash=1553c68bf4173825>
- 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.
</skills_instructions>
[02] <permissions instructions>
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: &&, ||
<OMITTED 44 LINES; ~790 TOKENS; hash=3a8900343e968d61>
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"]
</permissions instructions>
[03] <apps_instructions>
## 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.
</apps_instructions>
01:message/user:
<environment_context>
<cwd><CWD></cwd>
<shell><HOST_SHELL></shell>
<current_date><CURRENT_DATE></current_date>
<timezone><HOST_TIMEZONE></timezone>
<filesystem><workspace_roots><root><CWD></root></workspace_roots><permission_profile type="managed"><f...ntry></file_system></permission_profile></filesystem> [hash=e8c1de60307d0a3d]
</environment_context>
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.
@@ -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 {
+3
View File
@@ -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;
+8
View File
@@ -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;
@@ -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'))
&& ![
"<plugins_instructions>",
"</plugins_instructions>",
"<recommended_plugins>",
"</recommended_plugins>",
]
.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()
}
@@ -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 [
"</plugins_instructions>",
"<plugins_instructions>",
"</recommended_plugins>",
"<recommended_plugins>",
"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()
);
}
}
+9
View File
@@ -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<AppsMessages>,
/// Model-owned plugin wording; runtime capability facts and eligibility remain client-owned.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plugins: Option<PluginMessages>,
/// 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 {
@@ -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<GenericPluginMessages>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selected: Option<SelectedPluginMessages>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recommendations: Option<PluginRecommendationMessages>,
}
/// 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<String>,
/// Up to 2,000 UTF-8 bytes.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage_instructions: Option<String>,
}
/// 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<String>,
/// Up to 500 UTF-8 bytes.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub search_tools: Option<String>,
/// Up to 500 UTF-8 bytes; requires `{{ skill_namespace }}`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skill_namespace: Option<String>,
/// Up to 500 UTF-8 bytes; requires `{{ apps }}`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub apps: Option<String>,
/// Up to 500 UTF-8 bytes; requires `{{ mcp_servers }}`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mcp_servers: Option<String>,
/// Up to 500 UTF-8 bytes.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub closing: Option<String>,
/// Nonblank omission notice, up to 500 UTF-8 bytes, including any desired separator.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub omitted: Option<String>,
}
/// 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<String>,
}
#[cfg(test)]
#[path = "plugins_tests.rs"]
mod tests;
@@ -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);
}
}