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:
Renzo F
2026-07-03 00:07:55 +02:00
committed by GitHub
co-authored by Renn F
parent 48f2944086
commit da563487b8
39 changed files with 3808 additions and 27 deletions
@@ -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();
});
});
+256
View File
@@ -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&apos;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>
);
}
+35
View File
@@ -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;
}
+2
View File
@@ -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
+53
View File
@@ -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
View File
@@ -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)
// =============================================================================
+153
View File
@@ -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;
},
};