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
@@ -1,21 +1,28 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import type { AdminConversationSummary, A2AChatMessage } from "@/lib/api/a2a";
import { render, screen, fireEvent } from "@testing-library/react";
import type {
AdminConversationSummary,
AdminPairSummary,
A2AChatMessage,
} from "@/lib/api/a2a";
const {
useA2AConversations,
useA2AMessages,
useA2AAdminPairs,
useA2ALiveStream,
invalidateQueries,
a2aLiveKeys,
} = vi.hoisted(() => ({
useA2AConversations: vi.fn(),
useA2AMessages: vi.fn(),
useA2AAdminPairs: vi.fn(),
useA2ALiveStream: vi.fn(),
invalidateQueries: vi.fn(),
a2aLiveKeys: {
all: ["a2a-live"] as const,
conversations: ["a2a-live", "conversations"] as const,
pairs: ["a2a-live", "pairs"] as const,
messages: (conversationId: string) =>
["a2a-live", "messages", conversationId] as const,
},
@@ -30,6 +37,7 @@ vi.mock("@/hooks/use-a2a-live", () => ({
a2aLiveKeys,
useA2AConversations,
useA2AMessages,
useA2AAdminPairs,
useReplyAsCeo: () => ({ mutate: vi.fn(), isPending: false }),
}));
@@ -74,6 +82,22 @@ function buildConversation(
};
}
function buildPair(overrides: Partial<AdminPairSummary> = {}): AdminPairSummary {
return {
agent_a: "be-dev-1",
role_a: "developer",
team_a: "backend",
agent_b: "be-qa",
role_b: "qa",
team_b: "backend",
group_key: "cell-backend",
conversation_id: "conv-1",
last_message_at: "2026-07-02T09:00:00Z",
message_count: 2,
...overrides,
};
}
function buildMessage(): A2AChatMessage {
return {
id: "m1",
@@ -103,8 +127,14 @@ describe("A2APage", () => {
isLoading: false,
refetch: vi.fn(),
});
useA2AAdminPairs.mockReturnValue({
data: { items: [], total: 0 },
isLoading: false,
refetch: vi.fn(),
});
useA2ALiveStream.mockReturnValue({
lastMessage: null,
a2aMessages: [],
isConnected: true,
});
});
@@ -150,7 +180,7 @@ describe("A2APage", () => {
).toBeInTheDocument();
});
it("invalidates conversations + selected messages on a matching a2a.message frame", () => {
it("invalidates conversations + pairs + selected messages on a matching a2a.message frame", () => {
useA2ALiveStream.mockReturnValue({
lastMessage: {
type: "a2a.message",
@@ -161,33 +191,69 @@ describe("A2APage", () => {
body_excerpt: "capped",
timestamp: "2026-07-02T10:00:00Z",
},
a2aMessages: [],
isConnected: true,
});
render(<A2APage />);
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: a2aLiveKeys.conversations,
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: a2aLiveKeys.pairs,
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: a2aLiveKeys.messages("conv-1"),
});
});
it("only invalidates the conversation list for frames of other conversations", () => {
it("only invalidates the conversation + pair lists for frames of other conversations", () => {
useA2ALiveStream.mockReturnValue({
lastMessage: {
type: "a2a.message",
conversation_id: "conv-other",
timestamp: "2026-07-02T10:00:00Z",
},
a2aMessages: [],
isConnected: false,
});
render(<A2APage />);
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: a2aLiveKeys.conversations,
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: a2aLiveKeys.pairs,
});
expect(invalidateQueries).not.toHaveBeenCalledWith({
queryKey: a2aLiveKeys.messages("conv-1"),
});
expect(screen.getByText("Offline")).toBeInTheDocument();
});
it("shows the switchboard by default with pair cards grouped into sections", () => {
useA2AAdminPairs.mockReturnValue({
data: { items: [buildPair()], total: 1 },
isLoading: false,
refetch: vi.fn(),
});
render(<A2APage />);
expect(screen.getByText("Switchboard")).toBeInTheDocument();
expect(screen.getByText(/Backend Cell/)).toBeInTheDocument();
});
it("toggles to the classic conversation list and back", () => {
useA2AAdminPairs.mockReturnValue({
data: { items: [buildPair()], total: 1 },
isLoading: false,
refetch: vi.fn(),
});
render(<A2APage />);
expect(screen.getByText("Switchboard")).toBeInTheDocument();
fireEvent.click(screen.getByTitle("Classic conversation list"));
expect(screen.getByText("Conversations")).toBeInTheDocument();
expect(screen.queryByText(/Backend Cell/)).not.toBeInTheDocument();
fireEvent.click(screen.getByTitle("Switchboard: org-chart pair cards"));
expect(screen.getByText("Switchboard")).toBeInTheDocument();
});
});
+117 -12
View File
@@ -1,17 +1,21 @@
"use client";
import { Suspense, useCallback, useEffect } from "react";
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import {
a2aLiveKeys,
useA2AAdminPairs,
useA2AConversations,
useA2AMessages,
} from "@/hooks/use-a2a-live";
import { useA2ALiveStream } from "@/hooks/use-websocket";
import { A2AConversationList } from "@/components/a2a/a2a-conversation-list";
import { A2ASwitchboard } from "@/components/a2a/a2a-switchboard";
import { A2ATranscript } from "@/components/a2a/a2a-transcript";
import { A2AReplyComposer } from "@/components/a2a/a2a-reply-composer";
import { latestPulseTimestamps } from "@/components/a2a/a2a-switchboard-utils";
import type { AdminPairSummary } from "@/lib/api/a2a";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -20,9 +24,22 @@ import { OfflineState } from "@/components/ui/offline-state";
import { getAgentDisplayName } from "@/lib/agent-utils";
import { lastSenderOf } from "@/components/a2a/a2a-utils";
import { cn } from "@/lib/utils";
import { MessagesSquare, Radio, RefreshCw } from "lucide-react";
import {
LayoutGrid,
List as ListIcon,
MessagesSquare,
Radio,
RefreshCw,
} from "lucide-react";
import { formatDistanceToNow } from "date-fns";
type A2AView = "switchboard" | "list";
interface PeekedPair {
agent_a: string;
agent_b: string;
}
function EmptyPanel({
icon: Icon,
message,
@@ -47,12 +64,25 @@ function A2APageContent() {
const selectedId = searchParams.get("conversation");
// Desktop default is the switchboard (org-chart pair cards); the classic
// list stays one click away as the mobile/compact fallback.
const [view, setView] = useState<A2AView>("switchboard");
// A pair with no conversation yet, clicked from the switchboard — there is
// nothing to select via `?conversation=`, so it's tracked separately and
// shown as an explicit "no A2A yet" state in the drill-in panel.
const [peekedPair, setPeekedPair] = useState<PeekedPair | null>(null);
const {
data: conversationData,
isLoading: loadingConversations,
error,
refetch: refetchConversations,
} = useA2AConversations();
} = useA2AConversations(100);
const {
data: pairsData,
isLoading: loadingPairs,
refetch: refetchPairs,
} = useA2AAdminPairs();
const {
data: messagesData,
isLoading: loadingMessages,
@@ -63,10 +93,11 @@ function A2APageContent() {
// `a2a.message` frame. Invalidate-on-frame (the session-detail idiom) — the
// frame's excerpt is capped by design, so REST stays the source of truth and
// react-query refetches the affected queries.
const { lastMessage, isConnected } = useA2ALiveStream();
const { lastMessage, a2aMessages, isConnected } = useA2ALiveStream();
useEffect(() => {
if (lastMessage?.type !== "a2a.message") return;
queryClient.invalidateQueries({ queryKey: a2aLiveKeys.conversations });
queryClient.invalidateQueries({ queryKey: a2aLiveKeys.pairs });
if (selectedId && lastMessage.conversation_id === selectedId) {
queryClient.invalidateQueries({
queryKey: a2aLiveKeys.messages(selectedId),
@@ -74,8 +105,17 @@ function A2APageContent() {
}
}, [lastMessage, queryClient, selectedId]);
const pairs = useMemo(() => pairsData?.items ?? [], [pairsData]);
// Activity = A2A only: derived purely from a2a.message frames, never from
// verb/flow traffic on the same /ws/system stream.
const pulses = useMemo(
() => latestPulseTimestamps(a2aMessages, pairs),
[a2aMessages, pairs],
);
const handleSelect = useCallback(
(id: string) => {
setPeekedPair(null);
const params = new URLSearchParams(searchParams.toString());
params.set("conversation", id);
router.push(`/a2a?${params.toString()}`);
@@ -83,8 +123,26 @@ function A2APageContent() {
[router, searchParams],
);
const handleOpenPair = useCallback(
(pair: AdminPairSummary) => {
if (pair.conversation_id) {
handleSelect(pair.conversation_id);
return;
}
// Never-talked pair: nothing to select, clear any prior selection and
// show the pair's own empty state instead.
setPeekedPair({ agent_a: pair.agent_a, agent_b: pair.agent_b });
const params = new URLSearchParams(searchParams.toString());
params.delete("conversation");
const qs = params.toString();
router.push(qs ? `/a2a?${qs}` : "/a2a");
},
[handleSelect, router, searchParams],
);
const handleRefresh = () => {
refetchConversations();
refetchPairs();
if (selectedId) refetchMessages();
};
@@ -137,20 +195,56 @@ function A2APageContent() {
/>
) : (
<div className="grid grid-cols-12 gap-4 lg:gap-6 lg:flex-1 lg:min-h-0">
{/* Panel 1: Conversations */}
{/* Panel 1: Switchboard (default) / classic conversation list */}
<Card className="col-span-12 lg:col-span-4 flex flex-col overflow-hidden">
<CardContent className="p-3 flex flex-col h-full">
<div className="flex items-center gap-2 mb-3 pb-2 border-b">
<Radio className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Conversations</span>
<span className="text-sm font-medium">
{view === "switchboard" ? "Switchboard" : "Conversations"}
</span>
<div className="ml-auto flex items-center gap-1">
<Button
type="button"
variant={view === "switchboard" ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2"
aria-pressed={view === "switchboard"}
onClick={() => setView("switchboard")}
title="Switchboard: org-chart pair cards"
>
<LayoutGrid className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant={view === "list" ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2"
aria-pressed={view === "list"}
onClick={() => setView("list")}
title="Classic conversation list"
>
<ListIcon className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="flex-1 overflow-hidden -mx-3">
<A2AConversationList
conversations={conversations}
selectedId={selectedId}
onSelect={handleSelect}
isLoading={loadingConversations}
/>
{view === "switchboard" ? (
<A2ASwitchboard
pairs={pairs}
pulses={pulses}
selectedConversationId={selectedId}
isLoading={loadingPairs}
onOpenPair={handleOpenPair}
/>
) : (
<A2AConversationList
conversations={conversations}
selectedId={selectedId}
onSelect={handleSelect}
isLoading={loadingConversations}
/>
)}
</div>
</CardContent>
</Card>
@@ -211,6 +305,17 @@ function A2APageContent() {
)}
</div>
</>
) : peekedPair ? (
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center p-4 max-w-xs">
<MessagesSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">
{getAgentDisplayName(peekedPair.agent_a)} and{" "}
{getAgentDisplayName(peekedPair.agent_b)} haven&apos;t
A2A&apos;d each other yet.
</p>
</div>
</div>
) : (
<EmptyPanel
icon={MessagesSquare}
@@ -0,0 +1,199 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import type { AdminPairSummary } from "@/lib/api/a2a";
import { A2APairCard } from "../a2a-pair-card";
function buildPair(overrides: Partial<AdminPairSummary> = {}): AdminPairSummary {
return {
agent_a: "be-dev-1",
role_a: "developer",
team_a: "backend",
agent_b: "be-qa",
role_b: "qa",
team_b: "backend",
group_key: "cell-backend",
conversation_id: "conv-1",
last_message_at: "2026-07-02T09:00:00Z",
message_count: 5,
...overrides,
};
}
describe("A2APairCard", () => {
it("renders both display names, message count, and relative time", () => {
render(
<A2APairCard pair={buildPair()} pulsedAt={null} onOpen={vi.fn()} />,
);
expect(screen.getByText(/Backend Dev 1/)).toBeInTheDocument();
expect(screen.getByText(/Backend QA/)).toBeInTheDocument();
expect(screen.getByText("5")).toBeInTheDocument();
expect(screen.getByText(/ago$/)).toBeInTheDocument();
});
it("fires onOpen when clicked", () => {
const onOpen = vi.fn();
render(<A2APairCard pair={buildPair()} pulsedAt={null} onOpen={onOpen} />);
fireEvent.click(screen.getByTestId("pair-card"));
expect(onOpen).toHaveBeenCalledTimes(1);
});
it("renders dimmed and shows 'No A2A yet' for a never-talked pair", () => {
render(
<A2APairCard
pair={buildPair({ conversation_id: null, last_message_at: null })}
pulsedAt={null}
onOpen={vi.fn()}
/>,
);
expect(screen.getByText("No A2A yet")).toBeInTheDocument();
expect(screen.getByTestId("pair-card")).toHaveClass("opacity-60");
// No message-count badge for a pair with no history.
expect(screen.queryByText("5")).not.toBeInTheDocument();
});
it("marks the card as selected via aria-pressed", () => {
render(
<A2APairCard
pair={buildPair()}
pulsedAt={null}
isSelected
onOpen={vi.fn()}
/>,
);
expect(screen.getByTestId("pair-card")).toHaveAttribute(
"aria-pressed",
"true",
);
});
});
describe("A2APairCard pulsing (frame -> matching card lights up, then fades)", () => {
// Capture the rAF callback instead of letting it fire on the real event
// loop, so the activation/fade transition is fully deterministic.
let rafCallback: FrameRequestCallback | null = null;
beforeEach(() => {
rafCallback = null;
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
rafCallback = cb;
return 1;
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("starts cold with no pulse", () => {
render(<A2APairCard pair={buildPair()} pulsedAt={null} onOpen={vi.fn()} />);
expect(screen.getByTestId("pair-card")).toHaveAttribute(
"data-pulsing",
"false",
);
});
it("goes hot the instant a matching pulsedAt is received", () => {
const { rerender } = render(
<A2APairCard pair={buildPair()} pulsedAt={null} onOpen={vi.fn()} />,
);
rerender(
<A2APairCard
pair={buildPair()}
pulsedAt={1700000000000}
onOpen={vi.fn()}
/>,
);
expect(screen.getByTestId("pair-card")).toHaveAttribute(
"data-pulsing",
"true",
);
});
it("fades back to cold on the next paint frame (CSS transition then does the decay)", () => {
const { rerender } = render(
<A2APairCard pair={buildPair()} pulsedAt={null} onOpen={vi.fn()} />,
);
rerender(
<A2APairCard
pair={buildPair()}
pulsedAt={1700000000000}
onOpen={vi.fn()}
/>,
);
expect(screen.getByTestId("pair-card")).toHaveAttribute(
"data-pulsing",
"true",
);
act(() => {
rafCallback?.(0);
});
expect(screen.getByTestId("pair-card")).toHaveAttribute(
"data-pulsing",
"false",
);
});
it("does not re-trigger the pulse for an unchanged pulsedAt", () => {
const { rerender } = render(
<A2APairCard
pair={buildPair()}
pulsedAt={1700000000000}
onOpen={vi.fn()}
/>,
);
act(() => {
rafCallback?.(0);
});
expect(screen.getByTestId("pair-card")).toHaveAttribute(
"data-pulsing",
"false",
);
// Same pulsedAt as before (e.g. an unrelated parent re-render) — must
// stay cooled, not flash again.
rerender(
<A2APairCard
pair={buildPair()}
pulsedAt={1700000000000}
onOpen={vi.fn()}
/>,
);
expect(screen.getByTestId("pair-card")).toHaveAttribute(
"data-pulsing",
"false",
);
});
it("re-triggers on a newer pulsedAt after cooling down", () => {
const { rerender } = render(
<A2APairCard
pair={buildPair()}
pulsedAt={1700000000000}
onOpen={vi.fn()}
/>,
);
act(() => {
rafCallback?.(0);
});
expect(screen.getByTestId("pair-card")).toHaveAttribute(
"data-pulsing",
"false",
);
rerender(
<A2APairCard
pair={buildPair()}
pulsedAt={1700000005000}
onOpen={vi.fn()}
/>,
);
expect(screen.getByTestId("pair-card")).toHaveAttribute(
"data-pulsing",
"true",
);
});
});
@@ -0,0 +1,219 @@
import { describe, it, expect } from "vitest";
import type { AdminPairSummary } from "@/lib/api/a2a";
import {
pairKey,
pairMatchesFrame,
latestPulseTimestamps,
groupPairsBySection,
sortPairsForSection,
SECTION_LABELS,
} from "../a2a-switchboard-utils";
function buildPair(overrides: Partial<AdminPairSummary> = {}): AdminPairSummary {
return {
agent_a: "be-dev-1",
role_a: "developer",
team_a: "backend",
agent_b: "be-qa",
role_b: "qa",
team_b: "backend",
group_key: "cell-backend",
conversation_id: null,
last_message_at: null,
message_count: 0,
...overrides,
};
}
describe("pairKey", () => {
it("is order-independent", () => {
expect(pairKey("be-dev-1", "be-qa")).toBe(pairKey("be-qa", "be-dev-1"));
});
it("always puts the lexically smaller slug first", () => {
expect(pairKey("be-qa", "be-dev-1")).toBe("be-dev-1|be-qa");
});
});
describe("pairMatchesFrame", () => {
it("matches regardless of from/to direction", () => {
expect(pairMatchesFrame("be-dev-1", "be-qa", "be-qa", "be-dev-1")).toBe(
true,
);
expect(pairMatchesFrame("be-dev-1", "be-qa", "be-dev-1", "be-qa")).toBe(
true,
);
});
it("does not match a different pair", () => {
expect(pairMatchesFrame("be-dev-1", "be-qa", "fe-dev-1", "fe-qa")).toBe(
false,
);
});
it("does not match when either side is missing", () => {
expect(pairMatchesFrame("be-dev-1", "be-qa", undefined, "be-qa")).toBe(
false,
);
expect(pairMatchesFrame("be-dev-1", "be-qa", "be-dev-1", null)).toBe(
false,
);
});
});
describe("latestPulseTimestamps", () => {
const pairs = [
buildPair({ agent_a: "be-dev-1", agent_b: "be-qa" }),
buildPair({
agent_a: "fe-dev-1",
agent_b: "fe-qa",
group_key: "cell-frontend",
}),
];
it("returns the epoch ms of the matching frame for a pulsed pair", () => {
const result = latestPulseTimestamps(
[
{
from_agent: "be-dev-1",
to_agent: "be-qa",
timestamp: "2026-07-02T10:00:00Z",
},
],
pairs,
);
expect(result[pairKey("be-dev-1", "be-qa")]).toBe(
new Date("2026-07-02T10:00:00Z").getTime(),
);
});
it("omits pairs with no matching frame", () => {
const result = latestPulseTimestamps(
[
{
from_agent: "be-dev-1",
to_agent: "be-qa",
timestamp: "2026-07-02T10:00:00Z",
},
],
pairs,
);
expect(result[pairKey("fe-dev-1", "fe-qa")]).toBeUndefined();
});
it("keeps only the most recent matching frame per pair", () => {
const result = latestPulseTimestamps(
[
{
from_agent: "be-dev-1",
to_agent: "be-qa",
timestamp: "2026-07-02T09:00:00Z",
},
{
from_agent: "be-qa",
to_agent: "be-dev-1",
timestamp: "2026-07-02T11:00:00Z",
},
],
pairs,
);
expect(result[pairKey("be-dev-1", "be-qa")]).toBe(
new Date("2026-07-02T11:00:00Z").getTime(),
);
});
it("ignores frames with an unparseable timestamp", () => {
const result = latestPulseTimestamps(
[{ from_agent: "be-dev-1", to_agent: "be-qa", timestamp: "not-a-date" }],
pairs,
);
expect(result[pairKey("be-dev-1", "be-qa")]).toBeUndefined();
});
it("returns an empty map for an empty frame list", () => {
expect(latestPulseTimestamps([], pairs)).toEqual({});
});
});
describe("sortPairsForSection", () => {
it("sorts pairs with history before never-talked pairs, most recent first", () => {
const older = buildPair({
agent_a: "be-dev-1",
agent_b: "be-doc",
conversation_id: "c1",
last_message_at: "2026-07-01T00:00:00Z",
});
const newer = buildPair({
agent_a: "be-dev-2",
agent_b: "be-doc",
conversation_id: "c2",
last_message_at: "2026-07-02T00:00:00Z",
});
const neverTalked = buildPair({
agent_a: "be-pm",
agent_b: "be-qa",
conversation_id: null,
last_message_at: null,
});
const sorted = sortPairsForSection([older, neverTalked, newer]);
expect(sorted.map((p) => p.conversation_id)).toEqual(["c2", "c1", null]);
});
it("breaks ties between never-talked pairs alphabetically", () => {
const b = buildPair({ agent_a: "fe-dev-1", agent_b: "fe-qa" });
const a = buildPair({ agent_a: "be-dev-1", agent_b: "be-qa" });
const sorted = sortPairsForSection([b, a]);
expect(sorted[0]).toBe(a);
expect(sorted[1]).toBe(b);
});
});
describe("groupPairsBySection", () => {
it("groups pairs by group_key and orders sections canonically", () => {
const pairs = [
buildPair({ group_key: "cross", agent_a: "be-pm", agent_b: "fe-pm" }),
buildPair({
group_key: "board",
agent_a: "auditor",
agent_b: "product-owner",
}),
buildPair({ group_key: "cell-backend" }),
buildPair({
group_key: "pm-chain",
agent_a: "be-pm",
agent_b: "main-pm",
}),
];
const sections = groupPairsBySection(pairs);
expect(sections.map((s) => s.groupKey)).toEqual([
"cell-backend",
"pm-chain",
"board",
"cross",
]);
for (const section of sections) {
expect(section.label).toBe(SECTION_LABELS[section.groupKey]);
expect(section.pairs.length).toBe(1);
}
});
it("appends unrecognized group keys after the canonical sections", () => {
const pairs = [
buildPair({ group_key: "cell-backend" }),
buildPair({ group_key: "mystery-group" }),
];
const sections = groupPairsBySection(pairs);
expect(sections.map((s) => s.groupKey)).toEqual([
"cell-backend",
"mystery-group",
]);
expect(sections[1].label).toBe("mystery-group");
});
it("returns no sections for an empty pair list", () => {
expect(groupPairsBySection([])).toEqual([]);
});
});
@@ -0,0 +1,134 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import type { AdminPairSummary } from "@/lib/api/a2a";
import { A2ASwitchboard } from "../a2a-switchboard";
function buildPair(overrides: Partial<AdminPairSummary> = {}): AdminPairSummary {
return {
agent_a: "be-dev-1",
role_a: "developer",
team_a: "backend",
agent_b: "be-qa",
role_b: "qa",
team_b: "backend",
group_key: "cell-backend",
conversation_id: null,
last_message_at: null,
message_count: 0,
...overrides,
};
}
describe("A2ASwitchboard", () => {
it("shows a loading skeleton grid", () => {
render(
<A2ASwitchboard
pairs={[]}
pulses={{}}
selectedConversationId={null}
isLoading
onOpenPair={vi.fn()}
/>,
);
// No section headings or empty-state copy while loading.
expect(screen.queryByText(/Backend Cell/)).not.toBeInTheDocument();
expect(
screen.queryByText(/No allowed A2A pairs configured/),
).not.toBeInTheDocument();
});
it("shows an empty state when there are no pairs", () => {
render(
<A2ASwitchboard
pairs={[]}
pulses={{}}
selectedConversationId={null}
isLoading={false}
onOpenPair={vi.fn()}
/>,
);
expect(
screen.getByText("No allowed A2A pairs configured"),
).toBeInTheDocument();
});
it("groups pairs into labeled sections with counts", () => {
const pairs = [
buildPair({ group_key: "cell-backend" }),
buildPair({
group_key: "cell-backend",
agent_a: "be-dev-2",
agent_b: "be-doc",
}),
buildPair({
group_key: "board",
agent_a: "auditor",
agent_b: "product-owner",
}),
];
render(
<A2ASwitchboard
pairs={pairs}
pulses={{}}
selectedConversationId={null}
isLoading={false}
onOpenPair={vi.fn()}
/>,
);
expect(screen.getByText(/Backend Cell/)).toBeInTheDocument();
expect(screen.getByText("(2)")).toBeInTheDocument();
expect(screen.getByText(/^Board$/)).toBeInTheDocument();
expect(screen.getByText("(1)")).toBeInTheDocument();
expect(screen.getAllByTestId("pair-card")).toHaveLength(3);
});
it("calls onOpenPair with the clicked pair", () => {
const onOpenPair = vi.fn();
const pair = buildPair({ conversation_id: "conv-9" });
render(
<A2ASwitchboard
pairs={[pair]}
pulses={{}}
selectedConversationId={null}
isLoading={false}
onOpenPair={onOpenPair}
/>,
);
fireEvent.click(screen.getByTestId("pair-card"));
expect(onOpenPair).toHaveBeenCalledWith(pair);
});
it("marks the card matching selectedConversationId as selected", () => {
const pair = buildPair({ conversation_id: "conv-9" });
render(
<A2ASwitchboard
pairs={[pair]}
pulses={{}}
selectedConversationId="conv-9"
isLoading={false}
onOpenPair={vi.fn()}
/>,
);
expect(screen.getByTestId("pair-card")).toHaveAttribute(
"aria-pressed",
"true",
);
});
it("passes each pair's pulse timestamp through by canonical pair key", () => {
const pair = buildPair();
render(
<A2ASwitchboard
pairs={[pair]}
pulses={{ "be-dev-1|be-qa": 1700000000000 }}
selectedConversationId={null}
isLoading={false}
onOpenPair={vi.fn()}
/>,
);
expect(screen.getByTestId("pair-card")).toHaveAttribute(
"data-pulsing",
"true",
);
});
});
+117
View File
@@ -0,0 +1,117 @@
"use client";
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { getAgentDisplayName, getAgentInitials } from "@/lib/agent-utils";
import type { AdminPairSummary } from "@/lib/api/a2a";
import { cn } from "@/lib/utils";
import { formatDistanceToNow } from "date-fns";
import { PAIR_PULSE_FADE_MS } from "./a2a-switchboard-utils";
interface A2APairCardProps {
pair: AdminPairSummary;
/** Epoch ms of the latest a2a.message frame matching this pair, or null
* when neither side has ever A2A'd the other (this session). */
pulsedAt: number | null;
isSelected?: boolean;
onOpen: () => void;
}
function PairAvatar({ slug }: { slug: string }) {
return (
<div
className="h-7 w-7 rounded-full bg-primary/10 border flex items-center justify-center shrink-0"
title={getAgentDisplayName(slug)}
>
<span className="text-[9px] font-bold tracking-tight">
{getAgentInitials(slug)}
</span>
</div>
);
}
/**
* One pair card in the A2A switchboard. Lights up on a matching a2a.message
* frame — the card jumps to full "hot" intensity, then a plain CSS
* transition (no animation library) fades it back to baseline over
* PAIR_PULSE_FADE_MS. A never-talked pair (no conversation_id) renders
* dimmed/compact.
*/
export function A2APairCard({
pair,
pulsedAt,
isSelected,
onOpen,
}: A2APairCardProps) {
const hasHistory = pair.conversation_id !== null;
const [isPulsing, setIsPulsing] = useState(false);
// Render-phase derivation, not an Effect (react.dev/learn/you-might-not-
// need-an-effect#adjusting-some-state-when-a-prop-changes): flash hot in
// the very same render that receives a new pulsedAt, comparing against the
// last value we've seen. No cascading extra render from an Effect body.
// Seeded to null (not the initial pulsedAt) so a card that *mounts*
// already carrying a live pulse — e.g. switching into switchboard view
// right after a frame arrived — still flashes hot instead of looking cold.
const [lastSeenPulse, setLastSeenPulse] = useState<number | null>(null);
if (pulsedAt !== lastSeenPulse) {
setLastSeenPulse(pulsedAt);
if (pulsedAt !== null) setIsPulsing(true);
}
// Flip back on the next paint frame — the long CSS transition-duration
// below then animates the decay from "hot" to baseline over
// PAIR_PULSE_FADE_MS. The setState here is inside the (async) rAF
// callback, not the Effect body itself, so it's the intended "subscribe to
// an external clock" use of an Effect.
useEffect(() => {
if (!isPulsing) return;
const raf = requestAnimationFrame(() => setIsPulsing(false));
return () => cancelAnimationFrame(raf);
}, [isPulsing]);
return (
<button
type="button"
onClick={onOpen}
data-testid="pair-card"
data-pulsing={isPulsing}
data-group={pair.group_key}
aria-pressed={!!isSelected}
className={cn(
"w-full text-left rounded-lg border p-2.5 cursor-pointer",
"transition-[background-color,box-shadow] ease-out",
isSelected ? "border-primary" : "border-border",
!hasHistory && "opacity-60",
isPulsing
? "bg-emerald-500/15 shadow-[0_0_0_1px_rgba(16,185,129,0.6)]"
: "bg-card hover:bg-muted/50",
)}
style={{ transitionDuration: `${PAIR_PULSE_FADE_MS}ms` }}
>
<div className="flex items-center gap-2">
<div className="flex -space-x-2">
<PairAvatar slug={pair.agent_a} />
<PairAvatar slug={pair.agent_b} />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">
{getAgentDisplayName(pair.agent_a)}
{" ↔ "}
{getAgentDisplayName(pair.agent_b)}
</div>
<div className="text-xs text-muted-foreground">
{hasHistory && pair.last_message_at
? `${formatDistanceToNow(new Date(pair.last_message_at))} ago`
: "No A2A yet"}
</div>
</div>
{hasHistory && (
<Badge variant="secondary" className="text-[10px] shrink-0">
{pair.message_count}
</Badge>
)}
</div>
</button>
);
}
@@ -0,0 +1,135 @@
/**
* Pure helpers for the A2A switchboard (org-chart pair cards) — extracted
* for direct unit testing, same idiom as a2a-utils.ts.
*/
import type { AdminPairSummary } from "@/lib/api/a2a";
/** How long a card stays visibly "hot" after a matching frame (CSS transition). */
export const PAIR_PULSE_FADE_MS = 45_000;
/** Canonical, order-independent key for a pair — stable regardless of which
* slug is passed first. */
export function pairKey(agentA: string, agentB: string): string {
return agentA < agentB ? `${agentA}|${agentB}` : `${agentB}|${agentA}`;
}
/** True when an A2A live-stream frame's from/to agents are this unordered pair. */
export function pairMatchesFrame(
agentA: string,
agentB: string,
frameFrom?: string | null,
frameTo?: string | null,
): boolean {
if (!frameFrom || !frameTo) return false;
return (
(frameFrom === agentA && frameTo === agentB) ||
(frameFrom === agentB && frameTo === agentA)
);
}
interface PulseFrame {
from_agent?: string | null;
to_agent?: string | null;
timestamp?: string | null;
}
/**
* For each pair, the epoch ms of the most recent matching frame — or absent
* from the map when no frame has matched. Bounded: O(pairs * frames), both
* small (the switchboard's static matrix, and the live-stream's capped
* message buffer).
*/
export function latestPulseTimestamps(
frames: ReadonlyArray<PulseFrame>,
pairs: ReadonlyArray<Pick<AdminPairSummary, "agent_a" | "agent_b">>,
): Record<string, number> {
const out: Record<string, number> = {};
for (const pair of pairs) {
const key = pairKey(pair.agent_a, pair.agent_b);
for (const frame of frames) {
if (
!pairMatchesFrame(
pair.agent_a,
pair.agent_b,
frame.from_agent,
frame.to_agent,
)
) {
continue;
}
const ts = frame.timestamp ? new Date(frame.timestamp).getTime() : NaN;
if (Number.isNaN(ts)) continue;
if (out[key] === undefined || ts > out[key]) out[key] = ts;
}
}
return out;
}
/** Stable section ordering — cells first (org-chart top-down), then the PM
* chain, board, and finally the lateral catch-all. */
export const SECTION_ORDER = [
"cell-backend",
"cell-frontend",
"cell-ux_ui",
"pm-chain",
"board",
"cross",
] as const;
export const SECTION_LABELS: Record<string, string> = {
"cell-backend": "Backend Cell",
"cell-frontend": "Frontend Cell",
"cell-ux_ui": "UX/UI Cell",
"pm-chain": "PM Chain",
board: "Board",
cross: "Cross-Team",
};
export interface PairSection {
groupKey: string;
label: string;
pairs: AdminPairSummary[];
}
/** Pairs with history sort first (most recent activity first); never-talked
* pairs follow in a stable alphabetical order. */
export function sortPairsForSection(
pairs: ReadonlyArray<AdminPairSummary>,
): AdminPairSummary[] {
return [...pairs].sort((a, b) => {
const aTime = a.last_message_at
? new Date(a.last_message_at).getTime()
: null;
const bTime = b.last_message_at
? new Date(b.last_message_at).getTime()
: null;
if (aTime !== null && bTime !== null) return bTime - aTime;
if (aTime !== null) return -1;
if (bTime !== null) return 1;
return `${a.agent_a}${a.agent_b}`.localeCompare(`${b.agent_a}${b.agent_b}`);
});
}
/** Group pairs into ordered, labeled sections for the switchboard grid. */
export function groupPairsBySection(
pairs: ReadonlyArray<AdminPairSummary>,
): PairSection[] {
const byGroup = new Map<string, AdminPairSummary[]>();
for (const pair of pairs) {
const list = byGroup.get(pair.group_key) ?? [];
list.push(pair);
byGroup.set(pair.group_key, list);
}
const known = SECTION_ORDER.filter((key) => byGroup.has(key));
const unknown = [...byGroup.keys()].filter(
(key) => !(SECTION_ORDER as readonly string[]).includes(key),
);
return [...known, ...unknown].map((groupKey) => ({
groupKey,
label: SECTION_LABELS[groupKey] ?? groupKey,
pairs: sortPairsForSection(byGroup.get(groupKey) ?? []),
}));
}
@@ -0,0 +1,86 @@
"use client";
import { Radio } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import type { AdminPairSummary } from "@/lib/api/a2a";
import { A2APairCard } from "./a2a-pair-card";
import { groupPairsBySection, pairKey } from "./a2a-switchboard-utils";
interface A2ASwitchboardProps {
pairs: AdminPairSummary[];
/** pairKey(agent_a, agent_b) -> epoch ms of the latest matching frame. */
pulses: Record<string, number>;
selectedConversationId: string | null;
isLoading: boolean;
onOpenPair: (pair: AdminPairSummary) => void;
}
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.
*/
export function A2ASwitchboard({
pairs,
pulses,
selectedConversationId,
isLoading,
onOpenPair,
}: A2ASwitchboardProps) {
if (isLoading) {
return (
<div className="p-2 grid grid-cols-1 sm:grid-cols-2 gap-2">
{Array.from({ length: SKELETON_COUNT }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
);
}
if (pairs.length === 0) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center p-4">
<Radio className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No allowed A2A pairs configured</p>
</div>
</div>
);
}
const sections = groupPairsBySection(pairs);
return (
<div className="h-full overflow-y-auto p-2 space-y-4">
{sections.map((section) => (
<div key={section.groupKey}>
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2 px-1">
{section.label}
<span className="ml-1.5 text-muted-foreground/60 normal-case">
({section.pairs.length})
</span>
</h3>
<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>
</div>
))}
</div>
);
}
+12
View File
@@ -6,6 +6,7 @@ import { a2aApi, type AdminReplyRequest } from "@/lib/api/a2a";
export const a2aLiveKeys = {
all: ["a2a-live"] as const,
conversations: ["a2a-live", "conversations"] as const,
pairs: ["a2a-live", "pairs"] as const,
messages: (conversationId: string) =>
["a2a-live", "messages", conversationId] as const,
};
@@ -20,6 +21,17 @@ export function useA2AConversations(limit?: number) {
});
}
// Switchboard pair cards (the org-chart view) — every allowed agent pair
// joined with its representative conversation stats. Refreshed by WS
// `a2a.message` invalidation, same as the conversation list.
export function useA2AAdminPairs() {
return useQuery({
queryKey: a2aLiveKeys.pairs,
queryFn: () => a2aApi.listAdminPairs(),
staleTime: 30_000,
});
}
// Transcript for one conversation. WS frames for the selected conversation
// invalidate this key; full bodies always come from REST (excerpts are capped).
export function useA2AMessages(conversationId: string | null) {
+66
View File
@@ -103,6 +103,29 @@ export interface AdminConversationListResponse {
total: number;
}
/**
* One pair card for the CEO's A2A switchboard (org-chart view) — the static
* can_a2a_direct matrix joined with the pair's representative conversation
* stats when one exists.
*/
export interface AdminPairSummary {
agent_a: string;
role_a: string;
team_a: string;
agent_b: string;
role_b: string;
team_b: string;
group_key: string;
conversation_id: string | null;
last_message_at: string | null;
message_count: number;
}
export interface AdminPairListResponse {
items: AdminPairSummary[];
total: number;
}
export interface AdminMessageListResponse {
items: A2AChatMessage[];
total: number;
@@ -322,6 +345,49 @@ export const a2aApi = {
return data;
},
/**
* List the org-chart switchboard's pair cards (CEO-only): every agent pair
* allowed to A2A directly, joined with each pair's representative
* conversation stats when one exists.
*/
listAdminPairs: async (): Promise<AdminPairListResponse> => {
if (isMockMode()) {
return {
items: [
{
agent_a: "be-dev-1",
role_a: "developer",
team_a: "backend",
agent_b: "be-qa",
role_b: "qa",
team_b: "backend",
group_key: "cell-backend",
conversation_id: "mock-conversation-1",
last_message_at: new Date().toISOString(),
message_count: 3,
},
{
agent_a: "auditor",
role_a: "auditor",
team_a: "board",
agent_b: "product-owner",
role_b: "product_owner",
team_b: "board",
group_key: "board",
conversation_id: null,
last_message_at: null,
message_count: 0,
},
],
total: 2,
};
}
const { data } = await api.get<AdminPairListResponse>(
"/a2a/chat/admin/pairs",
);
return data;
},
/**
* Send a CEO reply. Lands in the CEO<->to_agent pairwise conversation (the
* A2A model is strictly pairwise), NOT inside the watched transcript.