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>
233 lines
7.2 KiB
TypeScript
233 lines
7.2 KiB
TypeScript
/**
|
|
* 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();
|
|
});
|
|
});
|