use std::time::Duration; 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::SkillsListExtraRootsForCwd; use codex_app_server_protocol::SkillsListParams; use codex_app_server_protocol::SkillsListResponse; use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::time::timeout; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); 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_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(()) }