Files
hive/core/tests/test_routes_skills.py
T
2026-04-28 10:43:54 -07:00

306 lines
11 KiB
Python

"""HTTP integration tests for the skills routes.
Covers the per-queen, per-colony, and aggregated-library surfaces plus
the multipart upload handler. Uses aiohttp's TestClient directly (no
pytest-aiohttp plugin), which is why each test sets up its own client.
"""
from __future__ import annotations
import io
import zipfile
from collections.abc import AsyncIterator
from pathlib import Path
import pytest
import pytest_asyncio
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from framework.server.routes_skills import register_routes
from framework.skills.overrides import (
OverrideEntry,
Provenance,
SkillOverrideStore,
)
pytestmark = pytest.mark.asyncio
class _StubSessionManager:
"""Tiny stand-in that satisfies the iter_* contracts used by routes.
The routes_skills handlers call ``manager.iter_queen_sessions`` and
``manager.iter_colony_runtimes`` to find live managers to reload.
In-process tests don't spin up runtimes, so these iterators yield
nothing — the routes fall back to the admin manager built from disk.
"""
def iter_queen_sessions(self, queen_id: str):
return iter([])
def iter_colony_runtimes(self, *, queen_id=None, colony_name=None):
return iter([])
def _build_app() -> web.Application:
application = web.Application()
application["manager"] = _StubSessionManager()
register_routes(application)
return application
@pytest_asyncio.fixture
async def client() -> AsyncIterator[TestClient]:
app = _build_app()
server = TestServer(app)
async with TestClient(server) as tc:
yield tc
@pytest.fixture
def _seed_queen(tmp_path: Path):
"""Write a queen profile so _queen_scope recognises the id."""
queen_home = Path.home() / ".hive" / "agents" / "queens" / "ops"
queen_home.mkdir(parents=True, exist_ok=True)
(queen_home / "profile.yaml").write_text("name: Ops\ntitle: Ops queen\n", encoding="utf-8")
return queen_home
@pytest.fixture
def _seed_colony(tmp_path: Path):
colony_home = Path.home() / ".hive" / "colonies" / "research_one"
colony_home.mkdir(parents=True, exist_ok=True)
return colony_home
async def test_get_queen_skills_returns_empty_for_fresh_queen(client: TestClient, _seed_queen) -> None:
resp = await client.get("/api/queen/ops/skills")
assert resp.status == 200
data = await resp.json()
assert data["queen_id"] == "ops"
assert data["all_defaults_disabled"] is False
# Fresh install → framework default skills show up via discovery.
assert isinstance(data["skills"], list)
async def test_create_queen_skill_writes_file_and_override(client: TestClient, _seed_queen) -> None:
payload = {
"name": "ops-runbook",
"description": "Runbook for ops",
"body": "## Steps\n1. Check\n",
"enabled": True,
}
resp = await client.post("/api/queen/ops/skills", json=payload)
assert resp.status == 201
data = await resp.json()
assert data["name"] == "ops-runbook"
# Verify files were written to the queen skill dir.
skill_md = _seed_queen / "skills" / "ops-runbook" / "SKILL.md"
assert skill_md.exists()
# Verify override was registered with USER_UI_CREATED provenance.
store = SkillOverrideStore.load(_seed_queen / "skills_overrides.json")
entry = store.get("ops-runbook")
assert entry is not None
assert entry.provenance == Provenance.USER_UI_CREATED
assert entry.enabled is True
async def test_patch_queen_skill_toggles_enabled(client: TestClient, _seed_queen) -> None:
await client.post(
"/api/queen/ops/skills",
json={"name": "ops-a", "description": "a", "body": "body"},
)
resp = await client.patch(
"/api/queen/ops/skills/ops-a",
json={"enabled": False},
)
assert resp.status == 200
store = SkillOverrideStore.load(_seed_queen / "skills_overrides.json")
assert store.get("ops-a").enabled is False
async def test_delete_queen_skill_removes_files(client: TestClient, _seed_queen) -> None:
await client.post(
"/api/queen/ops/skills",
json={"name": "tmp-skill", "description": "d", "body": "body"},
)
skill_dir = _seed_queen / "skills" / "tmp-skill"
assert skill_dir.exists()
resp = await client.delete("/api/queen/ops/skills/tmp-skill")
assert resp.status == 200
assert not skill_dir.exists()
store = SkillOverrideStore.load(_seed_queen / "skills_overrides.json")
assert "tmp-skill" in store.deleted_ui_skills
async def test_delete_framework_skill_is_refused(client: TestClient, _seed_queen) -> None:
# Pre-seed an override entry with framework provenance — simulates the
# user toggling a framework default so the override exists on disk.
store = SkillOverrideStore.load(_seed_queen / "skills_overrides.json")
store.upsert(
"hive.note-taking",
OverrideEntry(enabled=False, provenance=Provenance.FRAMEWORK),
)
store.save()
resp = await client.delete("/api/queen/ops/skills/hive.note-taking")
assert resp.status == 403
async def test_upload_markdown_places_in_user_library(client: TestClient) -> None:
skill_md = "---\nname: from-upload\ndescription: Uploaded skill\n---\n\n## Body\nHi.\n"
form = {
"file": skill_md.encode("utf-8"),
"scope": "user",
"enabled": "true",
}
# Use multipart writer pattern: aiohttp test client auto-serializes dicts.
data = _as_form(form, filename="SKILL.md")
resp = await client.post("/api/skills/upload", data=data)
assert resp.status == 201
body = await resp.json()
assert body["name"] == "from-upload"
assert (Path.home() / ".hive" / "skills" / "from-upload" / "SKILL.md").exists()
async def test_upload_zip_bundle_places_in_queen_scope(client: TestClient, _seed_queen) -> None:
# Build a zip in memory with SKILL.md + a supporting file.
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as z:
z.writestr(
"SKILL.md",
"---\nname: zipped-skill\ndescription: From zip\n---\n\nbody\n",
)
z.writestr("scripts/helper.py", "print('hi')\n")
payload = buf.getvalue()
form = {
"file": payload,
"scope": "queen",
"target_id": "ops",
"enabled": "true",
}
data = _as_form(form, filename="bundle.zip")
resp = await client.post("/api/skills/upload", data=data)
assert resp.status == 201
skill_dir = _seed_queen / "skills" / "zipped-skill"
assert (skill_dir / "SKILL.md").exists()
assert (skill_dir / "scripts" / "helper.py").exists()
async def test_create_colony_skill_writes_to_flat_path(client: TestClient, _seed_colony) -> None:
"""POSTing a new colony skill must write to the flat ``colonies/{name}/skills/``
layout, not the legacy nested ``.hive/skills/`` path.
"""
payload = {
"name": "new-flat-skill",
"description": "Created via UI",
"body": "## Body\nstuff\n",
"enabled": True,
}
resp = await client.post("/api/colonies/research_one/skills", json=payload)
assert resp.status == 201
flat_md = _seed_colony / "skills" / "new-flat-skill" / "SKILL.md"
nested_md = _seed_colony / ".hive" / "skills" / "new-flat-skill" / "SKILL.md"
assert flat_md.exists(), "new colony skill should land in flat skills/ dir"
assert not nested_md.exists(), "new colony skill must NOT land in legacy nested .hive/skills/"
async def test_legacy_nested_colony_skill_still_lists(client: TestClient, _seed_colony) -> None:
"""Pre-flatten colonies keep their skills under ``colonies/{name}/.hive/skills/``.
They must continue to surface in GET responses.
"""
skill_dir = _seed_colony / ".hive" / "skills" / "legacy-flat-test"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: legacy-flat-test\ndescription: Legacy nested\n---\n\nbody\n",
encoding="utf-8",
)
resp = await client.get("/api/colonies/research_one/skills")
assert resp.status == 200
rows = {r["name"]: r for r in (await resp.json())["skills"]}
assert "legacy-flat-test" in rows
async def test_patch_does_not_mislabel_legacy_colony_skill_as_framework(client: TestClient, _seed_colony) -> None:
"""Regression: toggling a legacy colony skill (no ledger entry yet)
must not stamp provenance=FRAMEWORK on the new entry. Before the fix,
the first PATCH wrote FRAMEWORK and the next GET displayed 'Framework'
instead of the queen-authored label.
"""
skill_dir = _seed_colony / ".hive" / "skills" / "legacy-queen-skill"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: legacy-queen-skill\ndescription: From create_colony\n---\n\nbody\n",
encoding="utf-8",
)
resp = await client.patch(
"/api/colonies/research_one/skills/legacy-queen-skill",
json={"enabled": False},
)
assert resp.status == 200
list_resp = await client.get("/api/colonies/research_one/skills")
rows = {r["name"]: r for r in (await list_resp.json())["skills"]}
assert rows["legacy-queen-skill"]["provenance"] == "queen_created"
assert rows["legacy-queen-skill"]["enabled"] is False
async def test_colony_skill_is_editable_even_without_override_entry(client: TestClient, _seed_colony) -> None:
"""Regression: a SKILL.md dropped into a colony's .hive/skills dir
(e.g. from a pre-override-store colony) must still be marked editable
when listed via /api/colonies/{name}/skills. The admin manager used
to set project_root=colony_home, which retagged the skill as
source_scope='project' and fell back to PROJECT_DROPPED provenance —
flipping editable to False.
"""
# Write a bare SKILL.md directly; no override ledger entry.
skill_dir = _seed_colony / ".hive" / "skills" / "legacy-skill"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: legacy-skill\ndescription: A legacy\n---\n\nbody\n",
encoding="utf-8",
)
resp = await client.get("/api/colonies/research_one/skills")
assert resp.status == 200
data = await resp.json()
rows = {r["name"]: r for r in data["skills"]}
assert "legacy-skill" in rows
assert rows["legacy-skill"]["editable"] is True
assert rows["legacy-skill"]["source_scope"] == "colony_ui"
# Legacy colony skills (no override ledger entry) were authored by
# create_colony() before the ledger existed — the fallback provenance
# must reflect that, not be misreported as user-UI-created.
assert rows["legacy-skill"]["provenance"] == "queen_created"
async def test_list_scopes_enumerates_queens_and_colonies(client: TestClient, _seed_queen, _seed_colony) -> None:
resp = await client.get("/api/skills/scopes")
assert resp.status == 200
data = await resp.json()
assert any(q["id"] == "ops" for q in data["queens"])
assert any(c["name"] == "research_one" for c in data["colonies"])
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _as_form(fields: dict, *, filename: str):
"""Build aiohttp FormData; bytes entries are attached as file parts."""
from aiohttp import FormData
fd = FormData()
for key, value in fields.items():
if isinstance(value, bytes):
fd.add_field(key, value, filename=filename)
else:
fd.add_field(key, value)
return fd