Move skill catalog rendering out of core (#36364)

## What changed

- Make the skills extension own the catalog prompt templates and rendering path.
- Remove the duplicate core fallback that injected available skills into initial context.
- Update core integration tests to install the skills extension explicitly when they expect model-visible skill context.

GitOrigin-RevId: bbe6b15c4e776a09c98b7f4166426fe4e58434ba
This commit is contained in:
felixxia-oai
2026-07-31 17:12:10 +00:00
committed by copyberry
parent da2c7ca8d1
commit 0d109f097c
21 changed files with 161 additions and 2167 deletions
@@ -1,63 +0,0 @@
use codex_core_skills::AvailableSkills;
use codex_core_skills::SKILLS_HOW_TO_USE_WITH_ABSOLUTE_PATHS;
use codex_core_skills::SKILLS_HOW_TO_USE_WITH_ALIASES;
use codex_core_skills::render_available_skills_body;
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_CLOSE_TAG;
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG;
use super::ContextualUserFragment;
/// Model-context fragment describing the skills available to Codex.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AvailableSkillsInstructions {
skill_root_lines: Vec<String>,
skill_lines: Vec<String>,
}
impl AvailableSkillsInstructions {
/// Creates a skills context fragment from pre-rendered catalog lines.
pub fn from_skill_lines(skill_lines: Vec<String>) -> Self {
Self {
skill_root_lines: Vec::new(),
skill_lines,
}
}
pub fn from_available_skills(
available_skills: &AvailableSkills,
include_skills_usage_instructions: bool,
) -> Self {
let mut skill_lines = available_skills.skill_lines.clone();
if include_skills_usage_instructions {
skill_lines.push("### How to use skills".to_string());
let instructions = if available_skills.skill_root_lines.is_empty() {
SKILLS_HOW_TO_USE_WITH_ABSOLUTE_PATHS
} else {
SKILLS_HOW_TO_USE_WITH_ALIASES
};
skill_lines.push(instructions.to_string());
}
Self {
skill_root_lines: available_skills.skill_root_lines.clone(),
skill_lines,
}
}
}
impl ContextualUserFragment for AvailableSkillsInstructions {
fn role(&self) -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
(SKILLS_INSTRUCTIONS_OPEN_TAG, SKILLS_INSTRUCTIONS_CLOSE_TAG)
}
fn body(&self) -> String {
render_available_skills_body(&self.skill_root_lines, &self.skill_lines)
}
}
-2
View File
@@ -3,7 +3,6 @@
mod approved_command_prefix_saved;
mod apps_instructions;
mod available_plugins_instructions;
mod available_skills_instructions;
mod contextual_user_message;
mod current_time_reminder;
mod environment_context;
@@ -38,7 +37,6 @@ pub(crate) mod world_state;
pub(crate) use approved_command_prefix_saved::ApprovedCommandPrefixSaved;
pub(crate) use apps_instructions::AppsInstructions;
pub(crate) use available_plugins_instructions::AvailablePluginsInstructions;
pub use available_skills_instructions::AvailableSkillsInstructions;
pub(crate) use codex_context_fragments::AdditionalContextDeveloperFragment;
pub(crate) use codex_context_fragments::AdditionalContextUserFragment;
pub use codex_context_fragments::ContextualUserFragment;
-2
View File
@@ -89,11 +89,9 @@ pub mod skills;
pub(crate) use skills::SkillInjections;
pub(crate) use skills::SkillMetadata;
pub(crate) use skills::SkillsService;
pub(crate) use skills::build_available_skills;
pub(crate) use skills::build_skill_injections;
pub(crate) use skills::build_skill_name_counts;
pub(crate) use skills::collect_explicit_skill_mentions;
pub(crate) use skills::default_skill_metadata_budget;
pub(crate) use skills::injection;
pub(crate) use skills::maybe_emit_implicit_skill_invocation;
pub(crate) use skills::skills_load_input_from_config;
-40
View File
@@ -18,20 +18,17 @@ use crate::agent_communication::AgentCommunicationContext;
use crate::agent_communication::AgentCommunicationKind;
use crate::attestation::AttestationProvider;
use crate::audio_preparation::prepare_response_items as prepare_audio_response_items;
use crate::build_available_skills;
use crate::compact;
use crate::compact::CompactedHistoryMetadata;
use crate::config::ManagedFeatures;
use crate::config::resolve_tool_suggest_config_from_layer_stack;
use crate::context::ApprovedCommandPrefixSaved;
use crate::context::AvailableSkillsInstructions;
use crate::context::ContextualUserFragment;
use crate::context::ModelSwitchInstructions;
use crate::context::NetworkRuleSaved;
use crate::context::RecommendedPluginsInstructions;
use crate::context::world_state::WorldState;
use crate::current_time::TimeProvider;
use crate::default_skill_metadata_budget;
use crate::environment_selection::TurnEnvironmentSnapshot;
use crate::exec_policy::BANNED_PREFIX_SUGGESTIONS;
use crate::exec_policy::ExecPolicyManager;
@@ -42,7 +39,6 @@ use crate::realtime_conversation::RealtimeConversationManager;
use crate::session::step_context::StepContext;
use crate::session::turn_context::TurnEnvironment;
use crate::session_prefix::format_inter_agent_completion_message;
use crate::skills::SkillRenderSideEffects;
use crate::skills_load_input_from_config;
use crate::turn_metadata::TurnMetadataState;
use crate::turn_timing::now_unix_timestamp_ms;
@@ -55,7 +51,6 @@ use codex_analytics::SubAgentThreadStartedInput;
use codex_analytics::TurnCodexErrorFact;
use codex_async_utils::OrCancelExt;
use codex_connectors::connector_runtime_context_key;
use codex_core_skills::injection::HostSkillsCatalogInWorldState;
use codex_exec_server::Environment;
use codex_exec_server::EnvironmentManager;
use codex_execpolicy::prefix_rule_migration;
@@ -304,8 +299,6 @@ pub(crate) struct PreviousTurnSettings {
pub(crate) realtime_active: Option<bool>,
}
#[cfg(test)]
use crate::SkillMetadata;
use crate::SkillsService;
use crate::exec_policy::ExecPolicyUpdateError;
use crate::guardian::GuardianReviewOptions;
@@ -315,8 +308,6 @@ use crate::network_policy_decision::execpolicy_network_rule_amendment;
use crate::rollout::map_session_init_error;
use crate::session_startup_prewarm::SessionStartupPrewarmHandle;
use crate::shell;
#[cfg(test)]
use crate::skills::SkillLoadOutcome;
use crate::state::AutoCompactWindowIds;
use crate::state::AutoCompactWindowSnapshot;
use crate::state::PendingRequestPermissions;
@@ -3410,37 +3401,6 @@ impl Session {
{
developer_sections.push(developer_instructions.to_string());
}
if turn_context.config.include_skill_instructions
&& turn_context
.extension_data
.get::<HostSkillsCatalogInWorldState>()
.is_none()
{
let available_skills = build_available_skills(
turn_context.turn_skills.snapshot.outcome(),
default_skill_metadata_budget(turn_context.model_info.context_window),
SkillRenderSideEffects::ThreadStart {
session_telemetry: &self.services.session_telemetry,
},
);
if let Some(available_skills) = available_skills {
let warning_message = available_skills.warning_message.clone();
let skills_instructions = AvailableSkillsInstructions::from_available_skills(
&available_skills,
turn_context.model_info.include_skills_usage_instructions,
);
if let Some(warning_message) = warning_message {
self.send_event_raw(Event {
id: String::new(),
msg: EventMsg::Warning(WarningEvent {
message: warning_message,
}),
})
.await;
}
developer_sections.push(skills_instructions.render());
}
}
let loaded_plugins = self
.services
.plugins_manager
-259
View File
@@ -14,8 +14,6 @@ use crate::function_tool::FunctionCallError;
use crate::session::step_context::StepContext;
use crate::shell::default_user_shell;
use crate::shell_snapshot::ShellSnapshot;
use crate::skills::SkillRenderSideEffects;
use crate::skills::render::SkillMetadataBudget;
use crate::test_support::models_manager_with_provider;
use crate::tools::format_exec_output_str;
use crate::tools::registry::CoreToolRuntime;
@@ -33,7 +31,6 @@ use codex_config::loader::project_trust_key;
use codex_config::types::McpServerConfig;
use codex_config::types::McpServerTransportConfig;
use codex_config::types::ToolSuggestDisabledTool;
use codex_core_skills::HostSkillsSnapshot;
use core_test_support::test_codex::local_selections;
use codex_features::Feature;
@@ -109,10 +106,6 @@ use codex_execpolicy::Policy;
use codex_network_proxy::NetworkProxyConfig;
use codex_otel::MetricsClient;
use codex_otel::MetricsConfig;
use codex_otel::THREAD_SKILLS_DESCRIPTION_TRUNCATED_CHARS_METRIC;
use codex_otel::THREAD_SKILLS_ENABLED_TOTAL_METRIC;
use codex_otel::THREAD_SKILLS_KEPT_TOTAL_METRIC;
use codex_otel::THREAD_SKILLS_TRUNCATED_METRIC;
use codex_otel::TelemetryAuthMode;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ModeKind;
@@ -143,7 +136,6 @@ use codex_protocol::protocol::ResumedHistory;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SessionMeta;
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::SkillScope;
use codex_protocol::protocol::Submission;
use codex_protocol::protocol::ThreadRolledBackEvent;
use codex_protocol::protocol::ThreadSettingsOverrides;
@@ -318,28 +310,6 @@ fn assistant_message(text: &str) -> ResponseItem {
}
}
fn test_session_telemetry_without_metadata() -> SessionTelemetry {
let exporter = InMemoryMetricExporter::default();
let metrics = MetricsClient::new(
MetricsConfig::in_memory("test", "codex-core", env!("CARGO_PKG_VERSION"), exporter)
.with_runtime_reader(),
)
.expect("in-memory metrics client");
SessionTelemetry::new(
ThreadId::new(),
"gpt-5.4",
"gpt-5.4",
/*account_id*/ None,
/*account_email*/ None,
/*auth_mode*/ None,
"test_originator".to_string(),
/*log_user_prompts*/ false,
"tty".to_string(),
SessionSource::Cli,
)
.with_metrics_without_metadata_tags(metrics)
}
fn find_metric<'a>(resource_metrics: &'a ResourceMetrics, name: &str) -> &'a Metric {
for scope_metrics in resource_metrics.scope_metrics() {
for metric in scope_metrics.metrics() {
@@ -351,21 +321,6 @@ fn find_metric<'a>(resource_metrics: &'a ResourceMetrics, name: &str) -> &'a Met
panic!("metric {name} missing");
}
fn histogram_sum(resource_metrics: &ResourceMetrics, name: &str) -> u64 {
let metric = find_metric(resource_metrics, name);
match metric.data() {
AggregatedMetrics::F64(data) => match data {
MetricData::Histogram(histogram) => {
let points: Vec<_> = histogram.data_points().collect();
assert_eq!(points.len(), 1);
points[0].sum().round() as u64
}
_ => panic!("unexpected histogram aggregation"),
},
_ => panic!("unexpected metric data type"),
}
}
fn single_histogram_attributes(
resource_metrics: &ResourceMetrics,
name: &str,
@@ -9168,220 +9123,6 @@ async fn build_initial_context_omits_multi_agent_v2_usage_hints_when_hint_is_emp
);
}
#[tokio::test]
async fn build_initial_context_trims_skill_metadata_from_context_window_budget() {
let (session, mut turn_context) = make_session_and_context().await;
let mut outcome = SkillLoadOutcome::default();
outcome.skills = vec![
SkillMetadata {
name: "admin-skill".to_string(),
description: "desc".to_string(),
short_description: None,
interface: None,
dependencies: None,
policy: None,
path_to_skills_md: test_path_buf("/tmp/admin-skill/SKILL.md").abs(),
scope: SkillScope::Admin,
plugin_id: None,
remote_plugin_id: None,
},
SkillMetadata {
name: "repo-skill".to_string(),
description: "desc".to_string(),
short_description: None,
interface: None,
dependencies: None,
policy: None,
path_to_skills_md: test_path_buf("/tmp/repo-skill/SKILL.md").abs(),
scope: SkillScope::Repo,
plugin_id: None,
remote_plugin_id: None,
},
];
turn_context.model_info.context_window = Some(100);
turn_context.turn_skills = TurnSkillsContext::new(HostSkillsSnapshot::new(Arc::new(outcome)));
let turn_context = Arc::new(turn_context);
let initial_context = build_initial_context(&session, &turn_context).await;
let developer_texts = developer_input_texts(&initial_context);
assert!(
developer_texts
.iter()
.all(|text| !text.contains("Exceeded skills context budget")),
"expected skill budget warning to stay out of the initial context, got {developer_texts:?}"
);
assert!(
developer_texts
.iter()
.all(|text| !text.contains("- admin-skill:") && !text.contains("- repo-skill:")),
"expected no skill metadata entries to fit the tiny budget, got {developer_texts:?}"
);
}
#[test]
fn emit_thread_start_skill_metrics_records_enabled_kept_and_truncated_values() {
let session_telemetry = test_session_telemetry_without_metadata();
let mut outcome = SkillLoadOutcome::default();
outcome.skills = vec![SkillMetadata {
name: "repo-skill".to_string(),
description: "desc".to_string(),
short_description: None,
interface: None,
dependencies: None,
policy: None,
path_to_skills_md: test_path_buf("/tmp/repo-skill/SKILL.md").abs(),
scope: SkillScope::Repo,
plugin_id: None,
remote_plugin_id: None,
}];
let rendered = build_available_skills(
&outcome,
SkillMetadataBudget::Characters(1),
SkillRenderSideEffects::ThreadStart {
session_telemetry: &session_telemetry,
},
)
.expect("skills should render");
assert_eq!(
rendered.warning_message,
Some(
"Exceeded skills context budget. All skill descriptions were removed and 1 additional skill was not included in the model-visible skills list."
.to_string()
)
);
let snapshot = session_telemetry
.snapshot_metrics()
.expect("runtime metrics snapshot");
assert_eq!(
histogram_sum(&snapshot, THREAD_SKILLS_ENABLED_TOTAL_METRIC),
1
);
assert_eq!(histogram_sum(&snapshot, THREAD_SKILLS_KEPT_TOTAL_METRIC), 0);
assert_eq!(histogram_sum(&snapshot, THREAD_SKILLS_TRUNCATED_METRIC), 1);
assert_eq!(
histogram_sum(&snapshot, THREAD_SKILLS_DESCRIPTION_TRUNCATED_CHARS_METRIC),
4
);
}
#[test]
fn emit_thread_start_skill_metrics_records_description_truncated_chars_without_omitted_skills() {
let session_telemetry = test_session_telemetry_without_metadata();
let alpha = SkillMetadata {
name: "alpha-skill".to_string(),
description: "abcdef".to_string(),
short_description: None,
interface: None,
dependencies: None,
policy: None,
path_to_skills_md: test_path_buf("/tmp/alpha-skill/SKILL.md").abs(),
scope: SkillScope::Repo,
plugin_id: None,
remote_plugin_id: None,
};
let beta = SkillMetadata {
name: "beta-skill".to_string(),
description: "uvwxyz".to_string(),
short_description: None,
interface: None,
dependencies: None,
policy: None,
path_to_skills_md: test_path_buf("/tmp/beta-skill/SKILL.md").abs(),
scope: SkillScope::Repo,
plugin_id: None,
remote_plugin_id: None,
};
let minimum_skill_line_cost = |skill: &SkillMetadata| {
let path = skill.path_to_skills_md.to_string_lossy().replace('\\', "/");
format!("- {}: (file: {})\n", skill.name, path)
.chars()
.count()
};
let minimum_budget = minimum_skill_line_cost(&alpha) + minimum_skill_line_cost(&beta);
let mut outcome = SkillLoadOutcome::default();
outcome.skills = vec![alpha, beta];
let rendered = build_available_skills(
&outcome,
SkillMetadataBudget::Characters(minimum_budget + 6),
SkillRenderSideEffects::ThreadStart {
session_telemetry: &session_telemetry,
},
)
.expect("skills should render");
assert_eq!(rendered.report.omitted_count, 0);
assert_eq!(rendered.report.truncated_description_chars, 8);
let snapshot = session_telemetry
.snapshot_metrics()
.expect("runtime metrics snapshot");
assert_eq!(histogram_sum(&snapshot, THREAD_SKILLS_TRUNCATED_METRIC), 0);
assert_eq!(
histogram_sum(&snapshot, THREAD_SKILLS_DESCRIPTION_TRUNCATED_CHARS_METRIC),
8
);
}
#[tokio::test]
async fn build_initial_context_emits_thread_start_skill_warning_on_repeated_builds() {
let (session, turn_context, rx) = make_session_and_context_with_rx().await;
let mut turn_context = Arc::into_inner(turn_context).expect("sole thread settings owner");
let mut outcome = SkillLoadOutcome::default();
outcome.skills = vec![
SkillMetadata {
name: "admin-skill".to_string(),
description: "desc".to_string(),
short_description: None,
interface: None,
dependencies: None,
policy: None,
path_to_skills_md: test_path_buf("/tmp/admin-skill/SKILL.md").abs(),
scope: SkillScope::Admin,
plugin_id: None,
remote_plugin_id: None,
},
SkillMetadata {
name: "repo-skill".to_string(),
description: "desc".to_string(),
short_description: None,
interface: None,
dependencies: None,
policy: None,
path_to_skills_md: test_path_buf("/tmp/repo-skill/SKILL.md").abs(),
scope: SkillScope::Repo,
plugin_id: None,
remote_plugin_id: None,
},
];
turn_context.model_info.context_window = Some(100);
turn_context.turn_skills = TurnSkillsContext::new(HostSkillsSnapshot::new(Arc::new(outcome)));
let turn_context = Arc::new(turn_context);
let _ = build_initial_context(&session, &turn_context).await;
let warning_event = timeout(Duration::from_secs(1), rx.recv())
.await
.expect("warning event should arrive")
.expect("warning event should be readable");
assert!(matches!(
warning_event.msg,
EventMsg::Warning(WarningEvent { message })
if message == "Exceeded skills context budget of 2%. All skill descriptions were removed and 2 additional skills were not included in the model-visible skills list."
));
let _ = build_initial_context(&session, &turn_context).await;
let warning_event = timeout(Duration::from_secs(1), rx.recv())
.await
.expect("warning event should arrive on repeated build")
.expect("warning event should be readable");
assert!(matches!(
warning_event.msg,
EventMsg::Warning(WarningEvent { message })
if message == "Exceeded skills context budget of 2%. All skill descriptions were removed and 2 additional skills were not included in the model-visible skills list."
));
}
#[tokio::test]
async fn build_initial_context_restates_realtime_start_when_reference_context_is_missing() {
let (session, mut turn_context) = make_session_and_context().await;
-5
View File
@@ -13,13 +13,10 @@ use codex_utils_plugins::PluginSkillRoot;
pub use codex_core_skills::SkillError;
pub use codex_core_skills::SkillLoadOutcome;
pub use codex_core_skills::SkillRenderReport;
pub use codex_core_skills::SkillsLoadInput;
pub use codex_core_skills::SkillsService;
pub use codex_core_skills::build_available_skills;
pub use codex_core_skills::build_skill_name_counts;
pub use codex_core_skills::config_rules;
pub use codex_core_skills::default_skill_metadata_budget;
pub use codex_core_skills::detect_implicit_skill_invocation_for_command;
pub use codex_core_skills::filter_skill_load_outcome_for_product;
pub use codex_core_skills::injection;
@@ -29,8 +26,6 @@ pub use codex_core_skills::injection::collect_explicit_skill_mentions;
pub use codex_core_skills::loader;
pub use codex_core_skills::model;
pub use codex_core_skills::remote;
pub use codex_core_skills::render;
pub use codex_core_skills::render::SkillRenderSideEffects;
pub use codex_core_skills::service;
pub use codex_core_skills::system;
pub use codex_skills::SkillMetadata;
-170
View File
@@ -1,4 +1,3 @@
use codex_config::ConfigLayerStack;
use codex_config::types::AuthCredentialsStoreMode;
use codex_core::ModelClient;
use codex_core::NewThread;
@@ -54,7 +53,6 @@ use codex_protocol::protocol::SessionMeta;
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::SessionSource;
use codex_protocol::user_input::UserInput;
use core_test_support::PathBufExt;
use core_test_support::TestCodexResponsesRequestKind;
use core_test_support::apps_test_server::AppsTestServer;
use core_test_support::load_default_config_for_test;
@@ -79,7 +77,6 @@ use core_test_support::test_codex::TestCodex;
use core_test_support::test_codex::local_selections;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use dunce::canonicalize as normalize_path;
use futures::StreamExt;
use pretty_assertions::assert_eq;
use serde_json::json;
@@ -87,7 +84,6 @@ use std::io::Write;
use std::num::NonZeroU64;
use std::sync::Arc;
use tempfile::TempDir;
use toml::toml;
use uuid::Uuid;
use wiremock::Mock;
use wiremock::MockServer;
@@ -2204,172 +2200,6 @@ async fn omits_environment_context_when_configured_off() {
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn skills_append_to_developer_message() {
skip_if_no_network!();
let server = MockServer::start().await;
let resp_mock = mount_sse_once(
&server,
sse(vec![ev_response_created("resp1"), ev_completed("resp1")]),
)
.await;
let codex_home = Arc::new(TempDir::new().unwrap());
let skill_dir = codex_home.path().join("skills/demo");
std::fs::create_dir_all(&skill_dir).expect("create skill dir");
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: demo\ndescription: build charts\n---\n\n# body\n",
)
.expect("write skill");
let codex_home_path = codex_home.path().to_path_buf();
let mut builder = test_codex()
.with_home(codex_home.clone())
.with_auth(CodexAuth::from_api_key("Test API Key"))
.with_config(move |config| {
config.cwd = codex_home_path.abs();
});
let codex = builder
.build(&server)
.await
.expect("create new conversation")
.codex;
codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "hello".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: Default::default(),
})
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let request = resp_mock.single_request();
let developer_messages = request.message_input_texts("developer");
let developer_text = developer_messages.join("\n\n");
assert!(
developer_text.contains("## Skills"),
"expected skills section present: {developer_messages:?}"
);
assert!(
developer_text.contains("demo: build charts"),
"expected skill summary: {developer_messages:?}"
);
assert!(
developer_text.find("demo: build charts")
< developer_text.find("<permissions instructions>"),
"host skills should precede permissions: {developer_messages:?}"
);
let expected_path = normalize_path(skill_dir.join("SKILL.md")).unwrap();
let expected_path_str = expected_path.to_string_lossy().replace('\\', "/");
assert!(
developer_text.contains(&expected_path_str),
"expected path {expected_path_str} in developer message: {developer_messages:?}"
);
let _codex_home_guard = codex_home;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn skills_use_aliases_in_developer_message_under_budget_pressure() {
skip_if_no_network!();
let server = MockServer::start().await;
let resp_mock = mount_sse_once(
&server,
sse(vec![ev_response_created("resp1"), ev_completed("resp1")]),
)
.await;
let codex_home_parent = TempDir::new().unwrap();
let long_home_parent = codex_home_parent
.path()
.join("codex-home-with-long-shared-prefix-for-skill-alias-budget-test");
std::fs::create_dir_all(&long_home_parent).expect("create long home parent");
let codex_home = Arc::new(TempDir::new_in(long_home_parent).unwrap());
let skill_root = codex_home.path().join("skills");
for index in 0..12 {
let skill_dir = skill_root.join(format!("s{index:02}"));
std::fs::create_dir_all(&skill_dir).expect("create skill dir");
std::fs::write(
skill_dir.join("SKILL.md"),
format!("---\nname: s{index:02}\ndescription: d\n---\n\n# body\n"),
)
.expect("write skill");
}
let codex_home_path = codex_home.path().to_path_buf();
let mut builder = test_codex()
.with_home(codex_home.clone())
.with_auth(CodexAuth::from_api_key("Test API Key"))
.with_config(move |config| {
config.cwd = codex_home_path.abs();
let user_config_path = codex_home_path.join("config.toml").abs();
config.config_layer_stack = ConfigLayerStack::default()
.with_user_config(
&user_config_path,
toml! { skills = { bundled = { enabled = false } } }.into(),
)
.expect("skills user config should be valid");
config.model_context_window = Some(12_000);
});
let codex = builder
.build(&server)
.await
.expect("create new conversation")
.codex;
codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "hello".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: Default::default(),
})
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let request = resp_mock.single_request();
let developer_messages = request.message_input_texts("developer");
let developer_text = developer_messages.join("\n\n");
let expected_root = normalize_path(skill_root).unwrap();
let expected_root_str = expected_root.to_string_lossy().replace('\\', "/");
assert!(
developer_text.contains("### Skill roots"),
"expected aliased skills root section: {developer_messages:?}"
);
assert!(
developer_text.contains(&format!("- `r0` = `{expected_root_str}`")),
"expected root alias for {expected_root_str}: {developer_messages:?}"
);
assert!(
developer_text.contains("- s00: d (file: r0/s00/SKILL.md)"),
"expected skill path to use root alias: {developer_messages:?}"
);
assert!(
developer_text.contains(
"expand the listed short `path` with the matching alias from `### Skill roots`"
),
"expected alias-specific skill instructions: {developer_messages:?}"
);
let _codex_home_guard = codex_home;
let _codex_home_parent_guard = codex_home_parent;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn includes_configured_max_effort_in_request() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
@@ -4,7 +4,10 @@ use std::sync::Arc;
use anyhow::Result;
use codex_config::types::Personality;
use codex_core::config::Config;
use codex_exec_server::LOCAL_ENVIRONMENT_ID;
use codex_extension_api::ExtensionRegistry;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_features::Feature;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
@@ -13,6 +16,8 @@ use codex_protocol::protocol::Op;
use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_protocol::protocol::TurnEnvironmentSelections;
use codex_protocol::user_input::UserInput;
use codex_skills_extension::SkillsExtensionConfig;
use codex_skills_extension::install;
use codex_utils_path_uri::PathUri;
use core_test_support::PathBufExt;
use core_test_support::context_snapshot;
@@ -34,6 +39,17 @@ use serde_json::json;
const PRETURN_CONTEXT_DIFF_CWD: &str = "PRETURN_CONTEXT_DIFF_CWD";
fn skills_extensions() -> Arc<ExtensionRegistry<Config>> {
let mut extensions = ExtensionRegistryBuilder::<Config>::new();
install(&mut extensions, |config: &Config| SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
bundled_skills_enabled: config.bundled_skills_enabled(),
orchestrator_skills_enabled: config.orchestrator_skills_enabled,
shadow_selection_enabled: config.features.enabled(Feature::SkillSearch),
});
Arc::new(extensions.build())
}
fn context_snapshot_options() -> ContextSnapshotOptions {
ContextSnapshotOptions::default()
.render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 96 })
@@ -162,13 +178,16 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> {
)
.await;
let mut builder = test_codex().with_model("gpt-5.4").with_config(|config| {
config
.features
.enable(Feature::Personality)
.expect("test config should allow feature update");
config.personality = Some(Personality::Pragmatic);
});
let mut builder = test_codex()
.with_extensions(skills_extensions())
.with_model("gpt-5.4")
.with_config(|config| {
config
.features
.enable(Feature::Personality)
.expect("test config should allow feature update");
config.personality = Some(Personality::Pragmatic);
});
let test = builder.build(&server).await?;
let preturn_context_diff_cwd = test.cwd_path().join(PRETURN_CONTEXT_DIFF_CWD);
fs::create_dir_all(&preturn_context_diff_cwd)?;
@@ -282,7 +301,9 @@ async fn snapshot_model_visible_layout_cwd_change_refreshes_agents() -> Result<(
)
.await;
let mut builder = test_codex().with_model("gpt-5.4");
let mut builder = test_codex()
.with_extensions(skills_extensions())
.with_model("gpt-5.4");
let test = builder.build(&server).await?;
let cwd_one = test.cwd_path().join("agents_one");
let cwd_two = test.cwd_path().join("agents_two");
@@ -396,9 +417,11 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let mut initial_builder = test_codex().with_config(|config| {
config.model = Some("gpt-5.2".to_string());
});
let mut initial_builder = test_codex()
.with_extensions(skills_extensions())
.with_config(|config| {
config.model = Some("gpt-5.2".to_string());
});
let initial = initial_builder.build(&server).await?;
let codex = Arc::clone(&initial.codex);
let home = initial.home.clone();
@@ -442,14 +465,16 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul
)
.await;
let mut resume_builder = test_codex().with_config(|config| {
config.model = Some("gpt-5.4".to_string());
config
.features
.enable(Feature::Personality)
.expect("test config should allow feature update");
config.personality = Some(Personality::Pragmatic);
});
let mut resume_builder = test_codex()
.with_extensions(skills_extensions())
.with_config(|config| {
config.model = Some("gpt-5.4".to_string());
config
.features
.enable(Feature::Personality)
.expect("test config should allow feature update");
config.personality = Some(Personality::Pragmatic);
});
let resumed = resume_builder.resume(&server, home, rollout_path).await?;
let resume_override_cwd = resumed.cwd_path().join(PRETURN_CONTEXT_DIFF_CWD);
fs::create_dir_all(&resume_override_cwd)?;
@@ -511,9 +536,11 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() -
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let mut initial_builder = test_codex().with_config(|config| {
config.model = Some("gpt-5.2".to_string());
});
let mut initial_builder = test_codex()
.with_extensions(skills_extensions())
.with_config(|config| {
config.model = Some("gpt-5.2".to_string());
});
let initial = initial_builder.build(&server).await?;
let codex = Arc::clone(&initial.codex);
let home = initial.home.clone();
@@ -557,9 +584,11 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() -
)
.await;
let mut resume_builder = test_codex().with_config(|config| {
config.model = Some("gpt-5.4".to_string());
});
let mut resume_builder = test_codex()
.with_extensions(skills_extensions())
.with_config(|config| {
config.model = Some("gpt-5.4".to_string());
});
let resumed = resume_builder.resume(&server, home, rollout_path).await?;
let resume_override_cwd = resumed.cwd_path().join(PRETURN_CONTEXT_DIFF_CWD);
fs::create_dir_all(&resume_override_cwd)?;
+29 -6
View File
@@ -6,7 +6,10 @@ use std::time::Duration;
use std::time::Instant;
use anyhow::Result;
use codex_core::config::Config;
use codex_core_plugins::store::PluginStore;
use codex_extension_api::ExtensionRegistry;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_features::Feature;
use codex_login::CodexAuth;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
@@ -19,6 +22,8 @@ use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::user_input::UserInput;
use codex_skills_extension::SkillsExtensionConfig;
use codex_skills_extension::install;
use core_test_support::apps_test_server::AppsTestServer;
use core_test_support::apps_test_server::SEARCH_CALENDAR_CREATE_TOOL;
use core_test_support::responses::ResponseMock;
@@ -59,6 +64,17 @@ const PLUGIN_APP_SEARCH_CALL_ID: &str = "plugin-app-search";
const PLUGIN_MCP_SEARCH_CALL_ID: &str = "plugin-mcp-search";
const REMOTE_PLUGIN_CONFIG_NAME: &str = "sample@openai-curated-remote";
fn skills_extensions() -> Arc<ExtensionRegistry<Config>> {
let mut extensions = ExtensionRegistryBuilder::<Config>::new();
install(&mut extensions, |config: &Config| SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
bundled_skills_enabled: config.bundled_skills_enabled(),
orchestrator_skills_enabled: config.orchestrator_skills_enabled,
shadow_selection_enabled: config.features.enabled(Feature::SkillSearch),
});
Arc::new(extensions.build())
}
fn sample_plugin_root(home: &TempDir) -> std::path::PathBuf {
home.path().join("plugins/cache/test/sample/local")
}
@@ -408,12 +424,18 @@ async fn capability_sections_render_in_developer_message_in_order() -> Result<()
let codex_home = Arc::new(TempDir::new()?);
write_plugin_skill_plugin(codex_home.as_ref());
write_plugin_app_plugin(codex_home.as_ref());
let test_codex = build_apps_enabled_plugin_test_codex(
&server,
Arc::clone(&codex_home),
apps_server.chatgpt_base_url,
)
.await?;
let mut builder = test_codex()
.with_home(Arc::clone(&codex_home))
.with_extensions(skills_extensions())
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_config(move |config| {
config
.features
.enable(Feature::Apps)
.expect("test config should allow feature update");
config.chatgpt_base_url = apps_server.chatgpt_base_url;
});
let test_codex = builder.build(&server).await?;
let codex = Arc::clone(&test_codex.codex);
codex
@@ -645,6 +667,7 @@ enabled = true
let mut builder = test_codex()
.with_home(Arc::clone(&codex_home))
.with_extensions(skills_extensions())
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing());
let test_codex = builder.build_with_auto_env(&server).await?;
let plugins_manager = test_codex.thread_manager.plugins_manager();
@@ -6,8 +6,8 @@ Scenario: New context window tool installs fresh full context before the next fo
## Final Follow-Up Request
00:message/developer[3]:
[01] <SKILLS_INSTRUCTIONS>
[02] <context_window>\nThread id: <THREAD_ID>\nFirst context window id: <FIRST_WINDOW_ID>\nCurrent context window id: <WINDOW_ID>\nPrevious context window id: <FIRST_WINDOW_ID>\n</context_window>
[01] <context_window>\nThread id: <THREAD_ID>\nFirst context window id: <FIRST_WINDOW_ID>\nCurrent context window id: <WINDOW_ID>\nPrevious context window id: <FIRST_WINDOW_ID>\n</context_window>
[02] <SKILLS_INSTRUCTIONS>
[03] <PERMISSIONS_INSTRUCTIONS>
01:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
02:function_call/update_plan
+12
View File
@@ -2,7 +2,9 @@ use anyhow::Result;
use codex_config::config_toml::ConfigLockfileToml;
use codex_config::types::McpServerConfig;
use codex_config::types::McpServerTransportConfig;
use codex_core::config::Config;
use codex_core::config::TokenBudgetConfig;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_features::Feature;
use codex_features::FeatureToml;
use codex_features::TokenBudgetConfigToml;
@@ -22,6 +24,8 @@ use codex_protocol::protocol::ItemStartedEvent;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ThreadSettingsOverrides;
use codex_protocol::user_input::UserInput;
use codex_skills_extension::SkillsExtensionConfig;
use codex_skills_extension::install;
use core_test_support::PathBufExt;
use core_test_support::assert_regex_match;
use core_test_support::context_snapshot;
@@ -1483,7 +1487,15 @@ async fn new_context_tool_skips_auto_compact_fallback() -> Result<()> {
],
)
.await;
let mut extensions = ExtensionRegistryBuilder::<Config>::new();
install(&mut extensions, |config: &Config| SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
bundled_skills_enabled: config.bundled_skills_enabled(),
orchestrator_skills_enabled: config.orchestrator_skills_enabled,
shadow_selection_enabled: config.features.enabled(Feature::SkillSearch),
});
let test = test_codex()
.with_extensions(Arc::new(extensions.build()))
.with_config(|config| {
config.model_context_window = Some(10_000);
config.token_budget = Some(TokenBudgetConfig {