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,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