use std::time::Duration; use anyhow::Context; use anyhow::Result; use app_test_support::McpProcess; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SkillsChangedNotification; use codex_app_server_protocol::SkillsListExtraRootsForCwd; use codex_app_server_protocol::SkillsListParams; use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::ThreadStartParams; use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::time::timeout; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); 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(()) } #[tokio::test] async fn skills_list_includes_skills_from_per_cwd_extra_user_roots() -> Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let extra_root = TempDir::new()?; write_skill(&extra_root, "extra-skill")?; let mut mcp = McpProcess::new(codex_home.path()).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, per_cwd_extra_user_roots: Some(vec![SkillsListExtraRootsForCwd { cwd: cwd.path().to_path_buf(), extra_user_roots: vec![extra_root.path().to_path_buf()], }]), }) .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!( data[0] .skills .iter() .any(|skill| skill.name == "extra-skill") ); Ok(()) } #[tokio::test] async fn skills_list_rejects_relative_extra_user_roots() -> Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let mut mcp = McpProcess::new(codex_home.path()).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, per_cwd_extra_user_roots: Some(vec![SkillsListExtraRootsForCwd { cwd: cwd.path().to_path_buf(), extra_user_roots: vec![std::path::PathBuf::from("relative/skills")], }]), }) .await?; let err = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_error_message(RequestId::Integer(request_id)), ) .await??; assert!( err.error .message .contains("perCwdExtraUserRoots extraUserRoots paths must be absolute"), "unexpected error: {}", err.error.message ); 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 = McpProcess::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, per_cwd_extra_user_roots: None, }) .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_ignores_per_cwd_extra_roots_for_unknown_cwd() -> Result<()> { let codex_home = TempDir::new()?; let requested_cwd = TempDir::new()?; let unknown_cwd = TempDir::new()?; let extra_root = TempDir::new()?; write_skill(&extra_root, "ignored-extra-skill")?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_skills_list_request(SkillsListParams { cwds: vec![requested_cwd.path().to_path_buf()], force_reload: true, per_cwd_extra_user_roots: Some(vec![SkillsListExtraRootsForCwd { cwd: unknown_cwd.path().to_path_buf(), extra_user_roots: vec![extra_root.path().to_path_buf()], }]), }) .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, requested_cwd.path().to_path_buf()); assert!( data[0] .skills .iter() .all(|skill| skill.name != "ignored-extra-skill") ); Ok(()) } #[tokio::test] async fn skills_list_uses_cached_result_until_force_reload() -> Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let extra_root = TempDir::new()?; write_skill(&extra_root, "late-extra-skill")?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; // Seed the cwd cache first without extra roots. let first_request_id = mcp .send_skills_list_request(SkillsListParams { cwds: vec![cwd.path().to_path_buf()], force_reload: false, per_cwd_extra_user_roots: None, }) .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 second_request_id = mcp .send_skills_list_request(SkillsListParams { cwds: vec![cwd.path().to_path_buf()], force_reload: false, per_cwd_extra_user_roots: Some(vec![SkillsListExtraRootsForCwd { cwd: cwd.path().to_path_buf(), extra_user_roots: vec![extra_root.path().to_path_buf()], }]), }) .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, per_cwd_extra_user_roots: Some(vec![SkillsListExtraRootsForCwd { cwd: cwd.path().to_path_buf(), extra_user_roots: vec![extra_root.path().to_path_buf()], }]), }) .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_changed_notification_is_emitted_after_skill_change() -> Result<()> { let codex_home = TempDir::new()?; write_skill(&codex_home, "demo")?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let thread_start_request_id = mcp .send_thread_start_request(ThreadStartParams { model: None, model_provider: None, service_tier: None, cwd: None, approval_policy: None, approvals_reviewer: None, sandbox: None, config: None, service_name: None, base_instructions: None, developer_instructions: None, personality: None, ephemeral: None, session_start_source: None, dynamic_tools: None, mock_experimental_field: None, experimental_raw_events: false, persist_extended_history: 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 {}); Ok(()) }