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:
25
.ideai/tickets/236/carnet.md
Normal file
25
.ideai/tickets/236/carnet.md
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
issueRef: "#236"
|
||||||
|
version: 12
|
||||||
|
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||||
|
updatedAt: 1787385711767
|
||||||
|
---
|
||||||
|
2026-08-21 Main
|
||||||
|
- Bug signalé: dans la custom CLI, la conversation s'affiche bien au début puis les `progress`, messages MCP et assimilés cessent de s'afficher au bout d'un moment.
|
||||||
|
- Architect: suspect prioritaire frontend, dans `frontend/src/features/agents/CustomAgentChatView.tsx`, autour de `receive`, `foldChunk`, `appendProgress`, `progressUpsertKey`, `progressEntityKey`.
|
||||||
|
- Hypothèse principale confirmée par DevFrontend: collisions de clé / upsert trop agressif sur `progress` `tool`/`mcp` sans `progressId`, causant des remplacements au lieu d'un enrichissement correct de l'historique visible.
|
||||||
|
- DevFrontend a corrigé la logique legacy: cycles successifs sans `progressId` restent visibles; `completed` finalise puis libère la clé active; doublons provider/IdeA d'une même complétion restent dédupliqués.
|
||||||
|
- Fichiers modifiés: `frontend/src/features/agents/CustomAgentChatView.tsx`, `frontend/src/features/agents/CustomAgentChatView.test.tsx`.
|
||||||
|
- QA: VERT. Commandes exécutées: `npm test -- src/features/agents/CustomAgentChatView.test.tsx` => 55/55 verts; `npm test -- src/features/agents` => 151/151 verts; `npm run typecheck` => OK.
|
||||||
|
- Git a commité le correctif: `aaf628f` `fix(cli): préserve l'affichage des progress legacy sans progressId via clés d'occurrence dédiées — #236`.
|
||||||
|
- Git a mergé localement le fix dans `develop` via `--no-ff`: `2c8c71c` `merge(cli): les messages progress/MCP ne disparaissent plus après un certain temps — #236 (QA verte)`.
|
||||||
|
- Aucun push effectué. Ticket clôturé.
|
||||||
|
|
||||||
|
2026-08-22 Main
|
||||||
|
- Réouverture du ticket pour un cas résiduel distinct observé utilisateur: un agent est en cours de travail mais le message MCP / l'annonce inter-agent attendu n'apparaît pas sur la surface des messages.
|
||||||
|
- UX: comportement attendu = preview côté demandeur + overlay côté cible visibles pendant le travail, puis purge à idle; après changement de projet, aucune fuite visuelle inter-projet, et réhydratation correcte dans le projet courant.
|
||||||
|
- Architect: diagnostic le plus probable = store/provider d'annonces non scopé par `projectId`; l'état app-global survit au switch, `seedBusy` n'hydrate que si absent, et un tour déjà en cours peut donc ne jamais remonter visuellement dans le nouveau projet.
|
||||||
|
- Correctif minimal demandé au frontend: `reset + filter + rehydrate` à chaque changement de `projectId`, idéalement avec un store project-scoped.
|
||||||
|
- Git: branche locale retenue `fix/236-annonces-store-par-projectid` depuis `develop` @ `85f659c`.
|
||||||
|
- DevFrontend: diagnostic confirmé = provider app-global non borné par projet; correctif implémenté = provider explicitement borné par `projectId`, reset de `index + busy` au changement de projet, filtrage des `agentAnnouncement` par `event.projectId`, tests ciblés verts et `npm run typecheck` vert.
|
||||||
|
- Risque résiduel signalé par DevFrontend: `agentBusyChanged` ne porte pas encore `projectId`, donc le lifecycle live busy ne peut pas être filtré aussi strictement que les annonces; le bug visé est néanmoins corrigé par reset + réhydratation au switch.
|
||||||
17
.ideai/tickets/236/issue.md
Normal file
17
.ideai/tickets/236/issue.md
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
id: "5c03537c-2daf-4802-a6fb-6c8aa92b8aa7"
|
||||||
|
number: 236
|
||||||
|
title: "Custom CLI: les messages progress/MCP disparaissent après un certain temps"
|
||||||
|
status: "inProgress"
|
||||||
|
priority: "critical"
|
||||||
|
sprint: null
|
||||||
|
links: []
|
||||||
|
agentRefs: []
|
||||||
|
attachments: []
|
||||||
|
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||||
|
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||||
|
createdAt: 1787326716055
|
||||||
|
updatedAt: 1787385711767
|
||||||
|
version: 12
|
||||||
|
---
|
||||||
|
Bug sur l'affichage des conversations dans la custom CLI: l'historique s'affiche correctement au début, puis au bout d'un moment les messages `progress`, usages MCP et assimilés cessent de s'afficher. Il faut reproduire, diagnostiquer la cause et corriger sans casser l'affichage initial.
|
||||||
@ -9,7 +9,6 @@ import { useEffect, useState } from "react";
|
|||||||
import type { DomainEvent, HealthReport } from "@/domain";
|
import type { DomainEvent, HealthReport } from "@/domain";
|
||||||
import { ProjectsView } from "@/features/projects";
|
import { ProjectsView } from "@/features/projects";
|
||||||
import { FirstRunWizard } from "@/features/first-run";
|
import { FirstRunWizard } from "@/features/first-run";
|
||||||
import { AnnouncementsProvider } from "@/features/announcements";
|
|
||||||
import { AppExitConfirmDialog } from "@/features/appExit";
|
import { AppExitConfirmDialog } from "@/features/appExit";
|
||||||
import { PluginRuntimeProvider } from "@/features/plugins";
|
import { PluginRuntimeProvider } from "@/features/plugins";
|
||||||
import { Panel, Spinner, Toolbar } from "@/shared";
|
import { Panel, Spinner, Toolbar } from "@/shared";
|
||||||
@ -65,7 +64,6 @@ export function App() {
|
|||||||
}, [profile]);
|
}, [profile]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnnouncementsProvider>
|
|
||||||
<PluginRuntimeProvider>
|
<PluginRuntimeProvider>
|
||||||
<div className="flex h-full flex-col bg-canvas text-content">
|
<div className="flex h-full flex-col bg-canvas text-content">
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
@ -119,7 +117,6 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
<AppExitConfirmDialog />
|
<AppExitConfirmDialog />
|
||||||
</PluginRuntimeProvider>
|
</PluginRuntimeProvider>
|
||||||
</AnnouncementsProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -24,17 +24,19 @@ import { TargetAnnouncementsOverlay } from "./TargetAnnouncementsOverlay";
|
|||||||
|
|
||||||
function setup(
|
function setup(
|
||||||
node: React.ReactNode,
|
node: React.ReactNode,
|
||||||
opts: { workState?: MockWorkStateGateway } = {},
|
opts: { projectId?: string | null; workState?: MockWorkStateGateway } = {},
|
||||||
) {
|
) {
|
||||||
const system = new MockSystemGateway();
|
const system = new MockSystemGateway();
|
||||||
const workState = opts.workState;
|
const workState = opts.workState;
|
||||||
const gateways = { system, workState } as unknown as Gateways;
|
const gateways = { system, workState } as unknown as Gateways;
|
||||||
render(
|
const result = render(
|
||||||
<DIProvider gateways={gateways}>
|
<DIProvider gateways={gateways}>
|
||||||
<AnnouncementsProvider>{node}</AnnouncementsProvider>
|
<AnnouncementsProvider projectId={opts.projectId ?? "p1"}>
|
||||||
|
{node}
|
||||||
|
</AnnouncementsProvider>
|
||||||
</DIProvider>,
|
</DIProvider>,
|
||||||
);
|
);
|
||||||
return { system };
|
return { system, ...result };
|
||||||
}
|
}
|
||||||
|
|
||||||
type AnnouncementEvent = Extract<DomainEvent, { type: "agentAnnouncement" }>;
|
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();
|
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 () => {
|
it("mounts on busy:true even before any announcement (banner only)", async () => {
|
||||||
const { system } = setup(
|
const { system } = setup(
|
||||||
<TargetAnnouncementsOverlay projectId="p1" agentId="agent-T" />,
|
<TargetAnnouncementsOverlay projectId="p1" agentId="agent-T" />,
|
||||||
@ -171,6 +194,64 @@ describe("F3 — overlay lifecycle is the target's busy state", () => {
|
|||||||
expect(overlay()).not.toBeNull();
|
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 () => {
|
it("stays down when the read-model reports the agent idle", async () => {
|
||||||
const workState = new MockWorkStateGateway();
|
const workState = new MockWorkStateGateway();
|
||||||
workState._setProjectWorkState("p1", {
|
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
|
* (ticket #4, F1 wiring). Subscribes **once** to the domain-event stream via the
|
||||||
* {@link SystemGateway} and folds two orthogonal signals:
|
* {@link SystemGateway} and folds two orthogonal signals:
|
||||||
*
|
*
|
||||||
@ -22,8 +22,9 @@
|
|||||||
* busy is the sole lifecycle authority, which is exactly what we want.)
|
* busy is the sole lifecycle authority, which is exactly what we want.)
|
||||||
*
|
*
|
||||||
* Consumers read via {@link useTargetAnnouncements} (F3) and
|
* Consumers read via {@link useTargetAnnouncements} (F3) and
|
||||||
* {@link useRequesterAnnouncements} (F2). The store is never persisted — it is
|
* {@link useRequesterAnnouncements} (F2). The store is never persisted and is
|
||||||
* rebuilt live from the event stream and the read-model snapshot.
|
* reset whenever the provider's project changes; it is rebuilt live from the
|
||||||
|
* event stream and the read-model snapshot for that project.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -76,7 +77,13 @@ const DEFAULT_STORE: AnnouncementsStore = {
|
|||||||
|
|
||||||
const AnnouncementsContext = createContext<AnnouncementsStore | null>(null);
|
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 { system } = useGateways();
|
||||||
const [index, setIndex] = useState<AnnouncementIndex>(emptyAnnouncementIndex);
|
const [index, setIndex] = useState<AnnouncementIndex>(emptyAnnouncementIndex);
|
||||||
const [busy, setBusy] = useState<BusyMap>({});
|
const [busy, setBusy] = useState<BusyMap>({});
|
||||||
@ -87,15 +94,21 @@ export function AnnouncementsProvider({ children }: { children: ReactNode }) {
|
|||||||
setBusy((prev) => (agentId in prev ? prev : { ...prev, [agentId]: value }));
|
setBusy((prev) => (agentId in prev ? prev : { ...prev, [agentId]: value }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIndex(emptyAnnouncementIndex());
|
||||||
|
setBusy({});
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// `system` may be absent in unit tests injecting a partial gateway set.
|
// `system` may be absent in unit tests injecting a partial gateway set.
|
||||||
if (!system) return;
|
if (!system || !projectId) return;
|
||||||
let unsubscribe: (() => void) | undefined;
|
let unsubscribe: (() => void) | undefined;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
void system
|
void system
|
||||||
.onDomainEvent((event) => {
|
.onDomainEvent((event) => {
|
||||||
if (event.type === "agentAnnouncement") {
|
if (event.type === "agentAnnouncement") {
|
||||||
|
if (event.projectId !== projectId) return;
|
||||||
// Content of the overlay/preview.
|
// Content of the overlay/preview.
|
||||||
setIndex((prev) =>
|
setIndex((prev) =>
|
||||||
appendAnnouncement(prev, {
|
appendAnnouncement(prev, {
|
||||||
@ -127,7 +140,7 @@ export function AnnouncementsProvider({ children }: { children: ReactNode }) {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
unsubscribe?.();
|
unsubscribe?.();
|
||||||
};
|
};
|
||||||
}, [system]);
|
}, [system, projectId]);
|
||||||
|
|
||||||
const store = useMemo<AnnouncementsStore>(
|
const store = useMemo<AnnouncementsStore>(
|
||||||
() => ({ index, busy, seedBusy }),
|
() => ({ index, busy, seedBusy }),
|
||||||
|
|||||||
@ -123,7 +123,7 @@ function renderGrid(
|
|||||||
) {
|
) {
|
||||||
return render(
|
return render(
|
||||||
<DIProvider gateways={gateways}>
|
<DIProvider gateways={gateways}>
|
||||||
<AnnouncementsProvider>
|
<AnnouncementsProvider projectId="p1">
|
||||||
<LayoutGrid projectId="p1" cwd="/home/me/proj" {...props} />
|
<LayoutGrid projectId="p1" cwd="/home/me/proj" {...props} />
|
||||||
</AnnouncementsProvider>
|
</AnnouncementsProvider>
|
||||||
</DIProvider>,
|
</DIProvider>,
|
||||||
|
|||||||
@ -52,6 +52,7 @@ import {
|
|||||||
type SettingsSection,
|
type SettingsSection,
|
||||||
} from "@/features/settings";
|
} from "@/features/settings";
|
||||||
import { GitGraphView } from "@/features/git";
|
import { GitGraphView } from "@/features/git";
|
||||||
|
import { AnnouncementsProvider } from "@/features/announcements";
|
||||||
import { usePluginMenus, usePluginRuntime } from "@/features/plugins";
|
import { usePluginMenus, usePluginRuntime } from "@/features/plugins";
|
||||||
import type { WhenContext } from "@/plugins/runtime";
|
import type { WhenContext } from "@/plugins/runtime";
|
||||||
import {
|
import {
|
||||||
@ -735,7 +736,7 @@ export function ProjectsView() {
|
|||||||
<p className="text-sm text-muted">Open a project to use this panel.</p>
|
<p className="text-sm text-muted">Open a project to use this panel.</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
const body = (
|
||||||
<ViewPanelBody
|
<ViewPanelBody
|
||||||
panel={panel as ViewPanelId}
|
panel={panel as ViewPanelId}
|
||||||
projectId={active.id}
|
projectId={active.id}
|
||||||
@ -743,6 +744,11 @@ export function ProjectsView() {
|
|||||||
onOpenConversation={openConversation}
|
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
|
// Projects manager renders inline in the welcome area only when it is not
|
||||||
@ -924,17 +930,19 @@ export function ProjectsView() {
|
|||||||
projectId={active.id}
|
projectId={active.id}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<LayoutGrid
|
<AnnouncementsProvider projectId={active.id}>
|
||||||
key={`${active.id}-${activeLayout?.id ?? "default"}`}
|
<LayoutGrid
|
||||||
projectId={active.id}
|
key={`${active.id}-${activeLayout?.id ?? "default"}`}
|
||||||
cwd={active.root}
|
projectId={active.id}
|
||||||
layoutId={activeLayout?.id}
|
cwd={active.root}
|
||||||
onOpenConversation={openConversation}
|
layoutId={activeLayout?.id}
|
||||||
onOpenPluginsSettings={() => {
|
onOpenConversation={openConversation}
|
||||||
setSettingsSection("plugins");
|
onOpenPluginsSettings={() => {
|
||||||
dismissFloating();
|
setSettingsSection("plugins");
|
||||||
}}
|
dismissFloating();
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
</AnnouncementsProvider>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : viewerConversation ? (
|
) : viewerConversation ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user