fix(cli): les annonces MCP/inter-agent restent affichées pendant qu'un agent est busy — #232

This commit is contained in:
2026-08-21 19:40:13 +02:00
parent a51813fc44
commit aa85d4dfc8
6 changed files with 258 additions and 32 deletions

View File

@ -20,15 +20,21 @@ export interface AnnouncementsPreviewProps {
ticketId?: string;
/** How many most-recent announcements to show. */
limit?: number;
/** Read-model lifecycle fallback for a requester waiting without live text yet. */
fallbackActive?: boolean;
/** Stable copy shown when the fallback is active and no live text is available. */
fallbackText?: string;
}
export function AnnouncementsPreview({
requester,
ticketId,
limit = 3,
fallbackActive = false,
fallbackText = "Délégation en cours.",
}: AnnouncementsPreviewProps) {
const announcements = useRequesterAnnouncements(requester, ticketId);
if (announcements.length === 0) return null;
if (announcements.length === 0 && !fallbackActive) return null;
// Most-recent `limit`, newest last (the list is oldest → newest).
const recent = announcements.slice(Math.max(0, announcements.length - limit));
@ -45,17 +51,23 @@ export function AnnouncementsPreview({
/>
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>
{recent.length === 0 ? (
<p className="truncate text-xs text-muted" title={fallbackText}>
{fallbackText}
</p>
) : (
<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

@ -95,6 +95,21 @@ describe("F3 — overlay lifecycle is the target's busy state", () => {
expect(overlay()).not.toBeNull();
});
it("shows a stable busy fallback when no live announcement text exists", async () => {
const { system } = setup(
<TargetAnnouncementsOverlay
projectId="p1"
agentId="agent-T"
fallbackText="Travaille sur une demande de Main."
/>,
);
await flush();
act(() => system.emit(busy("agent-T", true)));
expect(screen.getByText("Travaille sur une demande de Main.")).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
@ -209,6 +224,20 @@ describe("F3 — overlay lifecycle is the target's busy state", () => {
});
describe("F2 — AnnouncementsPreview (requester filter)", () => {
it("shows a requester-side fallback while waiting without live text", async () => {
setup(
<AnnouncementsPreview
requester="agent-A"
fallbackActive
fallbackText="Délégation en cours."
/>,
);
await flush();
expect(screen.getByTestId("announcements-preview")).not.toBeNull();
expect(screen.getByText("Délégation en cours.")).not.toBeNull();
});
it("shows the requester's own announcements and hides another's", async () => {
const { system } = setup(<AnnouncementsPreview requester="agent-A" />);
await flush();

View File

@ -26,6 +26,10 @@ export interface TargetAnnouncementsOverlayProps {
projectId: string;
/** The agent whose cell this overlay guards (the announcement `target`). */
agentId: string;
/** Reconciled busy state from the caller's read-model, used as lifecycle fallback. */
busyFallbackActive?: boolean;
/** Stable copy shown while the busy cycle has no live announcement content yet. */
fallbackText?: string;
}
function formatTime(atMs: number): string {
@ -40,11 +44,14 @@ function formatTime(atMs: number): string {
export function TargetAnnouncementsOverlay({
projectId,
agentId,
busyFallbackActive = false,
fallbackText = "Travaille sur une demande en cours.",
}: 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);
const effectiveActive = active || busyFallbackActive;
// Follow the tail as new announcements scroll in.
const feedRef = useRef<HTMLDivElement>(null);
@ -53,7 +60,7 @@ export function TargetAnnouncementsOverlay({
if (el) el.scrollTop = el.scrollHeight;
}, [announcements]);
if (!active) return null;
if (!effectiveActive) return null;
return (
<div
@ -75,20 +82,26 @@ export function TargetAnnouncementsOverlay({
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>
);
})}
{announcements.length === 0 ? (
<div className="flex items-center justify-center rounded-md border border-border/70 bg-surface/70 px-3 py-2 text-sm text-muted">
{fallbackText}
</div>
) : (
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>
);