basic BMAD setup
This commit is contained in:
@@ -0,0 +1,389 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# ///
|
||||
"""Resolve the party-mode roster, lazily.
|
||||
|
||||
Merges the roster the installed skills offer (agents, guests and groups, read
|
||||
by `_bmad/scripts/roster.py`) with the user's custom `party_members` and
|
||||
`party_groups` into one collective, then projects only what the moment needs:
|
||||
|
||||
* default (no flag) — the active roster to load on entry: the
|
||||
`default_party` group if one is configured, else the whole collective.
|
||||
Other groups come back as names only, so nothing you aren't using is
|
||||
loaded into the party.
|
||||
* --list-groups — just id + name + size for every configured group. The
|
||||
cheap menu for "which room?", with no member detail.
|
||||
* --party <id> — full member detail for one chosen group, on demand
|
||||
(e.g. when the user switches rooms). Unknown id returns the available
|
||||
names instead of an error wall.
|
||||
|
||||
The merge is deterministic (a keyed union; a custom member whose code
|
||||
matches an installed agent overrides it), so the orchestrator consumes a
|
||||
resolved roster instead of re-deriving it every session.
|
||||
|
||||
Stdlib only (Python 3.11+ for tomllib). Shells out to the project's roster.py
|
||||
and resolve_customization.py. An install whose `_bmad/scripts` predates
|
||||
roster.py falls back to the `[agents]` table resolve_config.py returns, and a
|
||||
missing customization resolver falls back to reading customize.toml directly.
|
||||
|
||||
resolve_party.py --project-root P --skill S
|
||||
resolve_party.py --project-root P --skill S --list-groups
|
||||
resolve_party.py --project-root P --skill S --party writers-room (alias: --group)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import tomllib
|
||||
except ImportError: # pragma: no cover - guarded for <3.11
|
||||
sys.stderr.write("error: Python 3.11+ is required (stdlib `tomllib`).\n")
|
||||
sys.exit(3)
|
||||
|
||||
|
||||
# Why the last _run_json call failed; tests stub _run_json with one argument.
|
||||
_last_error = ""
|
||||
|
||||
|
||||
def _run_json(cmd):
|
||||
"""Run a resolver script and parse its JSON stdout. None on any failure."""
|
||||
global _last_error
|
||||
_last_error = ""
|
||||
try:
|
||||
out = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", timeout=60)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
_last_error = str(exc)
|
||||
return None
|
||||
if out.returncode != 0 or not out.stdout.strip():
|
||||
_last_error = out.stderr.strip() or f"exit {out.returncode}, no output"
|
||||
return None
|
||||
try:
|
||||
return json.loads(out.stdout)
|
||||
except json.JSONDecodeError as exc:
|
||||
_last_error = f"resolver printed invalid JSON: {exc}"
|
||||
return None
|
||||
|
||||
|
||||
def load_roster(project_root: Path, skill_root: Path):
|
||||
"""(agents, guests, groups, resolved, problems) from the skills installed beside this one.
|
||||
|
||||
agents are {code: entry} for the default room; guests are roster members
|
||||
that are not installed agents, available to groups only. problems are what
|
||||
roster.py could not use, so a missing agent or room is explained.
|
||||
"""
|
||||
scripts = project_root / "_bmad" / "scripts"
|
||||
data = _run_json(
|
||||
[sys.executable, str(scripts / "roster.py"), "--skill", str(skill_root), "--project-root", str(project_root)]
|
||||
)
|
||||
if data is not None:
|
||||
agents = data.get("agents", {}) or {}
|
||||
guests = {code: m for code, m in (data.get("members", {}) or {}).items() if code not in agents}
|
||||
problems = [p.get("problem", "") for p in data.get("problems", []) or [] if isinstance(p, dict)]
|
||||
return agents, guests, data.get("groups", []) or [], True, [p for p in problems if p]
|
||||
data = _run_json(
|
||||
[sys.executable, str(scripts / "resolve_config.py"), "--project-root", str(project_root), "--key", "agents"]
|
||||
)
|
||||
if data is None:
|
||||
return {}, {}, [], False, []
|
||||
return data.get("agents", {}) or {}, {}, [], True, []
|
||||
|
||||
|
||||
def merge_groups(roster_groups: list, custom_groups: list) -> list:
|
||||
"""Roster groups first, then the user's; a custom group replaces a roster group with its id."""
|
||||
merged = {g["id"]: g for g in roster_groups if isinstance(g, dict) and g.get("id")}
|
||||
for g in custom_groups or []:
|
||||
if isinstance(g, dict) and g.get("id"):
|
||||
merged[g["id"]] = g
|
||||
return list(merged.values())
|
||||
|
||||
|
||||
def load_workflow(project_root: Path, skill_root: Path):
|
||||
"""Merged [workflow] table. Falls back to the skill's base customize.toml."""
|
||||
script = project_root / "_bmad" / "scripts" / "resolve_customization.py"
|
||||
data = _run_json(
|
||||
[
|
||||
sys.executable,
|
||||
str(script),
|
||||
"--skill",
|
||||
str(skill_root),
|
||||
"--project-root",
|
||||
str(project_root),
|
||||
"--key",
|
||||
"workflow",
|
||||
]
|
||||
)
|
||||
if data is not None and "workflow" in data:
|
||||
return data["workflow"]
|
||||
_warn(f"party customization override not applied, using the shipped party: {_last_error or 'no workflow table'}")
|
||||
# Fallback: read the skill's base customize.toml directly (no override merge).
|
||||
toml_path = skill_root / "customize.toml"
|
||||
if toml_path.exists():
|
||||
try:
|
||||
with toml_path.open("rb") as f:
|
||||
return tomllib.load(f).get("workflow", {})
|
||||
except (OSError, tomllib.TOMLDecodeError):
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def _warn(message: str):
|
||||
sys.stderr.write(f"warning: {message}\n")
|
||||
|
||||
|
||||
def _bad_member(code, name) -> bool:
|
||||
"""True, with a warning, when a member's code or name is not a string."""
|
||||
if isinstance(code, str) and (name is None or isinstance(name, str)):
|
||||
return False
|
||||
_warn(f"party member {code!r} left out: code and name must be strings")
|
||||
return True
|
||||
|
||||
|
||||
def _alias(code: str) -> str:
|
||||
"""Short alias for an installed agent code: bmad-agent-analyst -> analyst."""
|
||||
if "-agent-" in code:
|
||||
return code.split("-agent-", 1)[1]
|
||||
for prefix in ("bmad-agent-", "bmad-"):
|
||||
if code.startswith(prefix):
|
||||
return code[len(prefix) :]
|
||||
return code
|
||||
|
||||
|
||||
def build_collective(agents: dict, party_members: list, guests: dict | None = None):
|
||||
"""One pool keyed by code. Custom members override matching installed agents.
|
||||
|
||||
`guests` are roster members that are not installed agents: a module's extra
|
||||
personas, or an agent whose skill is absent. They join the pool so a group
|
||||
can seat them, and never the default room.
|
||||
|
||||
Returns (collective, index, installed_codes):
|
||||
* collective — every member (installed + custom), the pool groups draw
|
||||
from and the orchestrator can summon by name.
|
||||
* index — maps every resolvable token (code, prefix-stripped alias,
|
||||
lower-cased name) to a canonical code.
|
||||
* installed_codes — the codes occupying an installed-agent slot, in
|
||||
order. This is the DEFAULT room: installed agents (with any custom
|
||||
override applied in place), and NOT the pure-custom additions. So
|
||||
shipping or defining custom members grows the pool without crowding
|
||||
the default party.
|
||||
"""
|
||||
collective = {}
|
||||
index = {}
|
||||
installed_codes = []
|
||||
alias_owner = {}
|
||||
|
||||
def register(code, entry):
|
||||
collective[code] = entry
|
||||
index[code] = code
|
||||
index[code.lower()] = code
|
||||
# A short alias two codes claim resolves to neither; the full code still works.
|
||||
alias = _alias(code).lower()
|
||||
owner = alias_owner.setdefault(alias, code)
|
||||
if owner == code:
|
||||
index.setdefault(alias, code)
|
||||
elif owner is not None:
|
||||
alias_owner[alias] = None
|
||||
if index.get(alias) == owner and alias != owner.lower():
|
||||
del index[alias]
|
||||
name = entry.get("name")
|
||||
if name:
|
||||
index[name.lower()] = code
|
||||
|
||||
for code, info in agents.items():
|
||||
if _bad_member(code, info.get("name")):
|
||||
continue
|
||||
entry = {
|
||||
"code": code,
|
||||
"name": info.get("name", code),
|
||||
"icon": info.get("icon", ""),
|
||||
"title": info.get("title", ""),
|
||||
"module": info.get("module", ""),
|
||||
"source": "installed",
|
||||
}
|
||||
# Installs from before rosters recorded the persona as `description`.
|
||||
persona = info.get("persona") or info.get("description")
|
||||
if persona:
|
||||
entry["persona"] = persona
|
||||
for field in ("capabilities", "model"):
|
||||
if info.get(field):
|
||||
entry[field] = info[field]
|
||||
register(code, entry)
|
||||
installed_codes.append(code)
|
||||
|
||||
for code, info in (guests or {}).items():
|
||||
if _bad_member(code, info.get("name")):
|
||||
continue
|
||||
entry = {"code": code, "source": "roster"}
|
||||
for field in ("name", "icon", "title", "persona", "capabilities", "model", "module", "skill", "install"):
|
||||
if info.get(field):
|
||||
entry[field] = info[field]
|
||||
entry.setdefault("name", code)
|
||||
if info.get("installed") is False:
|
||||
entry["installed"] = False
|
||||
register(code, entry)
|
||||
|
||||
for m in party_members if isinstance(party_members, list) else []:
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
code = m.get("code")
|
||||
if code is None or code == "" or _bad_member(code, m.get("name")):
|
||||
continue
|
||||
# A custom member overrides an installed agent it matches by code/alias/name.
|
||||
canonical = index.get(code) or index.get(code.lower()) or code
|
||||
# Start from the installed entry so fields the override omits
|
||||
# (icon, title, persona, module) survive.
|
||||
entry = dict(collective.get(canonical, {}))
|
||||
entry.update({"code": canonical, "source": "custom"})
|
||||
for field in ("name", "icon", "title", "persona", "capabilities", "model"):
|
||||
if m.get(field) is not None:
|
||||
entry[field] = m[field]
|
||||
entry.setdefault("name", canonical)
|
||||
register(canonical, entry)
|
||||
# An override keeps the installed slot; a brand-new custom does not join it.
|
||||
|
||||
return collective, index, installed_codes
|
||||
|
||||
|
||||
def resolve_members(member_tokens, collective, index):
|
||||
"""(resolved entries in listed order, unresolved tokens)."""
|
||||
resolved, unresolved = [], []
|
||||
for token in member_tokens or []:
|
||||
if not isinstance(token, str):
|
||||
unresolved.append(token) # malformed config value — never a key lookup
|
||||
continue
|
||||
code = index.get(token) or index.get(token.lower())
|
||||
if code and code in collective:
|
||||
resolved.append(collective[code])
|
||||
else:
|
||||
unresolved.append(token)
|
||||
return resolved, unresolved
|
||||
|
||||
|
||||
def group_menu(groups):
|
||||
"""Names only — the cheap menu. Open-cast groups (no roster) are flagged."""
|
||||
out = []
|
||||
for g in groups or []:
|
||||
if not isinstance(g, dict) or not g.get("id"):
|
||||
continue
|
||||
members = g.get("members", []) or []
|
||||
entry = {"id": g["id"], "name": g.get("name", g["id"]), "member_count": len(members)}
|
||||
if not members:
|
||||
entry["open_cast"] = True
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
def find_group(groups, group_id):
|
||||
for g in groups or []:
|
||||
if isinstance(g, dict) and g.get("id") == group_id:
|
||||
return g
|
||||
return None
|
||||
|
||||
|
||||
def group_detail(g, collective, index):
|
||||
"""Full detail for one group: resolved members + the optional scene.
|
||||
|
||||
`scene` is a freeform line the orchestrator plays — setting, what's
|
||||
happening, room dynamics, in-the-moment character notes. Surfaced only
|
||||
here (when a group is the active/chosen roster), never in the menu.
|
||||
|
||||
`members` is optional. With none, the group is open-cast: `open_cast`
|
||||
is flagged and the scene describes the pool the orchestrator casts from
|
||||
on the fly (e.g. "figures from the Star Wars Rebels universe"). A few
|
||||
listed members anchor the room; the scene can still invite more.
|
||||
"""
|
||||
raw_members = g.get("members", []) or []
|
||||
members, unresolved = resolve_members(raw_members, collective, index)
|
||||
detail = {
|
||||
"active": g["id"],
|
||||
"name": g.get("name", g["id"]),
|
||||
"members": members,
|
||||
"unresolved": unresolved,
|
||||
"memory_enabled": bool(g.get("memory", False)),
|
||||
}
|
||||
if g.get("scene"):
|
||||
detail["scene"] = g["scene"]
|
||||
if not raw_members:
|
||||
detail["open_cast"] = True
|
||||
return detail
|
||||
|
||||
|
||||
def build_parser():
|
||||
ap = argparse.ArgumentParser(description="Resolve the party-mode roster, lazily.")
|
||||
ap.add_argument("--project-root", required=True)
|
||||
ap.add_argument("--skill", required=True, help="Path to the bmad-party-mode skill dir")
|
||||
ap.add_argument("--party", "--group", dest="party", help="Resolve full detail for this group id")
|
||||
ap.add_argument("--list-groups", action="store_true", help="Group names only")
|
||||
return ap
|
||||
|
||||
|
||||
def main():
|
||||
args = build_parser().parse_args()
|
||||
|
||||
project_root = Path(args.project_root).resolve()
|
||||
skill_root = Path(args.skill).resolve()
|
||||
|
||||
workflow = load_workflow(project_root, skill_root)
|
||||
agents, guests, roster_groups, agents_ok, roster_problems = load_roster(project_root, skill_root)
|
||||
groups = merge_groups(roster_groups, workflow.get("party_groups", []) or [])
|
||||
default_party = workflow.get("default_party", "") or ""
|
||||
party_mode = workflow.get("party_mode", "session") or "session"
|
||||
# The global party_memory flag governs only the DEFAULT installed-agent room;
|
||||
# a named group carries its own `memory` flag (resolved in group_detail).
|
||||
party_memory = bool(workflow.get("party_memory", True))
|
||||
|
||||
if args.list_groups:
|
||||
_emit(
|
||||
{
|
||||
"party_mode": party_mode,
|
||||
"default_party": default_party,
|
||||
"groups": group_menu(groups),
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
collective, index, installed_codes = build_collective(agents, workflow.get("party_members", []), guests)
|
||||
|
||||
if args.party:
|
||||
g = find_group(groups, args.party)
|
||||
if g is None:
|
||||
_emit({"error": "unknown_group", "requested": args.party, "available": group_menu(groups)})
|
||||
return
|
||||
detail = {**group_detail(g, collective, index), "party_mode": party_mode}
|
||||
if roster_problems:
|
||||
detail["roster_problems"] = roster_problems
|
||||
_emit(detail)
|
||||
return
|
||||
|
||||
# Default: the active roster to load on entry.
|
||||
result = {"party_mode": party_mode, "groups": group_menu(groups), "installed_agents_resolved": agents_ok}
|
||||
if roster_problems:
|
||||
result["roster_problems"] = roster_problems
|
||||
g = find_group(groups, default_party) if default_party else None
|
||||
if g is not None:
|
||||
result.update(group_detail(g, collective, index))
|
||||
else:
|
||||
# No default group: the installed agents (custom additions stay in the
|
||||
# pool but don't crowd the default room), exactly like a plain install.
|
||||
result.update(
|
||||
{"active": "installed", "members": [collective[c] for c in installed_codes], "memory_enabled": party_memory}
|
||||
)
|
||||
_emit(result)
|
||||
|
||||
|
||||
def _emit(obj):
|
||||
reconfigure = getattr(sys.stdout, "reconfigure", None)
|
||||
if reconfigure is not None:
|
||||
reconfigure(encoding="utf-8")
|
||||
sys.stdout.write(json.dumps(obj, indent=2, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if sys.platform == "win32":
|
||||
# Piped output on Windows defaults to a legacy code page, not UTF-8.
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
main()
|
||||
@@ -0,0 +1,309 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# ///
|
||||
"""Unit tests for resolve_party.py — merge, alias, override, group resolution."""
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
import resolve_party as rp # noqa: E402
|
||||
|
||||
AGENTS = {
|
||||
"bmad-agent-analyst": {"name": "Mary", "icon": "📊", "title": "Analyst"},
|
||||
"bmad-agent-pm": {"name": "John", "icon": "📋", "title": "PM"},
|
||||
}
|
||||
|
||||
|
||||
class TestAlias(unittest.TestCase):
|
||||
def test_strips_known_prefixes(self):
|
||||
self.assertEqual(rp._alias("bmad-agent-analyst"), "analyst")
|
||||
self.assertEqual(rp._alias("bmad-foo"), "foo")
|
||||
|
||||
def test_passes_through_unprefixed(self):
|
||||
self.assertEqual(rp._alias("morpheus"), "morpheus")
|
||||
|
||||
def test_strips_a_module_prefix_before_agent(self):
|
||||
self.assertEqual(rp._alias("bmad-cis-agent-storyteller"), "storyteller")
|
||||
|
||||
|
||||
class TestBuildCollective(unittest.TestCase):
|
||||
def test_installed_agents_indexed_by_code_alias_and_name(self):
|
||||
col, idx, _ = rp.build_collective(AGENTS, [])
|
||||
self.assertEqual(set(col), {"bmad-agent-analyst", "bmad-agent-pm"})
|
||||
self.assertEqual(idx["analyst"], "bmad-agent-analyst") # alias
|
||||
self.assertEqual(idx["mary"], "bmad-agent-analyst") # name (ci)
|
||||
self.assertEqual(idx["bmad-agent-pm"], "bmad-agent-pm") # full code
|
||||
self.assertEqual(col["bmad-agent-analyst"]["source"], "installed")
|
||||
|
||||
def test_custom_member_appends(self):
|
||||
col, _, _ = rp.build_collective(AGENTS, [{"code": "morpheus", "name": "Morpheus", "persona": "riddles"}])
|
||||
self.assertIn("morpheus", col)
|
||||
self.assertEqual(col["morpheus"]["source"], "custom")
|
||||
self.assertEqual(col["morpheus"]["persona"], "riddles")
|
||||
|
||||
def test_custom_overrides_installed_by_alias(self):
|
||||
col, _, _ = rp.build_collective(AGENTS, [{"code": "analyst", "name": "Mary-Custom", "persona": "p"}])
|
||||
# Override lands on the canonical installed code, not a new "analyst" entry.
|
||||
self.assertNotIn("analyst", col)
|
||||
self.assertEqual(col["bmad-agent-analyst"]["source"], "custom")
|
||||
self.assertEqual(col["bmad-agent-analyst"]["name"], "Mary-Custom")
|
||||
|
||||
def test_member_without_code_skipped(self):
|
||||
col, _, _ = rp.build_collective(AGENTS, [{"name": "Nameless"}])
|
||||
self.assertEqual(set(col), {"bmad-agent-analyst", "bmad-agent-pm"})
|
||||
|
||||
|
||||
class TestResolveMembers(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.col, self.idx, _ = rp.build_collective(AGENTS, [{"code": "morpheus", "name": "Morpheus"}])
|
||||
|
||||
def test_resolves_in_listed_order_and_flags_unknowns(self):
|
||||
resolved, unresolved = rp.resolve_members(["morpheus", "analyst", "ghost"], self.col, self.idx)
|
||||
self.assertEqual([m["code"] for m in resolved], ["morpheus", "bmad-agent-analyst"])
|
||||
self.assertEqual(unresolved, ["ghost"])
|
||||
|
||||
def test_empty(self):
|
||||
self.assertEqual(rp.resolve_members([], self.col, self.idx), ([], []))
|
||||
|
||||
|
||||
class TestGroups(unittest.TestCase):
|
||||
GROUPS = [
|
||||
{"id": "wr", "name": "Writers", "members": ["analyst", "morpheus"]},
|
||||
{"id": "bad"}, # no name -> falls back to id; no members -> count 0
|
||||
{"name": "no-id"}, # dropped from menu
|
||||
]
|
||||
|
||||
def test_menu_is_names_only_with_counts_and_open_cast_flag(self):
|
||||
menu = rp.group_menu(self.GROUPS)
|
||||
self.assertEqual(
|
||||
menu,
|
||||
[
|
||||
{"id": "wr", "name": "Writers", "member_count": 2},
|
||||
{"id": "bad", "name": "bad", "member_count": 0, "open_cast": True},
|
||||
],
|
||||
)
|
||||
|
||||
def test_find_group(self):
|
||||
self.assertEqual(rp.find_group(self.GROUPS, "wr")["name"], "Writers")
|
||||
self.assertIsNone(rp.find_group(self.GROUPS, "missing"))
|
||||
|
||||
|
||||
class TestGroupDetail(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.col, self.idx, _ = rp.build_collective(AGENTS, [{"code": "morpheus", "name": "Morpheus"}])
|
||||
|
||||
def test_scene_passes_through_when_present(self):
|
||||
g = {
|
||||
"id": "tos-10-forward",
|
||||
"name": "Ten Forward",
|
||||
"members": ["morpheus"],
|
||||
"scene": "Late evening, a few rounds in.",
|
||||
}
|
||||
d = rp.group_detail(g, self.col, self.idx)
|
||||
self.assertEqual(d["scene"], "Late evening, a few rounds in.")
|
||||
self.assertEqual([m["code"] for m in d["members"]], ["morpheus"])
|
||||
|
||||
def test_scene_omitted_when_absent_or_empty(self):
|
||||
for g in ({"id": "g", "members": ["morpheus"]}, {"id": "g", "members": ["morpheus"], "scene": ""}):
|
||||
self.assertNotIn("scene", rp.group_detail(g, self.col, self.idx))
|
||||
|
||||
def test_anchored_group_is_not_open_cast(self):
|
||||
g = {"id": "g", "members": ["morpheus"]}
|
||||
self.assertNotIn("open_cast", rp.group_detail(g, self.col, self.idx))
|
||||
|
||||
def test_open_cast_group_flagged_with_empty_members(self):
|
||||
g = {
|
||||
"id": "rebels",
|
||||
"name": "Star Wars Rebels",
|
||||
"scene": "Figures from the Rebels universe drop in as the topic calls for them.",
|
||||
}
|
||||
d = rp.group_detail(g, self.col, self.idx)
|
||||
self.assertTrue(d["open_cast"])
|
||||
self.assertEqual(d["members"], [])
|
||||
self.assertEqual(d["scene"][:7], "Figures")
|
||||
|
||||
def test_memory_enabled_follows_group_flag_and_defaults_off(self):
|
||||
on = rp.group_detail({"id": "g", "members": ["morpheus"], "memory": True}, self.col, self.idx)
|
||||
self.assertTrue(on["memory_enabled"])
|
||||
off = rp.group_detail({"id": "g", "members": ["morpheus"], "memory": False}, self.col, self.idx)
|
||||
self.assertFalse(off["memory_enabled"])
|
||||
absent = rp.group_detail({"id": "g", "members": ["morpheus"]}, self.col, self.idx)
|
||||
self.assertFalse(absent["memory_enabled"]) # opt-in per named group
|
||||
|
||||
|
||||
class TestInstalledCodesIsDefaultRoom(unittest.TestCase):
|
||||
"""The default room is installed agents only; pure customs stay in the pool."""
|
||||
|
||||
def test_pure_custom_excluded_override_kept_in_default_room(self):
|
||||
col, _, installed = rp.build_collective(
|
||||
AGENTS,
|
||||
[
|
||||
{"code": "morpheus", "name": "Morpheus"}, # pure custom
|
||||
{"code": "analyst", "name": "Mary-Custom", "persona": "p"}, # override
|
||||
{"code": "sec-hawk", "name": "Vex"}, # shipped crew member
|
||||
],
|
||||
)
|
||||
# Pure customs are in the pool...
|
||||
self.assertIn("morpheus", col)
|
||||
self.assertIn("sec-hawk", col)
|
||||
# ...but NOT in the default room.
|
||||
self.assertEqual(installed, ["bmad-agent-analyst", "bmad-agent-pm"])
|
||||
default_room = [col[c]["code"] for c in installed]
|
||||
self.assertEqual(default_room, ["bmad-agent-analyst", "bmad-agent-pm"])
|
||||
# An override keeps its installed slot (and its custom content).
|
||||
self.assertEqual(col["bmad-agent-analyst"]["name"], "Mary-Custom")
|
||||
|
||||
|
||||
class TestRoster(unittest.TestCase):
|
||||
GUESTS = {
|
||||
"pip": {"name": "Pip", "persona": "Asks why."},
|
||||
"bmad-agent-dev": {"name": "Amelia", "persona": "Exact.", "skill": "bmad-agent-dev", "installed": False},
|
||||
}
|
||||
|
||||
def test_guests_join_the_pool_and_never_the_default_room(self):
|
||||
col, idx, installed = rp.build_collective(AGENTS, [], self.GUESTS)
|
||||
self.assertEqual(installed, ["bmad-agent-analyst", "bmad-agent-pm"])
|
||||
self.assertEqual(col["pip"]["source"], "roster")
|
||||
self.assertEqual(idx["amelia"], "bmad-agent-dev")
|
||||
self.assertIs(col["bmad-agent-dev"]["installed"], False)
|
||||
|
||||
def test_a_group_can_seat_a_guest_and_an_agent_whose_skill_is_absent(self):
|
||||
col, idx, _ = rp.build_collective(AGENTS, [], self.GUESTS)
|
||||
detail = rp.group_detail({"id": "room", "members": ["analyst", "pip", "dev"]}, col, idx)
|
||||
self.assertEqual([m["name"] for m in detail["members"]], ["Mary", "Pip", "Amelia"])
|
||||
self.assertEqual(detail["unresolved"], [])
|
||||
|
||||
def test_a_short_alias_two_codes_claim_resolves_to_neither(self):
|
||||
agents = {"bmad-agent-dev": {"name": "Amelia"}, "bmad-cis-agent-dev": {"name": "Devi"}}
|
||||
col, idx, _ = rp.build_collective(agents, [])
|
||||
members, unresolved = rp.resolve_members(["dev", "bmad-cis-agent-dev", "amelia"], col, idx)
|
||||
self.assertEqual([m["name"] for m in members], ["Devi", "Amelia"])
|
||||
self.assertEqual(unresolved, ["dev"])
|
||||
|
||||
def test_what_the_roster_could_not_use_is_passed_on(self):
|
||||
absent = "module record bmod-demo is not installed; it is named by demo-one; install it with `npx skills add acme/tools --skill bmod-demo`"
|
||||
report = {
|
||||
"agents": {},
|
||||
"problems": [
|
||||
{"kind": "member", "problem": "demo: member 'x' is already defined by other"},
|
||||
{
|
||||
"kind": "module",
|
||||
"bmod": "bmod-demo",
|
||||
"install": "npx skills add acme/tools --skill bmod-demo",
|
||||
"problem": absent,
|
||||
},
|
||||
],
|
||||
}
|
||||
original = rp._run_json
|
||||
rp._run_json = lambda cmd: report
|
||||
try:
|
||||
problems = rp.load_roster(Path("project"), Path("skill"))[4]
|
||||
finally:
|
||||
rp._run_json = original
|
||||
self.assertEqual(problems, ["demo: member 'x' is already defined by other", absent])
|
||||
|
||||
def test_a_custom_group_replaces_a_roster_group_with_its_id(self):
|
||||
groups = rp.merge_groups(
|
||||
[{"id": "room", "name": "Shipped"}, {"id": "other", "name": "Other"}], [{"id": "room", "name": "Mine"}]
|
||||
)
|
||||
self.assertEqual([(g["id"], g["name"]) for g in groups], [("room", "Mine"), ("other", "Other")])
|
||||
|
||||
def test_an_install_from_before_rosters_keeps_its_description_as_the_persona(self):
|
||||
col, _, _ = rp.build_collective({"bmad-agent-pm": {"name": "John", "description": "Asks why."}}, [])
|
||||
self.assertEqual(col["bmad-agent-pm"]["persona"], "Asks why.")
|
||||
|
||||
|
||||
class TestResolverInvocation(unittest.TestCase):
|
||||
"""The wrapper knows the project root, so it must not let the resolver
|
||||
infer one from the working directory (#2796)."""
|
||||
|
||||
def _captured_command(self, tmp):
|
||||
captured = []
|
||||
original = rp._run_json
|
||||
rp._run_json = lambda cmd: captured.append(cmd) or {"workflow": {}}
|
||||
try:
|
||||
rp.load_workflow(Path(tmp) / "project", Path(tmp) / "skill")
|
||||
finally:
|
||||
rp._run_json = original
|
||||
return captured[0]
|
||||
|
||||
def test_passes_project_root_to_the_customization_resolver(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
cmd = self._captured_command(tmp)
|
||||
self.assertIn("--project-root", cmd)
|
||||
self.assertEqual(cmd[cmd.index("--project-root") + 1], str(Path(tmp) / "project"))
|
||||
|
||||
|
||||
class TestArguments(unittest.TestCase):
|
||||
def test_group_is_an_alias_of_party(self):
|
||||
for flag in ("--party", "--group"):
|
||||
args = rp.build_parser().parse_args(["--project-root", "p", "--skill", "s", flag, "writers-room"])
|
||||
self.assertEqual(args.party, "writers-room")
|
||||
|
||||
|
||||
class TestMalformedMembers(unittest.TestCase):
|
||||
"""A member whose code or name is not a string is left out with a warning; the rest load."""
|
||||
|
||||
def _build(self, agents, members, guests=None):
|
||||
err = io.StringIO()
|
||||
with contextlib.redirect_stderr(err):
|
||||
col, _, installed = rp.build_collective(agents, members, guests)
|
||||
return col, installed, err.getvalue()
|
||||
|
||||
def test_custom_member_with_non_string_code_or_name_is_left_out(self):
|
||||
members = [
|
||||
{"code": 7, "name": "Seven"},
|
||||
{"code": 0, "name": "Zero"},
|
||||
{"code": "neo", "name": ["Neo"]},
|
||||
{"code": "trin", "name": "Trinity"},
|
||||
]
|
||||
col, _, err = self._build(AGENTS, members)
|
||||
self.assertIn("trin", col)
|
||||
self.assertNotIn("neo", col)
|
||||
self.assertNotIn(7, col)
|
||||
self.assertIn("7", err)
|
||||
self.assertNotIn(0, col)
|
||||
self.assertIn("member 0 left out", err)
|
||||
self.assertIn("'neo'", err)
|
||||
|
||||
def test_guest_with_non_string_name_is_left_out(self):
|
||||
col, _, err = self._build(AGENTS, [], {"pip": {"name": 3}, "kit": {"name": "Kit"}})
|
||||
self.assertNotIn("pip", col)
|
||||
self.assertIn("kit", col)
|
||||
self.assertIn("'pip'", err)
|
||||
|
||||
def test_installed_agent_with_non_string_name_is_left_out(self):
|
||||
agents = {**AGENTS, "bmad-agent-odd": {"name": 42}}
|
||||
col, installed, err = self._build(agents, [])
|
||||
self.assertEqual(installed, ["bmad-agent-analyst", "bmad-agent-pm"])
|
||||
self.assertNotIn("bmad-agent-odd", col)
|
||||
self.assertIn("'bmad-agent-odd'", err)
|
||||
|
||||
|
||||
class TestRejectedOverride(unittest.TestCase):
|
||||
def test_a_rejected_override_warns_with_the_resolver_message_and_uses_the_shipped_party(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project, skill = Path(tmp) / "project", Path(tmp) / "skill"
|
||||
scripts = project / "_bmad" / "scripts"
|
||||
scripts.mkdir(parents=True)
|
||||
(scripts / "resolve_customization.py").write_text(
|
||||
"import sys\nsys.stderr.write('party_members[0].code must be a string')\nsys.exit(1)\n"
|
||||
)
|
||||
skill.mkdir()
|
||||
(skill / "customize.toml").write_text('[workflow]\ndefault_party = "shipped"\n')
|
||||
err = io.StringIO()
|
||||
with contextlib.redirect_stderr(err):
|
||||
wf = rp.load_workflow(project, skill)
|
||||
self.assertEqual(wf, {"default_party": "shipped"})
|
||||
self.assertIn("not applied", err.getvalue())
|
||||
self.assertIn("party_members[0].code must be a string", err.getvalue())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user