A2A switchboard (pair cards), Secretary/PM task access + closed over-permission hole, MegaTask conventions fix (#298)

* feat(tasks): Secretary full task access; PM lighter editing — and a closed over-permission hole

Secretary: the CEO-gated edit directive covers the full content surface
(title/description/AC/priority/team/complexity/nature + claim-aware
reassignment through the real reassign paths, enum coercion, slug or
UUID assignees), and read_task returns full detail (notes, plan,
bounded progress, PR refs). The submit_directive tool docs never
mentioned edit at all — fixed, it was undiscoverable.

PMs: scouted the PATCH route and found has_higher_perms gave PM
identities UNRESTRICTED admin (ASSIGN is not team-scoped) — wider than
'not that much'. Now: cell PMs hard-403 outside their team, and both PM
roles are capped to the content allowlist (title/description/AC/
priority) with zero status changes via this surface. CEO/Board/Auditor
keep full admin. Built subagent-driven (Sonnet 5), reviewed.

* feat(a2a): the switchboard — org-chart pair cards with live activity

70 permission-matrix-derived pair cards (cells/pm-chain/board/cross),
lighting on either direction's a2a.message frames with a 45s fade —
A2A only, never verbs, per CEO ruling. Click-through reuses the v1
transcript + chime-in drawer; v1 list stays as the mobile fallback.
One CEO-gated /a2a/chat/admin/pairs route joins the static matrix
against conversations in a single bulk query. Built subagent-driven
(Sonnet 5), reviewed; pre-existing agent-utils slug-map gap flagged.

* fix(runtime): conventions ambient covers the MegaTask project_ids scope

_resolve_intake_ambient forwarded project_ids only to the history-digest
resolver — a MegaTask intake got no architectural-conventions block even
with the flag on. The conventions resolver now takes project_ids first
(mirroring the history resolver), both share one order-preserving
_projects_by_ids helper, and a regression test pins the threading to
both sub-resolvers. Built subagent-driven (Sonnet 5), reviewed.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-03 01:58:38 +02:00
committed by GitHub
co-authored by Renn F
parent da563487b8
commit 876e19b389
30 changed files with 2734 additions and 67 deletions
+280 -1
View File
@@ -17,6 +17,7 @@ from unittest.mock import patch
from uuid import UUID, uuid4
import pytest
from roboco.config import settings
from roboco.runtime.orchestrator import (
INTAKE_AGENT_ID,
AgentInstance,
@@ -265,7 +266,7 @@ class TestIntakeScopeSlugs:
# ---------------------------------------------------------------------------
# _resolve_history_digest_projects — the prompter-memory ambient's project scope
# (covers all three intake scopes, unlike the conventions ambient resolver).
# (covers all three intake scopes: project_slug, product_id, project_ids).
# ---------------------------------------------------------------------------
@@ -375,6 +376,284 @@ class TestResolveHistoryDigestAmbient:
assert result is None
# ---------------------------------------------------------------------------
# _resolve_ambient_projects — the conventions ambient's project scope. Mirrors
# _resolve_history_digest_projects's shape, including the MegaTask project_ids
# scope (the pre-existing gap: this resolver used to stop at project_slug /
# product_id / task_id and never saw a MegaTask's explicit project_ids).
# ---------------------------------------------------------------------------
class TestResolveAmbientProjects:
@pytest.mark.asyncio
async def test_project_slug_branch_resolves_single_project(self) -> None:
orch = _make_minimal_orchestrator()
class _FakeProjectSvc:
async def get_by_slug(self, slug: str) -> Any:
return SimpleNamespace(slug=slug, id=uuid4())
with patch(
"roboco.services.project.get_project_service",
lambda _db: _FakeProjectSvc(),
):
projects = await orch._resolve_ambient_projects(
object(),
project_slug="roboco",
task_id=None,
product_id=None,
)
assert [p.slug for p in projects] == ["roboco"]
@pytest.mark.asyncio
async def test_project_slug_missing_returns_empty(self) -> None:
orch = _make_minimal_orchestrator()
class _FakeProjectSvc:
async def get_by_slug(self, _slug: str) -> Any:
return None
with patch(
"roboco.services.project.get_project_service",
lambda _db: _FakeProjectSvc(),
):
projects = await orch._resolve_ambient_projects(
object(),
project_slug="ghost",
task_id=None,
product_id=None,
)
assert projects == []
@pytest.mark.asyncio
async def test_product_id_branch_delegates_to_ambient_product_projects(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
orch = _make_minimal_orchestrator()
sentinel = [SimpleNamespace(slug="p1", id=uuid4())]
async def _fake_product_projects(_db: Any, product_id: str) -> list[Any]:
assert product_id == "prod-1"
return sentinel
# Patched on the instance (not the class): _ambient_product_projects is
# a staticmethod, so accessing it via `self.` never binds `self` — but a
# plain function patched onto the *class* would, since it's no longer
# wrapped in `staticmethod`. Patching the instance attribute sidesteps
# the descriptor lookup entirely.
monkeypatch.setattr(orch, "_ambient_product_projects", _fake_product_projects)
projects = await orch._resolve_ambient_projects(
object(),
project_slug=None,
task_id=None,
product_id="prod-1",
)
assert projects is sentinel
@pytest.mark.asyncio
async def test_project_ids_branch_preserves_order_and_skips_missing(
self,
) -> None:
"""The MegaTask scope: an explicit project_ids list must resolve into
projects (order preserved, unresolvable ids skipped) — this is the gap
fix, mirroring the history digest resolver's own project_ids branch."""
orch = _make_minimal_orchestrator()
good1 = "11111111-1111-1111-1111-111111111111"
missing = "22222222-2222-2222-2222-222222222222"
good2 = "33333333-3333-3333-3333-333333333333"
class _FakeProjectSvc:
async def get(self, pid: Any) -> Any:
if str(pid) == missing:
return None
return SimpleNamespace(slug=f"proj-{str(pid)[0]}", id=pid)
with patch(
"roboco.services.project.get_project_service",
lambda _db: _FakeProjectSvc(),
):
projects = await orch._resolve_ambient_projects(
object(),
project_slug=None,
task_id=None,
product_id=None,
project_ids=[good1, missing, good2],
)
assert [p.slug for p in projects] == ["proj-1", "proj-3"]
@pytest.mark.asyncio
async def test_project_ids_takes_priority_over_other_scopes(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A MegaTask spawn passes project_slug/product_id as None in practice,
but the resolver must still prefer the explicit project_ids set over a
stray product_id/task_id if both were somehow present."""
orch = _make_minimal_orchestrator()
pid = "11111111-1111-1111-1111-111111111111"
class _FakeProjectSvc:
async def get(self, _pid: Any) -> Any:
return SimpleNamespace(slug="from-ids", id=_pid)
async def _fail_product_projects(*_a: Any, **_k: Any) -> list[Any]:
raise AssertionError("product_id branch must not run")
monkeypatch.setattr(orch, "_ambient_product_projects", _fail_product_projects)
with patch(
"roboco.services.project.get_project_service",
lambda _db: _FakeProjectSvc(),
):
projects = await orch._resolve_ambient_projects(
object(),
project_slug=None,
task_id=None,
product_id="prod-should-be-ignored",
project_ids=[pid],
)
assert [p.slug for p in projects] == ["from-ids"]
@pytest.mark.asyncio
async def test_no_scope_given_returns_empty(self) -> None:
orch = _make_minimal_orchestrator()
projects = await orch._resolve_ambient_projects(
object(),
project_slug=None,
task_id=None,
product_id=None,
)
assert projects == []
# ---------------------------------------------------------------------------
# _resolve_conventions_ambient — flag-gated + best-effort, now MegaTask-aware.
# ---------------------------------------------------------------------------
class TestResolveConventionsAmbient:
@pytest.mark.asyncio
async def test_flag_off_returns_none(self, monkeypatch: pytest.MonkeyPatch) -> None:
orch = _make_minimal_orchestrator()
monkeypatch.setattr(settings, "conventions_enabled", False)
result = await orch._resolve_conventions_ambient(
"roboco", project_ids=["11111111-1111-1111-1111-111111111111"]
)
assert result is None
@pytest.mark.asyncio
async def test_failure_returns_none_not_raises(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
orch = _make_minimal_orchestrator()
monkeypatch.setattr(settings, "conventions_enabled", True)
def _boom() -> Any:
raise RuntimeError("db unavailable")
monkeypatch.setattr("roboco.db.base.get_session_factory", _boom)
result = await orch._resolve_conventions_ambient(
None, project_ids=["11111111-1111-1111-1111-111111111111"]
)
assert result is None
@pytest.mark.asyncio
async def test_project_ids_scope_reaches_conventions_layer(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The MegaTask project_ids scope must flow all the way through to
conventions_ambient_layer — the actual gap this fix closes."""
orch = _make_minimal_orchestrator()
monkeypatch.setattr(settings, "conventions_enabled", True)
class _FakeFactory:
def __call__(self) -> Any:
return self
async def __aenter__(self) -> Any:
return "fake-db"
async def __aexit__(self, *_a: Any) -> None:
return None
monkeypatch.setattr("roboco.db.base.get_session_factory", _FakeFactory)
sentinel_projects = [SimpleNamespace(slug="proj-1")]
captured: dict[str, Any] = {}
async def _fake_resolve_projects(_self: Any, _db: Any, **kwargs: Any) -> Any:
captured["kwargs"] = kwargs
return sentinel_projects
async def _fake_layer(_db: Any, projects: Any) -> str:
captured["projects"] = projects
return "RENDERED BLOCK"
monkeypatch.setattr(
AgentOrchestrator, "_resolve_ambient_projects", _fake_resolve_projects
)
monkeypatch.setattr(
"roboco.agents.factories._base.conventions_ambient_layer", _fake_layer
)
result = await orch._resolve_conventions_ambient(
None, project_ids=["11111111-1111-1111-1111-111111111111"]
)
assert result == "RENDERED BLOCK"
assert captured["kwargs"]["project_ids"] == [
"11111111-1111-1111-1111-111111111111"
]
assert captured["projects"] is sentinel_projects
# ---------------------------------------------------------------------------
# _resolve_intake_ambient — must forward project_ids to BOTH sub-resolvers.
# Regression test for the gap: it used to thread project_ids only to the
# history-digest resolver, leaving a MegaTask intake with no conventions block.
# ---------------------------------------------------------------------------
class TestResolveIntakeAmbientThreadsProjectIds:
@pytest.mark.asyncio
async def test_project_ids_forwarded_to_conventions_and_history(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
orch = _make_minimal_orchestrator()
conventions_calls: list[dict[str, Any]] = []
history_calls: list[dict[str, Any]] = []
async def _conventions(_slug: Any, **kwargs: Any) -> str | None:
conventions_calls.append(kwargs)
return "CONVENTIONS"
async def _history(_slug: Any, **kwargs: Any) -> str | None:
history_calls.append(kwargs)
return "HISTORY"
monkeypatch.setattr(orch, "_resolve_conventions_ambient", _conventions)
monkeypatch.setattr(orch, "_resolve_history_digest_ambient", _history)
result = await orch._resolve_intake_ambient(
None,
product_id=None,
project_ids=["11111111-1111-1111-1111-111111111111"],
)
assert result == "CONVENTIONS\n\n---\n\nHISTORY"
assert conventions_calls == [
{
"product_id": None,
"project_ids": ["11111111-1111-1111-1111-111111111111"],
}
]
assert history_calls == [
{
"product_id": None,
"project_ids": ["11111111-1111-1111-1111-111111111111"],
}
]
# ---------------------------------------------------------------------------
# spawn_intake_session / reap_intake_session — orchestration (docker mocked).
# ---------------------------------------------------------------------------
@@ -2,12 +2,14 @@
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.db.tables import SecretaryDirectiveTable
from roboco.models.base import Complexity, TaskNature, TaskStatus, Team
from roboco.models.secretary import DirectiveKind, DirectiveStatus
from roboco.services import secretary as sec_module
from roboco.services.base import ValidationError
@@ -35,7 +37,12 @@ def _patch(monkeypatch: pytest.MonkeyPatch) -> dict[str, MagicMock]:
task.approve_and_start = AsyncMock()
task.admin_set_status = AsyncMock()
task.update = AsyncMock()
task.get = AsyncMock(return_value=None)
task.reassign = AsyncMock()
task.reassign_active_claim = AsyncMock()
monkeypatch.setattr(sec_module, "get_task_service", lambda _s: task)
agent_lookup = AsyncMock(return_value=None)
monkeypatch.setattr(sec_module, "get_agent_by_slug", agent_lookup)
notifier = MagicMock()
notifier.send_ack_notification = AsyncMock()
monkeypatch.setattr(
@@ -47,6 +54,7 @@ def _patch(monkeypatch: pytest.MonkeyPatch) -> dict[str, MagicMock]:
"pitch": pitch,
"task": task,
"notifier": notifier,
"agent_lookup": agent_lookup,
}
@@ -224,3 +232,241 @@ async def test_control_task_edit_rejects_non_allowlisted_fields(
out = await svc.confirm_directive(row.id, uuid4())
assert out.status == DirectiveStatus.FAILED.value
svcs["task"].update.assert_not_awaited()
@pytest.mark.asyncio
async def test_confirm_control_task_edit_extended_content_fields(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Secretary FULL: team/estimated_complexity/nature ride the edit too,
coerced into their proper enums before hitting TaskService.update."""
svcs = _patch(monkeypatch)
svc = SecretaryService(_session())
tid = uuid4()
row = _pending(
DirectiveKind.CONTROL_TASK,
{
"task_id": str(tid),
"action": "edit",
"fields": {
"team": "frontend",
"estimated_complexity": "high",
"nature": "technical",
},
},
)
monkeypatch.setattr(svc, "get_directive", AsyncMock(return_value=row))
out = await svc.confirm_directive(row.id, uuid4())
assert out.status == DirectiveStatus.EXECUTED.value
svcs["task"].update.assert_awaited_once()
_, kwargs = svcs["task"].update.await_args
assert kwargs["team"] == Team.FRONTEND
assert kwargs["estimated_complexity"] == Complexity.HIGH
assert kwargs["nature"] == TaskNature.TECHNICAL
@pytest.mark.asyncio
async def test_confirm_control_task_edit_bad_enum_value_fails(
monkeypatch: pytest.MonkeyPatch,
) -> None:
svcs = _patch(monkeypatch)
svc = SecretaryService(_session())
row = _pending(
DirectiveKind.CONTROL_TASK,
{
"task_id": str(uuid4()),
"action": "edit",
"fields": {"team": "not-a-real-team"},
},
)
monkeypatch.setattr(svc, "get_directive", AsyncMock(return_value=row))
out = await svc.confirm_directive(row.id, uuid4())
assert out.status == DirectiveStatus.FAILED.value
svcs["task"].update.assert_not_awaited()
@pytest.mark.asyncio
async def test_confirm_control_task_edit_reassigns_active_claim(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Reassigning an active (claimed/in_progress) task goes through
``reassign_active_claim`` so the new assignee reseeds its heartbeat and
isn't immediately stale to the reaper — not a naive setattr."""
svcs = _patch(monkeypatch)
new_assignee = uuid4()
svcs["task"].get = AsyncMock(
return_value=SimpleNamespace(status=TaskStatus.IN_PROGRESS)
)
svcs["task"].reassign_active_claim = AsyncMock(
return_value=SimpleNamespace(status=TaskStatus.IN_PROGRESS)
)
svc = SecretaryService(_session())
tid = uuid4()
row = _pending(
DirectiveKind.CONTROL_TASK,
{
"task_id": str(tid),
"action": "edit",
"fields": {"assigned_to": str(new_assignee)},
},
)
monkeypatch.setattr(svc, "get_directive", AsyncMock(return_value=row))
out = await svc.confirm_directive(row.id, uuid4())
assert out.status == DirectiveStatus.EXECUTED.value
svcs["task"].reassign_active_claim.assert_awaited_once_with(tid, new_assignee)
svcs["task"].reassign.assert_not_awaited()
@pytest.mark.asyncio
async def test_confirm_control_task_edit_reassigns_non_active_task(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A non-active task (e.g. pending) reassigns through the general
``reassign`` path no heartbeat to reseed."""
svcs = _patch(monkeypatch)
new_assignee = uuid4()
svcs["task"].get = AsyncMock(
return_value=SimpleNamespace(status=TaskStatus.PENDING)
)
svc = SecretaryService(_session())
tid = uuid4()
row = _pending(
DirectiveKind.CONTROL_TASK,
{
"task_id": str(tid),
"action": "edit",
"fields": {"assigned_to": str(new_assignee)},
},
)
monkeypatch.setattr(svc, "get_directive", AsyncMock(return_value=row))
out = await svc.confirm_directive(row.id, uuid4())
assert out.status == DirectiveStatus.EXECUTED.value
svcs["task"].reassign.assert_awaited_once_with(tid, new_assignee)
svcs["task"].reassign_active_claim.assert_not_awaited()
@pytest.mark.asyncio
async def test_confirm_control_task_edit_reassigns_by_slug(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The CEO refers to agents by slug (e.g. 'be-dev-1'); the edit resolves
it to a UUID the same way the REST PATCH path does."""
svcs = _patch(monkeypatch)
agent_row_id = uuid4()
svcs["agent_lookup"].return_value = SimpleNamespace(id=agent_row_id)
svcs["task"].get = AsyncMock(
return_value=SimpleNamespace(status=TaskStatus.PENDING)
)
svc = SecretaryService(_session())
tid = uuid4()
row = _pending(
DirectiveKind.CONTROL_TASK,
{
"task_id": str(tid),
"action": "edit",
"fields": {"assigned_to": "be-dev-1"},
},
)
monkeypatch.setattr(svc, "get_directive", AsyncMock(return_value=row))
out = await svc.confirm_directive(row.id, uuid4())
assert out.status == DirectiveStatus.EXECUTED.value
svcs["task"].reassign.assert_awaited_once_with(tid, agent_row_id)
@pytest.mark.asyncio
async def test_confirm_control_task_edit_unknown_assignee_slug_fails(
monkeypatch: pytest.MonkeyPatch,
) -> None:
svcs = _patch(monkeypatch)
svc = SecretaryService(_session())
row = _pending(
DirectiveKind.CONTROL_TASK,
{
"task_id": str(uuid4()),
"action": "edit",
"fields": {"assigned_to": "no-such-agent"},
},
)
monkeypatch.setattr(svc, "get_directive", AsyncMock(return_value=row))
out = await svc.confirm_directive(row.id, uuid4())
assert out.status == DirectiveStatus.FAILED.value
svcs["task"].reassign.assert_not_awaited()
svcs["task"].reassign_active_claim.assert_not_awaited()
@pytest.mark.asyncio
async def test_confirm_control_task_edit_combines_fields_and_reassign(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A single edit directive may both update content fields and reassign."""
svcs = _patch(monkeypatch)
new_assignee = uuid4()
svcs["task"].get = AsyncMock(
return_value=SimpleNamespace(status=TaskStatus.PENDING)
)
svc = SecretaryService(_session())
tid = uuid4()
row = _pending(
DirectiveKind.CONTROL_TASK,
{
"task_id": str(tid),
"action": "edit",
"fields": {"title": "Renamed", "assigned_to": str(new_assignee)},
},
)
monkeypatch.setattr(svc, "get_directive", AsyncMock(return_value=row))
out = await svc.confirm_directive(row.id, uuid4())
assert out.status == DirectiveStatus.EXECUTED.value
svcs["task"].update.assert_awaited_once()
_, kwargs = svcs["task"].update.await_args
assert kwargs == {"title": "Renamed"}
svcs["task"].reassign.assert_awaited_once_with(tid, new_assignee)
_SEEDED_PR_NUMBER = 42
_SEEDED_PROGRESS_UPDATE_COUNT = sec_module._MAX_PROGRESS_UPDATES + 10
@pytest.mark.asyncio
async def test_read_task_includes_full_detail(monkeypatch: pytest.MonkeyPatch) -> None:
"""Secretary FULL read breadth: notes/progress/plan/pr fields join the
brief identity fields that read_task already carried."""
svcs = _patch(monkeypatch)
tid = uuid4()
fake_task = SimpleNamespace(
id=tid,
title="Ship the thing",
status=TaskStatus.IN_PROGRESS,
team="backend",
assigned_to=uuid4(),
description="A real description.",
acceptance_criteria=["works"],
priority=1,
estimated_complexity="high",
nature="technical",
plan={"approach": "do it"},
progress_updates=[
{"message": f"update {i}"} for i in range(_SEEDED_PROGRESS_UPDATE_COUNT)
],
dev_notes="dev note",
qa_notes="qa note",
auditor_notes="audit note",
pr_reviewer_notes="pr note",
doc_notes="doc note",
quick_context="ctx",
branch_name="feature/x",
pr_number=_SEEDED_PR_NUMBER,
pr_url="https://example.com/pr/42",
)
svcs["task"].get = AsyncMock(return_value=fake_task)
svc = SecretaryService(_session())
out = await svc.read_task(tid)
assert out["title"] == "Ship the thing"
assert out["plan"] == {"approach": "do it"}
assert out["dev_notes"] == "dev note"
assert out["pr_number"] == _SEEDED_PR_NUMBER
assert out["branch_name"] == "feature/x"
# Bounded: only the most recent entries survive.
assert len(out["progress_updates"]) == sec_module._MAX_PROGRESS_UPDATES
last_index = _SEEDED_PROGRESS_UPDATE_COUNT - 1
assert out["progress_updates"][-1]["message"] == f"update {last_index}"
+85
View File
@@ -2,9 +2,11 @@
from __future__ import annotations
from collections import Counter
from typing import TYPE_CHECKING
from roboco.agents_config import (
A2A_ALLOWED_PAIRS,
can_a2a_direct,
can_assign_tasks,
can_cancel_tasks,
@@ -27,6 +29,7 @@ from roboco.agents_config import (
issue_panel_token,
verify_agent_token,
)
from roboco.foundation import identity as foundation
from roboco.seeds.initial_data import CEO_AGENT_ID
if TYPE_CHECKING:
@@ -441,3 +444,85 @@ def test_get_a2a_route_hint_unknown_from_agent_falls_through() -> None:
"""from_agent with no team falls through to escalate fallback (line 774)."""
hint = get_a2a_route_hint("ghost", "be-dev-1")
assert "escalate" in hint.lower()
# ---------------------------------------------------------------------------
# A2A_ALLOWED_PAIRS — the switchboard's static org-chart pair matrix
# ---------------------------------------------------------------------------
_EXPECTED_PAIR_COUNT = 70
_EXPECTED_GROUP_COUNTS = {
"board": 3,
"cell-backend": 15,
"cell-frontend": 15,
"cell-ux_ui": 15,
"cross": 16,
"pm-chain": 6,
}
def test_a2a_allowed_pairs_total_count() -> None:
assert len(A2A_ALLOWED_PAIRS) == _EXPECTED_PAIR_COUNT
def test_a2a_allowed_pairs_canonical_lexical_order() -> None:
"""agent_a < agent_b always — matches A2AConversationTable's canonical
ordering, so the service's DB join keys line up."""
for p in A2A_ALLOWED_PAIRS:
assert p.agent_a < p.agent_b
def test_a2a_allowed_pairs_no_duplicates() -> None:
keys = [(p.agent_a, p.agent_b) for p in A2A_ALLOWED_PAIRS]
assert len(keys) == len(set(keys))
def test_a2a_allowed_pairs_excludes_human_only_and_sentinel_roles() -> None:
"""CEO, the intake interviewer, the secretary, and the system sentinel
are not real A2A participants in the org chart."""
slugs = {p.agent_a for p in A2A_ALLOWED_PAIRS} | {
p.agent_b for p in A2A_ALLOWED_PAIRS
}
for excluded in ("ceo", "intake-1", "secretary-1", "system"):
assert excluded not in slugs
def test_a2a_allowed_pairs_group_key_counts() -> None:
counts = Counter(p.group_key for p in A2A_ALLOWED_PAIRS)
assert dict(counts) == _EXPECTED_GROUP_COUNTS
def test_a2a_allowed_pairs_contains_same_cell_pair() -> None:
assert any(
{p.agent_a, p.agent_b} == {"be-dev-1", "be-qa"} for p in A2A_ALLOWED_PAIRS
)
def test_a2a_allowed_pairs_contains_pm_chain_pair() -> None:
assert any(
{p.agent_a, p.agent_b} == {"be-pm", "main-pm"} for p in A2A_ALLOWED_PAIRS
)
def test_a2a_allowed_pairs_contains_board_pair() -> None:
assert any(
{p.agent_a, p.agent_b} == {"auditor", "product-owner"}
for p in A2A_ALLOWED_PAIRS
)
def test_a2a_allowed_pairs_reflects_can_a2a_direct_matrix() -> None:
"""Every listed pair allows >=1 direction per the live matrix — catches
drift if can_a2a_direct changes without regenerating the static list."""
for p in A2A_ALLOWED_PAIRS:
allowed_ab, _ = can_a2a_direct(p.agent_a, p.agent_b)
allowed_ba, _ = can_a2a_direct(p.agent_b, p.agent_a)
assert allowed_ab or allowed_ba
def test_a2a_allowed_pairs_role_team_fields_match_registry() -> None:
for p in A2A_ALLOWED_PAIRS:
assert p.role_a == foundation.AGENTS[p.agent_a].role.value
assert p.team_a == foundation.AGENTS[p.agent_a].team.value
assert p.role_b == foundation.AGENTS[p.agent_b].role.value
assert p.team_b == foundation.AGENTS[p.agent_b].team.value