"""v0.3 PR #4 — Skills (LLM-routing) tests. Covers: 1. Bootstrap creates the user skills directory (idempotent, no example seeded). 2. `list_installed_skills` parses well-formed SKILL.md entries. 3. Silently skips malformed entries (no frontmatter / bad YAML / name-dir mismatch). 4. `read_skill_body` returns the full file contents or None for missing skills. 5. `resolve_skill_sources` returns the user skills dir as a single source. 6. End-to-end: `build_agent(..., skills_sources_override=[...])` forwards the list as `create_deep_agent(skills=...)`. """ from __future__ import annotations from pathlib import Path from textwrap import dedent from typing import Any import pytest from my_deepagent.config import load_config from my_deepagent.skills import ( SKILL_FILENAME, SkillInfo, ensure_skills_initialized, list_installed_skills, read_skill_body, resolve_skill_sources, user_skills_dir, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_skill( skills_dir: Path, name: str, *, description: str | None = None, body: str = "" ) -> Path: """Drop a well-formed `/SKILL.md` under skills_dir.""" skill_dir = skills_dir / name skill_dir.mkdir(parents=True, exist_ok=True) desc = description if description is not None else f"description for {name}" content = dedent( f"""\ --- name: {name} description: {desc} --- # {name} {body} """ ) (skill_dir / SKILL_FILENAME).write_text(content, encoding="utf-8") return skill_dir # --------------------------------------------------------------------------- # Bootstrap # --------------------------------------------------------------------------- def test_ensure_skills_initialized_creates_dir(tmp_path: Path) -> None: skills_dir = tmp_path / "skills" assert not skills_dir.exists() ensure_skills_initialized(skills_dir) assert skills_dir.is_dir() # Empty by design — no example skill seeded. assert list(skills_dir.iterdir()) == [] def test_ensure_skills_initialized_is_idempotent(tmp_path: Path) -> None: skills_dir = tmp_path / "skills" ensure_skills_initialized(skills_dir) _make_skill(skills_dir, "web-research") # Should NOT wipe existing skills. ensure_skills_initialized(skills_dir) assert (skills_dir / "web-research" / SKILL_FILENAME).is_file() # --------------------------------------------------------------------------- # list_installed_skills # --------------------------------------------------------------------------- def test_list_installed_skills_returns_sorted_skillinfo(tmp_path: Path) -> None: skills_dir = tmp_path / "skills" skills_dir.mkdir() _make_skill(skills_dir, "b-tool", description="second") _make_skill(skills_dir, "a-tool", description="first") infos = list_installed_skills(skills_dir) assert [i.name for i in infos] == ["a-tool", "b-tool"] for info in infos: assert isinstance(info, SkillInfo) assert info.description assert info.path.name == SKILL_FILENAME def test_list_installed_skills_skips_missing_skill_md(tmp_path: Path) -> None: skills_dir = tmp_path / "skills" skills_dir.mkdir() # Dir without SKILL.md should be skipped, not crash. (skills_dir / "empty-skill").mkdir() _make_skill(skills_dir, "good") infos = list_installed_skills(skills_dir) assert [i.name for i in infos] == ["good"] def test_list_installed_skills_skips_malformed_frontmatter(tmp_path: Path) -> None: skills_dir = tmp_path / "skills" skills_dir.mkdir() bad = skills_dir / "bad" bad.mkdir() (bad / SKILL_FILENAME).write_text("no frontmatter at all\nbody here", encoding="utf-8") bad_yaml = skills_dir / "bad-yaml" bad_yaml.mkdir() (bad_yaml / SKILL_FILENAME).write_text( "---\nname: bad-yaml\ndesc: [unclosed\n---\n", encoding="utf-8" ) _make_skill(skills_dir, "good") infos = list_installed_skills(skills_dir) assert [i.name for i in infos] == ["good"] def test_list_installed_skills_skips_name_dir_mismatch(tmp_path: Path) -> None: skills_dir = tmp_path / "skills" skills_dir.mkdir() # Directory "mismatch" but frontmatter name "other-name" → invalid per deepagents. skill_dir = skills_dir / "mismatch" skill_dir.mkdir() (skill_dir / SKILL_FILENAME).write_text( "---\nname: other-name\ndescription: bad mismatch\n---\nbody\n", encoding="utf-8" ) assert list_installed_skills(skills_dir) == [] def test_list_installed_skills_missing_dir_returns_empty(tmp_path: Path) -> None: assert list_installed_skills(tmp_path / "no-skills-dir") == [] def test_list_installed_skills_truncates_long_description(tmp_path: Path) -> None: skills_dir = tmp_path / "skills" skills_dir.mkdir() long_desc = "x" * 500 _make_skill(skills_dir, "long", description=long_desc) infos = list_installed_skills(skills_dir) assert len(infos) == 1 # Truncated to 200 chars + ellipsis. assert len(infos[0].description) <= 201 assert infos[0].description.endswith("…") # --------------------------------------------------------------------------- # read_skill_body # --------------------------------------------------------------------------- def test_read_skill_body_returns_full_text(tmp_path: Path) -> None: skills_dir = tmp_path / "skills" skills_dir.mkdir() _make_skill(skills_dir, "web-research", body="Use Google.") body = read_skill_body(skills_dir, "web-research") assert body is not None assert "name: web-research" in body assert "Use Google" in body def test_read_skill_body_missing_returns_none(tmp_path: Path) -> None: skills_dir = tmp_path / "skills" skills_dir.mkdir() assert read_skill_body(skills_dir, "nonexistent") is None def test_read_skill_body_empty_name_returns_none(tmp_path: Path) -> None: skills_dir = tmp_path / "skills" skills_dir.mkdir() assert read_skill_body(skills_dir, "") is None # --------------------------------------------------------------------------- # user_skills_dir / resolve_skill_sources # --------------------------------------------------------------------------- def test_user_skills_dir_under_data_dir(tmp_path: Path) -> None: cfg = load_config(workspace_root=tmp_path, data_dir=tmp_path / "data") sd = user_skills_dir(cfg) assert sd.parent == cfg.data_dir assert sd.name == "skills" def test_resolve_skill_sources_returns_user_dir(tmp_path: Path) -> None: cfg = load_config(workspace_root=tmp_path, data_dir=tmp_path / "data") sources = resolve_skill_sources(cfg) assert len(sources) == 1 assert sources[0] == str(user_skills_dir(cfg).resolve()) def test_resolve_skill_sources_with_project_key_returns_both(tmp_path: Path) -> None: from my_deepagent.skills import project_skills_dir cfg = load_config(workspace_root=tmp_path, data_dir=tmp_path / "data") sources = resolve_skill_sources(cfg, project_key="proj1234abcdef00") assert sources == [ str(user_skills_dir(cfg).resolve()), str(project_skills_dir(cfg, "proj1234abcdef00").resolve()), ] def test_list_all_skills_project_overrides_global(tmp_path: Path) -> None: from my_deepagent.skills import list_all_skills, project_skills_dir cfg = load_config(workspace_root=tmp_path, data_dir=tmp_path / "data") pk = "abc123def456ffff" global_dir = user_skills_dir(cfg) proj_dir = project_skills_dir(cfg, pk) global_dir.mkdir(parents=True) proj_dir.mkdir(parents=True) _make_skill(global_dir, "shared", description="global-version") _make_skill(proj_dir, "shared", description="project-version") _make_skill(global_dir, "global-only", description="g") _make_skill(proj_dir, "project-only", description="p") skills = list_all_skills(cfg, pk) by_name = {s.name: s for s in skills} assert set(by_name.keys()) == {"shared", "global-only", "project-only"} # Project overrides global on the shared name. assert by_name["shared"].scope == "project" assert by_name["shared"].description == "project-version" assert by_name["global-only"].scope == "global" assert by_name["project-only"].scope == "project" def test_find_skill_prefers_project_over_global(tmp_path: Path) -> None: from my_deepagent.skills import find_skill, project_skills_dir cfg = load_config(workspace_root=tmp_path, data_dir=tmp_path / "data") pk = "f0f0f0f0f0f0f0f0" global_dir = user_skills_dir(cfg) proj_dir = project_skills_dir(cfg, pk) global_dir.mkdir(parents=True) proj_dir.mkdir(parents=True) _make_skill(global_dir, "dup", description="g") _make_skill(proj_dir, "dup", description="p") skill = find_skill(cfg, pk, "dup") assert skill is not None assert skill.scope == "project" assert skill.description == "p" def test_find_skill_missing_returns_none(tmp_path: Path) -> None: from my_deepagent.skills import find_skill cfg = load_config(workspace_root=tmp_path, data_dir=tmp_path / "data") assert find_skill(cfg, "any-project-key", "nonexistent") is None # --------------------------------------------------------------------------- # Integration: build_agent threads skills sources to deepagents # --------------------------------------------------------------------------- def test_build_agent_passes_skills_sources_to_deepagents( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: from my_deepagent import session as session_mod from my_deepagent.persona import Persona captured: dict[str, Any] = {} def fake_create_deep_agent(**kwargs: Any) -> Any: captured.update(kwargs) return object() monkeypatch.setattr(session_mod, "create_deep_agent", fake_create_deep_agent) persona = Persona( name="test-persona", version=1, backend="openrouter", model="openrouter:deepseek/deepseek-chat", provider_origin="CN/DeepSeek", capabilities=("code_edit",), max_risk_level="high", system_prompt="System prompt for test persona (must be ≥10 chars)", deepagents_backend="state", ) cfg = load_config( workspace_root=tmp_path, data_dir=tmp_path / "data", openrouter_api_key="test-key", ) skills_dir = tmp_path / "user-skills" ensure_skills_initialized(skills_dir) _make_skill(skills_dir, "noop", description="does nothing") _agent = session_mod.build_agent( persona, cfg, root_dir=tmp_path, skills_sources_override=[str(skills_dir)], ) assert "skills" in captured assert captured["skills"] == [str(skills_dir)] def test_build_agent_skips_skills_kwarg_when_none( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: from my_deepagent import session as session_mod from my_deepagent.persona import Persona captured: dict[str, Any] = {} def fake_create_deep_agent(**kwargs: Any) -> Any: captured.update(kwargs) return object() monkeypatch.setattr(session_mod, "create_deep_agent", fake_create_deep_agent) persona = Persona( name="test-persona", version=1, backend="openrouter", model="openrouter:deepseek/deepseek-chat", provider_origin="CN/DeepSeek", capabilities=("code_edit",), max_risk_level="high", system_prompt="System prompt for test persona (must be ≥10 chars)", deepagents_backend="state", ) cfg = load_config( workspace_root=tmp_path, data_dir=tmp_path / "data", openrouter_api_key="test-key", ) _agent = session_mod.build_agent(persona, cfg, root_dir=tmp_path) assert "skills" not in captured