diff --git a/panel/src/components/a2a/__tests__/a2a-switchboard.test.tsx b/panel/src/components/a2a/__tests__/a2a-switchboard.test.tsx index 12c20c9d..a3473bd8 100644 --- a/panel/src/components/a2a/__tests__/a2a-switchboard.test.tsx +++ b/panel/src/components/a2a/__tests__/a2a-switchboard.test.tsx @@ -117,6 +117,23 @@ describe("A2ASwitchboard", () => { ); }); + it("collapses and re-expands a section when its header is clicked", () => { + render( + , + ); + expect(screen.getByTestId("pair-card")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /Backend Cell/ })); + expect(screen.queryByTestId("pair-card")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /Backend Cell/ })); + expect(screen.getByTestId("pair-card")).toBeInTheDocument(); + }); + it("passes each pair's pulse timestamp through by canonical pair key", () => { const pair = buildPair(); render( diff --git a/panel/src/components/a2a/a2a-switchboard-utils.ts b/panel/src/components/a2a/a2a-switchboard-utils.ts index 9ecb1a68..4d6a7670 100644 --- a/panel/src/components/a2a/a2a-switchboard-utils.ts +++ b/panel/src/components/a2a/a2a-switchboard-utils.ts @@ -66,9 +66,10 @@ export function latestPulseTimestamps( return out; } -/** Stable section ordering — cells first (org-chart top-down), then the PM - * chain, board, and finally the lateral catch-all. */ +/** Stable section ordering — the CEO's own 1:1 reach first, then cells + * (org-chart top-down), the PM chain, board, and the lateral catch-all. */ export const SECTION_ORDER = [ + "ceo", "cell-backend", "cell-frontend", "cell-ux_ui", @@ -78,6 +79,7 @@ export const SECTION_ORDER = [ ] as const; export const SECTION_LABELS: Record = { + ceo: "CEO Direct", "cell-backend": "Backend Cell", "cell-frontend": "Frontend Cell", "cell-ux_ui": "UX/UI Cell", diff --git a/panel/src/components/a2a/a2a-switchboard.tsx b/panel/src/components/a2a/a2a-switchboard.tsx index 9d28d5b8..cc97dc6d 100644 --- a/panel/src/components/a2a/a2a-switchboard.tsx +++ b/panel/src/components/a2a/a2a-switchboard.tsx @@ -1,6 +1,12 @@ "use client"; -import { Radio } from "lucide-react"; +import { useState } from "react"; +import { ChevronDown, ChevronRight, Radio } from "lucide-react"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; import { Skeleton } from "@/components/ui/skeleton"; import { HelpTip } from "@/components/ui/help-tip"; import type { AdminPairSummary } from "@/lib/api/a2a"; @@ -20,8 +26,9 @@ const SKELETON_COUNT = 9; /** * The org-chart switchboard: every allowed agent pair as a card, grouped - * into sections (each cell, the PM chain, board, cross-team) and sorted so - * pairs with history come first within their section. + * into collapsible sections (CEO direct, each cell, the PM chain, board, + * cross-team) and sorted so pairs with history come first within their + * section. */ export function A2ASwitchboard({ pairs, @@ -30,6 +37,9 @@ export function A2ASwitchboard({ isLoading, onOpenPair, }: A2ASwitchboardProps) { + // Sections start expanded; collapsed state is per-groupKey, session-local. + const [collapsed, setCollapsed] = useState>({}); + if (isLoading) { return (
@@ -55,35 +65,62 @@ export function A2ASwitchboard({ return (
- {sections.map((section) => ( -
- -

- {section.label} - - ({section.pairs.length}) - -

-
-
- {section.pairs.map((pair) => { - const key = pairKey(pair.agent_a, pair.agent_b); - return ( - onOpenPair(pair)} - /> - ); - })} -
-
- ))} + {sections.map((section) => { + const isOpen = !collapsed[section.groupKey]; + return ( + + setCollapsed((prev) => ({ ...prev, [section.groupKey]: !open })) + } + > + + + + +
+ {section.pairs.map((pair) => { + const key = pairKey(pair.agent_a, pair.agent_b); + return ( + onOpenPair(pair)} + /> + ); + })} +
+
+
+ ); + })}
); } diff --git a/roboco/agents_config.py b/roboco/agents_config.py index 67145b39..3af31634 100644 --- a/roboco/agents_config.py +++ b/roboco/agents_config.py @@ -731,7 +731,8 @@ def get_a2a_route_hint(from_agent: str, to_agent: str) -> str: # A2A SWITCHBOARD — ALLOWED AGENT PAIRS (CEO admin view) # ============================================================================= # Static, stateless derivation from can_a2a_direct(): every unordered pair of -# real (non-human, non-sentinel) agents where at least one direction is +# A2A participants (agents plus the CEO's asymmetric panel reach; never the +# sentinel or the prompter/secretary) where at least one direction is # permitted. This is the org-chart the CEO's A2A switchboard renders as pair # cards — computed once at import time, since the matrix never changes at # runtime. The route/service layer joins this list against live DB @@ -756,12 +757,14 @@ class A2AAllowedPair: # Slugs eligible for the switchboard: excludes the system sentinel and the -# human-only roles (CEO, prompter, secretary) — none of those are real A2A -# participants in the org chart the CEO is browsing. +# non-participant human roles (prompter, secretary). The CEO stays in — it is +# a real, asymmetric A2A participant (can_a2a_direct allows CEO → anyone via +# the panel's 1:1 DM flow), so its pairs must render on the switchboard. _SWITCHBOARD_SLUGS: Final[list[str]] = sorted( slug for slug, row in _foundation.AGENTS.items() - if slug != "system" and not _foundation.is_human_only_role(row.role) + if slug != "system" + and row.role not in (_foundation.Role.PROMPTER, _foundation.Role.SECRETARY) ) _BOARD_ROLE_VALUES: Final[frozenset[str]] = frozenset( @@ -782,11 +785,14 @@ def _a2a_group_key(role_a: str, team_a: str, role_b: str, team_b: str) -> str: -> board). - ``board``: pure board-to-board pairs (product_owner/head_marketing/ auditor). + - ``ceo``: the CEO's asymmetric 1:1 reach into any agent (panel DMs). - ``cross``: everything else — chiefly a PR reviewer's lateral reach outside its own cell/pm (delivering a gate verdict to another cell's PM or to main-pm), which isn't part of the escalation spine. """ roles = {role_a, role_b} + if "ceo" in roles: + return "ceo" if team_a == team_b and team_a in _CELL_TEAM_VALUES: return f"cell-{team_a}" if roles == {"cell_pm", "main_pm"}: diff --git a/tests/unit/test_agents_config.py b/tests/unit/test_agents_config.py index 11d14ebd..74f37694 100644 --- a/tests/unit/test_agents_config.py +++ b/tests/unit/test_agents_config.py @@ -539,9 +539,10 @@ def test_get_a2a_route_hint_unknown_from_agent_falls_through() -> None: # A2A_ALLOWED_PAIRS — the switchboard's static org-chart pair matrix # --------------------------------------------------------------------------- -_EXPECTED_PAIR_COUNT = 70 +_EXPECTED_PAIR_COUNT = 93 _EXPECTED_GROUP_COUNTS = { "board": 3, + "ceo": 23, "cell-backend": 15, "cell-frontend": 15, "cell-ux_ui": 15, @@ -566,14 +567,27 @@ def test_a2a_allowed_pairs_no_duplicates() -> None: 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.""" +def test_a2a_allowed_pairs_excludes_non_participants_keeps_ceo() -> None: + """The intake interviewer, the secretary, and the system sentinel are not + A2A participants — but the CEO is (asymmetric panel DMs), so its pairs + must be in the matrix.""" 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"): + for excluded in ("intake-1", "secretary-1", "system"): assert excluded not in slugs + assert "ceo" in slugs + + +def test_a2a_allowed_pairs_ceo_paired_with_every_agent() -> None: + """CEO → anyone is always allowed, so every non-CEO switchboard slug + appears in exactly one ``ceo``-group pair.""" + ceo_pairs = [p for p in A2A_ALLOWED_PAIRS if "ceo" in (p.agent_a, p.agent_b)] + non_ceo_slugs = ( + {p.agent_a for p in A2A_ALLOWED_PAIRS} | {p.agent_b for p in A2A_ALLOWED_PAIRS} + ) - {"ceo"} + assert all(p.group_key == "ceo" for p in ceo_pairs) + assert len(ceo_pairs) == len(non_ceo_slugs) def test_a2a_allowed_pairs_group_key_counts() -> None: