Files
roboco/panel/src/components/a2a/__tests__/a2a-view.test.tsx
T
4480dfe8d1 feat: Agents hub — Fleet + Conversations tabs, /a2a merged in, DM quick-action (#558)
* feat(panel): Agents hub — Fleet and Conversations tabs, /a2a redirect, DM quick-action

* fix(panel): validate deep-linked DM targets against roster and exclusions; re-arm the dm latch

* docs(map): panel entries for this wave

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-18 07:06:32 +02:00

543 lines
18 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { PageRefreshProvider } from "@/components/providers";
import type {
AdminConversationSummary,
AdminPairSummary,
A2AChatMessage,
} from "@/lib/api/a2a";
// Extracted from the standalone /a2a page (now the Agents hub's
// Conversations tab, see agents/page.tsx) — pure lift, same suite, new
// import, plus a new describe block for the `?dm=` quick-action handshake.
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,
},
}));
const mockPush = vi.fn();
const mockReplace = vi.fn();
// Stable objects (like real next/navigation — useRouter()'s return value and
// useSearchParams()'s per-URL value only change reference on an actual
// navigation, not on every render), same idiom as
// workstation/__tests__/page.test.tsx's `searchParams` variable. An unstable
// mock here would make the dm-param effect (deps: [dmParam, router,
// searchParams]) refire every render regardless of the real URL.
const mockRouter = { push: mockPush, replace: mockReplace };
let searchParams = new URLSearchParams("conversation=conv-1");
vi.mock("next/navigation", () => ({
useRouter: () => mockRouter,
useSearchParams: () => searchParams,
}));
vi.mock("@/hooks/use-agents", () => ({
useAgentDefinitions: () => ({
data: [
{ id: "be-dev-1", name: "Backend Dev 1", role: "developer", team: "backend" },
],
}),
}));
vi.mock("@/hooks/use-a2a-live", () => ({
a2aLiveKeys,
useA2AConversations,
useA2AMessages,
useA2AAdminPairs,
useReplyAsCeo: () => ({ mutate: vi.fn(), isPending: false }),
useCreateCeoConversation: () => ({ mutate: vi.fn(), isPending: false }),
useSendCeoMessage: () => ({ mutate: vi.fn(), isPending: false }),
}));
// AgentSelector (inside A2ANewDmDialog) pulls in useAgentDefinitions + Radix
// Select — irrelevant to this suite, stub it out like create-task-dialog's
// suite does for the same component.
vi.mock("@/components/agents/agent-selector", () => ({
AgentSelector: () => null,
}));
vi.mock("@/hooks/use-websocket", () => ({
useA2ALiveStream,
}));
// The xl:+ context pane's linked-task summary fetches via useTask — stub it
// so this suite doesn't need a real QueryClientProvider.
vi.mock("@/hooks/use-tasks", () => ({
useTask: () => ({ data: undefined, isLoading: false }),
}));
vi.mock("@tanstack/react-query", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tanstack/react-query")>();
return {
...actual,
useQueryClient: vi.fn(() => ({ invalidateQueries })),
};
});
vi.mock("@/components/ui/markdown", () => ({
Markdown: ({ children }: { children: string }) => <div>{children}</div>,
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
import { A2AView } from "../a2a-view";
function withPageRefresh(ui: ReactNode) {
return <PageRefreshProvider>{ui}</PageRefreshProvider>;
}
function buildConversation(
overrides: Partial<AdminConversationSummary> = {},
): AdminConversationSummary {
return {
id: "conv-1",
agent_a: "be-dev-1",
agent_b: "be-qa",
topic: "QA handoff",
task_id: "11111111-2222-3333-4444-555555555555",
status: "active",
message_count: 2,
last_message_at: "2026-07-02T09:00:00Z",
last_message_preview: "preview",
created_at: "2026-07-01T08:00:00Z",
updated_at: "2026-07-02T09:00:00Z",
...overrides,
};
}
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",
conversation_id: "conv-1",
from_agent: "be-qa",
content: "transcript body text",
message_kind: "text",
response_to_id: null,
requires_response: false,
read_at: null,
created_at: "2026-07-02T09:00:00Z",
edited_at: null,
};
}
describe("A2AView", () => {
beforeEach(() => {
searchParams = new URLSearchParams("conversation=conv-1");
mockPush.mockClear();
mockReplace.mockClear();
invalidateQueries.mockReset();
useA2AConversations.mockReturnValue({
data: { items: [buildConversation()], total: 1 },
isLoading: false,
error: null,
refetch: vi.fn(),
});
useA2AMessages.mockReturnValue({
data: { items: [buildMessage()], total: 1, has_more: false },
isLoading: false,
refetch: vi.fn(),
});
useA2AAdminPairs.mockReturnValue({
data: { items: [], total: 0 },
isLoading: false,
refetch: vi.fn(),
});
useA2ALiveStream.mockReturnValue({
lastMessage: null,
a2aMessages: [],
isConnected: true,
state: "connected",
});
});
it("shows the transcript pane and composer for a task-linked conversation", () => {
render(withPageRefresh(<A2AView />));
expect(screen.getByText("transcript body text")).toBeInTheDocument();
expect(screen.getByPlaceholderText(/chime in/i)).toBeInTheDocument();
expect(
screen.getByText(
/posts into this conversation — visible to both participants/i,
),
).toBeInTheDocument();
expect(screen.getByText("Live")).toBeInTheDocument();
});
it("keeps the composer for a closed but task-linked conversation", () => {
// The watched conversation's status must NOT gate the composer — the reply
// lands in the CEO's own direct thread with the participant.
useA2AConversations.mockReturnValue({
data: { items: [buildConversation({ status: "closed" })], total: 1 },
isLoading: false,
error: null,
refetch: vi.fn(),
});
render(withPageRefresh(<A2AView />));
expect(screen.getByPlaceholderText(/chime in/i)).toBeInTheDocument();
});
it("hides the composer and explains why for a task-less conversation", () => {
// task_id === null is the authoritative signal that the backend's reply
// route would 400 (replies require a task link), so the pane is read-only.
useA2AConversations.mockReturnValue({
data: { items: [buildConversation({ task_id: null })], total: 1 },
isLoading: false,
error: null,
refetch: vi.fn(),
});
render(withPageRefresh(<A2AView />));
expect(screen.queryByPlaceholderText(/chime in/i)).not.toBeInTheDocument();
expect(
screen.getByText(/no linked task, so a reply can't be sent/i),
).toBeInTheDocument();
});
it("invalidates conversations + pairs + selected messages on a matching a2a.message frame", () => {
useA2ALiveStream.mockReturnValue({
lastMessage: {
type: "a2a.message",
conversation_id: "conv-1",
message_id: "m9",
from_agent: "be-dev-1",
to_agent: "be-qa",
body_excerpt: "capped",
timestamp: "2026-07-02T10:00:00Z",
},
a2aMessages: [],
isConnected: true,
state: "connected",
});
render(withPageRefresh(<A2AView />));
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 + 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,
state: "disconnected",
});
render(withPageRefresh(<A2AView />));
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(withPageRefresh(<A2AView />));
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(withPageRefresh(<A2AView />));
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();
});
// M44: on /ws/system reconnect (isConnected false → true) the A2A list is
// stale (events missed during the disconnect); invalidate the a2a query
// family so react-query refetches.
it("invalidates a2a queries on a false → true reconnect transition", () => {
useA2ALiveStream.mockReturnValue({
lastMessage: null,
a2aMessages: [],
isConnected: false,
state: "disconnected",
});
const { rerender } = render(withPageRefresh(<A2AView />));
// No invalidation while offline.
expect(invalidateQueries).not.toHaveBeenCalledWith({
queryKey: a2aLiveKeys.all,
});
invalidateQueries.mockReset();
useA2ALiveStream.mockReturnValue({
lastMessage: null,
a2aMessages: [],
isConnected: true,
state: "connected",
});
act(() => {
rerender(withPageRefresh(<A2AView />));
});
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: a2aLiveKeys.all,
});
});
it("does not invalidate a2a queries on initial mount when already connected", () => {
// prevConnected starts unknown; a mount with isConnected=true must NOT
// fire a reconnect invalidation (only a real false → true transition does).
invalidateQueries.mockReset();
useA2ALiveStream.mockReturnValue({
lastMessage: null,
a2aMessages: [],
isConnected: true,
state: "connected",
});
render(withPageRefresh(<A2AView />));
expect(invalidateQueries).not.toHaveBeenCalledWith({
queryKey: a2aLiveKeys.all,
});
});
it("renders the filter trigger above the switchboard/list content", () => {
useA2AAdminPairs.mockReturnValue({
data: { items: [buildPair()], total: 1 },
isLoading: false,
refetch: vi.fn(),
});
render(withPageRefresh(<A2AView />));
expect(
screen.getByRole("button", { name: /^Filters$/ }),
).toBeInTheDocument();
});
it("narrows the switchboard's pairs by a selected agent", async () => {
const user = userEvent.setup();
useA2AAdminPairs.mockReturnValue({
data: {
items: [
buildPair(),
buildPair({
agent_a: "auditor",
agent_b: "product-owner",
group_key: "board",
conversation_id: null,
last_message_at: null,
message_count: 0,
}),
],
total: 2,
},
isLoading: false,
refetch: vi.fn(),
});
render(withPageRefresh(<A2AView />));
expect(screen.getByText(/Backend Cell/)).toBeInTheDocument();
expect(screen.getByText(/^Board$/)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /^Filters$/ }));
await user.click(screen.getByRole("checkbox", { name: "Auditor" }));
expect(screen.queryByText(/Backend Cell/)).not.toBeInTheDocument();
expect(screen.getByText(/^Board$/)).toBeInTheDocument();
});
it("narrows the classic list's conversations by a selected agent", async () => {
const user = userEvent.setup();
useA2AConversations.mockReturnValue({
data: {
items: [
buildConversation(),
buildConversation({
id: "conv-2",
agent_a: "ux-dev-1",
agent_b: "ux-qa",
topic: "Design review",
}),
],
total: 2,
},
isLoading: false,
error: null,
refetch: vi.fn(),
});
render(withPageRefresh(<A2AView />));
fireEvent.click(screen.getByTitle("Classic conversation list"));
expect(screen.getByText("QA handoff")).toBeInTheDocument();
expect(screen.getByText("Design review")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /^Filters$/ }));
await user.click(screen.getByRole("checkbox", { name: "UX/UI Dev 1" }));
expect(screen.queryByText("QA handoff")).not.toBeInTheDocument();
expect(screen.getByText("Design review")).toBeInTheDocument();
});
it("renders the New DM trigger in the header", () => {
render(withPageRefresh(<A2AView />));
expect(
screen.getByRole("button", { name: /new dm/i }),
).toBeInTheDocument();
});
it("uses the direct composer (no task required) for a CEO-owned conversation", () => {
// A CEO-initiated DM has no task link and no picker — it must render
// A2ADirectComposer, not the task-gated A2AReplyComposer.
useA2AConversations.mockReturnValue({
data: {
items: [
buildConversation({
agent_a: "ceo",
agent_b: "be-dev-1",
task_id: null,
}),
],
total: 1,
},
isLoading: false,
error: null,
refetch: vi.fn(),
});
render(withPageRefresh(<A2AView />));
expect(screen.getByPlaceholderText(/message\.\.\./i)).toBeInTheDocument();
expect(screen.queryByPlaceholderText(/chime in/i)).not.toBeInTheDocument();
expect(
screen.queryByText(/no linked task, so a reply can't be sent/i),
).not.toBeInTheDocument();
});
it("narrows the classic list's conversations by task id fragment", async () => {
const user = userEvent.setup();
useA2AConversations.mockReturnValue({
data: {
items: [
buildConversation({
task_id: "11111111-2222-3333-4444-555555555555",
}),
buildConversation({
id: "conv-2",
topic: "Design review",
task_id: "99999999-8888-7777-6666-555555555555",
}),
],
total: 2,
},
isLoading: false,
error: null,
refetch: vi.fn(),
});
render(withPageRefresh(<A2AView />));
fireEvent.click(screen.getByTitle("Classic conversation list"));
expect(screen.getByText("QA handoff")).toBeInTheDocument();
expect(screen.getByText("Design review")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /^Filters$/ }));
await user.type(screen.getByLabelText("Task id fragment"), "11111111");
expect(screen.getByText("QA handoff")).toBeInTheDocument();
expect(screen.queryByText("Design review")).not.toBeInTheDocument();
});
// DM quick-action deep link: the agent card's DM button lands here as
// `?tab=conversations&dm=<agent slug>`.
describe("`?dm=` quick-action handshake", () => {
it("opens the New DM dialog and strips the dm param, keeping the rest of the query string", () => {
searchParams = new URLSearchParams("tab=conversations&dm=be-dev-1");
render(withPageRefresh(<A2AView />));
expect(screen.getByText("New direct message")).toBeInTheDocument();
expect(mockReplace).toHaveBeenCalledTimes(1);
expect(mockReplace).toHaveBeenCalledWith("/agents?tab=conversations", {
scroll: false,
});
});
it("does not open the dialog when no dm param is present", () => {
render(withPageRefresh(<A2AView />));
expect(
screen.queryByText("New direct message"),
).not.toBeInTheDocument();
expect(mockReplace).not.toHaveBeenCalled();
});
it("re-arms for a repeated identical dm value after the strip (latch reset)", () => {
searchParams = new URLSearchParams("tab=conversations&dm=be-dev-1");
const { rerender } = render(withPageRefresh(<A2AView />));
expect(mockReplace).toHaveBeenCalledTimes(1);
// The strip landed: same mounted view, dm gone from the URL.
searchParams = new URLSearchParams("tab=conversations");
rerender(withPageRefresh(<A2AView />));
// The SAME dm value arrives again (re-pasted link / second click
// without a tab remount) — the handshake must fire again.
searchParams = new URLSearchParams("tab=conversations&dm=be-dev-1");
rerender(withPageRefresh(<A2AView />));
expect(mockReplace).toHaveBeenCalledTimes(2);
});
it("drops to a bare /agents path when dm was the only param", () => {
searchParams = new URLSearchParams("dm=be-dev-1");
render(withPageRefresh(<A2AView />));
expect(mockReplace).toHaveBeenCalledWith("/agents", { scroll: false });
});
});
});