/** * 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( {node} , ); return { system }; } type AnnouncementEvent = Extract; function announcement(over: Partial = {}): 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( , ); 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( , ); 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( , ); 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( , ); 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(, { 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(, { 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( , { 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(); 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(); await flush(); expect(screen.queryByTestId("announcements-preview")).toBeNull(); }); });