Files
roboco/panel/src/components/a2a/__tests__/a2a-reply-composer.test.tsx
T
da563487b8 Wave 2 features: A2A live view (CEO chime-in + reply budget) and prompter memory (#297)
* feat(a2a): live view — watch fleet conversations, CEO chime-in, reply budget

A2A_MESSAGE_SENT published from A2AService.send (excerpt-capped) and
fanned through the existing /ws/system bridge; CEO-only admin REST for
conversations/messages + a reply route on the publish-bearing send path;
panel /a2a page with live transcript and a composer gated on task-linked
conversations. The matrix gains its one asymmetric rule: CEO may message
anyone, nobody may target the CEO — and agent replies inside a
CEO-opened conversation are hard-budgeted to one per CEO message
(per conversation, per agent), rejected with wait-don't-retry guidance.
Built subagent-driven (Sonnet 5), reviewed; v1 seams documented in the
map delta.

* feat(prompter): intake remembers the task history

Intake spawns now carry a per-project chronological digest of recent
tasks (capped: 15 lines/project, 4000 chars total — ~300-1000 tokens)
merged into the ambient layer, and the interviewer gets a bounded
search_past_tasks tool (one shared implementation behind the grok MCP
tool and the Claude SDK in-process tool) to check precedent
mid-conversation. Informational memory only — the sequencing analyzer
keeps ownership of ordering. Built subagent-driven (Sonnet 5), reviewed;
pre-existing conventions-ambient MegaTask-scope gap flagged, untouched.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-03 00:07:55 +02:00

133 lines
3.8 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import React from "react";
const { mutate } = vi.hoisted(() => ({ mutate: vi.fn() }));
vi.mock("@/hooks/use-a2a-live", () => ({
useReplyAsCeo: () => ({ mutate, isPending: false }),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
// Make the Select testable without Radix's portal/pointer machinery: each
// SelectItem renders a button carrying its value; clicking it invokes the
// nearest Select's onValueChange (scoped via context).
vi.mock("@/components/ui/select", () => {
const Ctx = React.createContext<(v: string) => void>(() => {});
return {
Select: ({
onValueChange,
children,
}: {
onValueChange?: (v: string) => void;
children: React.ReactNode;
}) => (
<Ctx.Provider value={onValueChange ?? (() => {})}>
{children}
</Ctx.Provider>
),
SelectTrigger: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SelectValue: () => null,
SelectContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SelectItem: ({
value,
children,
}: {
value: string;
children: React.ReactNode;
}) => {
const onValueChange = React.useContext(Ctx);
return (
<button data-value={value} onClick={() => onValueChange(value)}>
{children}
</button>
);
},
};
});
import { A2AReplyComposer } from "../a2a-reply-composer";
function renderComposer(lastSender: string | null = "be-qa") {
return render(
<A2AReplyComposer
conversationId="conv-1"
agentA="be-dev-1"
agentB="be-qa"
lastSender={lastSender}
/>,
);
}
describe("A2AReplyComposer", () => {
beforeEach(() => {
mutate.mockReset();
});
it("disables Send when the textarea is empty", () => {
renderComposer();
expect(screen.getByRole("button", { name: /send/i })).toBeDisabled();
});
it("sends { to_agent, content } defaulting to the last message's sender", () => {
renderComposer("be-qa");
fireEvent.change(screen.getByPlaceholderText(/chime in/i), {
target: { value: "Ship it" },
});
fireEvent.click(screen.getByRole("button", { name: /send/i }));
expect(mutate).toHaveBeenCalledWith(
expect.objectContaining({
conversationId: "conv-1",
to_agent: "be-qa",
content: "Ship it",
}),
expect.anything(),
);
});
it("falls back to agent_a when there is no last sender", () => {
renderComposer(null);
fireEvent.change(screen.getByPlaceholderText(/chime in/i), {
target: { value: "Status?" },
});
fireEvent.click(screen.getByRole("button", { name: /send/i }));
expect(mutate).toHaveBeenCalledWith(
expect.objectContaining({ to_agent: "be-dev-1", content: "Status?" }),
expect.anything(),
);
});
it("sends to an explicitly selected participant", () => {
const { container } = renderComposer("be-qa");
container
.querySelector<HTMLButtonElement>('[data-value="be-dev-1"]')
?.click();
fireEvent.change(screen.getByPlaceholderText(/chime in/i), {
target: { value: "Over to you" },
});
fireEvent.click(screen.getByRole("button", { name: /send/i }));
expect(mutate).toHaveBeenCalledWith(
expect.objectContaining({ to_agent: "be-dev-1" }),
expect.anything(),
);
});
it("states the pairwise seam honestly in the helper text", () => {
renderComposer();
// Guard the honesty note: the reply is a DIRECT CEO->participant message,
// not an injection into the watched transcript.
expect(
screen.getByText(
/direct A2A message from you to the selected participant/i,
),
).toBeInTheDocument();
});
});