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:
@@ -42,6 +42,15 @@ vi.mock("@/hooks/use-a2a-live", () => ({
|
||||
useA2AMessages,
|
||||
useA2AAdminPairs,
|
||||
useReplyAsCeo: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useCreateCeoConversation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useSendCeoMessage: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
// AgentSelector (inside A2ANewDmDialog) pulls in useAgentDefinitions + Radix
|
||||
// Select — irrelevant to this suite, stub it out like create-task-dialog's
|
||||
// suite does for the same component.
|
||||
vi.mock("@/components/agents/agent-selector", () => ({
|
||||
AgentSelector: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-websocket", () => ({
|
||||
@@ -396,6 +405,39 @@ describe("A2APage", () => {
|
||||
expect(screen.getByText("Design review")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the New DM trigger in the header", () => {
|
||||
render(withPageRefresh(<A2APage />));
|
||||
expect(
|
||||
screen.getByRole("button", { name: /new dm/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses the direct composer (no task required) for a CEO-owned conversation", () => {
|
||||
// A CEO-initiated DM has no task link and no picker — it must render
|
||||
// A2ADirectComposer, not the task-gated A2AReplyComposer.
|
||||
useA2AConversations.mockReturnValue({
|
||||
data: {
|
||||
items: [
|
||||
buildConversation({
|
||||
agent_a: "ceo",
|
||||
agent_b: "be-dev-1",
|
||||
task_id: null,
|
||||
}),
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
render(withPageRefresh(<A2APage />));
|
||||
expect(screen.getByPlaceholderText(/message\.\.\./i)).toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText(/chime in/i)).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/no linked task, so a reply can't be sent/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("narrows the classic list's conversations by task id fragment", async () => {
|
||||
const user = userEvent.setup();
|
||||
useA2AConversations.mockReturnValue({
|
||||
|
||||
@@ -22,6 +22,8 @@ import { A2AConversationList } from "@/components/a2a/a2a-conversation-list";
|
||||
import { A2ASwitchboard } from "@/components/a2a/a2a-switchboard";
|
||||
import { A2ATranscript } from "@/components/a2a/a2a-transcript";
|
||||
import { A2AReplyComposer } from "@/components/a2a/a2a-reply-composer";
|
||||
import { A2ADirectComposer } from "@/components/a2a/a2a-direct-composer";
|
||||
import { A2ANewDmDialog } from "@/components/a2a/a2a-new-dm-dialog";
|
||||
import { A2AFilterBar } from "@/components/a2a/a2a-filter-bar";
|
||||
import { A2AContextPane } from "@/components/a2a/a2a-context-pane";
|
||||
import {
|
||||
@@ -45,7 +47,7 @@ import { OfflineState } from "@/components/ui/offline-state";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { useUIStore } from "@/store";
|
||||
import { getAgentDisplayName } from "@/lib/agent-utils";
|
||||
import { lastSenderOf } from "@/components/a2a/a2a-utils";
|
||||
import { CEO_SLUG, lastSenderOf } from "@/components/a2a/a2a-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -272,6 +274,7 @@ function A2APageContent() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<A2ANewDmDialog onCreated={handleSelect} />
|
||||
<A2AConnectionBadge state={connectionState} />
|
||||
{/* Context pane never appears below xl — its toggle is hidden
|
||||
there too, matching the switchboard/list toggle's placement
|
||||
@@ -470,16 +473,31 @@ function A2APageContent() {
|
||||
onRetry={() => void refetchMessages()}
|
||||
/>
|
||||
</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. */}
|
||||
{/* Composer: a conversation the CEO itself owns (opened
|
||||
via "New DM") always gets the direct composer — it's
|
||||
the CEO's own thread, not something being watched, so
|
||||
no task link is required. Otherwise this is a watched
|
||||
agent<->agent conversation: the backend's reply route
|
||||
rejects with 400 exactly when it has no task link
|
||||
(replies ride the gateway send path, which requires
|
||||
one), so a task-less one is read-only — say why instead
|
||||
of letting the send bounce. Status does NOT gate either
|
||||
composer: a reply lands in the CEO's own direct thread
|
||||
with the participant, not in the watched conversation. */}
|
||||
{selected && (
|
||||
<div className="shrink-0 border-t -mx-3">
|
||||
{selected.task_id ? (
|
||||
{selected.agent_a === CEO_SLUG ||
|
||||
selected.agent_b === CEO_SLUG ? (
|
||||
<A2ADirectComposer
|
||||
key={selected.id}
|
||||
conversationId={selected.id}
|
||||
otherAgent={
|
||||
selected.agent_a === CEO_SLUG
|
||||
? selected.agent_b
|
||||
: selected.agent_a
|
||||
}
|
||||
/>
|
||||
) : selected.task_id ? (
|
||||
<A2AReplyComposer
|
||||
key={selected.id}
|
||||
conversationId={selected.id}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { a2aApi, type AdminReplyRequest } from "@/lib/api/a2a";
|
||||
import {
|
||||
a2aApi,
|
||||
type AdminReplyRequest,
|
||||
type CreateCeoConversationRequest,
|
||||
} from "@/lib/api/a2a";
|
||||
|
||||
export const a2aLiveKeys = {
|
||||
all: ["a2a-live"] as const,
|
||||
@@ -63,3 +67,38 @@ export function useReplyAsCeo() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// CEO opens (or reopens) a fresh 1:1 with an agent, sending the first
|
||||
// message in the same call. Invalidates the conversation list so the new
|
||||
// thread appears; the caller selects it into view once the id comes back.
|
||||
export function useCreateCeoConversation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (request: CreateCeoConversationRequest) =>
|
||||
a2aApi.createConversation(request),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: a2aLiveKeys.conversations });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface SendCeoMessageVariables {
|
||||
conversationId: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
// CEO sends a follow-up message in a conversation it already owns (the plain
|
||||
// send route, not the watched-conversation interject-as-ceo path).
|
||||
export function useSendCeoMessage() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ conversationId, content }: SendCeoMessageVariables) =>
|
||||
a2aApi.sendCeoMessage(conversationId, content),
|
||||
onSuccess: (_sent, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: a2aLiveKeys.conversations });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: a2aLiveKeys.messages(variables.conversationId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+110
-1
@@ -132,6 +132,27 @@ export interface AdminMessageListResponse {
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
/** Request to open (or fetch) the CEO's own 1:1 with an agent. */
|
||||
export interface CreateCeoConversationRequest {
|
||||
target_agent: string;
|
||||
initial_message: string;
|
||||
}
|
||||
|
||||
/** The conversation POST /a2a/chat/conversations returns — mirrors the
|
||||
* backend's ConversationResponse, trimmed to what the panel renders. */
|
||||
export interface CeoConversation {
|
||||
id: string;
|
||||
agent_a: string;
|
||||
agent_b: string;
|
||||
topic: string | null;
|
||||
task_id: string | null;
|
||||
status: string;
|
||||
message_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
last_message_at: string | null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// API Client
|
||||
// =============================================================================
|
||||
@@ -299,8 +320,23 @@ export const a2aApi = {
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
// A CEO-initiated DM (from the "New DM" composer) — demonstrates
|
||||
// the CEO rendering as a first-class participant in the list.
|
||||
{
|
||||
id: "mock-conversation-ceo",
|
||||
agent_a: "ceo",
|
||||
agent_b: "main-pm",
|
||||
topic: null,
|
||||
task_id: null,
|
||||
status: "active",
|
||||
message_count: 1,
|
||||
last_message_at: now,
|
||||
last_message_preview: "Quick heads up on Q3 priorities.",
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
total: 2,
|
||||
};
|
||||
}
|
||||
const { data } = await api.get<AdminConversationListResponse>(
|
||||
@@ -416,4 +452,77 @@ export const a2aApi = {
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
// ===========================================================================
|
||||
// CEO-INITIATED DMs — a fresh 1:1 the CEO itself starts (not a reply into
|
||||
// a watched conversation). Both calls override X-Agent-ID to the literal
|
||||
// "ceo" slug: the default header carries the CEO's UUID, and these routes'
|
||||
// CurrentAgentSlug dependency returns it VERBATIM (no DB resolution) — the
|
||||
// UUID would otherwise persist as agent_a/from_agent, breaking every
|
||||
// downstream "ceo"-string check (the reply-budget gate, the reply
|
||||
// composer's recipient exclusion, admin pairing).
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* CEO opens (or reopens) a 1:1 with an agent and sends the first message —
|
||||
* POST /a2a/chat/conversations, backed by ConversationCreateRequest.
|
||||
*/
|
||||
createConversation: async (
|
||||
request: CreateCeoConversationRequest,
|
||||
): Promise<CeoConversation> => {
|
||||
if (isMockMode()) {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: `mock-ceo-conv-${Date.now()}`,
|
||||
agent_a: "ceo",
|
||||
agent_b: request.target_agent,
|
||||
topic: null,
|
||||
task_id: null,
|
||||
status: "active",
|
||||
message_count: 1,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
last_message_at: now,
|
||||
};
|
||||
}
|
||||
const { data } = await api.post<CeoConversation>(
|
||||
"/a2a/chat/conversations",
|
||||
request,
|
||||
{ headers: { "X-Agent-ID": "ceo" } },
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* CEO sends a follow-up message in a conversation it owns (agent_a or
|
||||
* agent_b === "ceo") via the plain per-conversation send route — NOT the
|
||||
* admin interject-as-ceo route (`replyAsCeo`), which requires a task link
|
||||
* this kind of conversation rarely has and is for chiming into OTHER
|
||||
* agents' threads, not the CEO's own.
|
||||
*/
|
||||
sendCeoMessage: async (
|
||||
conversationId: string,
|
||||
content: string,
|
||||
): Promise<A2AChatMessage> => {
|
||||
if (isMockMode()) {
|
||||
return {
|
||||
id: `mock-ceo-msg-${Date.now()}`,
|
||||
conversation_id: conversationId,
|
||||
from_agent: "ceo",
|
||||
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/conversations/${conversationId}/messages`,
|
||||
{ content },
|
||||
{ headers: { "X-Agent-ID": "ceo" } },
|
||||
);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -35,9 +35,18 @@ const api: AxiosInstance = axios.create({
|
||||
// Request interceptor to add auth headers and logging
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
// Add agent context headers for API authorization
|
||||
config.headers["X-Agent-ID"] = CEO_AGENT_ID;
|
||||
config.headers["X-Agent-Role"] = CEO_ROLE;
|
||||
// Add agent context headers for API authorization. A caller that already
|
||||
// set X-Agent-ID/X-Agent-Role (e.g. the CEO-initiated A2A DM composer,
|
||||
// whose target route resolves the caller's identity from this raw header
|
||||
// rather than a DB lookup, and needs the literal "ceo" slug — not the
|
||||
// CEO's UUID) wins; every other call keeps defaulting to the CEO
|
||||
// identity. has()/set(), not bracket access — AxiosHeaders brackets are
|
||||
// case-SENSITIVE, so a caller's lowercase header key would be silently
|
||||
// clobbered by the default.
|
||||
if (!config.headers.has("X-Agent-ID"))
|
||||
config.headers.set("X-Agent-ID", CEO_AGENT_ID);
|
||||
if (!config.headers.has("X-Agent-Role"))
|
||||
config.headers.set("X-Agent-Role", CEO_ROLE);
|
||||
|
||||
// Log request in development
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
|
||||
Reference in New Issue
Block a user