feat: CEO-grade A2A — New DM composer, CEO-DM wake, docs scrub (#547)

* feat(panel): CEO New-DM composer and direct-thread replies on the A2A page

* docs(agents): remove dm-the-CEO teaching; fix Board/HoM dead-end escalation recipes

* feat(a2a): CEO-authored DMs wake offline recipients via the a2a_request dispatch path

* fix(a2a,panel): wake only read_a2a-capable roles; case-insensitive header defaults; wider DM picker exclusions

* docs(map): CEO-DM wake mechanics, requires_ack override, A2A composer components; comms-model update

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-18 00:44:00 +02:00
committed by GitHub
co-authored by Renn F
parent 9b4ce6b9c8
commit 885d6bbe83
28 changed files with 1156 additions and 70 deletions
@@ -0,0 +1,65 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
const { mutate } = vi.hoisted(() => ({ mutate: vi.fn() }));
vi.mock("@/hooks/use-a2a-live", () => ({
useSendCeoMessage: () => ({ mutate, isPending: false }),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
import { A2ADirectComposer } from "../a2a-direct-composer";
function renderComposer() {
return render(
<A2ADirectComposer conversationId="conv-ceo" otherAgent="be-dev-1" />,
);
}
describe("A2ADirectComposer", () => {
beforeEach(() => {
mutate.mockReset();
});
it("disables Send when the textarea is empty", () => {
renderComposer();
expect(screen.getByRole("button", { name: /send/i })).toBeDisabled();
});
it("sends { conversationId, content } with no recipient to pick", () => {
renderComposer();
fireEvent.change(screen.getByPlaceholderText(/message/i), {
target: { value: "Following up" },
});
fireEvent.click(screen.getByRole("button", { name: /send/i }));
expect(mutate).toHaveBeenCalledWith(
{ conversationId: "conv-ceo", content: "Following up" },
expect.anything(),
);
});
it("clears the textarea on a successful send", () => {
renderComposer();
const textarea = screen.getByPlaceholderText(/message/i);
fireEvent.change(textarea, { target: { value: "Following up" } });
fireEvent.click(screen.getByRole("button", { name: /send/i }));
const [, callbacks] = mutate.mock.calls[0] as [
unknown,
{ onSuccess: () => void },
];
act(() => callbacks.onSuccess());
expect((textarea as HTMLTextAreaElement).value).toBe("");
});
it("names the direct-thread recipient, not the watched-conversation semantics", () => {
renderComposer();
expect(
screen.getByText(/your own direct thread with backend dev 1/i),
).toBeInTheDocument();
});
});
@@ -0,0 +1,103 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import React from "react";
const { mutate } = vi.hoisted(() => ({ mutate: vi.fn() }));
vi.mock("@/hooks/use-a2a-live", () => ({
useCreateCeoConversation: () => ({ mutate, isPending: false }),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
// AgentSelector pulls in useAgentDefinitions (react-query) + Radix Select —
// stub it as a plain input so this suite can drive `onChange` directly,
// mirroring the create-task-dialog test idiom for the same component.
vi.mock("@/components/agents/agent-selector", () => ({
AgentSelector: ({
value,
onChange,
}: {
value: string | null;
onChange: (v: string | null) => void;
}) => (
<input
aria-label="Agent"
value={value ?? ""}
onChange={(e) => onChange(e.target.value || null)}
/>
),
}));
import { A2ANewDmDialog } from "../a2a-new-dm-dialog";
function openDialog() {
render(<A2ANewDmDialog onCreated={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: /new dm/i }));
}
describe("A2ANewDmDialog", () => {
beforeEach(() => {
mutate.mockReset();
});
it("disables Start conversation until an agent is picked and a message is typed", () => {
openDialog();
const submit = screen.getByRole("button", { name: /start conversation/i });
expect(submit).toBeDisabled();
fireEvent.change(screen.getByLabelText("Agent"), {
target: { value: "be-dev-1" },
});
expect(submit).toBeDisabled();
fireEvent.change(screen.getByPlaceholderText(/what do you want to say/i), {
target: { value: "Status update please" },
});
expect(submit).not.toBeDisabled();
});
it("submits { target_agent, initial_message } on Start conversation", () => {
openDialog();
fireEvent.change(screen.getByLabelText("Agent"), {
target: { value: "be-dev-1" },
});
fireEvent.change(screen.getByPlaceholderText(/what do you want to say/i), {
target: { value: "Status update please" },
});
fireEvent.click(screen.getByRole("button", { name: /start conversation/i }));
expect(mutate).toHaveBeenCalledWith(
{ target_agent: "be-dev-1", initial_message: "Status update please" },
expect.anything(),
);
});
it("calls onCreated with the new conversation id and closes on success", () => {
const onCreated = vi.fn();
render(<A2ANewDmDialog onCreated={onCreated} />);
fireEvent.click(screen.getByRole("button", { name: /new dm/i }));
fireEvent.change(screen.getByLabelText("Agent"), {
target: { value: "be-dev-1" },
});
fireEvent.change(screen.getByPlaceholderText(/what do you want to say/i), {
target: { value: "Hello" },
});
fireEvent.click(screen.getByRole("button", { name: /start conversation/i }));
const [, callbacks] = mutate.mock.calls[0] as [
unknown,
{ onSuccess: (c: { id: string }) => void },
];
act(() => callbacks.onSuccess({ id: "conv-new" }));
expect(onCreated).toHaveBeenCalledWith("conv-new");
// Dialog closed -> the trigger is the only "New DM" text left, the
// "Start conversation" button is gone.
expect(
screen.queryByRole("button", { name: /start conversation/i }),
).not.toBeInTheDocument();
});
});
@@ -0,0 +1,89 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Send } from "lucide-react";
import { toast } from "sonner";
import { HelpTip } from "@/components/ui/help-tip";
import { getAgentDisplayName } from "@/lib/agent-utils";
import { getErrorMessage } from "@/lib/api/client";
import { useSendCeoMessage } from "@/hooks/use-a2a-live";
interface A2ADirectComposerProps {
conversationId: string;
/** The one non-CEO participant — always the implicit recipient, no picker
* needed (unlike A2AReplyComposer, which addresses either participant of
* a watched conversation). */
otherAgent: string;
disabled?: boolean;
}
/**
* Composer for a conversation the CEO itself owns (opened via "New DM").
* Posts through the plain per-conversation send route as "ceo" — NOT the
* interject-as-ceo route A2AReplyComposer uses, which requires a task link
* this kind of conversation rarely has.
*/
export function A2ADirectComposer({
conversationId,
otherAgent,
disabled,
}: A2ADirectComposerProps) {
const [content, setContent] = useState("");
const send = useSendCeoMessage();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = content.trim();
if (!trimmed || send.isPending) return;
send.mutate(
{ conversationId, content: trimmed },
{
onSuccess: () => 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="Message... (Shift+Enter for new line)"
className="min-h-[60px] resize-none"
disabled={disabled || send.isPending}
/>
</div>
<HelpTip label={`Sends directly to ${getAgentDisplayName(otherAgent)}`}>
<span>
<Button
type="submit"
size="sm"
disabled={!content.trim() || disabled || send.isPending}
>
<Send className="h-4 w-4 mr-1" />
Send
</Button>
</span>
</HelpTip>
</div>
<p className="text-xs text-muted-foreground mt-2">
Your own direct thread with {getAgentDisplayName(otherAgent)}
visible only to the two of you.
</p>
</form>
);
}
@@ -0,0 +1,153 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { AgentSelector } from "@/components/agents/agent-selector";
import { HelpTip } from "@/components/ui/help-tip";
import { AgentRole } from "@/types";
import { MessageSquarePlus } from "lucide-react";
import { toast } from "sonner";
import { getAgentDisplayName } from "@/lib/agent-utils";
import { getErrorMessage } from "@/lib/api/client";
import { useCreateCeoConversation } from "@/hooks/use-a2a-live";
// Self, plus every role that can't actually read/answer a DM: auditor and
// pr_reviewer carry no read_a2a on their manifests, prompter and secretary
// are human-only note/evidence roles — a DM to any of them is a black hole.
const EXCLUDE_NON_DM_ROLES = [
AgentRole.CEO,
AgentRole.AUDITOR,
AgentRole.PR_REVIEWER,
AgentRole.PROMPTER,
AgentRole.SECRETARY,
];
interface A2ANewDmDialogProps {
/** Called with the new (or reopened) conversation's id once the CEO's
* first message is sent — the caller selects/opens it in the page. */
onCreated: (conversationId: string) => void;
}
/**
* CEO-voiced "start a fresh 1:1" entry point — the org-chart switchboard and
* classic list only ever show conversations that already exist; this is the
* one surface that creates one, addressed to any agent (never itself).
*/
export function A2ANewDmDialog({ onCreated }: A2ANewDmDialogProps) {
const [open, setOpen] = useState(false);
const [targetAgent, setTargetAgent] = useState<string | null>(null);
const [message, setMessage] = useState("");
const create = useCreateCeoConversation();
const resetForm = () => {
setTargetAgent(null);
setMessage("");
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = message.trim();
if (!targetAgent || !trimmed || create.isPending) return;
create.mutate(
{ target_agent: targetAgent, initial_message: trimmed },
{
onSuccess: (conversation) => {
toast.success(
`Started a DM with ${getAgentDisplayName(targetAgent)}`,
);
setOpen(false);
resetForm();
onCreated(conversation.id);
},
onError: (error) => {
toast.error(getErrorMessage(error));
},
},
);
};
return (
<Dialog
open={open}
onOpenChange={(newOpen) => {
setOpen(newOpen);
if (!newOpen) resetForm();
}}
>
<HelpTip label="Open a fresh 1:1 conversation with any agent, as the CEO">
<DialogTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 gap-1 px-2 text-xs"
>
<MessageSquarePlus className="h-3.5 w-3.5" />
New DM
</Button>
</DialogTrigger>
</HelpTip>
<DialogContent>
<DialogHeader>
<DialogTitle>New direct message</DialogTitle>
<DialogDescription>
Starts (or reopens) your own 1:1 with an agent separate from
the threads you&apos;re watching, and visible only to you and
them.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<HelpTip label="Every agent may A2A the CEO in reply, but only the CEO may start a thread — pick who to open one with">
<Label>Agent</Label>
</HelpTip>
<AgentSelector
value={targetAgent}
onChange={setTargetAgent}
excludeRoles={EXCLUDE_NON_DM_ROLES}
placeholder="Select an agent..."
allowClear={false}
/>
</div>
<div className="space-y-2">
<HelpTip label="Required to start the conversation — the agent sees this the moment it's sent">
<Label htmlFor="new-dm-message">First message</Label>
</HelpTip>
<Textarea
id="new-dm-message"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="What do you want to say?"
className="min-h-[100px] resize-none"
disabled={create.isPending}
/>
</div>
<DialogFooter>
<HelpTip label="Sends as the CEO — opens the conversation and delivers this message in one step">
<span>
<Button
type="submit"
disabled={!targetAgent || !message.trim() || create.isPending}
>
Start conversation
</Button>
</span>
</HelpTip>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,95 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import React from "react";
import { AgentRole, Team } from "@/types";
// Covers the `excludeRoles` prop added for the A2A "New DM" agent picker:
// the CEO (role=ceo, team=board) would otherwise land in the Board group
// like any other board member, letting the CEO pick itself as a DM target.
const { useAgentDefinitions } = vi.hoisted(() => ({
useAgentDefinitions: vi.fn(),
}));
vi.mock("@/hooks/use-agents", () => ({ useAgentDefinitions }));
// Render Select content directly — no Radix portal/pointer machinery needed
// for a static "which items are present" assertion.
vi.mock("@/components/ui/select", () => ({
Select: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SelectTrigger: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SelectValue: ({ placeholder }: { placeholder?: string }) => (
<span>{placeholder}</span>
),
SelectContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SelectGroup: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SelectLabel: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SelectItem: ({
children,
value,
}: {
children: React.ReactNode;
value: string;
}) => <div data-value={value}>{children}</div>,
}));
import { AgentSelector } from "../agent-selector";
const AGENTS = [
{
id: "ceo",
uuid: "uuid-ceo",
name: "Renzo",
role: AgentRole.CEO,
team: Team.BOARD,
},
{
id: "product-owner",
uuid: "uuid-po",
name: "Product Owner",
role: AgentRole.PRODUCT_OWNER,
team: null,
},
{
id: "be-dev-1",
uuid: "uuid-bd1",
name: "Backend Developer 1",
role: AgentRole.DEVELOPER,
team: Team.BACKEND,
},
];
describe("AgentSelector excludeRoles", () => {
beforeEach(() => {
useAgentDefinitions.mockReturnValue({ data: AGENTS, isLoading: false });
});
it("includes the CEO in the Board group by default", () => {
render(<AgentSelector value={null} onChange={vi.fn()} />);
expect(screen.getByText("Renzo")).toBeInTheDocument();
});
it("drops the CEO when excludeRoles=[AgentRole.CEO], keeping everyone else", () => {
const { container } = render(
<AgentSelector
value={null}
onChange={vi.fn()}
excludeRoles={[AgentRole.CEO]}
/>,
);
expect(screen.queryByText("Renzo")).not.toBeInTheDocument();
expect(container.querySelector('[data-value="ceo"]')).toBeNull();
expect(
container.querySelector('[data-value="product-owner"]'),
).not.toBeNull();
expect(container.querySelector('[data-value="be-dev-1"]')).not.toBeNull();
});
});
+12 -1
View File
@@ -23,6 +23,10 @@ interface AgentSelectorProps {
placeholder?: string;
filterByTeam?: Team;
filterByRoles?: AgentRole[];
/** Roles dropped from the roster entirely before grouping — e.g. excluding
* the CEO (role=ceo, team=board) from an agent-to-agent picker, where it
* would otherwise land in the Board group like any other board member. */
excludeRoles?: AgentRole[];
disabled?: boolean;
allowClear?: boolean;
}
@@ -50,6 +54,7 @@ export function AgentSelector({
placeholder = "Select agent...",
filterByTeam,
filterByRoles,
excludeRoles,
disabled = false,
allowClear = true,
}: AgentSelectorProps) {
@@ -59,6 +64,12 @@ export function AgentSelector({
const groupedAgents = useMemo(() => {
let filtered = agents;
if (excludeRoles && excludeRoles.length > 0) {
filtered = filtered.filter(
(a) => !a.role || !excludeRoles.includes(a.role),
);
}
// Apply team filter - also match by role for Board and Main PM
if (filterByTeam) {
filtered = filtered.filter((a) => {
@@ -126,7 +137,7 @@ export function AgentSelector({
}
return groups;
}, [agents, filterByTeam, filterByRoles]);
}, [agents, filterByTeam, filterByRoles, excludeRoles]);
// Find selected agent for display (resolve UUID to slug if needed)
const selectedAgent = useMemo(() => {