Files
aigc-home/calliope-backend/tests/test_skill_admin.py
T
shigongyuan-agentandmultica-agent cdd6eba8ba feat: five named agents independent chat + custom skill management (HEL-613)
Backend:
- agents_registry: five AIGC roles (剧本创作/视觉设计/分镜设计/素材资产/镜头脚本)
  with zh personas, agent-chat mode text, and llm_role mapping onto the
  models-page five-role binding surface (story/style/script/assets/video)
- agent_sessions.agent_key column + migration; sessions filter/exclusion;
  named replies stamped with the owner agent's display name
- prompt assembly: per-agent persona + AGENT CHAT mode section replace the
  generic sandbox text; mentions section suppressed for agent chats
- main loop routes the LLM through the agent's bound role
- skill admin surface: skill_overrides/skill_bindings/skill_versions tables,
  PUT/PATCH/DELETE /api/agent/skills/{name} + bindings + versions/restore;
  effective rule = global enable ∩ enabled per-agent binding; builtin skills
  seeded to all five agents at upgrade; read_skill/list_skills gated for
  named-agent sessions

Frontend:
- /agents is now the three-column A-darkroom page (agent list + skill library
  / chat with reused AgentChat / skill manager with editor, assignment
  capsules, version history + rollback); per-agent model select synced with
  the models page; narrow breakpoint turns the manager into a drawer
- legacy ?project=&task= deep links still route into the canvas flow
- i18n keys added to all 7 locales

Tests: tests/test_agent_roles.py + tests/test_skill_admin.py (27 cases)
Co-authored-by: multica-agent <github@multica.ai>
2026-09-28 11:28:26 +08:00

221 lines
10 KiB
Python

