mirror of
https://github.com/hanzoai/codex.git
synced 2026-10-30 14:59:26 +00:00
## Why Skill selection and disabled-path matching need to recognize equivalent Windows paths across case and separator differences while preserving literal spaces, `%`, and `#` in native filenames. ## What changed - Carry `PathUri` through skill metadata, discovery mappings, filesystem lookups, disabled-path sets, and invocation analytics. - Match linked skill mentions and TUI selections by parsed path identity. - Use `LegacyAppPathString` for app-server skill paths and regenerate schemas and SDK types, preserving native path strings on the wire. - Preserve native catalog display paths and ordering. Keep host filesystem probes and configuration writes restricted to host-compatible paths. ## Testing Add regression tests for Windows path identity and disabled-skill matching, literal filename characters, catalog resource reads, native path ordering, and foreign-path analytics normalization. GitOrigin-RevId: c212916dc9fef0e7616542726e107f0d6a313267
1441 lines
47 KiB
Rust
1441 lines
47 KiB
Rust
use std::collections::BTreeMap;
|
|
use std::time::Duration;
|
|
|
|
use anyhow::Result;
|
|
use app_test_support::ChatGptAuthFixture;
|
|
use app_test_support::MockResponsesConfig;
|
|
use app_test_support::TestAppServer;
|
|
use app_test_support::create_mock_responses_server_repeating_assistant;
|
|
use app_test_support::write_chatgpt_auth;
|
|
use codex_app_server_protocol::ConfigBatchWriteParams;
|
|
use codex_app_server_protocol::ConfigEdit;
|
|
use codex_app_server_protocol::ConfigReadParams;
|
|
use codex_app_server_protocol::ConfigReadResponse;
|
|
use codex_app_server_protocol::ConfigRequirementsReadResponse;
|
|
use codex_app_server_protocol::ConfigWriteResponse;
|
|
use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams;
|
|
use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse;
|
|
use codex_app_server_protocol::MergeStrategy;
|
|
use codex_app_server_protocol::PermissionProfileListParams;
|
|
use codex_app_server_protocol::PermissionProfileListResponse;
|
|
use codex_app_server_protocol::PluginListParams;
|
|
use codex_app_server_protocol::PluginListResponse;
|
|
use codex_app_server_protocol::SkillScope;
|
|
use codex_app_server_protocol::SkillsChangedNotification;
|
|
use codex_app_server_protocol::SkillsExtraRootsSetParams;
|
|
use codex_app_server_protocol::SkillsExtraRootsSetResponse;
|
|
use codex_app_server_protocol::SkillsListParams;
|
|
use codex_app_server_protocol::SkillsListResponse;
|
|
use codex_app_server_protocol::ThreadStartParams;
|
|
use codex_app_server_protocol::ThreadStartResponse;
|
|
use codex_config::types::AuthCredentialsStoreMode;
|
|
use codex_core::config::set_project_trust_level;
|
|
use codex_core_plugins::store::PluginStore;
|
|
use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR;
|
|
use codex_exec_server::CreateDirectoryOptions;
|
|
use codex_plugin::PluginId;
|
|
use codex_protocol::config_types::TrustLevel;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use codex_utils_path_uri::PathUri;
|
|
use core_test_support::skip_if_remote;
|
|
use core_test_support::skip_if_wine_exec;
|
|
use pretty_assertions::assert_eq;
|
|
use tempfile::TempDir;
|
|
use test_case::test_case;
|
|
use tokio::time::timeout;
|
|
use wiremock::Mock;
|
|
use wiremock::MockServer;
|
|
use wiremock::ResponseTemplate;
|
|
use wiremock::matchers::header;
|
|
use wiremock::matchers::method;
|
|
use wiremock::matchers::path;
|
|
use wiremock::matchers::query_param;
|
|
use wiremock::matchers::query_param_is_missing;
|
|
|
|
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
|
|
const WATCHER_TIMEOUT: Duration = Duration::from_secs(20);
|
|
|
|
fn write_skill(root: &TempDir, name: &str) -> Result<()> {
|
|
let skill_dir = root.path().join("skills").join(name);
|
|
std::fs::create_dir_all(&skill_dir)?;
|
|
let content = format!("---\nname: {name}\ndescription: {name} description\n---\n\n# Body\n");
|
|
std::fs::write(skill_dir.join("SKILL.md"), content)?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn expect_skills_changed_notification(
|
|
mcp: &mut TestAppServer,
|
|
timeout_duration: Duration,
|
|
) -> Result<()> {
|
|
let notification: SkillsChangedNotification =
|
|
timeout(timeout_duration, mcp.read_notification("skills/changed")).await??;
|
|
assert_eq!(notification, SkillsChangedNotification {});
|
|
Ok(())
|
|
}
|
|
|
|
fn write_plugins_enabled_config_with_base_url(
|
|
codex_home: &std::path::Path,
|
|
base_url: &str,
|
|
) -> std::io::Result<()> {
|
|
std::fs::write(
|
|
codex_home.join("config.toml"),
|
|
format!(
|
|
r#"chatgpt_base_url = "{base_url}"
|
|
|
|
[features]
|
|
plugins = true
|
|
"#,
|
|
),
|
|
)
|
|
}
|
|
|
|
fn write_cached_remote_plugin_with_skill(
|
|
codex_home: &std::path::Path,
|
|
) -> Result<std::path::PathBuf> {
|
|
let plugin_root = codex_home.join("plugins/cache/openai-curated-remote/linear/local");
|
|
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
|
|
std::fs::write(
|
|
plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{"name":"linear"}"#,
|
|
)?;
|
|
|
|
let skill_dir = plugin_root.join("skills/triage-issues");
|
|
std::fs::create_dir_all(&skill_dir)?;
|
|
let skill_path = skill_dir.join("SKILL.md");
|
|
std::fs::write(
|
|
&skill_path,
|
|
"---\nname: triage-issues\ndescription: Triage Linear issues\n---\n\n# Body\n",
|
|
)?;
|
|
Ok(skill_path)
|
|
}
|
|
|
|
fn write_cached_local_curated_plugin_with_skill(
|
|
codex_home: &std::path::Path,
|
|
marketplace_name: &str,
|
|
) -> Result<()> {
|
|
let plugin_root = codex_home.join(format!(
|
|
"plugins/cache/{marketplace_name}/google-calendar/local"
|
|
));
|
|
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
|
|
std::fs::write(
|
|
plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{"name":"google-calendar"}"#,
|
|
)?;
|
|
|
|
let skill_dir = plugin_root.join("skills/meeting-prep");
|
|
std::fs::create_dir_all(&skill_dir)?;
|
|
std::fs::write(
|
|
skill_dir.join("SKILL.md"),
|
|
"---\nname: meeting-prep\ndescription: Prepare for meetings\n---\n\n# Body\n",
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn skills_list_disabled_bundled_skills_preserves_shared_system_skill_cache() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let cwd = TempDir::new()?;
|
|
let mut enabled_mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let enabled_skills_request_id = enabled_mcp
|
|
.send_skills_list_request(SkillsListParams {
|
|
cwds: vec![cwd.path().to_path_buf()],
|
|
force_reload: true,
|
|
})
|
|
.await?;
|
|
let SkillsListResponse { data } = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
enabled_mcp.read_response(enabled_skills_request_id),
|
|
)
|
|
.await??;
|
|
assert_eq!(data.len(), 1);
|
|
assert_eq!(data[0].errors, Vec::new());
|
|
let system_skill_paths = data[0]
|
|
.skills
|
|
.iter()
|
|
.filter(|skill| skill.scope == SkillScope::System)
|
|
.map(|skill| skill.path.clone())
|
|
.collect::<Vec<_>>();
|
|
assert!(
|
|
!system_skill_paths.is_empty(),
|
|
"expected enabled app-server to materialize bundled system skills"
|
|
);
|
|
|
|
let mut disabled_mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.with_args(&["-c", "skills.bundled.enabled=false"])
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
let disabled_skills_request_id = disabled_mcp
|
|
.send_skills_list_request(SkillsListParams {
|
|
cwds: vec![cwd.path().to_path_buf()],
|
|
force_reload: true,
|
|
})
|
|
.await?;
|
|
let SkillsListResponse { data } = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
disabled_mcp.read_response(disabled_skills_request_id),
|
|
)
|
|
.await??;
|
|
assert_eq!(data.len(), 1);
|
|
assert_eq!(data[0].errors, Vec::new());
|
|
assert!(
|
|
data[0]
|
|
.skills
|
|
.iter()
|
|
.all(|skill| skill.scope != SkillScope::System)
|
|
);
|
|
assert!(
|
|
system_skill_paths
|
|
.iter()
|
|
.all(|path| std::path::Path::new(path.as_str()).is_file()),
|
|
"disabled app-server must not remove the cache shared by other processes"
|
|
);
|
|
|
|
let reloaded_skills_request_id = enabled_mcp
|
|
.send_skills_list_request(SkillsListParams {
|
|
cwds: vec![cwd.path().to_path_buf()],
|
|
force_reload: true,
|
|
})
|
|
.await?;
|
|
let SkillsListResponse { data } = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
enabled_mcp.read_response(reloaded_skills_request_id),
|
|
)
|
|
.await??;
|
|
assert_eq!(data.len(), 1);
|
|
assert_eq!(data[0].errors, Vec::new());
|
|
let reloaded_system_skill_paths = data[0]
|
|
.skills
|
|
.iter()
|
|
.filter(|skill| skill.scope == SkillScope::System)
|
|
.map(|skill| skill.path.clone())
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(reloaded_system_skill_paths, system_skill_paths);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn skills_list_uses_each_cwds_bundled_skills_configuration() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let disabled_cwd = TempDir::new()?;
|
|
let enabled_cwd = TempDir::new()?;
|
|
|
|
for (cwd, enabled) in [(disabled_cwd.path(), false), (enabled_cwd.path(), true)] {
|
|
std::fs::create_dir_all(cwd.join(".git"))?;
|
|
std::fs::create_dir_all(cwd.join(".codex"))?;
|
|
std::fs::write(
|
|
cwd.join(".codex/config.toml"),
|
|
format!("[skills.bundled]\nenabled = {enabled}\n"),
|
|
)?;
|
|
set_project_trust_level(codex_home.path(), cwd, TrustLevel::Trusted)?;
|
|
}
|
|
|
|
let mut app_server = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = app_server
|
|
.send_skills_list_request(SkillsListParams {
|
|
cwds: vec![
|
|
disabled_cwd.path().to_path_buf(),
|
|
enabled_cwd.path().to_path_buf(),
|
|
],
|
|
force_reload: true,
|
|
})
|
|
.await?;
|
|
let SkillsListResponse { data } =
|
|
timeout(DEFAULT_TIMEOUT, app_server.read_response(request_id)).await??;
|
|
|
|
assert_eq!(data.len(), 2);
|
|
for (entry, (cwd, enabled)) in data
|
|
.iter()
|
|
.zip([(disabled_cwd.path(), false), (enabled_cwd.path(), true)])
|
|
{
|
|
assert_eq!(entry.cwd, cwd);
|
|
assert_eq!(entry.errors, Vec::new());
|
|
assert_eq!(
|
|
entry
|
|
.skills
|
|
.iter()
|
|
.any(|skill| skill.scope == SkillScope::System),
|
|
enabled
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn skills_list_runtime_enable_refreshes_shared_system_skill_cache() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let cwd = TempDir::new()?;
|
|
let stale_skill_path = codex_home
|
|
.path()
|
|
.join("skills/.system/stale-system-skill/SKILL.md");
|
|
std::fs::create_dir_all(
|
|
stale_skill_path
|
|
.parent()
|
|
.expect("stale system skill should have a parent"),
|
|
)?;
|
|
std::fs::write(
|
|
&stale_skill_path,
|
|
"---\nname: stale-system-skill\ndescription: stale system skill\n---\n\n# Body\n",
|
|
)?;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
"[skills.bundled]\nenabled = false\n",
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let disabled_skills_request_id = mcp
|
|
.send_skills_list_request(SkillsListParams {
|
|
cwds: vec![cwd.path().to_path_buf()],
|
|
force_reload: true,
|
|
})
|
|
.await?;
|
|
let SkillsListResponse { data } = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_response(disabled_skills_request_id),
|
|
)
|
|
.await??;
|
|
assert_eq!(data.len(), 1);
|
|
assert_eq!(data[0].errors, Vec::new());
|
|
assert!(
|
|
data[0]
|
|
.skills
|
|
.iter()
|
|
.all(|skill| skill.scope != SkillScope::System)
|
|
);
|
|
assert!(stale_skill_path.is_file());
|
|
|
|
let enable_request_id = mcp
|
|
.send_config_batch_write_request(ConfigBatchWriteParams {
|
|
edits: vec![ConfigEdit {
|
|
key_path: "skills.bundled.enabled".to_string(),
|
|
value: serde_json::json!(true),
|
|
merge_strategy: MergeStrategy::Replace,
|
|
}],
|
|
file_path: None,
|
|
expected_version: None,
|
|
reload_user_config: true,
|
|
})
|
|
.await?;
|
|
let _: ConfigWriteResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(enable_request_id)).await??;
|
|
|
|
let enabled_skills_request_id = mcp
|
|
.send_skills_list_request(SkillsListParams {
|
|
cwds: vec![cwd.path().to_path_buf()],
|
|
force_reload: true,
|
|
})
|
|
.await?;
|
|
let SkillsListResponse { data } = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_response(enabled_skills_request_id),
|
|
)
|
|
.await??;
|
|
assert_eq!(data.len(), 1);
|
|
assert_eq!(data[0].errors, Vec::new());
|
|
assert!(
|
|
data[0]
|
|
.skills
|
|
.iter()
|
|
.any(|skill| skill.scope == SkillScope::System)
|
|
);
|
|
assert!(
|
|
data[0]
|
|
.skills
|
|
.iter()
|
|
.all(|skill| skill.name != "stale-system-skill")
|
|
);
|
|
assert!(!stale_skill_path.exists());
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn runtime_remote_plugin_toggle_updates_local_curated_plugin_skills() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let cwd = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
write_cached_local_curated_plugin_with_skill(codex_home.path(), "openai-curated")?;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
format!(
|
|
r#"chatgpt_base_url = "{}/backend-api/"
|
|
|
|
[features]
|
|
plugins = true
|
|
|
|
[plugins."google-calendar@openai-curated"]
|
|
enabled = true
|
|
"#,
|
|
server.uri()
|
|
),
|
|
)?;
|
|
write_chatgpt_auth(
|
|
codex_home.path(),
|
|
ChatGptAuthFixture::new("chatgpt-token")
|
|
.account_id("account-123")
|
|
.chatgpt_user_id("user-123")
|
|
.chatgpt_account_id("account-123"),
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let disablement_request_id = mcp
|
|
.send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams {
|
|
enablement: BTreeMap::from([("remote_plugin".to_string(), false)]),
|
|
})
|
|
.await?;
|
|
let _: ExperimentalFeatureEnablementSetResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(disablement_request_id)).await??;
|
|
|
|
let initial_skills_list_request_id = mcp
|
|
.send_skills_list_request(SkillsListParams {
|
|
cwds: vec![cwd.path().to_path_buf()],
|
|
force_reload: false,
|
|
})
|
|
.await?;
|
|
let thread_start_request_id = mcp
|
|
.send_thread_start_request_with_auto_env(ThreadStartParams {
|
|
cwd: Some(cwd.path().to_string_lossy().into_owned()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let SkillsListResponse { data } = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_response(initial_skills_list_request_id),
|
|
)
|
|
.await??;
|
|
assert!(data.iter().any(|entry| {
|
|
entry
|
|
.skills
|
|
.iter()
|
|
.any(|skill| skill.name == "google-calendar:meeting-prep")
|
|
}));
|
|
let _: ThreadStartResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(thread_start_request_id)).await??;
|
|
|
|
std::fs::write(
|
|
codex_home.path().join(
|
|
"plugins/cache/openai-curated/google-calendar/local/skills/meeting-prep/SKILL.md",
|
|
),
|
|
"---\nname: meeting-prep\ndescription: Updated meeting preparation\n---\n\n# Body\n",
|
|
)?;
|
|
for force_reload in [true, false] {
|
|
let request_id = mcp
|
|
.send_skills_list_request(SkillsListParams {
|
|
cwds: vec![cwd.path().to_path_buf()],
|
|
force_reload,
|
|
})
|
|
.await?;
|
|
let SkillsListResponse { data } =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
assert!(data.iter().any(|entry| {
|
|
entry.skills.iter().any(|skill| {
|
|
skill.name == "google-calendar:meeting-prep"
|
|
&& skill.description == "Updated meeting preparation"
|
|
})
|
|
}));
|
|
}
|
|
|
|
let enablement_request_id = mcp
|
|
.send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams {
|
|
enablement: BTreeMap::from([("remote_plugin".to_string(), true)]),
|
|
})
|
|
.await?;
|
|
let _: ExperimentalFeatureEnablementSetResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(enablement_request_id)).await??;
|
|
|
|
let skills_list_request_id = mcp
|
|
.send_skills_list_request(SkillsListParams {
|
|
cwds: vec![cwd.path().to_path_buf()],
|
|
force_reload: true,
|
|
})
|
|
.await?;
|
|
let SkillsListResponse { data } =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(skills_list_request_id)).await??;
|
|
|
|
assert!(data.iter().all(|entry| {
|
|
entry
|
|
.skills
|
|
.iter()
|
|
.all(|skill| skill.name != "google-calendar:meeting-prep")
|
|
}));
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn skills_list_loads_remote_installed_plugin_skills_from_cache() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let cwd = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
let expected_skill_path =
|
|
std::fs::canonicalize(write_cached_remote_plugin_with_skill(codex_home.path())?)?;
|
|
write_plugins_enabled_config_with_base_url(
|
|
codex_home.path(),
|
|
&format!("{}/backend-api/", server.uri()),
|
|
)?;
|
|
write_chatgpt_auth(
|
|
codex_home.path(),
|
|
ChatGptAuthFixture::new("chatgpt-token")
|
|
.account_id("account-123")
|
|
.chatgpt_user_id("user-123")
|
|
.chatgpt_account_id("account-123"),
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
let global_directory_body = r#"{
|
|
"plugins": [
|
|
{
|
|
"id": "plugins~Plugin_linear",
|
|
"name": "linear",
|
|
"scope": "GLOBAL",
|
|
"installation_policy": "AVAILABLE",
|
|
"authentication_policy": "ON_USE",
|
|
"release": {
|
|
"display_name": "Linear",
|
|
"description": "Track work in Linear",
|
|
"app_ids": [],
|
|
"interface": {},
|
|
"skills": []
|
|
}
|
|
}
|
|
],
|
|
"pagination": {
|
|
"limit": 50,
|
|
"next_page_token": null
|
|
}
|
|
}"#;
|
|
let global_installed_body = r#"{
|
|
"plugins": [
|
|
{
|
|
"id": "plugins~Plugin_linear",
|
|
"name": "linear",
|
|
"scope": "GLOBAL",
|
|
"installation_policy": "AVAILABLE",
|
|
"authentication_policy": "ON_USE",
|
|
"release": {
|
|
"display_name": "Linear",
|
|
"description": "Track work in Linear",
|
|
"app_ids": [],
|
|
"interface": {},
|
|
"skills": []
|
|
},
|
|
"enabled": true,
|
|
"disabled_skill_names": []
|
|
}
|
|
],
|
|
"pagination": {
|
|
"limit": 50,
|
|
"next_page_token": null
|
|
}
|
|
}"#;
|
|
let empty_page_body = r#"{
|
|
"plugins": [],
|
|
"pagination": {
|
|
"limit": 50,
|
|
"next_page_token": null
|
|
}
|
|
}"#;
|
|
|
|
for (scope, body) in [
|
|
("GLOBAL", global_directory_body),
|
|
("WORKSPACE", empty_page_body),
|
|
] {
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/ps/plugins/list"))
|
|
.and(query_param("scope", scope))
|
|
.and(query_param("limit", "200"))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(body))
|
|
.mount(&server)
|
|
.await;
|
|
}
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let stale_skills_list_request_id = mcp
|
|
.send_skills_list_request(SkillsListParams {
|
|
cwds: vec![cwd.path().to_path_buf()],
|
|
force_reload: true,
|
|
})
|
|
.await?;
|
|
let SkillsListResponse { data } = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_response(stale_skills_list_request_id),
|
|
)
|
|
.await??;
|
|
assert_eq!(data.len(), 1);
|
|
assert!(
|
|
data[0]
|
|
.skills
|
|
.iter()
|
|
.all(|skill| skill.name != "linear:triage-issues"),
|
|
"remote installed plugin cache has not been refreshed yet"
|
|
);
|
|
|
|
for (scope, body) in [
|
|
("GLOBAL", global_installed_body),
|
|
("USER", empty_page_body),
|
|
("WORKSPACE", empty_page_body),
|
|
] {
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/ps/plugins/installed"))
|
|
.and(query_param("scope", scope))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(body))
|
|
.mount(&server)
|
|
.await;
|
|
}
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/ps/plugins/installed"))
|
|
.and(query_param_is_missing("scope"))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(global_installed_body))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let plugin_list_request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
let _: PluginListResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(plugin_list_request_id)).await??;
|
|
|
|
let SkillsListResponse { data } = timeout(DEFAULT_TIMEOUT, async {
|
|
loop {
|
|
let skills_list_request_id = mcp
|
|
.send_skills_list_request(SkillsListParams {
|
|
cwds: vec![cwd.path().to_path_buf()],
|
|
force_reload: false,
|
|
})
|
|
.await?;
|
|
let response: SkillsListResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(skills_list_request_id)).await??;
|
|
if response.data.iter().any(|entry| {
|
|
entry
|
|
.skills
|
|
.iter()
|
|
.any(|skill| skill.name == "linear:triage-issues")
|
|
}) {
|
|
break Ok::<SkillsListResponse, anyhow::Error>(response);
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
}
|
|
})
|
|
.await??;
|
|
|
|
assert_eq!(data.len(), 1);
|
|
assert_eq!(data[0].errors, Vec::new());
|
|
let skill = data[0]
|
|
.skills
|
|
.iter()
|
|
.find(|skill| skill.name == "linear:triage-issues")
|
|
.expect("expected skill from cached remote plugin");
|
|
assert_eq!(
|
|
std::fs::canonicalize(skill.path.as_str())?,
|
|
expected_skill_path
|
|
);
|
|
assert_eq!(
|
|
(skill.enabled, skill.plugin_id.as_deref()),
|
|
(true, Some("linear@openai-curated-remote")),
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn config_reads_complete_alongside_skills_list_request() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let cwd = TempDir::new()?;
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_managed_config()
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let skills_request_id = mcp
|
|
.send_skills_list_request(SkillsListParams {
|
|
cwds: vec![cwd.path().to_path_buf()],
|
|
force_reload: true,
|
|
})
|
|
.await?;
|
|
|
|
let config_request_id = mcp
|
|
.send_config_read_request(ConfigReadParams {
|
|
include_layers: false,
|
|
cwd: None,
|
|
})
|
|
.await?;
|
|
let requirements_request_id = mcp.send_config_requirements_read_request().await?;
|
|
let permission_profiles_request_id = mcp
|
|
.send_permission_profile_list_request(PermissionProfileListParams {
|
|
cursor: None,
|
|
limit: None,
|
|
cwd: None,
|
|
})
|
|
.await?;
|
|
|
|
let (config, requirements, permission_profiles) = timeout(Duration::from_secs(5), async {
|
|
let config: ConfigReadResponse = mcp.read_response(config_request_id).await?;
|
|
let requirements: ConfigRequirementsReadResponse =
|
|
mcp.read_response(requirements_request_id).await?;
|
|
let permission_profiles: PermissionProfileListResponse =
|
|
mcp.read_response(permission_profiles_request_id).await?;
|
|
anyhow::Ok((config, requirements, permission_profiles))
|
|
})
|
|
.await??;
|
|
assert!(config.layers.is_none());
|
|
assert_eq!(
|
|
requirements,
|
|
ConfigRequirementsReadResponse {
|
|
supports_independent_speed_modes: Some(true),
|
|
requirements: None,
|
|
}
|
|
);
|
|
assert!(!permission_profiles.data.is_empty());
|
|
|
|
let SkillsListResponse { data } =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(skills_request_id)).await??;
|
|
assert_eq!(data.len(), 1);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn skills_list_skips_cwd_roots_when_environment_disabled() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let cwd = TempDir::new()?;
|
|
write_skill(&codex_home, "home-skill")?;
|
|
let repo_skill_dir = cwd.path().join(".codex/skills/repo-skill");
|
|
std::fs::create_dir_all(&repo_skill_dir)?;
|
|
std::fs::write(
|
|
repo_skill_dir.join("SKILL.md"),
|
|
"---\nname: repo-skill\ndescription: from repo root\n---\n\n# Body\n",
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.with_env_overrides(&[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))])
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_skills_list_request(SkillsListParams {
|
|
cwds: vec![cwd.path().to_path_buf()],
|
|
force_reload: true,
|
|
})
|
|
.await?;
|
|
|
|
let SkillsListResponse { data } =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
assert_eq!(data.len(), 1);
|
|
assert_eq!(data[0].cwd, cwd.path().to_path_buf());
|
|
assert_eq!(data[0].errors, Vec::new());
|
|
assert!(
|
|
data[0]
|
|
.skills
|
|
.iter()
|
|
.any(|skill| skill.name == "home-skill")
|
|
);
|
|
assert!(
|
|
data[0]
|
|
.skills
|
|
.iter()
|
|
.all(|skill| skill.name != "repo-skill")
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn skills_list_accepts_relative_cwds() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let relative_cwd = std::path::PathBuf::from("relative-cwd");
|
|
std::fs::create_dir_all(codex_home.path().join(&relative_cwd))?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_skills_list_request(SkillsListParams {
|
|
cwds: vec![relative_cwd.clone()],
|
|
force_reload: true,
|
|
})
|
|
.await?;
|
|
|
|
let SkillsListResponse { data } =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
assert_eq!(data.len(), 1);
|
|
assert_eq!(data[0].cwd, relative_cwd);
|
|
assert_eq!(data[0].errors, Vec::new());
|
|
Ok(())
|
|
}
|
|
|
|
#[test_case(false; "plugins disabled by default")]
|
|
#[test_case(true; "plugins enabled by default")]
|
|
#[tokio::test]
|
|
async fn skills_list_preserves_requested_cwd_order(home_plugins_enabled: bool) -> Result<()> {
|
|
skip_if_wine_exec!(
|
|
Ok(()),
|
|
"skills/list currently requires host-native cwd paths for workspace config"
|
|
);
|
|
let codex_home = TempDir::new()?;
|
|
let first_cwd = TempDir::new()?;
|
|
let second_cwd = TempDir::new()?;
|
|
let third_cwd = TempDir::new()?;
|
|
write_skill(&codex_home, "shared-skill")?;
|
|
write_cached_local_curated_plugin_with_skill(codex_home.path(), "openai-api-curated")?;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
format!(
|
|
r#"[features]
|
|
plugins = {home_plugins_enabled}
|
|
|
|
[plugins."google-calendar@openai-api-curated"]
|
|
enabled = false
|
|
"#
|
|
),
|
|
)?;
|
|
|
|
for (cwd, plugins_enabled, plugin_enabled) in [
|
|
(first_cwd.path(), true, true),
|
|
(second_cwd.path(), false, true),
|
|
(third_cwd.path(), true, false),
|
|
] {
|
|
std::fs::create_dir_all(cwd.join(".git"))?;
|
|
std::fs::create_dir_all(cwd.join(".codex"))?;
|
|
std::fs::write(
|
|
cwd.join(".codex/config.toml"),
|
|
format!(
|
|
"[features]\nplugins = {plugins_enabled}\n[plugins.\"google-calendar@openai-api-curated\"]\nenabled = {plugin_enabled}\n"
|
|
),
|
|
)?;
|
|
set_project_trust_level(codex_home.path(), cwd, TrustLevel::Trusted)?;
|
|
}
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let file_system = mcp.auto_env()?.environment().get_filesystem();
|
|
for (cwd, name) in [
|
|
(first_cwd.path(), "first-project-skill"),
|
|
(second_cwd.path(), "second-project-skill"),
|
|
(third_cwd.path(), "third-project-skill"),
|
|
] {
|
|
let cwd = AbsolutePathBuf::try_from(cwd)?;
|
|
let git_dir = PathUri::from_abs_path(&cwd.join(".git"));
|
|
let skill_dir = PathUri::from_abs_path(&cwd.join(".agents/skills").join(name));
|
|
for directory in [&git_dir, &skill_dir] {
|
|
file_system
|
|
.create_directory(
|
|
directory,
|
|
CreateDirectoryOptions {
|
|
recursive: true,
|
|
follow_symlinks: true,
|
|
},
|
|
/*sandbox*/ None,
|
|
)
|
|
.await?;
|
|
}
|
|
file_system
|
|
.write_file(
|
|
&skill_dir.join("SKILL.md")?,
|
|
format!("---\nname: {name}\ndescription: {name}\n---\n").into_bytes(),
|
|
Default::default(),
|
|
/*sandbox*/ None,
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
for force_reload in [false, false, true] {
|
|
let request_id = mcp
|
|
.send_skills_list_request(SkillsListParams {
|
|
cwds: vec![
|
|
first_cwd.path().to_path_buf(),
|
|
second_cwd.path().to_path_buf(),
|
|
third_cwd.path().to_path_buf(),
|
|
],
|
|
force_reload,
|
|
})
|
|
.await?;
|
|
let SkillsListResponse { data } =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
assert_eq!(
|
|
data.iter()
|
|
.map(|entry| entry.cwd.as_path())
|
|
.collect::<Vec<_>>(),
|
|
vec![first_cwd.path(), second_cwd.path(), third_cwd.path()],
|
|
);
|
|
for (entry, (project_skill, plugin_enabled)) in data.iter().zip([
|
|
("first-project-skill", true),
|
|
("second-project-skill", false),
|
|
("third-project-skill", false),
|
|
]) {
|
|
assert_eq!(entry.errors, Vec::new());
|
|
assert!(
|
|
entry
|
|
.skills
|
|
.iter()
|
|
.any(|skill| skill.name == "shared-skill")
|
|
);
|
|
let mut expected_skills = vec![project_skill];
|
|
if plugin_enabled {
|
|
expected_skills.push("google-calendar:meeting-prep");
|
|
}
|
|
assert_eq!(
|
|
entry
|
|
.skills
|
|
.iter()
|
|
.filter(|skill| {
|
|
skill.name.ends_with("-project-skill")
|
|
|| skill.name.starts_with("google-calendar:")
|
|
})
|
|
.map(|skill| skill.name.as_str())
|
|
.collect::<Vec<_>>(),
|
|
expected_skills
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn skills_list_refreshes_externally_updated_plugin_versions() -> Result<()> {
|
|
skip_if_wine_exec!(
|
|
Ok(()),
|
|
"skills/list currently requires host-native cwd paths for workspace config"
|
|
);
|
|
let codex_home = TempDir::new()?;
|
|
let cwd = TempDir::new()?;
|
|
let source = TempDir::new()?;
|
|
std::fs::create_dir_all(source.path().join(".codex-plugin"))?;
|
|
std::fs::create_dir_all(source.path().join("skills"))?;
|
|
std::fs::write(
|
|
source.path().join(".codex-plugin/plugin.json"),
|
|
r#"{"name":"sample"}"#,
|
|
)?;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
r#"[features]
|
|
plugins = true
|
|
|
|
[plugins."sample@test"]
|
|
enabled = true
|
|
"#,
|
|
)?;
|
|
let plugin_id = PluginId::parse("sample@test")?;
|
|
let store = PluginStore::new(codex_home.path().to_path_buf());
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
let file_system = mcp.auto_env()?.environment().get_filesystem();
|
|
file_system
|
|
.create_directory(
|
|
&PathUri::from_abs_path(&AbsolutePathBuf::try_from(cwd.path())?).join(".git")?,
|
|
CreateDirectoryOptions {
|
|
recursive: true,
|
|
follow_symlinks: true,
|
|
},
|
|
/*sandbox*/ None,
|
|
)
|
|
.await?;
|
|
|
|
// Mutate the shared store from outside app-server, without its cache invalidation callback.
|
|
for version in ["1.0.0", "2.0.0", "0.9.0"] {
|
|
let body = format!("---\nname: search\ndescription: version {version}\n---\n");
|
|
std::fs::write(source.path().join("skills/SKILL.md"), &body)?;
|
|
let installed = store.install_with_version(
|
|
AbsolutePathBuf::try_from(source.path())?,
|
|
plugin_id.clone(),
|
|
version.to_string(),
|
|
)?;
|
|
let expected_path = AbsolutePathBuf::try_from(std::fs::canonicalize(
|
|
installed.installed_path.join("skills/SKILL.md"),
|
|
)?)?;
|
|
// Exercise both invalidation and the subsequent warm read for the same cwd.
|
|
for _ in 0..2 {
|
|
let request_id = mcp
|
|
.send_skills_list_request(SkillsListParams {
|
|
cwds: vec![cwd.path().to_path_buf()],
|
|
force_reload: false,
|
|
})
|
|
.await?;
|
|
let SkillsListResponse { data } =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
let skills = data[0]
|
|
.skills
|
|
.iter()
|
|
.filter(|skill| skill.name == "sample:search")
|
|
.map(|skill| {
|
|
Ok((
|
|
skill.path.clone(),
|
|
skill.description.clone(),
|
|
std::fs::read_to_string(skill.path.as_str())?,
|
|
))
|
|
})
|
|
.collect::<std::io::Result<Vec<_>>>()?;
|
|
assert_eq!(
|
|
skills,
|
|
vec![(
|
|
expected_path.clone().into(),
|
|
format!("version {version}"),
|
|
body.clone()
|
|
)],
|
|
);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn skills_list_force_reload_refreshes_cached_plugin_roots() -> Result<()> {
|
|
skip_if_wine_exec!(
|
|
Ok(()),
|
|
"skills/list currently requires host-native cwd paths for workspace config"
|
|
);
|
|
let codex_home = TempDir::new()?;
|
|
let first_cwd = TempDir::new()?;
|
|
let second_cwd = TempDir::new()?;
|
|
write_cached_local_curated_plugin_with_skill(codex_home.path(), "openai-api-curated")?;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
r#"[features]
|
|
plugins = true
|
|
|
|
[plugins."google-calendar@openai-api-curated"]
|
|
enabled = true
|
|
"#,
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let file_system = mcp.auto_env()?.environment().get_filesystem();
|
|
for cwd in [first_cwd.path(), second_cwd.path()] {
|
|
let cwd = PathUri::from_abs_path(&AbsolutePathBuf::try_from(cwd)?);
|
|
file_system
|
|
.create_directory(
|
|
&cwd.join(".git")?,
|
|
CreateDirectoryOptions {
|
|
recursive: true,
|
|
follow_symlinks: true,
|
|
},
|
|
/*sandbox*/ None,
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
for (cwd, force_reload, expected_skill) in [
|
|
(first_cwd.path(), false, "google-calendar:meeting-prep"),
|
|
(first_cwd.path(), true, "google-calendar:refreshed-skill"),
|
|
(second_cwd.path(), false, "google-calendar:refreshed-skill"),
|
|
] {
|
|
if force_reload {
|
|
let plugin_root = codex_home
|
|
.path()
|
|
.join("plugins/cache/openai-api-curated/google-calendar/local");
|
|
std::fs::write(
|
|
plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{"name":"google-calendar","skills":"./replacement-skills"}"#,
|
|
)?;
|
|
let skill_dir = plugin_root.join("replacement-skills/refreshed-skill");
|
|
std::fs::create_dir_all(&skill_dir)?;
|
|
std::fs::write(
|
|
skill_dir.join("SKILL.md"),
|
|
"---\nname: refreshed-skill\ndescription: refreshed skill\n---\n",
|
|
)?;
|
|
}
|
|
|
|
let request_id = mcp
|
|
.send_skills_list_request(SkillsListParams {
|
|
cwds: vec![cwd.to_path_buf()],
|
|
force_reload,
|
|
})
|
|
.await?;
|
|
let SkillsListResponse { data } =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
assert_eq!(
|
|
data[0]
|
|
.skills
|
|
.iter()
|
|
.filter(|skill| skill.name.starts_with("google-calendar:"))
|
|
.map(|skill| skill.name.as_str())
|
|
.collect::<Vec<_>>(),
|
|
vec![expected_skill]
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn skills_list_uses_cached_result_after_session_default_writes_until_force_reload()
|
|
-> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let cwd = TempDir::new()?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
// Seed the cwd cache before the cwd-local skill exists.
|
|
let first_request_id = mcp
|
|
.send_skills_list_request(SkillsListParams {
|
|
cwds: vec![cwd.path().to_path_buf()],
|
|
force_reload: false,
|
|
})
|
|
.await?;
|
|
let SkillsListResponse { data: first_data } =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(first_request_id)).await??;
|
|
assert_eq!(first_data.len(), 1);
|
|
assert!(
|
|
first_data[0]
|
|
.skills
|
|
.iter()
|
|
.all(|skill| skill.name != "late-extra-skill")
|
|
);
|
|
|
|
let skill_dir = cwd.path().join(".codex/skills/late-extra-skill");
|
|
std::fs::create_dir_all(&skill_dir)?;
|
|
std::fs::write(
|
|
skill_dir.join("SKILL.md"),
|
|
"---\nname: late-extra-skill\ndescription: late skill\n---\n\n# Body\n",
|
|
)?;
|
|
|
|
for edits in [
|
|
vec![ConfigEdit {
|
|
key_path: "plan_mode_reasoning_effort".to_string(),
|
|
value: serde_json::json!("high"),
|
|
merge_strategy: MergeStrategy::Replace,
|
|
}],
|
|
vec![ConfigEdit {
|
|
key_path: "service_tier".to_string(),
|
|
value: serde_json::json!("fast"),
|
|
merge_strategy: MergeStrategy::Replace,
|
|
}],
|
|
vec![ConfigEdit {
|
|
key_path: "personality".to_string(),
|
|
value: serde_json::json!("friendly"),
|
|
merge_strategy: MergeStrategy::Replace,
|
|
}],
|
|
vec![
|
|
ConfigEdit {
|
|
key_path: "model".to_string(),
|
|
value: serde_json::json!("gpt-5.4"),
|
|
merge_strategy: MergeStrategy::Replace,
|
|
},
|
|
ConfigEdit {
|
|
key_path: "model_reasoning_effort".to_string(),
|
|
value: serde_json::json!("high"),
|
|
merge_strategy: MergeStrategy::Replace,
|
|
},
|
|
],
|
|
] {
|
|
let write_id = mcp
|
|
.send_config_batch_write_request(ConfigBatchWriteParams {
|
|
edits,
|
|
file_path: None,
|
|
expected_version: None,
|
|
reload_user_config: true,
|
|
})
|
|
.await?;
|
|
let _: ConfigWriteResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(write_id)).await??;
|
|
}
|
|
|
|
let second_request_id = mcp
|
|
.send_skills_list_request(SkillsListParams {
|
|
cwds: vec![cwd.path().to_path_buf()],
|
|
force_reload: false,
|
|
})
|
|
.await?;
|
|
let SkillsListResponse { data: second_data } =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(second_request_id)).await??;
|
|
assert_eq!(second_data.len(), 1);
|
|
assert!(
|
|
second_data[0]
|
|
.skills
|
|
.iter()
|
|
.all(|skill| skill.name != "late-extra-skill")
|
|
);
|
|
|
|
let third_request_id = mcp
|
|
.send_skills_list_request(SkillsListParams {
|
|
cwds: vec![cwd.path().to_path_buf()],
|
|
force_reload: true,
|
|
})
|
|
.await?;
|
|
let SkillsListResponse { data: third_data } =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(third_request_id)).await??;
|
|
assert_eq!(third_data.len(), 1);
|
|
assert!(
|
|
third_data[0]
|
|
.skills
|
|
.iter()
|
|
.any(|skill| skill.name == "late-extra-skill")
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn skills_extra_roots_set_updates_process_runtime_roots() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let cwd = TempDir::new()?;
|
|
let extra_root = TempDir::new()?;
|
|
let extra_skills_root = extra_root.path().join("skills");
|
|
let skill_dir = extra_skills_root.join("runtime-skill");
|
|
std::fs::create_dir_all(&skill_dir)?;
|
|
std::fs::write(
|
|
skill_dir.join("SKILL.md"),
|
|
"---\nname: runtime-skill\ndescription: runtime skill\n---\n\n# Body\n",
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let set_request_id = mcp
|
|
.send_skills_extra_roots_set_request(SkillsExtraRootsSetParams {
|
|
extra_roots: vec![AbsolutePathBuf::from_absolute_path(&extra_skills_root)?],
|
|
})
|
|
.await?;
|
|
let _: SkillsExtraRootsSetResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(set_request_id)).await??;
|
|
expect_skills_changed_notification(&mut mcp, DEFAULT_TIMEOUT).await?;
|
|
|
|
let skills_request_id = mcp
|
|
.send_skills_list_request(SkillsListParams {
|
|
cwds: vec![cwd.path().to_path_buf()],
|
|
force_reload: false,
|
|
})
|
|
.await?;
|
|
let SkillsListResponse { data } =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(skills_request_id)).await??;
|
|
assert_eq!(data.len(), 1);
|
|
assert_eq!(data[0].errors, Vec::new());
|
|
assert!(
|
|
data[0]
|
|
.skills
|
|
.iter()
|
|
.any(|skill| skill.name == "runtime-skill")
|
|
);
|
|
|
|
let missing_root = extra_root.path().join("missing-skills");
|
|
let reset_request_id = mcp
|
|
.send_skills_extra_roots_set_request(SkillsExtraRootsSetParams {
|
|
extra_roots: vec![AbsolutePathBuf::from_absolute_path(&missing_root)?],
|
|
})
|
|
.await?;
|
|
let _: SkillsExtraRootsSetResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(reset_request_id)).await??;
|
|
expect_skills_changed_notification(&mut mcp, DEFAULT_TIMEOUT).await?;
|
|
|
|
let skills_request_id = mcp
|
|
.send_skills_list_request(SkillsListParams {
|
|
cwds: vec![cwd.path().to_path_buf()],
|
|
force_reload: false,
|
|
})
|
|
.await?;
|
|
let SkillsListResponse { data } =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(skills_request_id)).await??;
|
|
assert_eq!(data.len(), 1);
|
|
assert_eq!(data[0].errors, Vec::new());
|
|
assert!(
|
|
data[0]
|
|
.skills
|
|
.iter()
|
|
.all(|skill| skill.name != "runtime-skill")
|
|
);
|
|
|
|
let clear_request_id = mcp
|
|
.send_skills_extra_roots_set_request(SkillsExtraRootsSetParams {
|
|
extra_roots: Vec::new(),
|
|
})
|
|
.await?;
|
|
let _: SkillsExtraRootsSetResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(clear_request_id)).await??;
|
|
expect_skills_changed_notification(&mut mcp, DEFAULT_TIMEOUT).await?;
|
|
let skills_request_id = mcp
|
|
.send_skills_list_request(SkillsListParams {
|
|
cwds: vec![cwd.path().to_path_buf()],
|
|
force_reload: false,
|
|
})
|
|
.await?;
|
|
let SkillsListResponse { data } =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(skills_request_id)).await??;
|
|
assert_eq!(data.len(), 1);
|
|
assert_eq!(data[0].errors, Vec::new());
|
|
assert!(
|
|
data[0]
|
|
.skills
|
|
.iter()
|
|
.all(|skill| skill.name != "runtime-skill")
|
|
);
|
|
|
|
drop(mcp);
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
let skills_request_id = mcp
|
|
.send_skills_list_request(SkillsListParams {
|
|
cwds: vec![cwd.path().to_path_buf()],
|
|
force_reload: false,
|
|
})
|
|
.await?;
|
|
let SkillsListResponse { data } =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(skills_request_id)).await??;
|
|
assert_eq!(data.len(), 1);
|
|
assert_eq!(data[0].errors, Vec::new());
|
|
assert!(
|
|
data[0]
|
|
.skills
|
|
.iter()
|
|
.all(|skill| skill.name != "runtime-skill")
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<()> {
|
|
// TODO(anp): Remove after skill watching can bridge host-local storage into remote exec.
|
|
skip_if_remote!(
|
|
Ok(()),
|
|
"host-local skill changes are not visible to remote executors"
|
|
);
|
|
|
|
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
|
let codex_home = TempDir::new()?;
|
|
MockResponsesConfig::new(&server.uri())
|
|
.with_root_config(&format!("chatgpt_base_url = \"{}\"", server.uri()))
|
|
.write(codex_home.path())?;
|
|
write_skill(&codex_home, "demo")?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
let initial_skills_request_id = mcp
|
|
.send_skills_list_request(SkillsListParams {
|
|
cwds: vec![codex_home.path().to_path_buf()],
|
|
force_reload: true,
|
|
})
|
|
.await?;
|
|
let SkillsListResponse { data } = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_response(initial_skills_request_id),
|
|
)
|
|
.await??;
|
|
assert_eq!(data.len(), 1);
|
|
assert!(
|
|
data[0]
|
|
.skills
|
|
.iter()
|
|
.any(|skill| { skill.name == "demo" && skill.description == "demo description" })
|
|
);
|
|
|
|
let thread_start_request_id = mcp
|
|
.send_thread_start_request_with_auto_env(ThreadStartParams {
|
|
model: None,
|
|
model_provider: None,
|
|
allow_provider_model_fallback: false,
|
|
service_tier: None,
|
|
cwd: None,
|
|
runtime_workspace_roots: None,
|
|
approval_policy: None,
|
|
approvals_reviewer: None,
|
|
sandbox: None,
|
|
permissions: None,
|
|
config: None,
|
|
service_name: None,
|
|
base_instructions: None,
|
|
developer_instructions: None,
|
|
personality: None,
|
|
multi_agent_mode: None,
|
|
ephemeral: None,
|
|
history_mode: None,
|
|
session_start_source: None,
|
|
thread_source: None,
|
|
project_id: None,
|
|
daybreak_enabled: None,
|
|
dynamic_tools: None,
|
|
environments: None,
|
|
selected_capability_roots: None,
|
|
mock_experimental_field: None,
|
|
experimental_raw_events: false,
|
|
})
|
|
.await?;
|
|
let _: ThreadStartResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(thread_start_request_id)).await??;
|
|
|
|
let skill_path = codex_home
|
|
.path()
|
|
.join("skills")
|
|
.join("demo")
|
|
.join("SKILL.md");
|
|
std::fs::write(
|
|
&skill_path,
|
|
"---\nname: demo\ndescription: updated\n---\n\n# Updated\n",
|
|
)?;
|
|
|
|
expect_skills_changed_notification(&mut mcp, WATCHER_TIMEOUT).await?;
|
|
let updated_skills_request_id = mcp
|
|
.send_skills_list_request(SkillsListParams {
|
|
cwds: vec![codex_home.path().to_path_buf()],
|
|
force_reload: false,
|
|
})
|
|
.await?;
|
|
let SkillsListResponse { data } = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_response(updated_skills_request_id),
|
|
)
|
|
.await??;
|
|
assert_eq!(data.len(), 1);
|
|
assert!(
|
|
data[0]
|
|
.skills
|
|
.iter()
|
|
.any(|skill| skill.name == "demo" && skill.description == "updated")
|
|
);
|
|
Ok(())
|
|
}
|