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
@@ -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({
+27 -9
View File
@@ -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&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(() => {
+40 -1
View File
@@ -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
View File
@@ -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;
},
};
+12 -3
View File
@@ -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") {