"""Skill management surface (HEL-613): create/edit/enable/assign/delete user
skills, version snapshots with restore, and the effective-skill rule —
a named-agent session sees exactly: globally enabled ∩ enabled binding for
its agent_key. Plain sessions keep the legacy behavior (global enable only)."""
from __future__ import annotations
from calliope.agent.skills_store import (
effective_skill_dirs,
skills_prompt_lines,
)
def _mk(client, name: str, bindings: dict | None = None, body: str = "正文 v1") -> dict:
payload = {
"description": f"{name} 描述",
"skill_type": "template",
"content": body,
"tags": ["a", "b"],
}
if bindings is not None:
payload["bindings"] = bindings
r = client.put(f"/api/agent/skills/{name}", json=payload)
assert r.status_code == 200, r.text
return r.json()
# ─────────────────────────────────────────────────────────────────────────
# Create / list
# ─────────────────────────────────────────────────────────────────────────
def test_create_skill_and_admin_list(client):
_mk(client, "my-style-kit", bindings={"style": True, "script": False})
skills = client.get("/api/agent/skills?all=true").json()
mine = next(s for s in skills if s["dir"] == "my-style-kit")
assert mine["name"] == "my-style-kit"
assert mine["is_enabled"] is True
assert mine["bindings"] == {"style": True, "script": False}
assert mine["assigned_count"] == 1
assert mine["type"] == "template"
assert mine["version"] == "1.0.0"
# Non-admin list stays capped + without admin fields (composer contract).
plain = client.get("/api/agent/skills").json()
mine_plain = next(s for s in plain if s["dir"] == "my-style-kit")
assert "bindings" not in mine_plain
def test_migration_seeded_builtin_bindings_for_all_agents(client):
"""At upgrade, every pre-existing skill (the builtins) is bound to all
five agents so agent chats keep the out-of-the-box experience."""
skills = client.get("/api/agent/skills?all=true").json()
builtin = next(s for s in skills if s["dir"] == "h3-video-prompt-enhancer")
assert set(builtin["bindings"].keys()) == {
"script",
"style",
"storyboard",
"assets",
"shot",
}
assert builtin["assigned_count"] == 5
def test_invalid_names_rejected(client):
for bad in ("../escape", "a/b", "", " ", ".hidden", "x" * 65):
r = client.put(f"/api/agent/skills/{bad}", json={"content": "x"})
assert r.status_code in (404, 422), bad
def test_invalid_type_rejected(client):
r = client.put("/api/agent/skills/ok-name", json={"content": "x", "skill_type": "wild"})
assert r.status_code == 422
# ─────────────────────────────────────────────────────────────────────────
# Effective rule: global enable ∩ per-agent binding
# ─────────────────────────────────────────────────────────────────────────
def test_effective_rule_for_agent_sessions(client):
_mk(client, "gated-skill", bindings={"style": True, "script": True})
assert "gated-skill" in effective_skill_dirs("style")
assert "gated-skill" in effective_skill_dirs("script")
assert "gated-skill" not in effective_skill_dirs("assets")
lines = skills_prompt_lines(agent_key="assets")
assert not any("gated-skill" in ln for ln in lines)
lines = skills_prompt_lines(agent_key="style")
assert any("gated-skill" in ln for ln in lines)
def test_unassigned_skill_invisible_everywhere(client):
_mk(client, "orphan-skill", bindings={})
for key in ("script", "style", "storyboard", "assets", "shot"):
assert "orphan-skill" not in effective_skill_dirs(key)
# Plain sessions still see it (legacy behavior).
assert "orphan-skill" in effective_skill_dirs(None)
def test_global_disable_hides_from_all_sessions(client):
_mk(client, "kill-switch", bindings={"style": True})
r = client.patch("/api/agent/skills/kill-switch", json={"is_enabled": False})
assert r.status_code == 200
assert "kill-switch" not in effective_skill_dirs("style")
assert "kill-switch" not in effective_skill_dirs(None)
r = client.patch("/api/agent/skills/kill-switch", json={"is_enabled": True})
assert "kill-switch" in effective_skill_dirs("style")
def test_binding_matrix_replace(client):
_mk(client, "matrix-skill", bindings={"style": True})
r = client.put(
"/api/agent/skills/matrix-skill/bindings",
json={"bindings": {"shot": True, "style": False}},
)
assert r.status_code == 200
assert "matrix-skill" in effective_skill_dirs("shot")
assert "matrix-skill" not in effective_skill_dirs("style")
def test_unknown_agent_key_in_bindings_rejected(client):
r = client.put(
"/api/agent/skills/some-skill/bindings",
json={"bindings": {"director": True}},
)
assert r.status_code == 422
def test_read_skill_tool_gates_by_assignment(client):
"""The read_skill tool denies skills not effective for the agent."""
from calliope.agent.harness.plugins.skills import t_read_skill
from calliope.agent.harness.registry import ToolContext
_mk(client, "gated-read", bindings={"style": True})
style_ctx = ToolContext(session_id=1, agent_key="style")
shot_ctx = ToolContext(session_id=2, agent_key="shot")
ok = asyncio_run(t_read_skill(style_ctx, {"name": "gated-read"}))
assert ok["ok"] is True
denied = asyncio_run(t_read_skill(shot_ctx, {"name": "gated-read"}))
assert denied["ok"] is False
assert "not assigned" in denied["error"]
def asyncio_run(coro):
import asyncio
return asyncio.run(coro)
# ─────────────────────────────────────────────────────────────────────────
# Versions: snapshot on save, list, restore
# ─────────────────────────────────────────────────────────────────────────
def test_edit_creates_snapshot_and_version_bumps(client):
first = _mk(client, "versioned", bindings={"script": True}, body="# v1 正文")
v1 = first["skill"]["version"]
second = _mk(client, "versioned", bindings={"script": True}, body="# v2 正文改了")
v2 = second["skill"]["version"]
assert v2 != v1 # auto-bumped when the editor leaves version empty
versions = client.get("/api/agent/skills/versioned/versions").json()
assert len(versions) == 1 # the pre-edit snapshot
assert "# v1 正文" in versions[0]["preview"]
# File now holds v2 content.
body = client.get(
"/api/agent/skills/versioned/file?path=SKILL.md"
).json()["content"]
assert "# v2 正文改了" in body
def test_restore_writes_snapshot_back(client):
_mk(client, "rollback", bindings={"script": True}, body="第一版")
_mk(client, "rollback", bindings={"script": True}, body="第二版")
versions = client.get("/api/agent/skills/rollback/versions").json()
old_id = versions[0]["id"] # newest first: snapshot of 第一版
r = client.post(f"/api/agent/skills/rollback/versions/{old_id}/restore")
assert r.status_code == 200
body = client.get("/api/agent/skills/rollback/file?path=SKILL.md").json()["content"]
assert "第一版" in body
# The restore itself snapshotted 第二版 — undoable.
versions = client.get("/api/agent/skills/rollback/versions").json()
assert any("第二版" in v["preview"] for v in versions)
def test_restore_unknown_version_404(client):
_mk(client, "nope-skill", bindings={"script": True})
r = client.post("/api/agent/skills/nope-skill/versions/99999/restore")
assert r.status_code == 404
# ─────────────────────────────────────────────────────────────────────────
# Delete
# ─────────────────────────────────────────────────────────────────────────
def test_delete_removes_everything(client):
_mk(client, "doomed", bindings={"script": True}, body="拜拜")
r = client.delete("/api/agent/skills/doomed")
assert r.status_code == 200
skills = client.get("/api/agent/skills?all=true").json()
assert all(s["dir"] != "doomed" for s in skills)
assert client.get("/api/agent/skills/doomed/versions").json() == []
assert "doomed" not in effective_skill_dirs("script")
# ─────────────────────────────────────────────────────────────────────────
# Agents endpoint skill counts
# ─────────────────────────────────────────────────────────────────────────
def test_agents_endpoint_counts_effective_skills(client):
_mk(client, "count-me", bindings={"style": True, "shot": True})
_mk(client, "count-me-disabled", bindings={"style": True})
client.patch("/api/agent/skills/count-me-disabled", json={"is_enabled": False})
agents = {a["key"]: a for a in client.get("/api/agent/agents").json()}
style_count = agents["style"]["skill_count"]
shot_count = agents["shot"]["skill_count"]
assert style_count >= 1 and shot_count >= 1
# A globally disabled skill never counts.
skills = client.get("/api/agent/skills?all=true").json()
disabled_dirs = {s["dir"] for s in skills if not s["is_enabled"]}
assert "count-me-disabled" in disabled_dirs