Files
aigc-home/calliope-backend/tests/test_agent_roles.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

203 lines
8.1 KiB
Python

"""Named-agent sessions (HEL-613): the five AIGC roles. agent_key is stamped
at creation, filters the session list, injects a per-agent persona + agent-chat
mode section into the assembled system prompt, and routes the main loop's LLM
through the agent's bound role (models page 智能体默认模型 panel)."""
from __future__ import annotations
import asyncio
from calliope.agent.harness.registry import ToolContext
# ─────────────────────────────────────────────────────────────────────────
# Registry
# ─────────────────────────────────────────────────────────────────────────
def test_agents_endpoint_lists_five_roles(client):
agents = client.get("/api/agent/agents").json()
assert [a["key"] for a in agents] == [
"script",
"style",
"storyboard",
"assets",
"shot",
]
names = {a["name"] for a in agents}
assert names == {"剧本创作", "视觉设计", "分镜设计", "素材资产", "镜头脚本"}
# LLM roles reuse the models-page five-role binding surface.
assert {a["llm_role"] for a in agents} == {"story", "style", "script", "assets", "video"}
# Personas are server-side — never shipped to the browser.
assert all("persona" not in a for a in agents)
assert all(isinstance(a.get("skill_count"), int) for a in agents)
# ─────────────────────────────────────────────────────────────────────────
# Session create / list semantics
# ─────────────────────────────────────────────────────────────────────────
def test_create_session_with_agent_key(client):
s = client.post("/api/agent/sessions", json={"agent_key": "style"}).json()
assert s["agent_key"] == "style"
assert s["origin"] == "chat"
def test_agent_key_rejects_unknown_value(client):
r = client.post("/api/agent/sessions", json={"agent_key": "director"})
assert r.status_code == 422
def test_agent_sessions_hidden_from_generic_list(client):
plain = client.post("/api/agent/sessions", json={"title": "canvas chat"}).json()
named = client.post("/api/agent/sessions", json={"agent_key": "script"}).json()
listed = client.get("/api/agent/sessions").json()
ids = {s["id"] for s in listed}
assert plain["id"] in ids
assert named["id"] not in ids
def test_agent_key_filter_returns_only_that_agent(client):
a = client.post("/api/agent/sessions", json={"agent_key": "script"}).json()
client.post("/api/agent/sessions", json={"agent_key": "style"})
client.post("/api/agent/sessions", json={"title": "plain"})
listed = client.get("/api/agent/sessions?agent_key=script").json()
assert {s["id"] for s in listed} == {a["id"]}
assert all(s["agent_key"] == "script" for s in listed)
def test_plain_session_has_no_agent_key(client):
s = client.post("/api/agent/sessions", json={}).json()
assert s["agent_key"] is None
# ─────────────────────────────────────────────────────────────────────────
# Prompt assembly: persona + mode per agent_key
# ─────────────────────────────────────────────────────────────────────────
def _assemble(agent_key: str | None) -> str:
from calliope.agent.harness import get_prompts
ctx = ToolContext(session_id=1, project_id=None, origin="chat", agent_key=agent_key)
return asyncio.run(get_prompts().assemble(ctx))
def test_persona_section_injected_per_agent(client):
prompt = _assemble("script")
assert "「剧本创作」" in prompt
assert "编剧" in prompt
assert "SESSION MODE: AGENT CHAT" in prompt
# The generic sandbox mode text must be replaced for named agents.
assert "GENERATING IN SANDBOX" not in prompt
def test_each_agent_gets_its_own_persona(client):
for key, marker in (
("style", "视觉"),
("storyboard", "分镜"),
("assets", "资产"),
("shot", "镜头脚本"),
):
assert marker in _assemble(key), key
def test_plain_prompt_unchanged(client):
prompt = _assemble(None)
assert "Calliope's production agent" in prompt
assert "SESSION MODE: SANDBOX" in prompt
assert "AGENT CHAT" not in prompt
def test_agent_chat_mentions_section_suppressed(client):
assert "Tagged workflows" not in _assemble("assets")
assert "Tagged workflows" in _assemble(None)
# ─────────────────────────────────────────────────────────────────────────
# LLM routing: the main loop uses the agent's bound role
# ─────────────────────────────────────────────────────────────────────────
class _RoleSpyClient:
instances: list["_RoleSpyClient"] = []
def __init__(self, role: str | None = None) -> None:
self.role = role
self.requests: list[list[dict]] = []
_RoleSpyClient.instances.append(self)
@classmethod
def for_role(cls, role: str, *, timeout: float = 120.0) -> "_RoleSpyClient":
return cls(role=role)
def chat_stream(self, messages, temperature=0.4, tools=None):
self.requests.append(list(messages))
return self
def __aiter__(self):
return self
async def __anext__(self):
raise StopAsyncIteration
async def close(self):
pass
def test_run_turn_uses_agent_llm_role_and_prompt(client):
"""End-to-end through run_turn with a scripted LLM: the client must be
built for the agent's role (story ← 剧本创作) and receive the per-agent
persona in the system message."""
import calliope.agent.harness.loop as loop_mod
session = client.post(
"/api/agent/sessions", json={"agent_key": "script", "title": "剧本会话"}
).json()
from calliope.agent.harness import log as session_log
session_log.append_event(
session["id"], session_log.USER_MESSAGE, {"content": "写个三幕式大纲"}
)
orig = loop_mod.LLMClient
_RoleSpyClient.instances = []
loop_mod.LLMClient = _RoleSpyClient
try:
ctx = ToolContext(
session_id=session["id"], project_id=None, origin="chat", agent_key="script"
)
asyncio.run(loop_mod.run_turn(ctx, []))
finally:
loop_mod.LLMClient = orig
assert _RoleSpyClient.instances, "no LLM client was built"
spy = _RoleSpyClient.instances[0]
assert spy.role == "story"
system = next(
(m for m in spy.requests[0] if m.get("role") == "system"), None
)
assert system is not None
assert "「剧本创作」" in system["content"]
assert "SESSION MODE: AGENT CHAT" in system["content"]
def test_plain_run_turn_keeps_main_role(client):
import calliope.agent.harness.loop as loop_mod
session = client.post("/api/agent/sessions", json={}).json()
from calliope.agent.harness import log as session_log
session_log.append_event(session["id"], session_log.USER_MESSAGE, {"content": "hi"})
orig = loop_mod.LLMClient
_RoleSpyClient.instances = []
loop_mod.LLMClient = _RoleSpyClient
try:
ctx = ToolContext(session_id=session["id"], project_id=None, origin="chat")
asyncio.run(loop_mod.run_turn(ctx, []))
finally:
loop_mod.LLMClient = orig
assert _RoleSpyClient.instances[0].role == "main"