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:
2026-07-04 19:50:13 +02:00
parent aa5a4f30ae
commit 50b2adfde3
12 changed files with 1083 additions and 2 deletions

View File

@ -0,0 +1,61 @@
/**
* `AnnouncementsPreview` — F2 of ticket #4.
*
* The **requester** side: a small frame, shown near the agent drop-list, that
* previews the announcements the given agent is currently waiting on (it has
* contacted another agent and is receiving its réflexions live).
*
* Contract (Architect, point 7): filter on `requester == self`. The target
* thread is shared across every requester, so previewing by target alone would
* leak another requester's announcements; here `requester` is pinned to this
* agent's id. Renders nothing when the agent has no announcements in flight.
*/
import { useRequesterAnnouncements } from "./AnnouncementsProvider";
export interface AnnouncementsPreviewProps {
/** The requesting agent this preview belongs to (the announcement `requester`). */
requester: string;
/** Optional ticket scope (a single inter-agent turn). */
ticketId?: string;
/** How many most-recent announcements to show. */
limit?: number;
}
export function AnnouncementsPreview({
requester,
ticketId,
limit = 3,
}: AnnouncementsPreviewProps) {
const announcements = useRequesterAnnouncements(requester, ticketId);
if (announcements.length === 0) return null;
// Most-recent `limit`, newest last (the list is oldest → newest).
const recent = announcements.slice(Math.max(0, announcements.length - limit));
return (
<div
data-testid="announcements-preview"
className="flex flex-col gap-1 rounded-md border border-primary/25 bg-primary/5 px-2.5 py-1.5"
>
<span className="flex items-center gap-1.5 text-[0.65rem] font-medium uppercase tracking-wide text-primary">
<span
aria-hidden
className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-primary"
/>
En attente d'un agent
</span>
<ul className="flex flex-col gap-0.5">
{recent.map((a) => (
<li
key={a.seq}
className="truncate text-xs text-muted"
title={a.text}
>
{a.text}
</li>
))}
</ul>
</div>
);
}

View File

@ -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();
});
});

View File

