mirror of
https://github.com/openai/codex.git
synced 2026-10-21 17:48:44 +00:00
Load host skill roots through the skills extension (#37162)
## What changed - Load ordinary recursive host skill roots with the skills extension's host loader. - Keep plugin-specific roots on the existing loader so plugin snapshot caching and namespacing continue to work. - Merge both kinds of root snapshots through the existing precedence and deduplication logic, including discovery paths and source filesystem mappings. - Move `HostSkillsSnapshot` into the skills extension as the owner of host skill loading. ## Testing - Cover merged host and plugin catalogs, precedence for symlinked roots, discovery paths, and end-to-end rendering and invocation of host, repository, and plugin skills. GitOrigin-RevId: c18080afb917c33c57bc17e36b40dd0f3c72d1d7
This commit is contained in:
@@ -442,82 +442,6 @@ async fn persisted_remote_plugin_command_attribution_flows_through_turn_context(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn capability_sections_render_in_developer_message_in_order() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
let server = start_mock_server().await;
|
||||
let apps_server = AppsTestServer::mount_with_connector_name(&server, "Google Calendar").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()?);
|
||||
write_plugin_skill_plugin(codex_home.as_ref());
|
||||
write_plugin_app_plugin(codex_home.as_ref());
|
||||
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
|
||||
.submit(Op::UserInput {
|
||||
items: vec![codex_protocol::user_input::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?;
|
||||
|
||||
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 apps_pos = developer_text
|
||||
.find("## Apps")
|
||||
.expect("expected apps section in developer message");
|
||||
let skills_pos = developer_text
|
||||
.find("## Skills")
|
||||
.expect("expected skills section in developer message");
|
||||
let plugins_pos = developer_text
|
||||
.find("## Plugins")
|
||||
.expect("expected plugins section in developer message");
|
||||
assert!(
|
||||
skills_pos < apps_pos && apps_pos < plugins_pos,
|
||||
"expected Skills -> Apps -> Plugins order: {developer_messages:?}"
|
||||
);
|
||||
assert!(
|
||||
!developer_text.contains("`sample`: inspect sample data"),
|
||||
"did not expect plugin description in developer message: {developer_messages:?}"
|
||||
);
|
||||
assert!(
|
||||
developer_text.contains("skill entries are prefixed with `plugin_name:`"),
|
||||
"expected plugin skill naming guidance in developer message: {developer_messages:?}"
|
||||
);
|
||||
assert!(
|
||||
developer_text.contains("sample:sample-search: inspect sample data"),
|
||||
"expected namespaced plugin skill summary in developer message: {developer_messages:?}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn agent_plugin_skills_use_shared_catalog_and_direct_child_discovery() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -8,10 +8,12 @@ use codex_config::ConfigRequirements;
|
||||
use codex_config::ConfigRequirementsToml;
|
||||
use codex_core::StartThreadOptions;
|
||||
use codex_core::config::Config;
|
||||
use codex_exec_server::CreateDirectoryOptions;
|
||||
use codex_extension_api::ExtensionEventSink;
|
||||
use codex_extension_api::ExtensionRegistryBuilder;
|
||||
use codex_extension_api::ExtensionWarning;
|
||||
use codex_features::Feature;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
@@ -32,12 +34,14 @@ 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::SkillProviderFuture;
|
||||
use codex_skills_extension::provider::SkillReadRequest;
|
||||
use codex_skills_extension::provider::SkillSearchRequest;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use codex_utils_string::approx_token_count;
|
||||
use core_test_support::apps_test_server::AppsTestServer;
|
||||
use core_test_support::apps_test_server::apps_enabled_builder;
|
||||
@@ -47,6 +51,7 @@ use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::mount_sse_once;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::skip_if_wine_exec;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_mcp_server;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -446,6 +451,204 @@ async fn rendered_catalogs_for_turns(
|
||||
Ok((developer_texts, client_warning_messages))
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn capability_sections_render_in_order_with_host_repo_and_plugin_skills() -> Result<()> {
|
||||
skip_if_wine_exec!(
|
||||
Ok(()),
|
||||
"executor-backed repo skills require matching host and executor path conventions"
|
||||
);
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
const HOST_SKILL_BODY: &str = "Use the host skill instructions.";
|
||||
const REPO_SKILL_BODY: &str = "Use the repository skill instructions.";
|
||||
const PLUGIN_SKILL_BODY: &str = "Use the legacy plugin skill instructions.";
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let apps_server = AppsTestServer::mount_with_connector_name(&server, "Google Calendar").await?;
|
||||
let response = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![ev_response_created("resp1"), ev_completed("resp1")]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let codex_home = Arc::new(TempDir::new()?);
|
||||
let host_skill_dir = codex_home.path().join("skills/host-search");
|
||||
std::fs::create_dir_all(&host_skill_dir)?;
|
||||
let host_skill_path = host_skill_dir.join("SKILL.md");
|
||||
std::fs::write(
|
||||
&host_skill_path,
|
||||
format!(
|
||||
"---\nname: host-search\ndescription: inspect host data\n---\n\n{HOST_SKILL_BODY}\n"
|
||||
),
|
||||
)?;
|
||||
let host_skill_path = dunce::canonicalize(host_skill_path)?;
|
||||
let plugin_root = codex_home.path().join("plugins/cache/test/sample/local");
|
||||
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
|
||||
std::fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{"name":"sample","description":"inspect sample data"}"#,
|
||||
)?;
|
||||
let plugin_skill_dir = plugin_root.join("skills/sample-search");
|
||||
std::fs::create_dir_all(&plugin_skill_dir)?;
|
||||
let plugin_skill_path = plugin_skill_dir.join("SKILL.md");
|
||||
std::fs::write(
|
||||
&plugin_skill_path,
|
||||
format!("---\ndescription: inspect sample data\n---\n\n{PLUGIN_SKILL_BODY}\n"),
|
||||
)?;
|
||||
let plugin_skill_path = dunce::canonicalize(plugin_skill_path)?;
|
||||
std::fs::write(
|
||||
plugin_root.join(".app.json"),
|
||||
r#"{"apps":{"sample":{"id":"calendar"}}}"#,
|
||||
)?;
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
"[features]\nplugins = true\n\n[plugins.\"sample@test\"]\nenabled = true\n",
|
||||
)?;
|
||||
|
||||
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 mut builder = test_codex()
|
||||
.with_home(Arc::clone(&codex_home))
|
||||
.with_extensions(Arc::new(extensions.build()))
|
||||
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
|
||||
.with_workspace_setup(|cwd, fs| async move {
|
||||
let skill_dir = cwd.join(".agents/skills/repo-search");
|
||||
fs.create_directory(
|
||||
&PathUri::from_host_native_path(&skill_dir)?,
|
||||
CreateDirectoryOptions { recursive: true },
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
fs.write_file(
|
||||
&PathUri::from_host_native_path(skill_dir.join("SKILL.md"))?,
|
||||
format!(
|
||||
"---\nname: repo-search\ndescription: inspect repo data\n---\n\n{REPO_SKILL_BODY}\n"
|
||||
)
|
||||
.into_bytes(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
.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 = builder.build_with_auto_env(&server).await?;
|
||||
let repo_skill_path = test
|
||||
.fs()
|
||||
.canonicalize(
|
||||
&PathUri::from_abs_path(&test.config.cwd.join(".agents/skills/repo-search/SKILL.md")),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?
|
||||
.to_abs_path()?
|
||||
.to_path_buf();
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![
|
||||
UserInput::Text {
|
||||
text: "use all skills".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
UserInput::Skill {
|
||||
name: "host-search".to_string(),
|
||||
path: host_skill_path.clone(),
|
||||
},
|
||||
UserInput::Skill {
|
||||
name: "repo-search".to_string(),
|
||||
path: repo_skill_path.clone(),
|
||||
},
|
||||
UserInput::Skill {
|
||||
name: "sample:sample-search".to_string(),
|
||||
path: plugin_skill_path.clone(),
|
||||
},
|
||||
],
|
||||
final_output_json_schema: None,
|
||||
responsesapi_client_metadata: None,
|
||||
additional_context: Default::default(),
|
||||
thread_settings: Default::default(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
core_test_support::wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
})
|
||||
.await;
|
||||
|
||||
let request = response.single_request();
|
||||
let developer_messages = request.message_input_texts("developer");
|
||||
let developer_text = developer_messages.join("\n\n");
|
||||
let apps_pos = developer_text
|
||||
.find("## Apps")
|
||||
.expect("expected apps section in developer message");
|
||||
let skills_pos = developer_text
|
||||
.find("## Skills")
|
||||
.expect("expected skills section in developer message");
|
||||
let plugins_pos = developer_text
|
||||
.find("## Plugins")
|
||||
.expect("expected plugins section in developer message");
|
||||
assert!(
|
||||
skills_pos < apps_pos && apps_pos < plugins_pos,
|
||||
"expected Skills -> Apps -> Plugins order: {developer_messages:?}"
|
||||
);
|
||||
assert!(
|
||||
!developer_text.contains("`sample`: inspect sample data"),
|
||||
"did not expect plugin description in developer message: {developer_messages:?}"
|
||||
);
|
||||
assert!(
|
||||
developer_text.contains("skill entries are prefixed with `plugin_name:`"),
|
||||
"expected plugin skill naming guidance in developer message: {developer_messages:?}"
|
||||
);
|
||||
assert!(
|
||||
developer_text.contains("sample:sample-search: inspect sample data"),
|
||||
"expected namespaced plugin skill summary in developer message: {developer_messages:?}"
|
||||
);
|
||||
assert!(
|
||||
developer_text.contains("repo-search: inspect repo data"),
|
||||
"expected repo skill summary in developer message: {developer_messages:?}"
|
||||
);
|
||||
assert!(
|
||||
developer_text.contains("host-search: inspect host data"),
|
||||
"expected host skill summary in developer message: {developer_messages:?}"
|
||||
);
|
||||
|
||||
let user_text = request.message_input_texts("user").join("\n");
|
||||
for (name, path, body) in [
|
||||
("host-search", &host_skill_path, HOST_SKILL_BODY),
|
||||
("repo-search", &repo_skill_path, REPO_SKILL_BODY),
|
||||
(
|
||||
"sample:sample-search",
|
||||
&plugin_skill_path,
|
||||
PLUGIN_SKILL_BODY,
|
||||
),
|
||||
] {
|
||||
assert!(
|
||||
user_text.contains(&format!("<skill>\n<name>{name}</name>")),
|
||||
"expected injected skill `{name}` in user input: {user_text}"
|
||||
);
|
||||
assert!(
|
||||
user_text.contains(path.to_string_lossy().as_ref()),
|
||||
"expected path for `{name}` in user input: {user_text}"
|
||||
);
|
||||
assert!(
|
||||
user_text.contains(body),
|
||||
"expected body for `{name}` in user input: {user_text}"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn explicit_only_orchestrator_skill_is_hidden_but_can_be_invoked() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
Reference in New Issue
Block a user