feat(inter-agent): surface frontend des annonces live sur les cellules (F1-F3)
Affiche les annonces d'agent en direct au-dessus des cellules, sur la base develop : - announcements: nouveau feature module — store réactif, provider d'abonnement aux événements, overlay par cible et aperçu, avec tests (announcementsStore + provider). - App.tsx: montage du provider dans l'arbre applicatif. - domain/index.ts: types partagés de l'événement d'annonce côté front. - layout: composition de l'overlay dans LayoutGrid + règle d'exclusion couverte par overlayExclusion.test.ts. - AgentsPanel: intègre l'aperçu des annonces dans la surface existante. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -0,0 +1,232 @@
|
||||
/**
|
||||
* F2/F3 integration (ticket #4, develop base — busy is the sole F3 authority):
|
||||
*
|
||||
* - {@link TargetAnnouncementsOverlay} (F3) is driven by the target's **busy**
|
||||
* state, NOT by the presence of announcements. It mounts on
|
||||
* `agentBusyChanged { busy:true }` (or a read-model hydration snapshot) and
|
||||
* retracts on `busy:false`, **including when no completion event is ever
|
||||
* emitted** — the interruption/crash/rate-limit case that used to stick the
|
||||
* overlay. There is no `agentTurnEvent` on this base.
|
||||
* - {@link AnnouncementsPreview} (F2) shows only the caller-requester's
|
||||
* announcements on a shared target thread (`requester == self`).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
|
||||
import type { DomainEvent } from "@/domain";
|
||||
import type { Gateways } from "@/ports";
|
||||
import { MockSystemGateway, MockWorkStateGateway } from "@/adapters/mock";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { AnnouncementsProvider } from "./AnnouncementsProvider";
|
||||
import { AnnouncementsPreview } from "./AnnouncementsPreview";
|
||||
import { TargetAnnouncementsOverlay } from "./TargetAnnouncementsOverlay";
|
||||
|
||||
function setup(
|
||||
node: React.ReactNode,
|
||||
opts: { workState?: MockWorkStateGateway } = {},
|
||||
) {
|
||||
const system = new MockSystemGateway();
|
||||
const workState = opts.workState;
|
||||
const gateways = { system, workState } as unknown as Gateways;
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<AnnouncementsProvider>{node}</AnnouncementsProvider>
|
||||
</DIProvider>,
|
||||
);
|
||||
return { system };
|
||||
}
|
||||
|
||||
type AnnouncementEvent = Extract<DomainEvent, { type: "agentAnnouncement" }>;
|
||||
|
||||
function announcement(over: Partial<AnnouncementEvent> = {}): AnnouncementEvent {
|
||||
return {
|
||||
type: "agentAnnouncement",
|
||||
projectId: "p1",
|
||||
requester: "agent-A",
|
||||
target: "agent-T",
|
||||
ticketId: "t1",
|
||||
text: "je réfléchis…",
|
||||
atMs: 1,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function busy(agentId: string, isBusy: boolean): DomainEvent {
|
||||
return { type: "agentBusyChanged", agentId, busy: isBusy };
|
||||
}
|
||||
|
||||
// The provider subscribes asynchronously (onDomainEvent returns a Promise) and
|
||||
// hydration awaits the read-model; flush microtasks before/after emitting.
|
||||
async function flush() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
const overlay = () => screen.queryByTestId("target-announcements-overlay");
|
||||
|
||||
describe("F3 — overlay lifecycle is the target's busy state", () => {
|
||||
it("mounts on busy:true and shows the streamed announcement as content", async () => {
|
||||
const { system } = setup(
|
||||
<TargetAnnouncementsOverlay projectId="p1" agentId="agent-T" />,
|
||||
);
|
||||
await flush();
|
||||
expect(overlay()).toBeNull();
|
||||
|
||||
act(() => {
|
||||
system.emit(busy("agent-T", true));
|
||||
system.emit(announcement({ target: "agent-T", text: "en cours" }));
|
||||
});
|
||||
|
||||
expect(overlay()).not.toBeNull();
|
||||
expect(screen.getByText("en cours")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("mounts on busy:true even before any announcement (banner only)", async () => {
|
||||
const { system } = setup(
|
||||
<TargetAnnouncementsOverlay projectId="p1" agentId="agent-T" />,
|
||||
);
|
||||
await flush();
|
||||
|
||||
act(() => system.emit(busy("agent-T", true)));
|
||||
|
||||
expect(overlay()).not.toBeNull();
|
||||
});
|
||||
|
||||
it("RETRACTS on busy:false even when NO completion event is emitted", async () => {
|
||||
// The regression the arbitrage targets: a turn that ends without any
|
||||
// completion signal (interruption / error / crash) must still bring the
|
||||
// overlay down. On develop the only signal is busy — and it suffices.
|
||||
const { system } = setup(
|
||||
<TargetAnnouncementsOverlay projectId="p1" agentId="agent-T" />,
|
||||
);
|
||||
await flush();
|
||||
|
||||
act(() => {
|
||||
system.emit(busy("agent-T", true));
|
||||
system.emit(announcement({ target: "agent-T", text: "orphan" }));
|
||||
});
|
||||
expect(overlay()).not.toBeNull();
|
||||
|
||||
act(() => system.emit(busy("agent-T", false)));
|
||||
|
||||
expect(overlay()).toBeNull();
|
||||
});
|
||||
|
||||
it("clears stale content on busy:false so a next turn starts fresh", async () => {
|
||||
const { system } = setup(
|
||||
<TargetAnnouncementsOverlay projectId="p1" agentId="agent-T" />,
|
||||
);
|
||||
await flush();
|
||||
|
||||
act(() => {
|
||||
system.emit(busy("agent-T", true));
|
||||
system.emit(announcement({ target: "agent-T", text: "old" }));
|
||||
system.emit(busy("agent-T", false));
|
||||
system.emit(busy("agent-T", true));
|
||||
});
|
||||
|
||||
// New turn: the previous "old" réflexion must not be shown.
|
||||
expect(overlay()).not.toBeNull();
|
||||
expect(screen.queryByText("old")).toBeNull();
|
||||
});
|
||||
|
||||
it("hydrates busy from the reconciled read-model at mount (no live event)", async () => {
|
||||
const workState = new MockWorkStateGateway();
|
||||
workState._setProjectWorkState("p1", {
|
||||
agents: [
|
||||
{
|
||||
agentId: "agent-T",
|
||||
name: "Target",
|
||||
profileId: "prof",
|
||||
busy: { state: "busy", ticket: "t9", sinceMs: 123 },
|
||||
tickets: [],
|
||||
},
|
||||
],
|
||||
conversations: [],
|
||||
});
|
||||
|
||||
setup(<TargetAnnouncementsOverlay projectId="p1" agentId="agent-T" />, {
|
||||
workState,
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(overlay()).not.toBeNull();
|
||||
});
|
||||
|
||||
it("stays down when the read-model reports the agent idle", async () => {
|
||||
const workState = new MockWorkStateGateway();
|
||||
workState._setProjectWorkState("p1", {
|
||||
agents: [
|
||||
{
|
||||
agentId: "agent-T",
|
||||
name: "Target",
|
||||
profileId: "prof",
|
||||
busy: { state: "idle" },
|
||||
tickets: [],
|
||||
},
|
||||
],
|
||||
conversations: [],
|
||||
});
|
||||
|
||||
setup(<TargetAnnouncementsOverlay projectId="p1" agentId="agent-T" />, {
|
||||
workState,
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(overlay()).toBeNull();
|
||||
});
|
||||
|
||||
it("lets a live busy:false win over a stale busy hydration snapshot", async () => {
|
||||
// Hydration seeds only when the agent is unknown; a later live idle event must
|
||||
// still retract (live authority wins).
|
||||
const workState = new MockWorkStateGateway();
|
||||
workState._setProjectWorkState("p1", {
|
||||
agents: [
|
||||
{
|
||||
agentId: "agent-T",
|
||||
name: "Target",
|
||||
profileId: "prof",
|
||||
busy: { state: "busy", ticket: "t9", sinceMs: 1 },
|
||||
tickets: [],
|
||||
},
|
||||
],
|
||||
conversations: [],
|
||||
});
|
||||
|
||||
const { system } = setup(
|
||||
<TargetAnnouncementsOverlay projectId="p1" agentId="agent-T" />,
|
||||
{ workState },
|
||||
);
|
||||
await flush();
|
||||
expect(overlay()).not.toBeNull();
|
||||
|
||||
act(() => system.emit(busy("agent-T", false)));
|
||||
expect(overlay()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("F2 — AnnouncementsPreview (requester filter)", () => {
|
||||
it("shows the requester's own announcements and hides another's", async () => {
|
||||
const { system } = setup(<AnnouncementsPreview requester="agent-A" />);
|
||||
await flush();
|
||||
|
||||
act(() => {
|
||||
system.emit(announcement({ requester: "agent-A", text: "for-A" }));
|
||||
system.emit(
|
||||
announcement({ requester: "agent-B", ticketId: "t2", text: "for-B" }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByText("for-A")).not.toBeNull();
|
||||
expect(screen.queryByText("for-B")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing when the requester has no announcements", async () => {
|
||||
setup(<AnnouncementsPreview requester="agent-A" />);
|
||||
await flush();
|
||||
expect(screen.queryByTestId("announcements-preview")).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user