@ -0,0 +1,217 @@
/**
* `AnnouncementsProvider` — the live, app-wide store for inter-agent announcements
* (ticket #4, F1 wiring). Subscribes **once** to the domain-event stream via the
* {@link SystemGateway} and folds two orthogonal signals:
*
* 1. **Announcement content** — `agentAnnouncement` events appended to the
* bounded `(target, ticketId)` index (the scrolling text of the overlay F3
* and the requester preview F2).
* 2. **Overlay lifecycle** — the per-agent **busy/idle** state, the single
* authority for mounting/retracting the target overlay (Architect arbitrage
* 2026-07-04). Fed live by `agentBusyChanged { agentId, busy }` and hydrated
* from the reconciled read-model `ProjectWorkState.agents[].busy` at mount /
* reboot (via {@link useHydrateAgentBusy}).
*
* Why busy — not "there are announcements" — drives F3: a turn can end **without**
* any completion event (interruption / error / crash / rate-limit). The mediator's
* `agentBusyChanged` `busy:false` falls at idle in *every* case, and the read-model
* is reconciled on reboot, so the overlay can never stick. A rate-limited agent
* stays `busy:true`, so its overlay persists with no special-casing.
*
* (On this `develop` base there is no canonical `agentTurnEvent`/`final` signal;
* busy is the sole lifecycle authority, which is exactly what we want.)
*
* Consumers read via {@link useTargetAnnouncements} (F3) and
* {@link useRequesterAnnouncements} (F2). The store is never persisted — it is
* rebuilt live from the event stream and the read-model snapshot.
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { useGateways } from "@/app/di";
import {
announcementsForRequester,
announcementsForTarget,
appendAnnouncement,
emptyAnnouncementIndex,
purgeAllForTarget,
type Announcement,
type AnnouncementIndex,
} from "./announcementsStore";
/** Per-agent busy map: `true` while the agent owns a turn (drives the overlay). */
type BusyMap = Record<string, boolean>;
interface AnnouncementsStore {
/** Bounded announcement content, indexed by `(target, ticketId)`. */
index: AnnouncementIndex;
/** Per-agent busy state — the overlay's lifecycle authority. */
busy: BusyMap;
/**
* Seeds an agent's busy state from the read-model, but only when no live signal
* is yet known for it: live `agentBusyChanged` events always win over a
* (possibly staler) hydration snapshot.
*/
seedBusy: (agentId: string, busy: boolean) => void;
}
const noopSeed = () => {};
const EMPTY_INDEX = emptyAnnouncementIndex();
const EMPTY_BUSY: BusyMap = {};
/** Store shape read outside a provider — a silent, inert default (never throws). */
const DEFAULT_STORE: AnnouncementsStore = {
index: EMPTY_INDEX,
busy: EMPTY_BUSY,
seedBusy: noopSeed,
};
const AnnouncementsContext = createContext<AnnouncementsStore | null>(null);
export function AnnouncementsProvider({ children }: { children: ReactNode }) {
const { system } = useGateways();
const [index, setIndex] = useState<AnnouncementIndex>(emptyAnnouncementIndex);
const [busy, setBusy] = useState<BusyMap>({});
// Hydration seed: apply only when the agent is unknown, so a live event that
// already landed (or lands before the async read-model resolves) is not clobbered.
const seedBusy = useCallback((agentId: string, value: boolean) => {
setBusy((prev) => (agentId in prev ? prev : { ...prev, [agentId]: value }));
}, []);
useEffect(() => {
// `system` may be absent in unit tests injecting a partial gateway set.
if (!system) return;
let unsubscribe: (() => void) | undefined;
let cancelled = false;
void system
.onDomainEvent((event) => {
if (event.type === "agentAnnouncement") {
// Content of the overlay/preview.
setIndex((prev) =>
appendAnnouncement(prev, {
requester: event.requester,
target: event.target,
ticketId: event.ticketId,
text: event.text,
atMs: event.atMs,
}),
);
} else if (event.type === "agentBusyChanged") {
// Lifecycle authority. `true` mounts the target overlay; `false` retracts
// it (idle) and clears the target's content so a next turn starts fresh —
// even when no completion event was ever emitted (the sticking case).
const { agentId, busy: isBusy } = event;
setBusy((prev) => ({ ...prev, [agentId]: isBusy }));
if (!isBusy) setIndex((prev) => purgeAllForTarget(prev, agentId));
}
})
.then((un) => {
if (cancelled) un();
else unsubscribe = un;
})
.catch(() => {
// Event relay unavailable in this environment — the store stays empty.
});
return () => {
cancelled = true;
unsubscribe?.();
};
}, [system]);
const store = useMemo<AnnouncementsStore>(
() => ({ index, busy, seedBusy }),
[index, busy, seedBusy],
);
return (
<AnnouncementsContext.Provider value={store}>
{children}
</AnnouncementsContext.Provider>
);
}
/** The store, or an inert default when read outside a provider. */
function useStore(): AnnouncementsStore {
return useContext(AnnouncementsContext) ?? DEFAULT_STORE;
}
/** Whether a real provider is mounted above (hydration only runs when it is). */
function useWithinProvider(): boolean {
return useContext(AnnouncementsContext) !== null;
}
/**
* Announcements destined to `target` (F3): the flattened, time-ordered content
* plus `active` — whether the overlay should be mounted. `active` is the target's
* **busy** state (the lifecycle authority), NOT the mere presence of announcements.
*/
export function useTargetAnnouncements(target: string): {
announcements: Announcement[];
active: boolean;
} {
const { index, busy } = useStore();
return useMemo(
() => ({
announcements: announcementsForTarget(index, target),
active: busy[target] === true,
}),
[index, busy, target],
);
}
/**
* Hydrates the target's busy state from the reconciled read-model at mount/reboot,
* so an overlay mounts for a turn already in flight when its cell appears — no live
* `agentBusyChanged` will replay for it. Live events subsequently win (see
* `seedBusy`). Guarded: no-op outside a provider or without a `workState` gateway.
*/
export function useHydrateAgentBusy(projectId: string, agentId: string): void {
const { workState } = useGateways();
const { seedBusy } = useStore();
const within = useWithinProvider();
useEffect(() => {
if (!within || !workState) return;
let cancelled = false;
void workState
.getProjectWorkState(projectId)
.then((state) => {
if (cancelled) return;
const agent = state.agents.find((a) => a.agentId === agentId);
if (agent) seedBusy(agentId, agent.busy.state === "busy");
})
.catch(() => {
// Read-model unavailable — fall back to the live event stream only.
});
return () => {
cancelled = true;
};
}, [within, workState, projectId, agentId, seedBusy]);
}
/**
* Announcements the given `requester` is waiting on (F2), filtered by
* `requester == self` so a shared target thread never leaks another requester's
* announcements. Optionally scoped to a single `ticketId`.
*/
export function useRequesterAnnouncements(
requester: string,
ticketId?: string,
): Announcement[] {
const { index } = useStore();
return useMemo(
() => announcementsForRequester(index, requester, ticketId),
[index, requester, ticketId],
);
}

