Files
openswarm/backend/tests/test_skills_folders.py
T

222 lines
9.5 KiB
Python

"""Multi-file (folder) skills, plus backward compatibility with legacy flat skills.
A skill is now either ~/.claude/skills/<id>/SKILL.md (with optional supporting
files) or a legacy ~/.claude/skills/<id>.md. Both must list, read, and delete
correctly, and a folder skill with supporting files must get its folder path
appended to the prompt so the agent can read those files on demand.
"""
from __future__ import annotations
import os
import json
import pytest
import backend.apps.skills.skills as skills_mod
from backend.apps.agents.manager.prompt.prompt_context import resolve_attached_skills
@pytest.fixture
def skills_dir(tmp_path, monkeypatch):
d = tmp_path / "skills"
d.mkdir()
monkeypatch.setattr(skills_mod, "SKILLS_DIR", str(d))
monkeypatch.setattr(skills_mod, "INDEX_PATH", str(d / ".skills_index.json"))
return d
def p_write(path, text):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
f.write(text)
def test_corrupt_index_does_not_brick_skills_and_is_preserved(skills_dir):
p_write(str(skills_dir / "alpha.md"), "content")
with open(skills_dir / ".skills_index.json", "w") as f:
f.write("{ not valid json")
# Load returns empty instead of raising, and moves the bad file aside.
assert skills_mod.load_index() == {}
assert (skills_dir / ".skills_index.json.corrupt").exists()
# Skills still list (name falls back to the filename), so nothing is bricked.
assert "alpha" in {s.id for s in skills_mod.sync_skills()}
def test_non_object_index_is_rejected(skills_dir):
with open(skills_dir / ".skills_index.json", "w") as f:
f.write("[1, 2, 3]")
assert skills_mod.load_index() == {}
def test_save_index_is_atomic_no_temp_leftover(skills_dir):
skills_mod.save_index({"x": {"name": "X"}})
assert skills_mod.load_index() == {"x": {"name": "X"}}
leftovers = [n for n in __import__("os").listdir(skills_dir) if n.startswith(".skills_index.") and n.endswith(".tmp")]
assert leftovers == []
def test_flat_skill_still_syncs(skills_dir):
p_write(str(skills_dir / "my-flat.md"), "do the flat thing")
skills = {s.id: s for s in skills_mod.sync_skills()}
assert "my-flat" in skills
s = skills["my-flat"]
assert s.content == "do the flat thing"
assert s.dir_path == ""
assert s.has_supporting_files is False
def test_folder_skill_syncs_with_supporting_files(skills_dir):
base = skills_dir / "remotion"
p_write(str(base / "SKILL.md"), "---\nname: Remotion\ndescription: make videos\n---\nrender stuff")
p_write(str(base / "helper.py"), "print('hi')")
skills = {s.id: s for s in skills_mod.sync_skills()}
assert "remotion" in skills
s = skills["remotion"]
assert "render stuff" in s.content
assert s.dir_path == str(base)
assert s.has_supporting_files is True
# Frontmatter fills name/description when the index hasn't catalogued it.
assert s.name == "Remotion"
assert s.description == "make videos"
def test_folder_skill_without_extra_files_flags_false(skills_dir):
base = skills_dir / "solo"
p_write(str(base / "SKILL.md"), "just one file")
s = {x.id: x for x in skills_mod.sync_skills()}["solo"]
assert s.dir_path == str(base)
assert s.has_supporting_files is False
@pytest.mark.asyncio
async def test_delete_removes_folder(skills_dir):
base = skills_dir / "doomed"
p_write(str(base / "SKILL.md"), "x")
p_write(str(base / "data.txt"), "y")
assert base.is_dir()
await skills_mod.delete_skill("doomed")
assert not base.exists()
@pytest.mark.asyncio
async def test_update_writes_folder_skill_md(skills_dir):
base = skills_dir / "editable"
p_write(str(base / "SKILL.md"), "old body")
from backend.apps.skills.models import SkillUpdate
res = await skills_mod.update_skill("editable", SkillUpdate(content="new body", description="d"))
assert res["ok"]
with open(base / "SKILL.md", encoding="utf-8") as f:
assert f.read() == "new body"
assert res["skill"]["dir_path"] == str(base)
def test_injection_points_at_folder_for_supporting_files(skills_dir):
base = skills_dir / "withfiles"
p_write(str(base / "SKILL.md"), "use the template")
p_write(str(base / "template.html"), "<html></html>")
block = resolve_attached_skills([{"id": "withfiles", "name": "WithFiles", "content": "use the template"}])
assert "[Using skill: WithFiles]" in block
assert str(base) in block
assert "Read" in block # tells the agent to read supporting files
def test_skill_injection_is_provider_agnostic_by_construction(skills_dir):
"""The provider-agnostic claim, proven structurally (a live GPT/Gemini run
needs a key): the injector takes no provider arg so it CAN'T differ by model,
it points at supporting files via the universal Read tool, and Read is in the
builtin set every provider gets. So a non-Claude agent receives byte-identical
skill text and the same file-reading tools."""
import inspect
from backend.apps.agents.manager.prompt.prompt_context import resolve_attached_skills
from backend.apps.agents.manager.prompt.tool_catalog import FULL_TOOLS
# 1. No provider/api parameter -> the injected text cannot branch on the model.
assert set(inspect.signature(resolve_attached_skills).parameters) == {"attached_skills"}
# 2. A folder skill yields the body + a pointer to its folder via Read/Glob/Bash.
base = skills_dir / "vid"
p_write(str(base / "SKILL.md"), "render it")
p_write(str(base / "helper.py"), "x")
block = resolve_attached_skills([{"id": "vid", "name": "Vid", "content": "render it"}])
assert "[Using skill: Vid]" in block
assert str(base) in block and "Read" in block
# 3. Those file tools are universal builtins, not an Anthropic-only set.
assert {"Read", "Glob", "Bash"} <= set(FULL_TOOLS)
def test_injection_no_folder_note_for_flat_skill(skills_dir):
p_write(str(skills_dir / "plain.md"), "plain content")
block = resolve_attached_skills([{"id": "plain", "name": "Plain", "content": "plain content"}])
assert "[Using skill: Plain]" in block
assert "supporting files" not in block.lower()
# --------------------------------------------------------------------------- .swarm round-trip for folder skills (export carries files, import rebuilds them). ---------------------------------------------------------------------------
def test_swarm_export_folder_skill_carries_supporting_files(skills_dir):
from backend.apps.swarm.entities.skills import SkillExportable
base = skills_dir / "vid"
p_write(str(base / "SKILL.md"), "render")
p_write(str(base / "scripts" / "go.py"), "print(1)")
exp = SkillExportable.load("vid")
assert exp is not None
files = exp.files()
assert "scripts/go.py" in files
assert files["scripts/go.py"] == b"print(1)"
assert exp.payload["content"] == "render"
def test_swarm_import_writes_folder_when_files_present(skills_dir):
from backend.apps.swarm.entities.skills import SkillExportable
payload = {"slug": "vid", "name": "Vid", "description": "d", "command": "vid", "content": "render"}
new_id = SkillExportable.import_(payload, {"scripts/go.py": b"print(1)"}, None)
assert os.path.isfile(skills_dir / new_id / "SKILL.md")
assert os.path.isfile(skills_dir / new_id / "scripts" / "go.py")
synced = {s.id: s for s in skills_mod.sync_skills()}
assert synced[new_id].has_supporting_files is True
def test_swarm_import_always_writes_folder(skills_dir):
# Unified storage: even a one-file skill imports as a folder, so a skill's on-disk shape never depends on whether it had supporting files.
from backend.apps.swarm.entities.skills import SkillExportable
payload = {"slug": "note", "name": "Note", "content": "just text"}
new_id = SkillExportable.import_(payload, {}, None)
assert os.path.isfile(skills_dir / new_id / "SKILL.md")
assert not (skills_dir / f"{new_id}.md").exists()
@pytest.mark.asyncio
async def test_create_writes_folder_and_supersedes_legacy_flat(skills_dir):
from backend.apps.skills.models import SkillCreate
# A pre-existing legacy flat skill of the same id...
p_write(str(skills_dir / "notes.md"), "old flat")
# ...is superseded (not shadowed) when the user (re)creates it; folder wins, and the phantom flat file is removed so there's exactly one shape on disk.
res = await skills_mod.create_skill(SkillCreate(name="Notes", content="new body", description="d"))
sid = res["skill"]["id"]
assert sid == "notes"
assert os.path.isfile(skills_dir / "notes" / "SKILL.md")
assert not (skills_dir / "notes.md").exists()
only = [s for s in skills_mod.sync_skills() if s.id == "notes"]
assert len(only) == 1 and only[0].content == "new body"
def test_stage_zip_carries_supporting_files_into_sandbox():
import io as p_io, zipfile, os as p_os, shutil
from backend.apps.swarm.closure import stage_skill_from_zip
buf = p_io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("my-skill/SKILL.md", "do it")
zf.writestr("my-skill/scripts/run.sh", "echo hi")
sandbox, manifest, warnings = stage_skill_from_zip(buf.getvalue(), "my-skill.zip", [])
try:
bid = manifest.entities[0].bundle_id
files_dir = p_os.path.join(sandbox, "entities", bid, "files")
assert p_os.path.isfile(p_os.path.join(files_dir, "scripts", "run.sh"))
# SKILL.md is the payload body, not a supporting file.
assert not p_os.path.exists(p_os.path.join(files_dir, "SKILL.md"))
finally:
shutil.rmtree(sandbox, ignore_errors=True)