From d46a98d31a9c32949580e6e3ce65b85772f3470d Mon Sep 17 00:00:00 2001 From: jif Date: Thu, 4 Jun 2026 15:28:06 +0200 Subject: [PATCH] Bridge host-loaded skills into the skills extension (#26172) ## Why The skills extension needs to become the path that exposes local host skills without losing the behavior already owned by core skill loading. Host skill discovery is not just `$CODEX_HOME/skills`: it also includes config layers, bundled-skill settings, plugin roots, runtime extra roots, and the filesystem for the selected primary environment. Rather than making the extension reload host skills and risk drifting from that authoritative load, this PR bridges the already-loaded per-turn skills outcome into the extension. That lets the extension advertise host skills and inject explicit `$skill` prompts while preserving the same roots, disabled/hidden state, rendered paths, and environment-backed file reads that the legacy path uses. ## What Changed - Adds `HostLoadedSkills` in `core-skills` to wrap the turn's `SkillLoadOutcome` and read `SKILL.md` through the filesystem that loaded that skill. - Stores `HostLoadedSkills` in turn extension data for normal turns and review turns, so the skills extension can consume the loaded host catalog without reloading it. - Adds `HostSkillProvider` under `ext/skills/src/provider/host.rs`, mapping host-loaded skill metadata into the skills-extension catalog/read contract. - Registers the host provider by default from `codex_skills_extension::install()`. - Preserves host skill metadata such as dependencies, disabled state, hidden-from-prompt policy, and slash-normalized display paths. - Passes host-loaded skills through `SkillListQuery` and `SkillReadRequest` so explicit skill invocation reads only resources from the loaded host catalog. - Adds integration coverage for a real legacy `$CODEX_HOME/skills/.../SKILL.md` skill being listed and injected through the installed extension. ## Testing - Added `installed_extension_loads_host_skills_from_legacy_roots` in `ext/skills/tests/skills_extension.rs`. - `just test -p codex-skills-extension` --- codex-rs/core-skills/src/injection.rs | 30 ++++ codex-rs/core-skills/src/lib.rs | 1 + codex-rs/core-skills/src/model.rs | 32 +++- codex-rs/core/src/session/review.rs | 10 +- codex-rs/core/src/session/turn.rs | 15 +- codex-rs/core/src/session/turn_context.rs | 2 + .../ext/extension-api/src/contributors.rs | 1 - codex-rs/ext/skills/src/extension.rs | 29 +++- codex-rs/ext/skills/src/lib.rs | 1 + codex-rs/ext/skills/src/provider.rs | 13 +- codex-rs/ext/skills/src/provider/host.rs | 134 +++++++++++++++ codex-rs/ext/skills/tests/skills_extension.rs | 155 +++++++++++++++--- 12 files changed, 390 insertions(+), 33 deletions(-) create mode 100644 codex-rs/ext/skills/src/provider/host.rs diff --git a/codex-rs/core-skills/src/injection.rs b/codex-rs/core-skills/src/injection.rs index 45979fa5c..a358ddc63 100644 --- a/codex-rs/core-skills/src/injection.rs +++ b/codex-rs/core-skills/src/injection.rs @@ -28,6 +28,32 @@ pub struct SkillInjection { pub contents: String, } +/// Host skill prompts that have already been injected by an extension for this +/// turn. +/// +/// Core uses this to keep the legacy skill-injection path from sending the same +/// host `SKILL.md` body again while the skills extension is being wired in. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct InjectedHostSkillPrompts { + paths: HashSet, +} + +impl InjectedHostSkillPrompts { + pub fn insert_path(&mut self, path: impl Into) { + let path = path.into(); + self.paths.insert(normalize_host_skill_path(&path)); + self.paths.insert(path); + } + + pub fn is_empty(&self) -> bool { + self.paths.is_empty() + } + + pub fn contains_path(&self, path: &str) -> bool { + self.paths.contains(path) || self.paths.contains(&normalize_host_skill_path(path)) + } +} + pub async fn build_skill_injections( mentioned_skills: &[SkillMetadata], loaded_skills: Option<&SkillLoadOutcome>, @@ -85,6 +111,10 @@ pub async fn build_skill_injections( result } +fn normalize_host_skill_path(path: &str) -> String { + normalize_skill_path(path).replace('\\', "/") +} + fn emit_skill_injected_metric( otel: Option<&SessionTelemetry>, skill: &SkillMetadata, diff --git a/codex-rs/core-skills/src/lib.rs b/codex-rs/core-skills/src/lib.rs index e4a61697d..0390302af 100644 --- a/codex-rs/core-skills/src/lib.rs +++ b/codex-rs/core-skills/src/lib.rs @@ -15,6 +15,7 @@ pub use invocation_utils::detect_implicit_skill_invocation_for_command; pub use manager::SkillsLoadInput; pub use manager::SkillsManager; pub use mention_counts::build_skill_name_counts; +pub use model::HostLoadedSkills; pub use model::SkillError; pub use model::SkillLoadOutcome; pub use model::SkillMetadata; diff --git a/codex-rs/core-skills/src/model.rs b/codex-rs/core-skills/src/model.rs index fc8e9f591..a9cc24306 100644 --- a/codex-rs/core-skills/src/model.rs +++ b/codex-rs/core-skills/src/model.rs @@ -1,9 +1,11 @@ use std::collections::HashMap; use std::collections::HashSet; use std::fmt; +use std::io; use std::sync::Arc; use codex_exec_server::ExecutorFileSystem; +use codex_exec_server::LOCAL_FS; use codex_protocol::protocol::Product; use codex_protocol::protocol::SkillScope; use codex_utils_absolute_path::AbsolutePathBuf; @@ -23,7 +25,7 @@ pub struct SkillMetadata { } impl SkillMetadata { - fn allow_implicit_invocation(&self) -> bool { + pub fn allows_implicit_invocation(&self) -> bool { self.policy .as_ref() .and_then(|policy| policy.allow_implicit_invocation) @@ -103,7 +105,7 @@ impl SkillLoadOutcome { } pub fn is_skill_allowed_for_implicit_invocation(&self, skill: &SkillMetadata) -> bool { - self.is_skill_enabled(skill) && skill.allow_implicit_invocation() + self.is_skill_enabled(skill) && skill.allows_implicit_invocation() } pub fn allowed_skills_for_implicit_invocation(&self) -> Vec { @@ -129,6 +131,32 @@ impl SkillLoadOutcome { } } +/// Host-loaded skills for one turn, including the filesystem mapping needed to +/// read skill bodies through the environment that loaded them. +#[derive(Debug, Clone)] +pub struct HostLoadedSkills { + outcome: Arc, +} + +impl HostLoadedSkills { + pub fn new(outcome: Arc) -> Self { + Self { outcome } + } + + pub fn outcome(&self) -> &SkillLoadOutcome { + self.outcome.as_ref() + } + + pub async fn read_skill_text(&self, skill: &SkillMetadata) -> io::Result { + let fs = self + .outcome + .file_system_for_skill(skill) + .unwrap_or_else(|| Arc::clone(&LOCAL_FS)); + fs.read_file_text(&skill.path_to_skills_md, /*sandbox*/ None) + .await + } +} + #[derive(Clone, Default)] pub(crate) struct SkillFileSystemsByPath { values: Arc>>, diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index 5a26cce2e..de864ebb0 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -1,4 +1,5 @@ use super::*; +use codex_core_skills::HostLoadedSkills; use codex_protocol::openai_models::ToolMode; use std::sync::atomic::AtomicBool; @@ -100,6 +101,13 @@ pub(super) async fn spawn_review_thread( parent_turn_context.network.is_some(), )); + let extension_data = Arc::new(codex_extension_api::ExtensionData::new( + review_turn_id.clone(), + )); + extension_data.insert(HostLoadedSkills::new( + parent_turn_context.turn_skills.outcome.clone(), + )); + let review_turn_context = TurnContext { sub_id: review_turn_id.clone(), trace_id: current_span_trace_id(), @@ -143,7 +151,7 @@ pub(super) async fn spawn_review_thread( dynamic_tools: parent_turn_context.dynamic_tools.clone(), truncation_policy: model_info.truncation_policy.into(), turn_metadata_state, - extension_data: Arc::new(codex_extension_api::ExtensionData::new(review_turn_id)), + extension_data, turn_skills: TurnSkillsContext::new(parent_turn_context.turn_skills.outcome.clone()), turn_timing_state: Arc::new(TurnTimingState::default()), server_model_warning_emitted: AtomicBool::new(false), diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 42d609561..2765de76f 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -69,6 +69,7 @@ use codex_analytics::InvocationType; use codex_analytics::TurnResolvedConfigFact; use codex_analytics::build_track_events_context; use codex_async_utils::OrCancelExt; +use codex_core_skills::injection::InjectedHostSkillPrompts; use codex_extension_api::TurnInputContext; use codex_extension_api::TurnInputEnvironment; use codex_features::Feature; @@ -535,6 +536,9 @@ async fn build_skills_and_plugins( ) .await; + let injected_host_skill_prompts = turn_context + .extension_data + .get::(); let SkillInjections { items: skill_injections, warnings: skill_warnings, @@ -591,7 +595,16 @@ async fn build_skills_and_plugins( .track_plugin_used(tracking.clone(), plugin); } - let mut injection_items = skill_items; + let mut injection_items: Vec = match injected_host_skill_prompts { + Some(injected_host_skill_prompts) => skill_injections + .iter() + .filter(|skill| !injected_host_skill_prompts.contains_path(&skill.path)) + .map(|skill| { + ContextualUserFragment::into(crate::context::SkillInstructions::from(skill)) + }) + .collect(), + None => skill_items, + }; injection_items.extend(plugin_items); injection_items.extend(extension_injection_items); Some((injection_items, explicitly_enabled_connectors)) diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index a6fcfca78..fea422cb4 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -2,6 +2,7 @@ use super::*; use crate::SkillLoadOutcome; use crate::config::GhostSnapshotConfig; use crate::environment_selection::ResolvedTurnEnvironments; +use codex_core_skills::HostLoadedSkills; use codex_model_provider::SharedModelProvider; use codex_model_provider::create_model_provider; use codex_protocol::SessionId; @@ -526,6 +527,7 @@ impl Session { )); let (current_date, timezone) = local_time_context(); let extension_data = Arc::new(codex_extension_api::ExtensionData::new(sub_id.clone())); + extension_data.insert(HostLoadedSkills::new(Arc::clone(&skills_outcome))); TurnContext { sub_id, trace_id: current_span_trace_id(), diff --git a/codex-rs/ext/extension-api/src/contributors.rs b/codex-rs/ext/extension-api/src/contributors.rs index 14ccb6890..8706e8ee7 100644 --- a/codex-rs/ext/extension-api/src/contributors.rs +++ b/codex-rs/ext/extension-api/src/contributors.rs @@ -89,7 +89,6 @@ pub trait TurnLifecycleContributor: Send + Sync { async fn on_turn_error(&self, _input: TurnErrorInput<'_>) {} } -/// WARNING: DO NOT USE YET /// Extension contribution that can add turn-local model input. /// /// Implementations should resolve only the model-visible input they own and diff --git a/codex-rs/ext/skills/src/extension.rs b/codex-rs/ext/skills/src/extension.rs index 85ff513ac..3961d7d85 100644 --- a/codex-rs/ext/skills/src/extension.rs +++ b/codex-rs/ext/skills/src/extension.rs @@ -1,7 +1,9 @@ use std::sync::Arc; use codex_core::config::Config; +use codex_core_skills::HostLoadedSkills; use codex_core_skills::SkillInstructions; +use codex_core_skills::injection::InjectedHostSkillPrompts; use codex_core_skills::injection::SkillInjection; use codex_extension_api::ConfigContributor; use codex_extension_api::ContextualUserFragment; @@ -20,6 +22,7 @@ use crate::catalog::SkillAuthority; use crate::catalog::SkillCatalogEntry; use crate::catalog::SkillReadResult; use crate::catalog::SkillSourceKind; +use crate::provider::HostSkillProvider; use crate::provider::SkillListQuery; use crate::provider::SkillReadRequest; use crate::render::available_skills_fragment; @@ -78,6 +81,7 @@ impl TurnInputContributor for SkillsExtension { }; let config = thread_state.config(); + let host_loaded_skills = turn_store.get::(); let query = SkillListQuery { turn_id: input.turn_id.clone(), executor_authorities: input @@ -90,6 +94,7 @@ impl TurnInputContributor for SkillsExtension { ) }) .collect(), + host: host_loaded_skills.clone(), include_host_skills: true, include_bundled_skills: config.bundled_skills_enabled, include_remote_skills: true, @@ -109,8 +114,12 @@ impl TurnInputContributor for SkillsExtension { let mut warnings = catalog.warnings.clone(); let mut main_prompts_injected = false; + let mut injected_host_skill_prompts = InjectedHostSkillPrompts::default(); for entry in &selected_entries { - match self.read_main_prompt(entry).await { + match self + .read_main_prompt(entry, host_loaded_skills.clone()) + .await + { Ok(read_result) => { let (contents, truncated) = truncate_main_prompt_contents(read_result.contents.as_str()); @@ -129,6 +138,9 @@ impl TurnInputContributor for SkillsExtension { }; fragments.push(Box::new(SkillInstructions::from(&injection))); main_prompts_injected = true; + if entry.authority.kind == SkillSourceKind::Host { + injected_host_skill_prompts.insert_path(entry.main_prompt.0.clone()); + } } Err(message) => { let warning = format!("Failed to load skill `{}`: {message}", entry.name); @@ -144,18 +156,26 @@ impl TurnInputContributor for SkillsExtension { warnings, main_prompts_injected, }); + if !injected_host_skill_prompts.is_empty() { + turn_store.insert(injected_host_skill_prompts); + } fragments } } impl SkillsExtension { - async fn read_main_prompt(&self, entry: &SkillCatalogEntry) -> Result { + async fn read_main_prompt( + &self, + entry: &SkillCatalogEntry, + host_loaded_skills: Option>, + ) -> Result { self.providers .read(SkillReadRequest { authority: entry.authority.clone(), package: entry.id.clone(), resource: entry.main_prompt.clone(), + host: host_loaded_skills, }) .await .map_err(|err| err.message) @@ -170,7 +190,10 @@ impl SkillsExtension { } pub fn install(registry: &mut ExtensionRegistryBuilder) { - install_with_providers(registry, SkillProviders::default()); + install_with_providers( + registry, + SkillProviders::new().with_host_provider(Arc::new(HostSkillProvider::new())), + ); } pub fn install_with_providers( diff --git a/codex-rs/ext/skills/src/lib.rs b/codex-rs/ext/skills/src/lib.rs index b5e224d49..3a28c5fbd 100644 --- a/codex-rs/ext/skills/src/lib.rs +++ b/codex-rs/ext/skills/src/lib.rs @@ -8,5 +8,6 @@ mod state; pub use extension::install; pub use extension::install_with_providers; +pub use provider::HostSkillProvider; pub use sources::SkillProviderSource; pub use sources::SkillProviders; diff --git a/codex-rs/ext/skills/src/provider.rs b/codex-rs/ext/skills/src/provider.rs index f9c1b41fe..a968774af 100644 --- a/codex-rs/ext/skills/src/provider.rs +++ b/codex-rs/ext/skills/src/provider.rs @@ -1,5 +1,10 @@ use std::future::Future; use std::pin::Pin; +use std::sync::Arc; + +mod host; + +use codex_core_skills::HostLoadedSkills; use crate::catalog::SkillAuthority; use crate::catalog::SkillCatalog; @@ -9,20 +14,24 @@ use crate::catalog::SkillReadResult; use crate::catalog::SkillResourceId; use crate::catalog::SkillSearchResult; -#[derive(Clone, Debug, PartialEq, Eq)] +pub use host::HostSkillProvider; + +#[derive(Clone, Debug)] pub struct SkillListQuery { pub turn_id: String, pub executor_authorities: Vec, + pub host: Option>, pub include_host_skills: bool, pub include_bundled_skills: bool, pub include_remote_skills: bool, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug)] pub struct SkillReadRequest { pub authority: SkillAuthority, pub package: SkillPackageId, pub resource: SkillResourceId, + pub host: Option>, } #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/codex-rs/ext/skills/src/provider/host.rs b/codex-rs/ext/skills/src/provider/host.rs new file mode 100644 index 000000000..27293aa1d --- /dev/null +++ b/codex-rs/ext/skills/src/provider/host.rs @@ -0,0 +1,134 @@ +use codex_core_skills::SkillLoadOutcome; +use codex_core_skills::SkillMetadata; + +use crate::catalog::SkillAuthority; +use crate::catalog::SkillCatalog; +use crate::catalog::SkillCatalogEntry; +use crate::catalog::SkillPackageId; +use crate::catalog::SkillProviderError; +use crate::catalog::SkillReadResult; +use crate::catalog::SkillResourceId; +use crate::catalog::SkillSearchResult; +use crate::catalog::SkillSourceKind; +use crate::provider::SkillListQuery; +use crate::provider::SkillProvider; +use crate::provider::SkillProviderFuture; +use crate::provider::SkillReadRequest; +use crate::provider::SkillSearchRequest; + +const HOST_AUTHORITY_ID: &str = "host"; + +/// Host-owned skill provider backed by the already-loaded turn skills. +/// +/// The provider intentionally does not reload or cache host skills. Core owns +/// skill loading, including plugin roots, runtime extra roots, and the primary +/// environment filesystem. This adapter only maps that loaded outcome into the +/// skills-extension catalog/read contract. +#[derive(Clone, Default)] +pub struct HostSkillProvider; + +impl HostSkillProvider { + pub fn new() -> Self { + Self + } +} + +impl SkillProvider for HostSkillProvider { + fn list(&self, query: SkillListQuery) -> SkillProviderFuture<'_, SkillCatalog> { + Box::pin(async move { + let Some(host_loaded_skills) = query.host else { + return Err(SkillProviderError::new( + "host skill provider requires loaded host skills", + )); + }; + + Ok(catalog_from_outcome(host_loaded_skills.outcome())) + }) + } + + fn read(&self, request: SkillReadRequest) -> SkillProviderFuture<'_, SkillReadResult> { + Box::pin(async move { + let Some(host_loaded_skills) = request.host else { + return Err(SkillProviderError::new( + "host skill provider requires loaded host skills", + )); + }; + let Some(skill) = host_loaded_skills.outcome().skills.iter().find(|skill| { + let skill_path = skill.path_to_skills_md.to_string_lossy(); + skill_path == request.resource.0.as_str() + || skill_path.replace('\\', "/") == request.resource.0 + }) else { + return Err(SkillProviderError::new(format!( + "host skill resource is not loaded: {}", + request.resource.0 + ))); + }; + + let contents = host_loaded_skills + .read_skill_text(skill) + .await + .map_err(|err| { + SkillProviderError::new(format!( + "failed to read host skill resource {}: {err}", + request.resource.0 + )) + })?; + + Ok(SkillReadResult { + resource: request.resource, + contents, + }) + }) + } + + fn search(&self, _request: SkillSearchRequest) -> SkillProviderFuture<'_, SkillSearchResult> { + Box::pin(async { Ok(SkillSearchResult::default()) }) + } +} + +fn catalog_from_outcome(outcome: &SkillLoadOutcome) -> SkillCatalog { + let mut catalog = SkillCatalog { + entries: Vec::new(), + warnings: outcome + .errors + .iter() + .map(|err| { + format!( + "Failed to load skill at {}: {}", + err.path.display(), + err.message + ) + }) + .collect(), + }; + + for (skill, enabled) in outcome.skills_with_enabled() { + catalog.push_entry(catalog_entry_from_skill(skill, enabled)); + } + + catalog +} + +fn catalog_entry_from_skill(skill: &SkillMetadata, enabled: bool) -> SkillCatalogEntry { + let skill_path = skill.path_to_skills_md.to_string_lossy().into_owned(); + let display_path = skill_path.replace('\\', "/"); + let mut entry = SkillCatalogEntry::new( + SkillPackageId(skill_path.clone()), + SkillAuthority::new(SkillSourceKind::Host, HOST_AUTHORITY_ID), + skill.name.clone(), + skill.description.clone(), + SkillResourceId(skill_path), + ) + .with_short_description(skill.short_description.clone()) + .with_display_path(display_path) + .with_dependencies(skill.dependencies.clone()); + + if !enabled { + entry = entry.disabled(); + } + if !skill.allows_implicit_invocation() { + entry = entry.hidden_from_prompt(); + } + + entry +} diff --git a/codex-rs/ext/skills/tests/skills_extension.rs b/codex-rs/ext/skills/tests/skills_extension.rs index acdb5587b..1e2a2f0b6 100644 --- a/codex-rs/ext/skills/tests/skills_extension.rs +++ b/codex-rs/ext/skills/tests/skills_extension.rs @@ -1,9 +1,15 @@ +use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; use codex_core::config::Config; +use codex_core::config::ConfigBuilder; +use codex_core_skills::HostLoadedSkills; +use codex_core_skills::SkillsLoadInput; +use codex_core_skills::SkillsManager; +use codex_core_skills::injection::InjectedHostSkillPrompts; use codex_extension_api::ExtensionData; use codex_extension_api::ExtensionRegistryBuilder; use codex_extension_api::ThreadStartInput; @@ -21,6 +27,7 @@ use codex_skills_extension::catalog::SkillReadResult; use codex_skills_extension::catalog::SkillResourceId; use codex_skills_extension::catalog::SkillSearchResult; use codex_skills_extension::catalog::SkillSourceKind; +use codex_skills_extension::install; use codex_skills_extension::install_with_providers; use codex_skills_extension::provider::SkillListQuery; use codex_skills_extension::provider::SkillProvider; @@ -33,6 +40,93 @@ type TestResult = Result<(), Box>; static NEXT_CODEX_HOME_ID: AtomicUsize = AtomicUsize::new(0); +#[tokio::test] +async fn installed_extension_loads_host_skills_from_legacy_roots() -> TestResult { + let codex_home = test_codex_home(); + let skill_path = codex_home.join("skills").join("demo").join("SKILL.md"); + std::fs::create_dir_all( + skill_path + .parent() + .ok_or("skill path should have a parent")?, + )?; + std::fs::write( + &skill_path, + "---\nname: demo\ndescription: Demo skill.\n---\n# Demo\n\nUse the demo skill.\n", + )?; + let config = ConfigBuilder::default() + .codex_home(codex_home.clone()) + .fallback_cwd(Some(codex_home.clone())) + .build() + .await?; + + let mut builder = ExtensionRegistryBuilder::new(); + install(&mut builder); + let registry = builder.build(); + let session_store = ExtensionData::new("session"); + let thread_store = ExtensionData::new("thread"); + let session_source = SessionSource::Cli; + registry.thread_lifecycle_contributors()[0] + .on_thread_start(ThreadStartInput { + config: &config, + session_source: &session_source, + persistent_thread_state_available: true, + session_store: &session_store, + thread_store: &thread_store, + }) + .await; + + let manager = SkillsManager::new(config.codex_home.clone(), config.bundled_skills_enabled()); + let input = SkillsLoadInput::new( + config.cwd.clone(), + Vec::new(), + config.config_layer_stack.clone(), + config.bundled_skills_enabled(), + ); + let loaded_skills = Arc::new(manager.skills_for_config(&input, /*fs*/ None).await); + let skill_path_string = loaded_skills + .skills + .iter() + .find(|skill| skill.name == "demo") + .ok_or("demo skill should load")? + .path_to_skills_md + .to_string_lossy() + .into_owned(); + let skill_prompt_path = skill_path_string.replace('\\', "/"); + let turn_store = ExtensionData::new("turn-1"); + turn_store.insert(HostLoadedSkills::new(Arc::clone(&loaded_skills))); + + let fragments = registry.turn_input_contributors()[0] + .contribute( + TurnInputContext { + turn_id: "turn-1".to_string(), + user_input: vec![UserInput::Text { + text: "$demo".to_string(), + text_elements: Vec::new(), + }], + environments: Vec::new(), + }, + &session_store, + &thread_store, + &turn_store, + ) + .await; + + assert_eq!(2, fragments.len()); + assert!(fragments[0].render().contains("demo")); + assert!(fragments[0].render().contains(&skill_prompt_path)); + assert_eq!("user", fragments[1].role()); + assert!(fragments[1].render().contains("demo")); + assert!(fragments[1].render().contains("# Demo")); + assert!(fragments[1].render().contains(&skill_prompt_path)); + let injected_host_skill_prompts = turn_store + .get::() + .ok_or("host skill prompt marker should be set")?; + assert!(injected_host_skill_prompts.contains_path(&skill_path_string)); + + std::fs::remove_dir_all(codex_home)?; + Ok(()) +} + #[tokio::test] async fn installed_extension_injects_available_catalog_and_selected_entrypoint() -> TestResult { let host_read_requests = Arc::new(Mutex::new(Vec::new())); @@ -115,15 +209,12 @@ async fn installed_extension_injects_available_catalog_and_selected_entrypoint() assert!(fragments[1].render().contains("lint-fix")); assert!(fragments[1].render().contains("# Lint Fix")); assert_eq!( - vec![SkillReadRequest { - authority: SkillAuthority::new(SkillSourceKind::Host, "host"), - package: SkillPackageId("host/lint-fix".to_string()), - resource: SkillResourceId("lint-fix/SKILL.md".to_string()), - }], - host_read_requests - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .clone() + vec![( + SkillAuthority::new(SkillSourceKind::Host, "host"), + SkillPackageId("host/lint-fix".to_string()), + SkillResourceId("lint-fix/SKILL.md".to_string()), + )], + read_request_keys(&host_read_requests) ); assert!( remote_read_requests @@ -220,15 +311,12 @@ async fn prompt_hidden_skill_can_still_be_invoked() -> TestResult { assert!(!catalog_fragment.contains("hidden-skill")); assert!(fragments[1].render().contains("hidden-skill")); assert_eq!( - vec![SkillReadRequest { - authority: SkillAuthority::new(SkillSourceKind::Host, "host"), - package: SkillPackageId("host/hidden-skill".to_string()), - resource: SkillResourceId("hidden-skill/SKILL.md".to_string()), - }], - read_requests - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .clone() + vec![( + SkillAuthority::new(SkillSourceKind::Host, "host"), + SkillPackageId("host/hidden-skill".to_string()), + SkillResourceId("hidden-skill/SKILL.md".to_string()), + )], + read_request_keys(&read_requests) ); Ok(()) @@ -287,14 +375,35 @@ fn test_entry( } async fn default_config() -> std::io::Result { - let id = NEXT_CODEX_HOME_ID.fetch_add(1, Ordering::Relaxed); - let codex_home = std::env::temp_dir().join(format!( - "codex-skills-extension-test-{}-{id}", - std::process::id(), - )); + let codex_home = test_codex_home(); std::fs::create_dir_all(&codex_home)?; let config = Config::load_default_with_cli_overrides_for_codex_home(codex_home.clone(), vec![]).await?; std::fs::remove_dir_all(codex_home)?; Ok(config) } + +fn test_codex_home() -> PathBuf { + let id = NEXT_CODEX_HOME_ID.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "codex-skills-extension-test-{}-{id}", + std::process::id(), + )) +} + +fn read_request_keys( + requests: &Arc>>, +) -> Vec<(SkillAuthority, SkillPackageId, SkillResourceId)> { + requests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .map(|request| { + ( + request.authority.clone(), + request.package.clone(), + request.resource.clone(), + ) + }) + .collect() +}