fix(a2a): CEO pairs join the switchboard matrix; sections collapsible

_SWITCHBOARD_SLUGS reused is_human_only_role (spawn semantics) and dropped
the CEO before can_a2a_direct — which allows CEO -> anyone — ever ran, so
the static pair matrix had no CEO pairs and a Renzo filter emptied the
switchboard. Only prompter/secretary/system are excluded now; CEO pairs
get their own 'CEO Direct' section (matrix 70 -> 93). Every switchboard
section header is now a collapse toggle (Radix Collapsible, default open).
This commit is contained in:
Renn F
2026-07-18 15:55:40 +02:00
parent f0782cb858
commit fc6d6f6458
5 changed files with 119 additions and 43 deletions
@@ -117,6 +117,23 @@ describe("A2ASwitchboard", () => {
); );
}); });
it("collapses and re-expands a section when its header is clicked", () => {
render(
<A2ASwitchboard
pairs={[buildPair()]}
pulses={{}}
selectedConversationId={null}
isLoading={false}
onOpenPair={vi.fn()}
/>,
);
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", () => { it("passes each pair's pulse timestamp through by canonical pair key", () => {
const pair = buildPair(); const pair = buildPair();
render( render(
@@ -66,9 +66,10 @@ export function latestPulseTimestamps(
return out; return out;
} }
/** Stable section ordering — cells first (org-chart top-down), then the PM /** Stable section ordering — the CEO's own 1:1 reach first, then cells
* chain, board, and finally the lateral catch-all. */ * (org-chart top-down), the PM chain, board, and the lateral catch-all. */
export const SECTION_ORDER = [ export const SECTION_ORDER = [
"ceo",
"cell-backend", "cell-backend",
"cell-frontend", "cell-frontend",
"cell-ux_ui", "cell-ux_ui",
@@ -78,6 +79,7 @@ export const SECTION_ORDER = [
] as const; ] as const;
export const SECTION_LABELS: Record<string, string> = { export const SECTION_LABELS: Record<string, string> = {
ceo: "CEO Direct",
"cell-backend": "Backend Cell", "cell-backend": "Backend Cell",
"cell-frontend": "Frontend Cell", "cell-frontend": "Frontend Cell",
"cell-ux_ui": "UX/UI Cell", "cell-ux_ui": "UX/UI Cell",
+69 -32
View File
@@ -1,6 +1,12 @@
"use client"; "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 { Skeleton } from "@/components/ui/skeleton";
import { HelpTip } from "@/components/ui/help-tip"; import { HelpTip } from "@/components/ui/help-tip";
import type { AdminPairSummary } from "@/lib/api/a2a"; 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 * 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 * into collapsible sections (CEO direct, each cell, the PM chain, board,
* pairs with history come first within their section. * cross-team) and sorted so pairs with history come first within their
* section.
*/ */
export function A2ASwitchboard({ export function A2ASwitchboard({
pairs, pairs,
@@ -30,6 +37,9 @@ export function A2ASwitchboard({
isLoading, isLoading,
onOpenPair, onOpenPair,
}: A2ASwitchboardProps) { }: A2ASwitchboardProps) {
// Sections start expanded; collapsed state is per-groupKey, session-local.
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
if (isLoading) { if (isLoading) {
return ( return (
<div className="p-2 grid grid-cols-1 sm:grid-cols-2 gap-2"> <div className="p-2 grid grid-cols-1 sm:grid-cols-2 gap-2">
@@ -55,35 +65,62 @@ export function A2ASwitchboard({
return ( return (
<div className="h-full overflow-y-auto p-2 space-y-4"> <div className="h-full overflow-y-auto p-2 space-y-4">
{sections.map((section) => ( {sections.map((section) => {
<div key={section.groupKey}> const isOpen = !collapsed[section.groupKey];
<HelpTip label="Pairs with prior conversation history are listed first"> return (
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2 px-1 w-fit"> <Collapsible
{section.label} key={section.groupKey}
<span className="ml-1.5 text-muted-foreground/60 normal-case"> open={isOpen}
({section.pairs.length}) onOpenChange={(open) =>
</span> setCollapsed((prev) => ({ ...prev, [section.groupKey]: !open }))
</h3> }
</HelpTip> >
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2"> <CollapsibleTrigger asChild>
{section.pairs.map((pair) => { <button
const key = pairKey(pair.agent_a, pair.agent_b); type="button"
return ( className="flex items-center gap-1 mb-2 px-1 w-fit text-xs font-semibold uppercase tracking-wide text-muted-foreground hover:text-foreground transition-colors"
<A2APairCard >
key={key} {/* Tip goes on the inner span, not the CollapsibleTrigger
pair={pair} asChild button — wrapping the trigger itself would clobber
pulsedAt={pulses[key] ?? null} its open/closed data-state (same trap as Switch/
isSelected={ TabsTrigger). */}
!!pair.conversation_id && {isOpen ? (
pair.conversation_id === selectedConversationId <ChevronDown className="h-3.5 w-3.5" />
} ) : (
onOpen={() => onOpenPair(pair)} <ChevronRight className="h-3.5 w-3.5" />
/> )}
); <HelpTip label="Pairs with prior conversation history are listed first — click to collapse or expand this section">
})} <span>
</div> {section.label}
</div> <span className="ml-1.5 text-muted-foreground/60 normal-case">
))} ({section.pairs.length})
</span>
</span>
</HelpTip>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{section.pairs.map((pair) => {
const key = pairKey(pair.agent_a, pair.agent_b);
return (
<A2APairCard
key={key}
pair={pair}
pulsedAt={pulses[key] ?? null}
isSelected={
!!pair.conversation_id &&
pair.conversation_id === selectedConversationId
}
onOpen={() => onOpenPair(pair)}
/>
);
})}
</div>
</CollapsibleContent>
</Collapsible>
);
})}
</div> </div>
); );
} }
+10 -4
View File
@@ -731,7 +731,8 @@ def get_a2a_route_hint(from_agent: str, to_agent: str) -> str:
# A2A SWITCHBOARD — ALLOWED AGENT PAIRS (CEO admin view) # A2A SWITCHBOARD — ALLOWED AGENT PAIRS (CEO admin view)
# ============================================================================= # =============================================================================
# Static, stateless derivation from can_a2a_direct(): every unordered pair of # 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 # 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 # cards — computed once at import time, since the matrix never changes at
# runtime. The route/service layer joins this list against live DB # 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 # Slugs eligible for the switchboard: excludes the system sentinel and the
# human-only roles (CEO, prompter, secretary) — none of those are real A2A # non-participant human roles (prompter, secretary). The CEO stays in — it is
# participants in the org chart the CEO is browsing. # 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( _SWITCHBOARD_SLUGS: Final[list[str]] = sorted(
slug slug
for slug, row in _foundation.AGENTS.items() 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( _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).
- ``board``: pure board-to-board pairs (product_owner/head_marketing/ - ``board``: pure board-to-board pairs (product_owner/head_marketing/
auditor). auditor).
- ``ceo``: the CEO's asymmetric 1:1 reach into any agent (panel DMs).
- ``cross``: everything else — chiefly a PR reviewer's lateral reach - ``cross``: everything else — chiefly a PR reviewer's lateral reach
outside its own cell/pm (delivering a gate verdict to another cell's 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. PM or to main-pm), which isn't part of the escalation spine.
""" """
roles = {role_a, role_b} roles = {role_a, role_b}
if "ceo" in roles:
return "ceo"
if team_a == team_b and team_a in _CELL_TEAM_VALUES: if team_a == team_b and team_a in _CELL_TEAM_VALUES:
return f"cell-{team_a}" return f"cell-{team_a}"
if roles == {"cell_pm", "main_pm"}: if roles == {"cell_pm", "main_pm"}:
+19 -5
View File
@@ -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 # A2A_ALLOWED_PAIRS — the switchboard's static org-chart pair matrix
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_EXPECTED_PAIR_COUNT = 70 _EXPECTED_PAIR_COUNT = 93
_EXPECTED_GROUP_COUNTS = { _EXPECTED_GROUP_COUNTS = {
"board": 3, "board": 3,
"ceo": 23,
"cell-backend": 15, "cell-backend": 15,
"cell-frontend": 15, "cell-frontend": 15,
"cell-ux_ui": 15, "cell-ux_ui": 15,
@@ -566,14 +567,27 @@ def test_a2a_allowed_pairs_no_duplicates() -> None:
assert len(keys) == len(set(keys)) assert len(keys) == len(set(keys))
def test_a2a_allowed_pairs_excludes_human_only_and_sentinel_roles() -> None: def test_a2a_allowed_pairs_excludes_non_participants_keeps_ceo() -> None:
"""CEO, the intake interviewer, the secretary, and the system sentinel """The intake interviewer, the secretary, and the system sentinel are not
are not real A2A participants in the org chart.""" 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} | { slugs = {p.agent_a for p in A2A_ALLOWED_PAIRS} | {
p.agent_b 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 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: def test_a2a_allowed_pairs_group_key_counts() -> None: