mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Wave 2 features: A2A live view (CEO chime-in + reply budget) and prompter memory (#297)
* feat(a2a): live view — watch fleet conversations, CEO chime-in, reply budget A2A_MESSAGE_SENT published from A2AService.send (excerpt-capped) and fanned through the existing /ws/system bridge; CEO-only admin REST for conversations/messages + a reply route on the publish-bearing send path; panel /a2a page with live transcript and a composer gated on task-linked conversations. The matrix gains its one asymmetric rule: CEO may message anyone, nobody may target the CEO — and agent replies inside a CEO-opened conversation are hard-budgeted to one per CEO message (per conversation, per agent), rejected with wait-don't-retry guidance. Built subagent-driven (Sonnet 5), reviewed; v1 seams documented in the map delta. * feat(prompter): intake remembers the task history Intake spawns now carry a per-project chronological digest of recent tasks (capped: 15 lines/project, 4000 chars total — ~300-1000 tokens) merged into the ambient layer, and the interviewer gets a bounded search_past_tasks tool (one shared implementation behind the grok MCP tool and the Claude SDK in-process tool) to check precedent mid-conversation. Informational memory only — the sequencing analyzer keeps ownership of ordering. Built subagent-driven (Sonnet 5), reviewed; pre-existing conventions-ambient MegaTask-scope gap flagged, untouched. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { AdminConversationSummary, A2AChatMessage } from "@/lib/api/a2a";
|
||||
|
||||
const {
|
||||
useA2AConversations,
|
||||
useA2AMessages,
|
||||
useA2ALiveStream,
|
||||
invalidateQueries,
|
||||
a2aLiveKeys,
|
||||
} = vi.hoisted(() => ({
|
||||
useA2AConversations: vi.fn(),
|
||||
useA2AMessages: vi.fn(),
|
||||
useA2ALiveStream: vi.fn(),
|
||||
invalidateQueries: vi.fn(),
|
||||
a2aLiveKeys: {
|
||||
all: ["a2a-live"] as const,
|
||||
conversations: ["a2a-live", "conversations"] as const,
|
||||
messages: (conversationId: string) =>
|
||||
["a2a-live", "messages", conversationId] as const,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
useSearchParams: () => new URLSearchParams("conversation=conv-1"),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-a2a-live", () => ({
|
||||
a2aLiveKeys,
|
||||
useA2AConversations,
|
||||
useA2AMessages,
|
||||
useReplyAsCeo: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-websocket", () => ({
|
||||
useA2ALiveStream,
|
||||
}));
|
||||
|
||||
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 A2APage from "../page";
|
||||
|
||||
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 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("A2APage", () => {
|
||||
beforeEach(() => {
|
||||
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(),
|
||||
});
|
||||
useA2ALiveStream.mockReturnValue({
|
||||
lastMessage: null,
|
||||
isConnected: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the transcript pane and composer for a task-linked conversation", () => {
|
||||
render(<A2APage />);
|
||||
expect(screen.getByText("transcript body text")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/chime in/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
/direct A2A message from you to the selected participant/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(<A2APage />);
|
||||
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(<A2APage />);
|
||||
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 + 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",
|
||||
},
|
||||
isConnected: true,
|
||||
});
|
||||
render(<A2APage />);
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: a2aLiveKeys.conversations,
|
||||
});
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: a2aLiveKeys.messages("conv-1"),
|
||||
});
|
||||
});
|
||||
|
||||
it("only invalidates the conversation list for frames of other conversations", () => {
|
||||
useA2ALiveStream.mockReturnValue({
|
||||
lastMessage: {
|
||||
type: "a2a.message",
|
||||
conversation_id: "conv-other",
|
||||
timestamp: "2026-07-02T10:00:00Z",
|
||||
},
|
||||
isConnected: false,
|
||||
});
|
||||
render(<A2APage />);
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: a2aLiveKeys.conversations,
|
||||
});
|
||||
expect(invalidateQueries).not.toHaveBeenCalledWith({
|
||||
queryKey: a2aLiveKeys.messages("conv-1"),
|
||||
});
|
||||
expect(screen.getByText("Offline")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useCallback, useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
a2aLiveKeys,
|
||||
useA2AConversations,
|
||||
useA2AMessages,
|
||||
} from "@/hooks/use-a2a-live";
|
||||
import { useA2ALiveStream } from "@/hooks/use-websocket";
|
||||
import { A2AConversationList } from "@/components/a2a/a2a-conversation-list";
|
||||
import { A2ATranscript } from "@/components/a2a/a2a-transcript";
|
||||
import { A2AReplyComposer } from "@/components/a2a/a2a-reply-composer";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
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 { formatDistanceToNow } from "date-fns";
|
||||
|
||||
function EmptyPanel({
|
||||
icon: Icon,
|
||||
message,
|
||||
}: {
|
||||
icon: typeof MessagesSquare;
|
||||
message: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center p-4">
|
||||
<Icon className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function A2APageContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const selectedId = searchParams.get("conversation");
|
||||
|
||||
const {
|
||||
data: conversationData,
|
||||
isLoading: loadingConversations,
|
||||
error,
|
||||
refetch: refetchConversations,
|
||||
} = useA2AConversations();
|
||||
const {
|
||||
data: messagesData,
|
||||
isLoading: loadingMessages,
|
||||
refetch: refetchMessages,
|
||||
} = useA2AMessages(selectedId);
|
||||
|
||||
// Live wiring: every persisted A2A message is announced on /ws/system as an
|
||||
// `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();
|
||||
useEffect(() => {
|
||||
if (lastMessage?.type !== "a2a.message") return;
|
||||
queryClient.invalidateQueries({ queryKey: a2aLiveKeys.conversations });
|
||||
if (selectedId && lastMessage.conversation_id === selectedId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: a2aLiveKeys.messages(selectedId),
|
||||
});
|
||||
}
|
||||
}, [lastMessage, queryClient, selectedId]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("conversation", id);
|
||||
router.push(`/a2a?${params.toString()}`);
|
||||
},
|
||||
[router, searchParams],
|
||||
);
|
||||
|
||||
const handleRefresh = () => {
|
||||
refetchConversations();
|
||||
if (selectedId) refetchMessages();
|
||||
};
|
||||
|
||||
const conversations = conversationData?.items ?? [];
|
||||
const selected = conversations.find((c) => c.id === selectedId) ?? null;
|
||||
const messages = messagesData?.items ?? [];
|
||||
const lastSender = lastSenderOf(messages);
|
||||
|
||||
const isOffline =
|
||||
error &&
|
||||
(error.message?.includes("Network Error") ||
|
||||
(error as { code?: string })?.code === "ERR_NETWORK");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col lg:h-[calc(100vh-7rem)]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">A2A Live</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Live agent-to-agent conversations — watch and chime in
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
isConnected
|
||||
? "bg-emerald-500 animate-pulse"
|
||||
: "bg-muted-foreground/40",
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{isConnected ? "Live" : "Offline"}
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handleRefresh}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOffline ? (
|
||||
<OfflineState
|
||||
title="Cannot Load A2A Conversations"
|
||||
description="Start the RoboCo orchestrator to view agent-to-agent chats."
|
||||
onRetry={() => refetchConversations()}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-12 gap-4 lg:gap-6 lg:flex-1 lg:min-h-0">
|
||||
{/* Panel 1: Conversations */}
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden -mx-3">
|
||||
<A2AConversationList
|
||||
conversations={conversations}
|
||||
selectedId={selectedId}
|
||||
onSelect={handleSelect}
|
||||
isLoading={loadingConversations}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Panel 2: Transcript + composer */}
|
||||
<Card className="col-span-12 lg:col-span-8 flex flex-col overflow-hidden">
|
||||
<CardContent className="p-3 flex flex-col h-full">
|
||||
{selected ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-3 pb-2 border-b flex-wrap">
|
||||
<MessagesSquare className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{getAgentDisplayName(selected.agent_a)}
|
||||
{" ↔ "}
|
||||
{getAgentDisplayName(selected.agent_b)}
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
selected.status === "active" ? "default" : "secondary"
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{selected.status}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{selected.message_count} msgs · updated{" "}
|
||||
{formatDistanceToNow(new Date(selected.updated_at))} ago
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden -mx-3">
|
||||
<A2ATranscript
|
||||
messages={messages}
|
||||
isLoading={loadingMessages}
|
||||
/>
|
||||
</div>
|
||||
{/* Reply composer. The backend's reply route rejects with
|
||||
400 exactly when the watched conversation has no task
|
||||
link (replies ride the gateway send path, which requires
|
||||
one), so a task-less conversation is read-only — say why
|
||||
instead of letting the send bounce. Status does NOT gate
|
||||
the composer: the CEO's reply lands in their own direct
|
||||
thread with the participant, not in this conversation. */}
|
||||
<div className="shrink-0 border-t -mx-3">
|
||||
{selected.task_id ? (
|
||||
<A2AReplyComposer
|
||||
key={selected.id}
|
||||
conversationId={selected.id}
|
||||
agentA={selected.agent_a}
|
||||
agentB={selected.agent_b}
|
||||
lastSender={lastSender}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
This conversation has no linked task, so a reply
|
||||
can't be sent (A2A messages are always scoped to a
|
||||
task).
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EmptyPanel
|
||||
icon={MessagesSquare}
|
||||
message="Select a conversation to watch it live"
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Wrap in Suspense for useSearchParams
|
||||
export default function A2APage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex flex-col lg:h-[calc(100vh-7rem)]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<Skeleton className="h-9 w-48 mb-2" />
|
||||
<Skeleton className="h-5 w-64" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-12 gap-4 lg:gap-6">
|
||||
<Card className="col-span-12 lg:col-span-4">
|
||||
<CardContent className="p-3 space-y-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-20 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="col-span-12 lg:col-span-8" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<A2APageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import type { AdminConversationSummary } from "@/lib/api/a2a";
|
||||
import { A2AConversationList } from "../a2a-conversation-list";
|
||||
|
||||
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: 7,
|
||||
last_message_at: "2026-07-02T09:00:00Z",
|
||||
last_message_preview: "Tests are green on the branch.",
|
||||
created_at: "2026-07-01T08:00:00Z",
|
||||
updated_at: "2026-07-02T09:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("A2AConversationList", () => {
|
||||
it("renders participants, relative time, preview, status badge and task chip", () => {
|
||||
render(
|
||||
<A2AConversationList
|
||||
conversations={[buildConversation()]}
|
||||
selectedId={null}
|
||||
onSelect={vi.fn()}
|
||||
isLoading={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Participants via getAgentDisplayName ("{a} <-> {b}").
|
||||
expect(screen.getByText(/Backend Dev 1/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Backend QA/)).toBeInTheDocument();
|
||||
// Topic, preview, message count, relative timestamp.
|
||||
expect(screen.getByText("QA handoff")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Tests are green on the branch."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("7 msgs")).toBeInTheDocument();
|
||||
expect(screen.getByText(/ago$/)).toBeInTheDocument();
|
||||
// Status badge.
|
||||
expect(screen.getByText("active")).toBeInTheDocument();
|
||||
// Task chip links to the task page.
|
||||
const chip = screen.getByRole("link", { name: /Task 11111111/ });
|
||||
expect(chip).toHaveAttribute(
|
||||
"href",
|
||||
"/tasks/11111111-2222-3333-4444-555555555555",
|
||||
);
|
||||
});
|
||||
|
||||
it("fires onSelect with the conversation id on row click", () => {
|
||||
const onSelect = vi.fn();
|
||||
render(
|
||||
<A2AConversationList
|
||||
conversations={[buildConversation()]}
|
||||
selectedId={null}
|
||||
onSelect={onSelect}
|
||||
isLoading={false}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(onSelect).toHaveBeenCalledWith("conv-1");
|
||||
});
|
||||
|
||||
it("does not hijack row selection when the task chip is clicked", () => {
|
||||
const onSelect = vi.fn();
|
||||
render(
|
||||
<A2AConversationList
|
||||
conversations={[buildConversation()]}
|
||||
selectedId={null}
|
||||
onSelect={onSelect}
|
||||
isLoading={false}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("link", { name: /Task 11111111/ }));
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the empty state when there are no conversations", () => {
|
||||
render(
|
||||
<A2AConversationList
|
||||
conversations={[]}
|
||||
selectedId={null}
|
||||
onSelect={vi.fn()}
|
||||
isLoading={false}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/No A2A conversations yet/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
const { mutate } = vi.hoisted(() => ({ mutate: vi.fn() }));
|
||||
|
||||
vi.mock("@/hooks/use-a2a-live", () => ({
|
||||
useReplyAsCeo: () => ({ mutate, isPending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
// Make the Select testable without Radix's portal/pointer machinery: each
|
||||
// SelectItem renders a button carrying its value; clicking it invokes the
|
||||
// nearest Select's onValueChange (scoped via context).
|
||||
vi.mock("@/components/ui/select", () => {
|
||||
const Ctx = React.createContext<(v: string) => void>(() => {});
|
||||
return {
|
||||
Select: ({
|
||||
onValueChange,
|
||||
children,
|
||||
}: {
|
||||
onValueChange?: (v: string) => void;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<Ctx.Provider value={onValueChange ?? (() => {})}>
|
||||
{children}
|
||||
</Ctx.Provider>
|
||||
),
|
||||
SelectTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectValue: () => null,
|
||||
SelectContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectItem: ({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: string;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const onValueChange = React.useContext(Ctx);
|
||||
return (
|
||||
<button data-value={value} onClick={() => onValueChange(value)}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { A2AReplyComposer } from "../a2a-reply-composer";
|
||||
|
||||
function renderComposer(lastSender: string | null = "be-qa") {
|
||||
return render(
|
||||
<A2AReplyComposer
|
||||
conversationId="conv-1"
|
||||
agentA="be-dev-1"
|
||||
agentB="be-qa"
|
||||
lastSender={lastSender}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("A2AReplyComposer", () => {
|
||||
beforeEach(() => {
|
||||
mutate.mockReset();
|
||||
});
|
||||
|
||||
it("disables Send when the textarea is empty", () => {
|
||||
renderComposer();
|
||||
expect(screen.getByRole("button", { name: /send/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("sends { to_agent, content } defaulting to the last message's sender", () => {
|
||||
renderComposer("be-qa");
|
||||
fireEvent.change(screen.getByPlaceholderText(/chime in/i), {
|
||||
target: { value: "Ship it" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /send/i }));
|
||||
expect(mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
conversationId: "conv-1",
|
||||
to_agent: "be-qa",
|
||||
content: "Ship it",
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to agent_a when there is no last sender", () => {
|
||||
renderComposer(null);
|
||||
fireEvent.change(screen.getByPlaceholderText(/chime in/i), {
|
||||
target: { value: "Status?" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /send/i }));
|
||||
expect(mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ to_agent: "be-dev-1", content: "Status?" }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends to an explicitly selected participant", () => {
|
||||
const { container } = renderComposer("be-qa");
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('[data-value="be-dev-1"]')
|
||||
?.click();
|
||||
fireEvent.change(screen.getByPlaceholderText(/chime in/i), {
|
||||
target: { value: "Over to you" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /send/i }));
|
||||
expect(mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ to_agent: "be-dev-1" }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("states the pairwise seam honestly in the helper text", () => {
|
||||
renderComposer();
|
||||
// Guard the honesty note: the reply is a DIRECT CEO->participant message,
|
||||
// not an injection into the watched transcript.
|
||||
expect(
|
||||
screen.getByText(
|
||||
/direct A2A message from you to the selected participant/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { A2AChatMessage } from "@/lib/api/a2a";
|
||||
|
||||
// react-markdown is heavyweight and irrelevant here — render bodies as-is.
|
||||
vi.mock("@/components/ui/markdown", () => ({
|
||||
Markdown: ({ children }: { children: string }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
import { A2ATranscript } from "../a2a-transcript";
|
||||
|
||||
function buildMessage(overrides: Partial<A2AChatMessage>): A2AChatMessage {
|
||||
return {
|
||||
id: "m1",
|
||||
conversation_id: "conv-1",
|
||||
from_agent: "be-dev-1",
|
||||
content: "hello",
|
||||
message_kind: "text",
|
||||
response_to_id: null,
|
||||
requires_response: false,
|
||||
read_at: null,
|
||||
created_at: "2026-07-02T10:00:00Z",
|
||||
edited_at: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("A2ATranscript", () => {
|
||||
it("renders messages chronologically with sender names and timestamps", () => {
|
||||
// Deliberately unordered payload: the later message first.
|
||||
const { container } = render(
|
||||
<A2ATranscript
|
||||
messages={[
|
||||
buildMessage({
|
||||
id: "m2",
|
||||
from_agent: "be-qa",
|
||||
content: "second message body",
|
||||
created_at: "2026-07-02T10:05:00Z",
|
||||
}),
|
||||
buildMessage({
|
||||
id: "m1",
|
||||
from_agent: "be-dev-1",
|
||||
content: "first message body",
|
||||
created_at: "2026-07-02T10:00:00Z",
|
||||
}),
|
||||
]}
|
||||
isLoading={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Backend Dev 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Backend QA")).toBeInTheDocument();
|
||||
// Every message carries a relative timestamp.
|
||||
expect(screen.getAllByText(/ago$/)).toHaveLength(2);
|
||||
// Chronological order: oldest first regardless of payload order.
|
||||
const text = container.textContent ?? "";
|
||||
expect(text.indexOf("first message body")).toBeLessThan(
|
||||
text.indexOf("second message body"),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the message kind as an outline badge when present", () => {
|
||||
render(
|
||||
<A2ATranscript
|
||||
messages={[buildMessage({ message_kind: "escalation" })]}
|
||||
isLoading={false}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("escalation")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the empty state when there are no messages", () => {
|
||||
render(<A2ATranscript messages={[]} isLoading={false} />);
|
||||
expect(
|
||||
screen.getByText(/No messages in this conversation yet/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { lastSenderOf, pickDefaultRecipient } from "../a2a-utils";
|
||||
|
||||
describe("lastSenderOf", () => {
|
||||
it("returns null for an empty transcript", () => {
|
||||
expect(lastSenderOf([])).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the chronologically latest sender even when payload is unordered", () => {
|
||||
expect(
|
||||
lastSenderOf([
|
||||
{ from_agent: "be-qa", created_at: "2026-07-02T10:05:00Z" },
|
||||
{ from_agent: "be-dev-1", created_at: "2026-07-02T10:00:00Z" },
|
||||
]),
|
||||
).toBe("be-qa");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickDefaultRecipient", () => {
|
||||
it("picks the last sender when they are a participant", () => {
|
||||
expect(pickDefaultRecipient("be-dev-1", "be-qa", "be-qa")).toBe("be-qa");
|
||||
});
|
||||
|
||||
it("falls back to agent_a when the transcript is empty", () => {
|
||||
expect(pickDefaultRecipient("be-dev-1", "be-qa", null)).toBe("be-dev-1");
|
||||
});
|
||||
|
||||
it("falls back to agent_a when the last sender is not a participant", () => {
|
||||
expect(pickDefaultRecipient("be-dev-1", "be-qa", "fe-dev-1")).toBe(
|
||||
"be-dev-1",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { getAgentDisplayName } from "@/lib/agent-utils";
|
||||
import type { AdminConversationSummary } from "@/lib/api/a2a";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { ListTodo, MessagesSquare } from "lucide-react";
|
||||
|
||||
interface A2AConversationListProps {
|
||||
conversations: AdminConversationSummary[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function A2AConversationList({
|
||||
conversations,
|
||||
selectedId,
|
||||
onSelect,
|
||||
isLoading,
|
||||
}: A2AConversationListProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-2 space-y-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-20 w-full" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (conversations.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center p-4">
|
||||
<MessagesSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No A2A conversations yet</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-2 space-y-2">
|
||||
{conversations.map((conversation) => (
|
||||
<div
|
||||
key={conversation.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(conversation.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSelect(conversation.id);
|
||||
}
|
||||
}}
|
||||
className={
|
||||
"block w-full cursor-pointer p-3 rounded-lg border transition-all " +
|
||||
(selectedId === conversation.id
|
||||
? "bg-primary/10 border-primary"
|
||||
: "bg-card hover:bg-muted/50 hover:border-primary/50")
|
||||
}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-sm truncate">
|
||||
{getAgentDisplayName(conversation.agent_a)}
|
||||
{" ↔ "}
|
||||
{getAgentDisplayName(conversation.agent_b)}
|
||||
</div>
|
||||
{conversation.topic && (
|
||||
<div className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{conversation.topic}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{formatDistanceToNow(
|
||||
new Date(
|
||||
conversation.last_message_at ?? conversation.created_at,
|
||||
),
|
||||
)}{" "}
|
||||
ago
|
||||
</div>
|
||||
{conversation.last_message_preview && (
|
||||
<p className="text-xs text-muted-foreground truncate mt-1">
|
||||
{conversation.last_message_preview}
|
||||
</p>
|
||||
)}
|
||||
{conversation.task_id && (
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={`/tasks/${conversation.task_id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center gap-1 text-xs text-primary hover:underline mt-1"
|
||||
>
|
||||
<ListTodo className="h-3 w-3" />
|
||||
Task {conversation.task_id.slice(0, 8)}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||
<Badge
|
||||
variant={
|
||||
conversation.status === "active" ? "default" : "secondary"
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{conversation.status}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{conversation.message_count} msgs
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Send } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { getAgentDisplayName } from "@/lib/agent-utils";
|
||||
import { getErrorMessage } from "@/lib/api/client";
|
||||
import { useReplyAsCeo } from "@/hooks/use-a2a-live";
|
||||
import { pickDefaultRecipient } from "./a2a-utils";
|
||||
|
||||
interface A2AReplyComposerProps {
|
||||
conversationId: string;
|
||||
agentA: string;
|
||||
agentB: string;
|
||||
/** Slug of the sender of the latest transcript message (default recipient). */
|
||||
lastSender: string | null;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function A2AReplyComposer({
|
||||
conversationId,
|
||||
agentA,
|
||||
agentB,
|
||||
lastSender,
|
||||
disabled,
|
||||
}: A2AReplyComposerProps) {
|
||||
const [content, setContent] = useState("");
|
||||
// null = follow the default (last sender) until the CEO picks explicitly.
|
||||
const [chosenRecipient, setChosenRecipient] = useState<string | null>(null);
|
||||
const reply = useReplyAsCeo();
|
||||
|
||||
const recipient =
|
||||
chosenRecipient ?? pickDefaultRecipient(agentA, agentB, lastSender);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed || reply.isPending) return;
|
||||
|
||||
reply.mutate(
|
||||
{ conversationId, to_agent: recipient, content: trimmed },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(`Reply sent to ${getAgentDisplayName(recipient)}`);
|
||||
setContent("");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getErrorMessage(error));
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="p-4">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Chime in... (Shift+Enter for new line)"
|
||||
className="min-h-[60px] resize-none"
|
||||
disabled={disabled || reply.isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Select value={recipient} onValueChange={setChosenRecipient}>
|
||||
<SelectTrigger className="w-auto min-w-32 h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[agentA, agentB].map((slug) => (
|
||||
<SelectItem key={slug} value={slug}>
|
||||
{getAgentDisplayName(slug)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={!content.trim() || disabled || reply.isPending}
|
||||
>
|
||||
<Send className="h-4 w-4 mr-1" />
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Sends a direct A2A message from you to the selected participant. It
|
||||
lands in your own conversation with that agent, not inside this
|
||||
transcript.
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Markdown } from "@/components/ui/markdown";
|
||||
import { getAgentDisplayName, getAgentInitials } from "@/lib/agent-utils";
|
||||
import type { A2AChatMessage } from "@/lib/api/a2a";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { MessagesSquare } from "lucide-react";
|
||||
|
||||
interface A2ATranscriptProps {
|
||||
messages: A2AChatMessage[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function A2ATranscript({ messages, isLoading }: A2ATranscriptProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const hasScrolledRef = useRef(false);
|
||||
|
||||
// Chronological (oldest first) regardless of payload ordering.
|
||||
const sorted = [...messages].sort(
|
||||
(a, b) =>
|
||||
new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
|
||||
);
|
||||
|
||||
// Auto-scroll to bottom only once on initial load.
|
||||
useEffect(() => {
|
||||
if (scrollRef.current && sorted.length > 0 && !hasScrolledRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
hasScrolledRef.current = true;
|
||||
}
|
||||
}, [sorted.length]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex gap-3">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<div className="flex-1">
|
||||
<Skeleton className="h-4 w-32 mb-2" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (sorted.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center p-4">
|
||||
<MessagesSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No messages in this conversation yet</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} className="h-full overflow-y-auto p-4">
|
||||
<div className="space-y-3">
|
||||
{sorted.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className="flex gap-3 p-3 rounded-lg border bg-card hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<div className="h-9 w-10 rounded-lg bg-primary/10 flex items-center justify-center shrink-0 border">
|
||||
<span className="text-[10px] font-bold tracking-tight">
|
||||
{getAgentInitials(message.from_agent)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="font-semibold text-sm">
|
||||
{getAgentDisplayName(message.from_agent)}
|
||||
</span>
|
||||
{message.message_kind && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{message.message_kind}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{formatDistanceToNow(new Date(message.created_at))} ago
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm prose prose-sm dark:prose-invert max-w-none">
|
||||
<Markdown>{message.content}</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Pure helpers for the A2A live view (extracted for direct unit testing).
|
||||
*/
|
||||
|
||||
import type { A2AChatMessage } from "@/lib/api/a2a";
|
||||
|
||||
/**
|
||||
* Slug of the sender of the chronologically latest message, or null when the
|
||||
* transcript is empty. Sorts defensively — the API contract is oldest-first,
|
||||
* but the default-recipient pick must not depend on payload ordering.
|
||||
*/
|
||||
export function lastSenderOf(
|
||||
messages: ReadonlyArray<Pick<A2AChatMessage, "from_agent" | "created_at">>,
|
||||
): string | null {
|
||||
if (messages.length === 0) return null;
|
||||
const sorted = [...messages].sort(
|
||||
(a, b) =>
|
||||
new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
|
||||
);
|
||||
return sorted[sorted.length - 1].from_agent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default reply recipient: the participant who spoke last (the natural
|
||||
* "answer them" target), falling back to agent_a when the transcript is empty
|
||||
* or the last sender is not one of the two participants.
|
||||
*/
|
||||
export function pickDefaultRecipient(
|
||||
agentA: string,
|
||||
agentB: string,
|
||||
lastSender: string | null,
|
||||
): string {
|
||||
if (lastSender === agentA || lastSender === agentB) return lastSender;
|
||||
return agentA;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
Cpu,
|
||||
Sparkles,
|
||||
Building2,
|
||||
Radio,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
@@ -50,6 +51,7 @@ export const navItems = [
|
||||
|
||||
// History
|
||||
{ title: "Communications", href: "/communications", icon: MessageSquare },
|
||||
{ title: "A2A Live", href: "/a2a", icon: Radio },
|
||||
{ title: "Journals", href: "/journals", icon: BookOpen },
|
||||
|
||||
// System
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { a2aApi, type AdminReplyRequest } from "@/lib/api/a2a";
|
||||
|
||||
export const a2aLiveKeys = {
|
||||
all: ["a2a-live"] as const,
|
||||
conversations: ["a2a-live", "conversations"] as const,
|
||||
messages: (conversationId: string) =>
|
||||
["a2a-live", "messages", conversationId] as const,
|
||||
};
|
||||
|
||||
// Conversation list — refreshed by WS `a2a.message` invalidation and the
|
||||
// manual Refresh button; a short staleTime keeps remounts reasonably fresh.
|
||||
export function useA2AConversations(limit?: number) {
|
||||
return useQuery({
|
||||
queryKey: [...a2aLiveKeys.conversations, limit ?? 50],
|
||||
queryFn: () => a2aApi.listAdminConversations(limit),
|
||||
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) {
|
||||
return useQuery({
|
||||
queryKey: a2aLiveKeys.messages(conversationId || ""),
|
||||
queryFn: () => a2aApi.listAdminMessages(conversationId!),
|
||||
enabled: !!conversationId,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export interface ReplyAsCeoVariables extends AdminReplyRequest {
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
// CEO chime-in. The reply is a direct CEO->to_agent message (pairwise model);
|
||||
// invalidate the list (the CEO<->agent conversation appears/updates there) and
|
||||
// the watched transcript's messages.
|
||||
export function useReplyAsCeo() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ conversationId, ...reply }: ReplyAsCeoVariables) =>
|
||||
a2aApi.replyAsCeo(conversationId, reply),
|
||||
onSuccess: (_sent, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: a2aLiveKeys.conversations });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: a2aLiveKeys.messages(variables.conversationId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -44,6 +44,18 @@ export interface NotificationMessage {
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export interface A2ASystemMessage {
|
||||
type: "connected" | "a2a.message";
|
||||
conversation_id?: string;
|
||||
message_id?: string;
|
||||
task_id?: string;
|
||||
from_agent?: string;
|
||||
to_agent?: string;
|
||||
skill?: string | null;
|
||||
body_excerpt?: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export interface SessionMessage {
|
||||
type: "connected" | "message.new";
|
||||
message_id?: string;
|
||||
@@ -296,6 +308,38 @@ export function useNotificationStream() {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to live A2A traffic on the operator stream (`/ws/system`).
|
||||
*
|
||||
* The bridge publishes an `a2a.message` frame for every persisted
|
||||
* agent<->agent chat message. Frames carry a capped `body_excerpt` only —
|
||||
* consumers invalidate their REST queries for full bodies (the A2A live view
|
||||
* idiom), never render the excerpt as the message.
|
||||
*/
|
||||
export function useA2ALiveStream() {
|
||||
const {
|
||||
state,
|
||||
lastMessage,
|
||||
messages,
|
||||
clearMessages,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
} = useWebSocket<A2ASystemMessage>("/system", undefined, true);
|
||||
|
||||
// Filter to A2A frames (the system stream also carries rate-limit/usage).
|
||||
const a2aMessages = messages.filter((m) => m.type === "a2a.message");
|
||||
|
||||
return {
|
||||
state,
|
||||
lastMessage,
|
||||
a2aMessages,
|
||||
allMessages: messages,
|
||||
clearMessages,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Connection Status Hook (for UI indicators)
|
||||
// =============================================================================
|
||||
|
||||
@@ -58,6 +58,57 @@ export interface A2AStreamChunk {
|
||||
is_final: boolean;
|
||||
}
|
||||
|
||||
/** Summary row for the CEO's admin view of an agent-to-agent conversation. */
|
||||
export interface AdminConversationSummary {
|
||||
id: string;
|
||||
agent_a: string;
|
||||
agent_b: string;
|
||||
topic: string | null;
|
||||
task_id: string | null;
|
||||
status: string;
|
||||
message_count: number;
|
||||
last_message_at: string | null;
|
||||
last_message_preview: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** One persisted A2A chat message (full body — WS frames only carry excerpts). */
|
||||
export interface A2AChatMessage {
|
||||
id: string;
|
||||
conversation_id: string;
|
||||
from_agent: string;
|
||||
content: string;
|
||||
message_kind: string;
|
||||
response_to_id: string | null;
|
||||
requires_response: boolean;
|
||||
read_at: string | null;
|
||||
created_at: string;
|
||||
edited_at: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* CEO reply payload. The backend sends a DIRECT CEO -> to_agent message (it
|
||||
* lands in the CEO<->to_agent pairwise conversation), not an injection into
|
||||
* the watched transcript.
|
||||
*/
|
||||
export interface AdminReplyRequest {
|
||||
to_agent: string;
|
||||
content: string;
|
||||
skill?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminConversationListResponse {
|
||||
items: AdminConversationSummary[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface AdminMessageListResponse {
|
||||
items: A2AChatMessage[];
|
||||
total: number;
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// API Client
|
||||
// =============================================================================
|
||||
@@ -197,4 +248,106 @@ export const a2aApi = {
|
||||
const { data } = await api.get<A2AAgentCard>(`/a2a/agents/${agentId}/card`);
|
||||
return data;
|
||||
},
|
||||
|
||||
// ===========================================================================
|
||||
// ADMIN (CEO) ENDPOINTS — A2A live view
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* List agent<->agent conversations, most-recent-first (CEO-only).
|
||||
*/
|
||||
listAdminConversations: async (
|
||||
limit: number = 50,
|
||||
): Promise<AdminConversationListResponse> => {
|
||||
if (isMockMode()) {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
id: "mock-conversation-1",
|
||||
agent_a: "be-dev-1",
|
||||
agent_b: "be-qa",
|
||||
topic: "QA handoff",
|
||||
task_id: null,
|
||||
status: "active",
|
||||
message_count: 3,
|
||||
last_message_at: now,
|
||||
last_message_preview: "Tests are green on the branch.",
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
};
|
||||
}
|
||||
const { data } = await api.get<AdminConversationListResponse>(
|
||||
"/a2a/chat/admin/conversations",
|
||||
{ params: { limit } },
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* List a conversation's messages, chronological oldest-first (CEO-only).
|
||||
*/
|
||||
listAdminMessages: async (
|
||||
conversationId: string,
|
||||
limit: number = 100,
|
||||
): Promise<AdminMessageListResponse> => {
|
||||
if (isMockMode()) {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
id: "mock-a2a-message-1",
|
||||
conversation_id: conversationId,
|
||||
from_agent: "be-dev-1",
|
||||
content: "Branch is ready for QA.",
|
||||
message_kind: "text",
|
||||
response_to_id: null,
|
||||
requires_response: false,
|
||||
read_at: null,
|
||||
created_at: now,
|
||||
edited_at: null,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
has_more: false,
|
||||
};
|
||||
}
|
||||
const { data } = await api.get<AdminMessageListResponse>(
|
||||
`/a2a/chat/admin/conversations/${conversationId}/messages`,
|
||||
{ params: { limit } },
|
||||
);
|
||||
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.
|
||||
*/
|
||||
replyAsCeo: async (
|
||||
conversationId: string,
|
||||
request: AdminReplyRequest,
|
||||
): Promise<A2AChatMessage> => {
|
||||
if (isMockMode()) {
|
||||
return {
|
||||
id: `mock-reply-${Date.now()}`,
|
||||
conversation_id: conversationId,
|
||||
from_agent: "ceo",
|
||||
content: request.content,
|
||||
message_kind: "text",
|
||||
response_to_id: null,
|
||||
requires_response: false,
|
||||
read_at: null,
|
||||
created_at: new Date().toISOString(),
|
||||
edited_at: null,
|
||||
};
|
||||
}
|
||||
const { data } = await api.post<A2AChatMessage>(
|
||||
`/a2a/chat/admin/conversations/${conversationId}/reply`,
|
||||
request,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user