fix(cli): store annonces inter-agent borné par projectId avec reset et réhydratation busy au changement de projet — #236
This commit is contained in:
@ -9,7 +9,6 @@ import { useEffect, useState } from "react";
|
||||
import type { DomainEvent, HealthReport } from "@/domain";
|
||||
import { ProjectsView } from "@/features/projects";
|
||||
import { FirstRunWizard } from "@/features/first-run";
|
||||
import { AnnouncementsProvider } from "@/features/announcements";
|
||||
import { AppExitConfirmDialog } from "@/features/appExit";
|
||||
import { PluginRuntimeProvider } from "@/features/plugins";
|
||||
import { Panel, Spinner, Toolbar } from "@/shared";
|
||||
@ -65,7 +64,6 @@ export function App() {
|
||||
}, [profile]);
|
||||
|
||||
return (
|
||||
<AnnouncementsProvider>
|
||||
<PluginRuntimeProvider>
|
||||
<div className="flex h-full flex-col bg-canvas text-content">
|
||||
{/* ── Header ── */}
|
||||
@ -119,7 +117,6 @@ export function App() {
|
||||
</div>
|
||||
<AppExitConfirmDialog />
|
||||
</PluginRuntimeProvider>
|
||||
</AnnouncementsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -24,17 +24,19 @@ import { TargetAnnouncementsOverlay } from "./TargetAnnouncementsOverlay";
|
||||
|
||||
function setup(
|
||||
node: React.ReactNode,
|
||||
opts: { workState?: MockWorkStateGateway } = {},
|
||||
opts: { projectId?: string | null; workState?: MockWorkStateGateway } = {},
|
||||
) {
|
||||
const system = new MockSystemGateway();
|
||||
const workState = opts.workState;
|
||||
const gateways = { system, workState } as unknown as Gateways;
|
||||
render(
|
||||
const result = render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<AnnouncementsProvider>{node}</AnnouncementsProvider>
|
||||
<AnnouncementsProvider projectId={opts.projectId ?? "p1"}>
|
||||
{node}
|
||||
</AnnouncementsProvider>
|
||||
</DIProvider>,
|
||||
);
|
||||
return { system };
|
||||
return { system, ...result };
|
||||
}
|
||||
|
||||
type AnnouncementEvent = Extract<DomainEvent, { type: "agentAnnouncement" }>;
|
||||
@ -84,6 +86,27 @@ describe("F3 — overlay lifecycle is the target's busy state", () => {
|
||||
expect(screen.getByText("en cours")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("ignores streamed announcements from another project", async () => {
|
||||
const { system } = setup(
|
||||
<TargetAnnouncementsOverlay projectId="p1" agentId="agent-T" />,
|
||||
);
|
||||
await flush();
|
||||
|
||||
act(() => {
|
||||
system.emit(busy("agent-T", true));
|
||||
system.emit(
|
||||
announcement({
|
||||
projectId: "p2",
|
||||
target: "agent-T",
|
||||
text: "other-project",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(overlay()).not.toBeNull();
|
||||
expect(screen.queryByText("other-project")).toBeNull();
|
||||
});
|
||||
|
||||
it("mounts on busy:true even before any announcement (banner only)", async () => {
|
||||
const { system } = setup(
|
||||
<TargetAnnouncementsOverlay projectId="p1" agentId="agent-T" />,
|
||||
@ -171,6 +194,64 @@ describe("F3 — overlay lifecycle is the target's busy state", () => {
|
||||
expect(overlay()).not.toBeNull();
|
||||
});
|
||||
|
||||
it("resets and rehydrates when switching project", async () => {
|
||||
const workState = new MockWorkStateGateway();
|
||||
workState._setProjectWorkState("p1", {
|
||||
agents: [
|
||||
{
|
||||
agentId: "agent-T",
|
||||
name: "Target",
|
||||
profileId: "prof",
|
||||
busy: { state: "idle" },
|
||||
tickets: [],
|
||||
},
|
||||
],
|
||||
conversations: [],
|
||||
});
|
||||
workState._setProjectWorkState("p2", {
|
||||
agents: [
|
||||
{
|
||||
agentId: "agent-T",
|
||||
name: "Target",
|
||||
profileId: "prof",
|
||||
busy: { state: "busy", ticket: "t-p2", sinceMs: 456 },
|
||||
tickets: [],
|
||||
},
|
||||
],
|
||||
conversations: [],
|
||||
});
|
||||
|
||||
const system = new MockSystemGateway();
|
||||
const gateways = { system, workState } as unknown as Gateways;
|
||||
const { rerender } = render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<AnnouncementsProvider projectId="p1">
|
||||
<TargetAnnouncementsOverlay projectId="p1" agentId="agent-T" />
|
||||
</AnnouncementsProvider>
|
||||
</DIProvider>,
|
||||
);
|
||||
await flush();
|
||||
|
||||
act(() => {
|
||||
system.emit(busy("agent-T", true));
|
||||
system.emit(announcement({ projectId: "p1", text: "old-p1" }));
|
||||
});
|
||||
expect(screen.getByText("old-p1")).not.toBeNull();
|
||||
|
||||
rerender(
|
||||
<DIProvider gateways={gateways}>
|
||||
<AnnouncementsProvider projectId="p2">
|
||||
<TargetAnnouncementsOverlay projectId="p2" agentId="agent-T" />
|
||||
</AnnouncementsProvider>
|
||||
</DIProvider>,
|
||||
);
|
||||
await flush();
|
||||
|
||||
expect(overlay()).not.toBeNull();
|
||||
expect(screen.queryByText("old-p1")).toBeNull();
|
||||
expect(screen.getByText("Travaille sur une demande en cours.")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("stays down when the read-model reports the agent idle", async () => {
|
||||
const workState = new MockWorkStateGateway();
|
||||
workState._setProjectWorkState("p1", {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/**
|
||||
* `AnnouncementsProvider` — the live, app-wide store for inter-agent announcements
|
||||
* `AnnouncementsProvider` — the live, project-scoped 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:
|
||||
*
|
||||
@ -22,8 +22,9 @@
|
||||
* 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.
|
||||
* {@link useRequesterAnnouncements} (F2). The store is never persisted and is
|
||||
* reset whenever the provider's project changes; it is rebuilt live from the
|
||||
* event stream and the read-model snapshot for that project.
|
||||
*/
|
||||
|
||||
import {
|
||||
@ -76,7 +77,13 @@ const DEFAULT_STORE: AnnouncementsStore = {
|
||||
|
||||
const AnnouncementsContext = createContext<AnnouncementsStore | null>(null);
|
||||
|
||||
export function AnnouncementsProvider({ children }: { children: ReactNode }) {
|
||||
export function AnnouncementsProvider({
|
||||
projectId,
|
||||
children,
|
||||
}: {
|
||||
projectId: string | null;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { system } = useGateways();
|
||||
const [index, setIndex] = useState<AnnouncementIndex>(emptyAnnouncementIndex);
|
||||
const [busy, setBusy] = useState<BusyMap>({});
|
||||
@ -87,15 +94,21 @@ export function AnnouncementsProvider({ children }: { children: ReactNode }) {
|
||||
setBusy((prev) => (agentId in prev ? prev : { ...prev, [agentId]: value }));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setIndex(emptyAnnouncementIndex());
|
||||
setBusy({});
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
// `system` may be absent in unit tests injecting a partial gateway set.
|
||||
if (!system) return;
|
||||
if (!system || !projectId) return;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
let cancelled = false;
|
||||
|
||||
void system
|
||||
.onDomainEvent((event) => {
|
||||
if (event.type === "agentAnnouncement") {
|
||||
if (event.projectId !== projectId) return;
|
||||
// Content of the overlay/preview.
|
||||
setIndex((prev) =>
|
||||
appendAnnouncement(prev, {
|
||||
@ -127,7 +140,7 @@ export function AnnouncementsProvider({ children }: { children: ReactNode }) {
|
||||
cancelled = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [system]);
|
||||
}, [system, projectId]);
|
||||
|
||||
const store = useMemo<AnnouncementsStore>(
|
||||
() => ({ index, busy, seedBusy }),
|
||||
|
||||
@ -123,7 +123,7 @@ function renderGrid(
|
||||
) {
|
||||
return render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<AnnouncementsProvider>
|
||||
<AnnouncementsProvider projectId="p1">
|
||||
<LayoutGrid projectId="p1" cwd="/home/me/proj" {...props} />
|
||||
</AnnouncementsProvider>
|
||||
</DIProvider>,
|
||||
|
||||
@ -52,6 +52,7 @@ import {
|
||||
type SettingsSection,
|
||||
} from "@/features/settings";
|
||||
import { GitGraphView } from "@/features/git";
|
||||
import { AnnouncementsProvider } from "@/features/announcements";
|
||||
import { usePluginMenus, usePluginRuntime } from "@/features/plugins";
|
||||
import type { WhenContext } from "@/plugins/runtime";
|
||||
import {
|
||||
@ -735,7 +736,7 @@ export function ProjectsView() {
|
||||
<p className="text-sm text-muted">Open a project to use this panel.</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
const body = (
|
||||
<ViewPanelBody
|
||||
panel={panel as ViewPanelId}
|
||||
projectId={active.id}
|
||||
@ -743,6 +744,11 @@ export function ProjectsView() {
|
||||
onOpenConversation={openConversation}
|
||||
/>
|
||||
);
|
||||
return panel === "agents" ? (
|
||||
<AnnouncementsProvider projectId={active.id}>{body}</AnnouncementsProvider>
|
||||
) : (
|
||||
body
|
||||
);
|
||||
}
|
||||
|
||||
// Projects manager renders inline in the welcome area only when it is not
|
||||
@ -924,17 +930,19 @@ export function ProjectsView() {
|
||||
projectId={active.id}
|
||||
/>
|
||||
) : (
|
||||
<LayoutGrid
|
||||
key={`${active.id}-${activeLayout?.id ?? "default"}`}
|
||||
projectId={active.id}
|
||||
cwd={active.root}
|
||||
layoutId={activeLayout?.id}
|
||||
onOpenConversation={openConversation}
|
||||
onOpenPluginsSettings={() => {
|
||||
setSettingsSection("plugins");
|
||||
dismissFloating();
|
||||
}}
|
||||
/>
|
||||
<AnnouncementsProvider projectId={active.id}>
|
||||
<LayoutGrid
|
||||
key={`${active.id}-${activeLayout?.id ?? "default"}`}
|
||||
projectId={active.id}
|
||||
cwd={active.root}
|
||||
layoutId={activeLayout?.id}
|
||||
onOpenConversation={openConversation}
|
||||
onOpenPluginsSettings={() => {
|
||||
setSettingsSection("plugins");
|
||||
dismissFloating();
|
||||
}}
|
||||
/>
|
||||
</AnnouncementsProvider>
|
||||
)}
|
||||
</>
|
||||
) : viewerConversation ? (
|
||||
|
||||
Reference in New Issue
Block a user