View File

@ -0,0 +1,95 @@
/**
* `TargetAnnouncementsOverlay` — F3 of ticket #4.
*
* Overlays the **target** agent's cell while another agent is talking to it: a
* top banner ("un agent est en train de lui parler") and, below, its live
* réflexions/annonces scrolling by. Mount/retract is driven by the target's
* **busy** state (the single lifecycle authority — `agentBusyChanged` +
* read-model hydration), so the overlay comes down at idle even when a turn ends
* without any completion event. The announcements are its *content*.
*
* This is **not** a PTY block: the inter-agent contact runs on a separate
* structured session. The overlay is a pure, non-interactive availability veil
* (`pointer-events-none`) so it never traps focus or clicks.
*/
import { useEffect, useRef } from "react";
import { Spinner } from "@/shared";
import {
useHydrateAgentBusy,
useTargetAnnouncements,
} from "./AnnouncementsProvider";
export interface TargetAnnouncementsOverlayProps {
/** Project hosting the agent (for read-model busy hydration at mount/reboot). */
projectId: string;
/** The agent whose cell this overlay guards (the announcement `target`). */
agentId: string;
}
function formatTime(atMs: number): string {
if (!Number.isFinite(atMs) || atMs <= 0) return "";
try {
return new Date(atMs).toLocaleTimeString();
} catch {
return "";
}
}
export function TargetAnnouncementsOverlay({
projectId,
agentId,
}: TargetAnnouncementsOverlayProps) {
// Seed busy from the reconciled read-model so a turn already in flight surfaces
// when this cell mounts (reboot / late open); live events then take over.
useHydrateAgentBusy(projectId, agentId);
const { announcements, active } = useTargetAnnouncements(agentId);
// Follow the tail as new announcements scroll in.
const feedRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = feedRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [announcements]);
if (!active) return null;
return (
<div
data-testid="target-announcements-overlay"
role="status"
aria-live="polite"
className="pointer-events-none absolute inset-0 z-20 flex flex-col bg-canvas/85 backdrop-blur-sm"
>
{/* ── Banner ── */}
<div className="flex shrink-0 items-center gap-2 border-b border-border/60 bg-surface/80 px-4 py-2">
<Spinner size={14} />
<span className="text-sm font-medium text-content">
Un agent est en train de lui parler
</span>
</div>
{/* ── Scrolling réflexions ── */}
<div
ref={feedRef}
className="flex flex-1 flex-col justify-end gap-1.5 overflow-hidden px-6 py-4"
>
{announcements.map((a) => {
const time = formatTime(a.atMs);
return (
<div
key={a.seq}
className="flex items-baseline gap-2 text-sm text-muted"
>
{time && <span className="shrink-0 text-xs text-faint">{time}</span>}
<span className="whitespace-pre-wrap break-words text-content/90">
{a.text}
</span>
</div>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,169 @@
/**
* F1 — pure announcements store (ticket #4): indexation, bornage, purge per
* busy-cycle, and the requester filter that backs F2 (`requester == self` on a
* shared thread).
*/
import { describe, it, expect } from "vitest";
import {
DEFAULT_ANNOUNCEMENT_CAP,
announcementsForRequester,
announcementsForTarget,
appendAnnouncement,
emptyAnnouncementIndex,
purgeAllForTarget,
targetHasActiveAnnouncements,
type AnnouncementInput,
} from "./announcementsStore";
function ann(over: Partial<AnnouncementInput> = {}): AnnouncementInput {
return {
requester: "agent-req",
target: "agent-tgt",
ticketId: "ticket-1",
text: "réflexion",
atMs: 1000,
...over,
};
}
describe("appendAnnouncement — indexation", () => {
it("indexes by (target, ticketId) and assigns monotonic seq", () => {
let idx = emptyAnnouncementIndex();
idx = appendAnnouncement(idx, ann({ text: "a", atMs: 1 }));
idx = appendAnnouncement(idx, ann({ text: "b", atMs: 2 }));
const list = announcementsForTarget(idx, "agent-tgt");
expect(list.map((a) => a.text)).toEqual(["a", "b"]);
expect(list.map((a) => a.seq)).toEqual([1, 2]);
expect(idx.nextSeq).toBe(3);
});
it("keeps separate buckets per ticket under the same target", () => {
let idx = emptyAnnouncementIndex();
idx = appendAnnouncement(idx, ann({ ticketId: "t1", text: "x" }));
idx = appendAnnouncement(idx, ann({ ticketId: "t2", text: "y" }));
expect(Object.keys(idx.byTarget["agent-tgt"])).toEqual(["t1", "t2"]);
expect(announcementsForTarget(idx, "agent-tgt").map((a) => a.text)).toEqual([
"x",
"y",
]);
});
it("orders a flattened target list by time then seq", () => {
let idx = emptyAnnouncementIndex();
idx = appendAnnouncement(idx, ann({ ticketId: "t1", text: "late", atMs: 30 }));
idx = appendAnnouncement(idx, ann({ ticketId: "t2", text: "early", atMs: 10 }));
expect(announcementsForTarget(idx, "agent-tgt").map((a) => a.text)).toEqual([
"early",
"late",
]);
});
it("does not mutate the previous index (immutability)", () => {
const idx0 = emptyAnnouncementIndex();
const idx1 = appendAnnouncement(idx0, ann());
expect(idx0.byTarget).toEqual({});
expect(idx1).not.toBe(idx0);
});
});
describe("appendAnnouncement — bornage", () => {
it("bounds a bucket to the cap, dropping the oldest", () => {
let idx = emptyAnnouncementIndex();
const cap = 3;
for (let i = 0; i < 5; i++) {
idx = appendAnnouncement(idx, ann({ text: `m${i}`, atMs: i }), cap);
}
const list = announcementsForTarget(idx, "agent-tgt");
expect(list).toHaveLength(cap);
// Oldest (m0, m1) dropped; most recent kept.
expect(list.map((a) => a.text)).toEqual(["m2", "m3", "m4"]);
});
it("defaults to DEFAULT_ANNOUNCEMENT_CAP", () => {
let idx = emptyAnnouncementIndex();
for (let i = 0; i < DEFAULT_ANNOUNCEMENT_CAP + 10; i++) {
idx = appendAnnouncement(idx, ann({ text: `m${i}`, atMs: i }));
}
expect(announcementsForTarget(idx, "agent-tgt")).toHaveLength(
DEFAULT_ANNOUNCEMENT_CAP,
);
});
});
describe("purgeAllForTarget — retract au busy:false (idle, sans final)", () => {
it("drops every ticket of the target at once", () => {
let idx = emptyAnnouncementIndex();
idx = appendAnnouncement(idx, ann({ ticketId: "t1", text: "a" }));
idx = appendAnnouncement(idx, ann({ ticketId: "t2", text: "b" }));
idx = purgeAllForTarget(idx, "agent-tgt");
expect(targetHasActiveAnnouncements(idx, "agent-tgt")).toBe(false);
expect(idx.byTarget["agent-tgt"]).toBeUndefined();
});
it("leaves other targets untouched", () => {
let idx = emptyAnnouncementIndex();
idx = appendAnnouncement(idx, ann({ target: "T1", text: "x" }));
idx = appendAnnouncement(idx, ann({ target: "T2", text: "y" }));
idx = purgeAllForTarget(idx, "T1");
expect(targetHasActiveAnnouncements(idx, "T1")).toBe(false);
expect(announcementsForTarget(idx, "T2").map((a) => a.text)).toEqual(["y"]);
});
it("is a no-op (stable reference) for an unknown target", () => {
const idx = appendAnnouncement(emptyAnnouncementIndex(), ann());
expect(purgeAllForTarget(idx, "unknown")).toBe(idx);
});
});
describe("announcementsForRequester — F2 filter (requester == self)", () => {
it("returns only the caller's announcements on a shared target thread", () => {
// Two requesters (A, B) both talk to the same target on distinct tickets:
// the target thread is shared, so filtering must be by requester.
let idx = emptyAnnouncementIndex();
idx = appendAnnouncement(
idx,
ann({ requester: "A", ticketId: "ta", text: "for-A" }),
);
idx = appendAnnouncement(
idx,
ann({ requester: "B", ticketId: "tb", text: "for-B" }),
);
expect(announcementsForRequester(idx, "A").map((a) => a.text)).toEqual([
"for-A",
]);
expect(announcementsForRequester(idx, "B").map((a) => a.text)).toEqual([
"for-B",
]);
});
it("scopes to a single ticket when ticketId is given", () => {
let idx = emptyAnnouncementIndex();
idx = appendAnnouncement(
idx,
ann({ requester: "A", ticketId: "ta", text: "ta-msg", atMs: 1 }),
);
idx = appendAnnouncement(
idx,
ann({ requester: "A", ticketId: "tb", text: "tb-msg", atMs: 2 }),
);
expect(
announcementsForRequester(idx, "A", "ta").map((a) => a.text),
).toEqual(["ta-msg"]);
});
it("returns nothing for a requester with no announcements", () => {
const idx = appendAnnouncement(emptyAnnouncementIndex(), ann({ requester: "A" }));
expect(announcementsForRequester(idx, "someone-else")).toEqual([]);
});
});

View File

@ -0,0 +1,161 @@
/**
* Pure store logic for the **inter-agent announcements** feature (ticket #4, F1).
*
* When an agent contacts another via `idea_ask_agent`, the backend streams the
* target's intermediate réflexions as `agentAnnouncement` domain events. These
* are **ephemeral** live signals — never persisted — folded here into a bounded
* index keyed by `(target, ticketId)`:
*
* - the **target cell** overlays the announcements destined to it (F3);
* - the **requester cell** previews the announcements it is waiting on (F2),
* filtered by `requester == self` — the target thread is shared across
* requesters, so without that filter one requester would see another's
* announcements (Architect contract, point 7).
*
* The overlay's mount/retract is driven by the target's **busy/idle** state (the
* single lifecycle authority — see `AnnouncementsProvider`), not by this index;
* the index only carries the scrolling *content*. It is retracted per busy-cycle
* (`purgeAllForTarget` on `busy:false`) so a fresh turn starts from a clean feed,
* and each bucket is bounded (oldest dropped first) so a long turn cannot grow it
* without limit.
*
* This module is intentionally free of React/Tauri: it is a set of immutable
* reducers + selectors, unit-tested in isolation (`announcementsStore.test.ts`).
*/
/** A single live announcement folded from an `agentAnnouncement` event. */
export interface Announcement {
/** Requesting party: `"user"` or the requesting agent's id. */
requester: string;
/** Target agent being contacted. */
target: string;
/** FIFO ticket correlating the inter-agent turn. */
ticketId: string;
/** Human-readable réflexion/annonce text. */
text: string;
/** Wall-clock timestamp (epoch-ms) as emitted by the backend. */
atMs: number;
/**
* Monotonic local sequence assigned on ingest. Provides a stable React key and
* a total order even when two announcements share the same `atMs`.
*/
seq: number;
}
/**
* Immutable announcement index. `byTarget[target][ticketId]` is a bounded list of
* announcements ordered oldest → newest. Empty ticket/target buckets are pruned,
* so `byTarget[target]` existing implies the target has ≥1 active ticket.
*/
export interface AnnouncementIndex {
byTarget: Record<string, Record<string, Announcement[]>>;
/** Next sequence number to assign (monotonic, never reused). */
nextSeq: number;
}
/** Default per-`(target, ticketId)` bucket cap (Architect: ~2050). */
export const DEFAULT_ANNOUNCEMENT_CAP = 50;
/** An empty index. */
export function emptyAnnouncementIndex(): AnnouncementIndex {
return { byTarget: {}, nextSeq: 1 };
}
/** Fields carried by an `agentAnnouncement` event (sans the local `seq`). */
export type AnnouncementInput = Omit<Announcement, "seq">;
/**
* Appends one announcement to its `(target, ticketId)` bucket, assigning the next
* sequence and trimming the bucket to `cap` (dropping the oldest). Returns a new
* index; the input is never mutated.
*/
export function appendAnnouncement(
index: AnnouncementIndex,
input: AnnouncementInput,
cap: number = DEFAULT_ANNOUNCEMENT_CAP,
): AnnouncementIndex {
const { target, ticketId } = input;
const announcement: Announcement = { ...input, seq: index.nextSeq };
const targetBuckets = index.byTarget[target] ?? {};
const prevBucket = targetBuckets[ticketId] ?? [];
const grown = [...prevBucket, announcement];
// Keep only the last `cap` (bound the live index; oldest fall off first).
const bounded = grown.length > cap ? grown.slice(grown.length - cap) : grown;
return {
byTarget: {
...index.byTarget,
[target]: { ...targetBuckets, [ticketId]: bounded },
},
nextSeq: index.nextSeq + 1,
};
}
/**
* Retracts **all** tickets of `target` at once — called when the target goes idle
* (`agentBusyChanged` `busy:false`), which has no ticket to scope by. Keeps the
* overlay content ephemeral per busy-cycle: a fresh turn starts from a clean feed
* rather than showing the previous turn's stale réflexions. No-op (stable
* reference) when the target has nothing indexed.
*/
export function purgeAllForTarget(
index: AnnouncementIndex,
target: string,
): AnnouncementIndex {
if (!(target in index.byTarget)) return index;
const { [target]: _dropped, ...rest } = index.byTarget;
return { byTarget: rest, nextSeq: index.nextSeq };
}
/** Orders two announcements oldest → newest (by `atMs`, tie-broken by `seq`). */
function byTime(a: Announcement, b: Announcement): number {
return a.atMs - b.atMs || a.seq - b.seq;
}
/**
* All active announcements destined to `target`, flattened across its tickets and
* ordered oldest → newest. Empty when the target has no active turn (F3 source).
*/
export function announcementsForTarget(
index: AnnouncementIndex,
target: string,
): Announcement[] {
const buckets = index.byTarget[target];
if (!buckets) return [];
return Object.values(buckets).flat().sort(byTime);
}
/** Whether `target` has ≥1 active announcement (drives the overlay's mount). */
export function targetHasActiveAnnouncements(
index: AnnouncementIndex,
target: string,
): boolean {
const buckets = index.byTarget[target];
if (!buckets) return false;
return Object.values(buckets).some((bucket) => bucket.length > 0);
}
/**
* All active announcements whose `requester === requester`, across every target
* and ticket, ordered oldest → newest. This is the F2 filter: because the target
* thread is **shared** across requesters, filtering by `requester` prevents one
* requester from previewing another's announcements. When `ticketId` is given the
* result is further scoped to that ticket.
*/
export function announcementsForRequester(
index: AnnouncementIndex,
requester: string,
ticketId?: string,
): Announcement[] {
const out: Announcement[] = [];
for (const buckets of Object.values(index.byTarget)) {
for (const [tid, bucket] of Object.entries(buckets)) {
if (ticketId !== undefined && tid !== ticketId) continue;
for (const ann of bucket) {
if (ann.requester === requester) out.push(ann);
}
}
}
return out.sort(byTime);
}

View File

@ -0,0 +1,19 @@
/**
* Inter-agent announcements feature (ticket #4) — live, ephemeral overlay/preview
* of the réflexions streamed while one agent talks to another.
*/
export {
AnnouncementsProvider,
useTargetAnnouncements,
useRequesterAnnouncements,
} from "./AnnouncementsProvider";
export {
TargetAnnouncementsOverlay,
type TargetAnnouncementsOverlayProps,
} from "./TargetAnnouncementsOverlay";
export {
AnnouncementsPreview,
type AnnouncementsPreviewProps,
} from "./AnnouncementsPreview";
export * from "./announcementsStore";