Codex Handbook
app-server/tests/suite/v2/skills_list.rs 849 lines
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 = trueremote_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),        ("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;    }    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(())}