mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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'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();
|
||||
});
|
||||
});
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user