mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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",
|
||||||
|
|||||||
@@ -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,16 +65,41 @@ 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
|
||||||
|
key={section.groupKey}
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={(open) =>
|
||||||
|
setCollapsed((prev) => ({ ...prev, [section.groupKey]: !open }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{/* Tip goes on the inner span, not the CollapsibleTrigger
|
||||||
|
asChild button — wrapping the trigger itself would clobber
|
||||||
|
its open/closed data-state (same trap as Switch/
|
||||||
|
TabsTrigger). */}
|
||||||
|
{isOpen ? (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
{section.label}
|
{section.label}
|
||||||
<span className="ml-1.5 text-muted-foreground/60 normal-case">
|
<span className="ml-1.5 text-muted-foreground/60 normal-case">
|
||||||
({section.pairs.length})
|
({section.pairs.length})
|
||||||
</span>
|
</span>
|
||||||
</h3>
|
</span>
|
||||||
</HelpTip>
|
</HelpTip>
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
{section.pairs.map((pair) => {
|
{section.pairs.map((pair) => {
|
||||||
const key = pairKey(pair.agent_a, pair.agent_b);
|
const key = pairKey(pair.agent_a, pair.agent_b);
|
||||||
@@ -82,8 +117,10 @@ export function A2ASwitchboard({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CollapsibleContent>
|
||||||
))}
|
</Collapsible>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-4
@@ -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"}:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user