openworker/tests/test_skills_api.py
2026-08-25 22:26:37 +03:00

380 lines
14 KiB
Python

"""SKILLS-SPEC §4.6 — REST endpoints, session mutes over HTTP, WS force-run framing.
Follows the codebase's API convention: validation failures return ``{"ok": False,
"error": …}`` bodies (not raw 4xx), matching every other /v1 management endpoint.
Engine integration runs on ScriptedProvider — no LLM, no network.
"""
from __future__ import annotations
import base64
import io
import zipfile
import pytest
from fastapi.testclient import TestClient
from coworker.providers import AssistantTurn, ModelCapabilities, ProviderClient
from coworker.server import SessionManager, create_app
class ScriptedProvider(ProviderClient):
"""Queued turns + captured `messages` so tests can assert what the model saw."""
def __init__(self, turns=None):
self._turns = list(turns or [])
self.seen: list[list[dict]] = []
def complete(self, *, model, messages, tools=None, **settings):
self.seen.append(messages)
return self._turns.pop(0)
def capabilities(self, model):
return ModelCapabilities()
def _client(tmp_path, turns=None):
provider = ScriptedProvider(turns)
manager = SessionManager(workspace=tmp_path, provider=provider)
return TestClient(create_app(manager)), manager, provider
def _zip_b64(entries: dict[str, str]) -> str:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
for name, content in entries.items():
zf.writestr(name, content)
return base64.b64encode(buf.getvalue()).decode()
GREET = {
"name": "greet",
"description": "says hello",
"instructions": "Say hello warmly.",
}
# -- CRUD -----------------------------------------------------------------------
def test_create_then_list_enriched(tmp_path):
client, _m, _p = _client(tmp_path)
assert client.post("/v1/skills", json=GREET).json()["ok"] is True
rows = client.get("/v1/skills").json()["skills"]
assert rows == [
{
"name": "greet",
"description": "says hello",
"instructions": "Say hello warmly.",
"scope": "global",
"source": "local",
"enabled": True,
"path": rows[0]["path"],
"files": 0,
}
]
def test_create_duplicate_and_blank_rejected(tmp_path):
client, _m, _p = _client(tmp_path)
client.post("/v1/skills", json=GREET)
dup = client.post("/v1/skills", json=GREET).json()
assert dup["ok"] is False and "already exists" in dup["error"]
for bad in (
{},
{"name": "", "instructions": "x"},
{"name": "ok-name", "instructions": " "},
):
res = client.post("/v1/skills", json=bad).json()
assert res["ok"] is False and res["error"]
def test_patch_edit_and_toggle(tmp_path):
client, _m, _p = _client(tmp_path)
client.post("/v1/skills", json=GREET)
assert (
client.patch(
"/v1/skills/greet", json={"description": "hi", "enabled": False}
).json()["ok"]
is True
)
row = client.get("/v1/skills").json()["skills"][0]
assert row["description"] == "hi" and row["enabled"] is False
unknown = client.patch("/v1/skills/ghost", json={"description": "x"}).json()
assert unknown["ok"] is False
def test_delete_and_unknown(tmp_path):
client, _m, _p = _client(tmp_path)
client.post("/v1/skills", json=GREET)
assert client.delete("/v1/skills/greet").json()["ok"] is True
assert client.get("/v1/skills").json()["skills"] == []
assert client.delete("/v1/skills/greet").json()["ok"] is False
def test_move_happy_and_collision(tmp_path):
client, _m, _p = _client(tmp_path)
ws = tmp_path / "proj"
ws.mkdir()
client.post("/v1/skills", json=GREET)
moved = client.post(
"/v1/skills/greet/move", json={"scope": "project", "workspace": str(ws)}
).json()
assert moved["ok"] is True and moved["skill"]["scope"] == "project"
client.post("/v1/skills", json=GREET) # recreate global → collision on move back
res = client.post(
"/v1/skills/greet/move", json={"scope": "global", "workspace": str(ws)}
).json()
assert res["ok"] is False and "already exists" in res["error"]
def test_create_project_scope_requires_real_workspace(tmp_path):
client, _m, _p = _client(tmp_path)
res = client.post(
"/v1/skills",
json={**GREET, "scope": "project", "workspace": str(tmp_path / "nope")},
).json()
assert res["ok"] is False and "workspace" in res["error"].lower()
def test_scratch_workspace_rejected_for_skill_writes(tmp_path):
"""A per-conversation scratch dir is not a project: create/move-into/confirm all refuse
it at the manager chokepoint; moving OUT of one still works (the rescue path)."""
client, manager, _p = _client(tmp_path)
scratch_base = tmp_path / "scratchpads"
manager.set_scratch_base(str(scratch_base))
scratch_ws = scratch_base / "6d57038c-50d"
(scratch_ws / ".coworker" / "skills").mkdir(parents=True)
res = client.post(
"/v1/skills",
json={**GREET, "scope": "project", "workspace": str(scratch_ws)},
).json()
assert res["ok"] is False and "temporary" in res["error"].lower()
client.post("/v1/skills", json=GREET) # global
res = client.post(
"/v1/skills/greet/move", json={"scope": "project", "workspace": str(scratch_ws)}
).json()
assert res["ok"] is False and "temporary" in res["error"].lower()
md = "---\nname: zipped\ndescription: d\n---\nbody\n"
preview = client.post(
"/v1/skills/upload", json={"data_b64": _zip_b64({"zipped/SKILL.md": md})}
).json()
res = client.post(
"/v1/skills/upload/confirm",
json={"token": preview["token"], "scope": "project", "workspace": str(scratch_ws)},
).json()
assert res["ok"] is False and "temporary" in res["error"].lower()
# Rescue path: a skill already stranded in scratch can still move OUT to global.
manager.skill_store.create(
name="stranded", description="", instructions="x",
scope="project", workspace=scratch_ws,
)
res = client.post(
"/v1/skills/stranded/move",
json={"scope": "global", "workspace": str(scratch_ws)},
).json()
assert res["ok"] is True and res["skill"]["scope"] == "global"
def test_path_traversal_names_rejected(tmp_path):
client, _m, _p = _client(tmp_path)
# ".." must be encoded — the HTTP client itself normalizes a literal /.. away.
assert client.delete("/v1/skills/%2e%2e").json()["ok"] is False
res = client.post("/v1/skills", json={**GREET, "name": "..%2Fevil"}).json()
assert res["ok"] is False
# -- upload + draft -----------------------------------------------------------------
def test_upload_preview_then_confirm(tmp_path):
client, _m, _p = _client(tmp_path)
md = "---\nname: greet\ndescription: says hello\n---\nSay hello.\n"
preview = client.post(
"/v1/skills/upload", json={"data_b64": _zip_b64({"greet/SKILL.md": md})}
).json()
assert preview["ok"] is True and preview["name"] == "greet"
assert client.get("/v1/skills").json()["skills"] == [] # preview installs nothing
confirmed = client.post(
"/v1/skills/upload/confirm", json={"token": preview["token"]}
).json()
assert confirmed["ok"] is True
row = client.get("/v1/skills").json()["skills"][0]
assert row["source"] == "uploaded"
def test_upload_confirm_rejects_forged_absolute_token(tmp_path):
client, _m, _p = _client(tmp_path)
outside = tmp_path / "outside-staging"
outside.mkdir()
(outside / "SKILL.md").write_text(
"---\nname: forged\ndescription: d\n---\nbody\n", encoding="utf-8"
)
marker = outside / "keep.txt"
marker.write_text("must survive", encoding="utf-8")
result = client.post(
"/v1/skills/upload/confirm", json={"token": str(outside.resolve())}
).json()
assert result["ok"] is False and "expired" in result["error"].lower()
assert marker.read_text(encoding="utf-8") == "must survive"
def test_upload_invalid_archive_friendly(tmp_path):
client, _m, _p = _client(tmp_path)
bad = client.post(
"/v1/skills/upload",
json={"data_b64": base64.b64encode(b"not a zip").decode(), "filename": "x.zip"},
).json()
assert bad["ok"] is False and "zip" in bad["error"].lower()
# A bare .md without frontmatter gets the md-specific guidance.
bare = client.post(
"/v1/skills/upload",
json={"data_b64": base64.b64encode(b"no frontmatter").decode(), "filename": "a.md"},
).json()
assert bare["ok"] is False and "frontmatter" in bare["error"].lower()
assert client.post("/v1/skills/upload", json={}).json()["ok"] is False
assert (
client.post("/v1/skills/upload", json={"data_b64": "!!!"}).json()["ok"] is False
)
def test_draft_endpoint_is_gone(tmp_path):
"""The drafting path retired with the worker-authors flow (SKILLS-SPEC §5.2/§9):
creation is a conversation ending in save_skill, not a Settings endpoint."""
client, _m, _p = _client(tmp_path)
# 405 not 404: the path now falls through to PATCH /v1/skills/{name}. Either way,
# POSTing a draft is no longer a thing.
assert client.post("/v1/skills/draft", json={"description": "x"}).status_code in (404, 405)
# -- session mutes over HTTP ----------------------------------------------------------
def test_session_mute_roundtrip(tmp_path):
client, _m, _p = _client(tmp_path)
client.post("/v1/skills", json=GREET)
view = client.get("/v1/sessions/s1/skills").json()["skills"]
assert view == [
{"name": "greet", "description": "says hello", "scope": "global", "enabled": True}
]
after = client.post(
"/v1/sessions/s1/skills", json={"skill": "greet", "enabled": False}
).json()["skills"]
assert after[0]["enabled"] is False
other = client.get("/v1/sessions/s2/skills").json()["skills"]
assert other[0]["enabled"] is True # mute is per-session
cleared = client.post(
"/v1/sessions/s1/skills", json={"skill": "greet", "clear": True}
).json()["skills"]
assert cleared[0]["enabled"] is True
assert (
client.post("/v1/sessions/s1/skills", json={}).json()["ok"] is False
) # null input
# -- engine integration (ScriptedProvider, no LLM) --------------------------------------
def test_engine_catalog_respects_settings_disable(tmp_path):
client, manager, _p = _client(tmp_path)
client.post("/v1/skills", json=GREET)
client.post(
"/v1/skills", json={"name": "hidden", "description": "off", "instructions": "x"}
)
client.patch("/v1/skills/hidden", json={"enabled": False})
from coworker.agent import build_engine
from coworker.agents.chat import chat_agent
engine = build_engine(
agent=chat_agent(), # workspace-free agent (persona retired; builder remains)
provider=ScriptedProvider(),
skill_filter=lambda: manager.effective_skill_names("s1"),
)
# The menu rides the live per-turn context block (§4.1), not the system prompt.
menu = engine.context_provider()
assert "greet" in menu
assert "hidden" not in menu
assert "greet" not in engine.messages[0]["content"]
def _drain(ws):
types = []
while True:
evt = ws.receive_json()
types.append(evt["type"])
if evt["type"] in {"turn_done", "input_rejected"}:
return types, evt
def test_ws_force_run_frames_the_turn(tmp_path):
"""The display/model split (§4.1 #3): the provider gets the framing; the transcript
(TURN_START + the persisted message's `_display`) gets the user's literal '/name …'."""
client, _m, provider = _client(tmp_path, [AssistantTurn(text="done")])
client.post("/v1/skills", json=GREET)
with client.websocket_connect("/ws/session/s1?agent=chat") as ws:
assert ws.receive_json()["type"] == "ready"
ws.send_json({"type": "user_message", "text": "hello", "skill": "greet"})
events = []
while True:
evt = ws.receive_json()
events.append(evt)
if evt["type"] == "turn_done":
break
framed = provider.seen[-1][-1]["content"]
assert 'load_skill("greet")' in str(framed)
assert "hello" in str(framed)
start = next(e for e in events if e["type"] == "turn_start")
assert start["data"]["display"] == "/greet hello" # what the transcript shows
assert "load_skill" in str(start["data"]["input"]) # what the model saw
stored = client.get("/v1/sessions/s1/messages").json()["messages"]
user = next(m for m in stored if m["role"] == "user")
assert user["_display"] == "/greet hello"
assert "load_skill" in str(user["content"])
def test_ws_force_run_unknown_and_muted_error_without_killing_socket(tmp_path):
client, _m, provider = _client(tmp_path, [AssistantTurn(text="ok")])
client.post("/v1/skills", json=GREET)
client.post("/v1/sessions/s1/skills", json={"skill": "greet", "enabled": False})
with client.websocket_connect("/ws/session/s1?agent=chat") as ws:
assert ws.receive_json()["type"] == "ready"
# unknown skill → visible rejection, no turn
ws.send_json({"type": "user_message", "text": "x", "skill": "ghost"})
evt = ws.receive_json()
assert evt["type"] == "input_rejected"
assert "not available" in evt["data"]["error"]
# muted skill → same rejection (§4.6 #15: no silent auto-unmute)
ws.send_json({"type": "user_message", "text": "x", "skill": "greet"})
evt = ws.receive_json()
assert evt["type"] == "input_rejected"
# empty name → invalid frame
ws.send_json({"type": "user_message", "text": "x", "skill": " "})
assert ws.receive_json()["type"] == "input_rejected"
# socket still healthy: a normal message runs a turn
ws.send_json({"type": "user_message", "text": "plain"})
types, _ = _drain(ws)
assert "turn_done" in types
# No force-run framing ever reached the model (the catalog line in the system prompt
# legitimately mentions load_skill — the invariant is about USER messages only).
users = [
str(m.get("content"))
for msgs in provider.seen
for m in msgs
if m.get("role") == "user"
]
assert all("Use the skill" not in u for u in users)
assert any("plain" in u for u in users)
def test_reveal_unknown_skill_is_a_friendly_error(tmp_path):
client, _m, _p = _client(tmp_path)
res = client.post("/v1/skills/nope/reveal", json={}).json()
assert res["ok"] is False and "nope" in res["error"]