Files
codex/codex-rs/app-server/tests/suite/v2/skills_list.rs
T
jifandGitHub 89ac3ec27c Load selected executor skills through extensions (#27184)
## Why

CCA is moving toward a split runtime where the orchestrator may not have
a filesystem, while executors can expose preinstalled plugins and
skills. A thread therefore needs to select capabilities without asking
app-server or core to interpret executor-owned paths through the
orchestrator's filesystem.

The longer-term model is broader than executor skills:

- A plugin is a bundle of skills, MCP servers, connectors/apps, and
hooks.
- A plugin root can be local, executor-owned, or hosted by a backend.
- Components inside one plugin can use different access and execution
mechanisms. A skill may be read from a filesystem or through backend
tools; an HTTP MCP server can run without an executor; a stdio MCP
server or hook needs an execution environment.
- Core should carry generic extension initialization data. The extension
that owns a component should discover it, expose it to the model, and
invoke it through the appropriate runtime.

This PR establishes that architecture through one complete vertical:
selecting a root on an executor, discovering the skills beneath it,
exposing those skills to the model, and reading an explicitly invoked
`SKILL.md` through the same executor.

## Contract

`thread/start` gains an experimental `selectedCapabilityRoots` field:

```json
{
  "selectedCapabilityRoots": [
    {
      "id": "deploy-plugin@1",
      "location": {
        "type": "environment",
        "environmentId": "workspace",
        "path": "/opt/codex/plugins/deploy"
      }
    }
  ]
}
```

The root is intentionally not classified as a "plugin" or "skill" in the
API. It can point at a standalone skill, a directory containing several
skills, or a plugin containing skills and other components. This PR only
teaches the skills extension how to consume it; later extensions can
resolve MCP, connector, and hook components from the same selection.

The platform-supplied `id` is stable selection identity. The location
says which runtime owns the root and gives that runtime an opaque path.
App-server does not inspect or canonicalize the path.

## What changed

### Generic thread extension initialization

App-server converts selected roots into `ExtensionDataInit`. Core
carries that generic initialization value until the final thread ID is
known, then creates thread-scoped `ExtensionData` before lifecycle
contributors run.

This keeps `Session` and core independent of the capability-selection
contract. The initialization value is consumed during construction; it
is not retained as another long-lived `Session` field.

### Executor-backed skills

The skills extension now owns an `ExecutorSkillProvider` that:

- resolves the selected environment through `EnvironmentManager`
- discovers, canonicalizes, and reads skills through that environment's
`ExecutorFileSystem`
- contributes the bounded selected-skill catalog as stable developer
context
- reads an explicitly invoked skill body through the authority that
listed it
- warns when an environment or root is unavailable
- never falls back to the orchestrator filesystem for an executor-owned
root

Skill catalog and instruction fragments have hard byte bounds, which
also bound them below the 10K-token per-item context limit. If a
selected executor skill has the same name as a legacy local skill, the
executor selection owns that invocation and the local body is not
injected a second time.

Existing local and bundled skill loading remains in place. Omitting
`selectedCapabilityRoots` therefore preserves current local-only
behavior.

## Current semantics

- Only environment-owned locations are represented in this first
contract.
- Roots are resolved by the destination extension, not by app-server or
core.
- An unavailable executor or invalid root produces a warning and no
capabilities from that root; it does not trigger a local-filesystem
fallback.
- Selection applies to a newly started active thread.
- MCP servers, connectors, and hooks beneath a selected plugin root are
not activated yet.
- Selection is not yet persisted or inherited across resume, fork, or
subagent creation. Existing local capabilities continue to behave as
they do today in those flows.

## Planned vertical follow-ups

1. **Hosted HTTP MCP:** add an extension-backed HTTP MCP source that
works without an executor, then replace the special-purpose MCP plugins
loader with that implementation.
2. **Executor MCP:** register and execute stdio MCP servers through the
environment that owns the selected plugin root.
3. **Backend skills:** add a hosted skill source whose catalog and
bodies are accessed through extension tools rather than a filesystem.
4. **Connectors and hooks:** activate those components through their
owning extensions, using the same selected-root boundary and
component-specific runtime.
5. **Durable selection:** define the desired-selection lifecycle,
persist it, and make resume, fork, and subagent inheritance explicit
rather than accidental.
6. **Local convergence:** incrementally route existing local plugin,
skill, and MCP loading through the same extension model while preserving
current local behavior.

Each follow-up remains reviewable as an end-to-end capability. The
platform selects roots, generic thread extension data carries the
selection, and the owning extension resolves and operates its component.

## Verification

Coverage added for:

- app-server end-to-end discovery and explicit invocation of a skill
inside an executor-selected plugin root
- exclusive invocation when a selected executor skill collides with a
local skill name
- executor filesystem authority for discovery, canonicalization, and
reads
- thread extension initialization before lifecycle contributors run
- stable executor catalog context, explicit invocation, context
rebuilding, hidden skills, and preserved host/remote catalog behavior

Targeted protocol, core-skills, skills-extension, core lifecycle, and
app-server executor-skill tests were run during development.
2026-06-09 19:51:54 +02:00

849 lines
27 KiB
Rust

use std::time::Duration;
use anyhow::Context;
use anyhow::Result;
use app_test_support::ChatGptAuthFixture;
use app_test_support::TestAppServer;
use app_test_support::create_mock_responses_server_repeating_assistant;
use app_test_support::to_response;
use app_test_support::write_chatgpt_auth;
use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::PluginListParams;
use codex_app_server_protocol::PluginListResponse;
use codex_app_server_protocol::RequestId;
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_config::types::AuthCredentialsStoreMode;
use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
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;
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 = timeout(
timeout_duration,
mcp.read_stream_until_notification_message("skills/changed"),
)
.await??;
let params = notification
.params
.context("skills/changed params must be present")?;
let notification: SkillsChangedNotification = serde_json::from_value(params)?;
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_remote_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
remote_plugin = true
"#,
),
)
}
fn write_plugin_with_skill(
repo_root: &std::path::Path,
plugin_name: &str,
skill_name: &str,
) -> Result<()> {
std::fs::create_dir_all(repo_root.join(".git"))?;
std::fs::create_dir_all(repo_root.join(".agents/plugins"))?;
std::fs::write(
repo_root.join(".agents/plugins/marketplace.json"),
format!(
r#"{{
"name": "local-marketplace",
"plugins": [
{{
"name": "{plugin_name}",
"source": {{
"source": "local",
"path": "./{plugin_name}"
}}
}}
]
}}"#
),
)?;
let plugin_root = repo_root.join(plugin_name);
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
std::fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
format!(r#"{{"name":"{plugin_name}"}}"#),
)?;
let skill_dir = plugin_root.join("skills").join(skill_name);
std::fs::create_dir_all(&skill_dir)?;
std::fs::write(
skill_dir.join("SKILL.md"),
format!("---\nname: {skill_name}\ndescription: {skill_name} description\n---\n\n# Body\n"),
)?;
Ok(())
}
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)
}
#[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_remote_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::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).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 stale_skills_list_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(stale_skills_list_request_id)),
)
.await??;
let SkillsListResponse { data } = to_response(stale_skills_list_response)?;
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),
("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;
}
let plugin_list_request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: None,
marketplace_kinds: None,
})
.await?;
let plugin_list_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(plugin_list_request_id)),
)
.await??;
let _: PluginListResponse = to_response(plugin_list_response)?;
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 skills_list_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(skills_list_request_id)),
)
.await??;
let response: SkillsListResponse = to_response(skills_list_response)?;
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_path())?,
expected_skill_path
);
assert_eq!(skill.enabled, true);
Ok(())
}
#[tokio::test]
async fn skills_list_excludes_plugin_skills_when_workspace_codex_plugins_disabled() -> Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
let server = MockServer::start().await;
write_skill(&codex_home, "home-skill")?;
write_plugin_with_skill(repo_root.path(), "demo-plugin", "plugin-skill")?;
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")
.plan_type("team"),
AuthCredentialsStoreMode::File,
)?;
Mock::given(method("GET"))
.and(path("/backend-api/accounts/account-123/settings"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(r#"{"beta_settings":{"enable_plugins":false}}"#),
)
.mount(&server)
.await;
let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_skills_list_request(SkillsListParams {
cwds: vec![repo_root.path().to_path_buf()],
force_reload: true,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let SkillsListResponse { data } = to_response(response)?;
assert_eq!(data.len(), 1);
assert!(
data[0]
.skills
.iter()
.any(|skill| skill.name == "home-skill"),
"non-plugin skills should remain available"
);
assert!(
data[0]
.skills
.iter()
.all(|skill| skill.name != "demo-plugin:plugin-skill"),
"plugin skills should be hidden when workspace Codex plugins are disabled"
);
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::new_with_env(
codex_home.path(),
&[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))],
)
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_skills_list_request(SkillsListParams {
cwds: vec![cwd.path().to_path_buf()],
force_reload: true,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let SkillsListResponse { data } = to_response(response)?;
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::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_skills_list_request(SkillsListParams {
cwds: vec![relative_cwd.clone()],
force_reload: true,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let SkillsListResponse { data } = to_response(response)?;
assert_eq!(data.len(), 1);
assert_eq!(data[0].cwd, relative_cwd);
assert_eq!(data[0].errors, Vec::new());
Ok(())
}
#[tokio::test]
async fn skills_list_preserves_requested_cwd_order() -> Result<()> {
let codex_home = TempDir::new()?;
let first_cwd = TempDir::new()?;
let second_cwd = TempDir::new()?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_skills_list_request(SkillsListParams {
cwds: vec![
first_cwd.path().to_path_buf(),
second_cwd.path().to_path_buf(),
],
force_reload: true,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let SkillsListResponse { data } = to_response(response)?;
assert_eq!(
data.iter()
.map(|entry| entry.cwd.clone())
.collect::<Vec<_>>(),
vec![
first_cwd.path().to_path_buf(),
second_cwd.path().to_path_buf(),
]
);
Ok(())
}
#[tokio::test]
async fn skills_list_uses_cached_result_until_force_reload() -> Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).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 first_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(first_request_id)),
)
.await??;
let SkillsListResponse { data: first_data } = to_response(first_response)?;
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",
)?;
let second_request_id = mcp
.send_skills_list_request(SkillsListParams {
cwds: vec![cwd.path().to_path_buf()],
force_reload: false,
})
.await?;
let second_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(second_request_id)),
)
.await??;
let SkillsListResponse { data: second_data } = to_response(second_response)?;
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 third_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(third_request_id)),
)
.await??;
let SkillsListResponse { data: third_data } = to_response(third_response)?;
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::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).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 set_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(set_request_id)),
)
.await??;
let _: SkillsExtraRootsSetResponse = to_response(set_response)?;
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 skills_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(skills_request_id)),
)
.await??;
let SkillsListResponse { data } = to_response(skills_response)?;
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 reset_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(reset_request_id)),
)
.await??;
let _: SkillsExtraRootsSetResponse = to_response(reset_response)?;
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 skills_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(skills_request_id)),
)
.await??;
let SkillsListResponse { data } = to_response(skills_response)?;
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 clear_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(clear_request_id)),
)
.await??;
let _: SkillsExtraRootsSetResponse = to_response(clear_response)?;
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 skills_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(skills_request_id)),
)
.await??;
let SkillsListResponse { data } = to_response(skills_response)?;
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::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let skills_request_id = mcp
.send_skills_list_request(SkillsListParams {
cwds: vec![cwd.path().to_path_buf()],
force_reload: false,
})
.await?;
let skills_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(skills_request_id)),
)
.await??;
let SkillsListResponse { data } = to_response(skills_response)?;
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<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
write_mock_responses_config_toml_with_chatgpt_base_url(
codex_home.path(),
&server.uri(),
&server.uri(),
)?;
write_skill(&codex_home, "demo")?;
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[(CODEX_EXEC_SERVER_URL_ENV_VAR, None)])
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).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 initial_skills_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(initial_skills_request_id)),
)
.await??;
let SkillsListResponse { data } = to_response(initial_skills_response)?;
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(ThreadStartParams {
model: None,
model_provider: None,
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,
ephemeral: None,
session_start_source: None,
thread_source: None,
dynamic_tools: None,
environments: None,
selected_capability_roots: None,
mock_experimental_field: None,
experimental_raw_events: false,
})
.await?;
let _: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(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",
)?;
let notification = timeout(
WATCHER_TIMEOUT,
mcp.read_stream_until_notification_message("skills/changed"),
)
.await??;
let params = notification
.params
.context("skills/changed params must be present")?;
let notification: SkillsChangedNotification = serde_json::from_value(params)?;
assert_eq!(notification, SkillsChangedNotification {});
let updated_skills_request_id = mcp
.send_skills_list_request(SkillsListParams {
cwds: vec![codex_home.path().to_path_buf()],
force_reload: false,
})
.await?;
let updated_skills_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(updated_skills_request_id)),
)
.await??;
let SkillsListResponse { data } = to_response(updated_skills_response)?;
assert_eq!(data.len(), 1);
assert!(
data[0]
.skills
.iter()
.any(|skill| skill.name == "demo" && skill.description == "updated")
);
Ok(())
}