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

@ -31,6 +31,7 @@ import type {
SlashCommand, SlashCommand,
} from "@/domain"; } from "@/domain";
import { useGateways } from "@/app/di"; import { useGateways } from "@/app/di";
import { AnnouncementsPreview } from "@/features/announcements";
import { usePluginRuntime } from "@/features/plugins/PluginRuntimeProvider"; import { usePluginRuntime } from "@/features/plugins/PluginRuntimeProvider";
import { import {
markProjectCliInteractionFinished, markProjectCliInteractionFinished,
@ -127,6 +128,10 @@ interface ChatErrorState {
openFailure: boolean; openFailure: boolean;
} }
interface RequesterDelegationState {
active: boolean;
}
function describe(e: unknown): string { function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) { if (e && typeof e === "object" && "message" in e) {
return String((e as GatewayError).message); return String((e as GatewayError).message);
@ -842,7 +847,7 @@ export function CustomAgentChatView({
onSessionId, onSessionId,
onConversationId, onConversationId,
}: CustomAgentChatViewProps) { }: CustomAgentChatViewProps) {
const { agent, profile: profileGateway, system } = useGateways(); const { agent, profile: profileGateway, system, workState } = useGateways();
const pluginRuntime = usePluginRuntime(); const pluginRuntime = usePluginRuntime();
const initialConversationCacheKey = conversationCacheKey( const initialConversationCacheKey = conversationCacheKey(
projectId, projectId,
@ -1013,6 +1018,33 @@ export function CustomAgentChatView({
[agentId, conversationId, projectId], [agentId, conversationId, projectId],
); );
const displayTurns = turns; const displayTurns = turns;
const [requesterDelegation, setRequesterDelegation] =
useState<RequesterDelegationState>({ active: false });
const refreshRequesterDelegation = useCallback(async () => {
if (!workState) {
setRequesterDelegation({ active: false });
return;
}
try {
const state = await workState.getProjectWorkState(projectId);
const target = state.agents.find((row) =>
row.tickets.some(
(ticket) =>
ticket.status === "inProgress" &&
ticket.source.kind === "agent" &&
ticket.source.agentId === agentId,
),
);
setRequesterDelegation(
target
? { active: true }
: { active: false },
);
} catch {
setRequesterDelegation({ active: false });
}
}, [agentId, projectId, workState]);
const clearPersistedDraft = useCallback(async () => { const clearPersistedDraft = useCallback(async () => {
if (!draftScope) return; if (!draftScope) return;
@ -1034,6 +1066,36 @@ export function CustomAgentChatView({
setDraft(text); setDraft(text);
}, []); }, []);
useEffect(() => {
void refreshRequesterDelegation();
}, [refreshRequesterDelegation]);
useEffect(() => {
if (!system?.onDomainEvent) return;
let unsubscribe: (() => void) | undefined;
let cancelled = false;
void system
.onDomainEvent((event) => {
if (
event.type === "agentBusyChanged" ||
event.type === "delegationReady" ||
event.type === "orchestratorRequestProcessed" ||
event.type === "agentInboxChanged" ||
event.type === "agentWakeChanged"
) {
void refreshRequesterDelegation();
}
})
.then((un) => {
if (cancelled) un();
else unsubscribe = un;
});
return () => {
cancelled = true;
unsubscribe?.();
};
}, [refreshRequesterDelegation, system]);
useEffect(() => { useEffect(() => {
mountedRef.current = true; mountedRef.current = true;
return () => { return () => {
@ -1914,7 +1976,7 @@ export function CustomAgentChatView({
<div <div
ref={chatRootRef} ref={chatRootRef}
data-testid="custom-agent-chat-view" data-testid="custom-agent-chat-view"
className="flex h-full min-h-0 flex-col overflow-hidden bg-surface text-content" className="relative z-0 flex h-full min-h-0 flex-col overflow-hidden bg-surface text-content"
> >
<div <div
role="toolbar" role="toolbar"
@ -2076,6 +2138,13 @@ export function CustomAgentChatView({
data-testid="custom-agent-chat-scroll" data-testid="custom-agent-chat-scroll"
className="flex min-h-0 flex-1 basis-0 flex-col gap-2 overflow-x-hidden overflow-y-auto px-3 py-3" className="flex min-h-0 flex-1 basis-0 flex-col gap-2 overflow-x-hidden overflow-y-auto px-3 py-3"
> >
<div className="shrink-0">
<AnnouncementsPreview
requester={agentId}
fallbackActive={requesterDelegation.active}
fallbackText="Délégation en cours."
/>
</div>
{opening && displayTurns.length === 0 ? ( {opening && displayTurns.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-muted"> <div className="flex items-center gap-2 text-sm text-muted">
<Spinner size={14} /> <Spinner size={14} />

View File

@ -20,15 +20,21 @@ export interface AnnouncementsPreviewProps {
ticketId?: string; ticketId?: string;
/** How many most-recent announcements to show. */ /** How many most-recent announcements to show. */
limit?: number; 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({ export function AnnouncementsPreview({
requester, requester,
ticketId, ticketId,
limit = 3, limit = 3,
fallbackActive = false,
fallbackText = "Délégation en cours.",
}: AnnouncementsPreviewProps) { }: AnnouncementsPreviewProps) {
const announcements = useRequesterAnnouncements(requester, ticketId); 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). // Most-recent `limit`, newest last (the list is oldest → newest).
const recent = announcements.slice(Math.max(0, announcements.length - limit)); const recent = announcements.slice(Math.max(0, announcements.length - limit));
@ -45,17 +51,23 @@ export function AnnouncementsPreview({
/> />
En attente d'un agent En attente d'un agent
</span> </span>
<ul className="flex flex-col gap-0.5"> {recent.length === 0 ? (
{recent.map((a) => ( <p className="truncate text-xs text-muted" title={fallbackText}>
<li {fallbackText}
key={a.seq} </p>
className="truncate text-xs text-muted" ) : (
title={a.text} <ul className="flex flex-col gap-0.5">
> {recent.map((a) => (
{a.text} <li
</li> key={a.seq}
))} className="truncate text-xs text-muted"
</ul> title={a.text}
>
{a.text}
</li>
))}
</ul>
)}
</div> </div>
); );
} }

View File

@ -95,6 +95,21 @@ describe("F3 — overlay lifecycle is the target's busy state", () => {
expect(overlay()).not.toBeNull(); 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 () => { it("RETRACTS on busy:false even when NO completion event is emitted", async () => {
// The regression the arbitrage targets: a turn that ends without any // The regression the arbitrage targets: a turn that ends without any
// completion signal (interruption / error / crash) must still bring the // 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)", () => { 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 () => { it("shows the requester's own announcements and hides another's", async () => {
const { system } = setup(<AnnouncementsPreview requester="agent-A" />); const { system } = setup(<AnnouncementsPreview requester="agent-A" />);
await flush(); await flush();

View File

@ -26,6 +26,10 @@ export interface TargetAnnouncementsOverlayProps {
projectId: string; projectId: string;
/** The agent whose cell this overlay guards (the announcement `target`). */ /** The agent whose cell this overlay guards (the announcement `target`). */
agentId: string; 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 { function formatTime(atMs: number): string {
@ -40,11 +44,14 @@ function formatTime(atMs: number): string {
export function TargetAnnouncementsOverlay({ export function TargetAnnouncementsOverlay({
projectId, projectId,
agentId, agentId,
busyFallbackActive = false,
fallbackText = "Travaille sur une demande en cours.",
}: TargetAnnouncementsOverlayProps) { }: TargetAnnouncementsOverlayProps) {
// Seed busy from the reconciled read-model so a turn already in flight surfaces // 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. // when this cell mounts (reboot / late open); live events then take over.
useHydrateAgentBusy(projectId, agentId); useHydrateAgentBusy(projectId, agentId);
const { announcements, active } = useTargetAnnouncements(agentId); const { announcements, active } = useTargetAnnouncements(agentId);
const effectiveActive = active || busyFallbackActive;
// Follow the tail as new announcements scroll in. // Follow the tail as new announcements scroll in.
const feedRef = useRef<HTMLDivElement>(null); const feedRef = useRef<HTMLDivElement>(null);
@ -53,7 +60,7 @@ export function TargetAnnouncementsOverlay({
if (el) el.scrollTop = el.scrollHeight; if (el) el.scrollTop = el.scrollHeight;
}, [announcements]); }, [announcements]);
if (!active) return null; if (!effectiveActive) return null;
return ( return (
<div <div
@ -75,20 +82,26 @@ export function TargetAnnouncementsOverlay({
ref={feedRef} ref={feedRef}
className="flex flex-1 flex-col justify-end gap-1.5 overflow-hidden px-6 py-4" className="flex flex-1 flex-col justify-end gap-1.5 overflow-hidden px-6 py-4"
> >
{announcements.map((a) => { {announcements.length === 0 ? (
const time = formatTime(a.atMs); <div className="flex items-center justify-center rounded-md border border-border/70 bg-surface/70 px-3 py-2 text-sm text-muted">
return ( {fallbackText}
<div </div>
key={a.seq} ) : (
className="flex items-baseline gap-2 text-sm text-muted" announcements.map((a) => {
> const time = formatTime(a.atMs);
{time && <span className="shrink-0 text-xs text-faint">{time}</span>} return (
<span className="whitespace-pre-wrap break-words text-content/90"> <div
{a.text} key={a.seq}
</span> className="flex items-baseline gap-2 text-sm text-muted"
</div> >
); {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>
</div> </div>
); );

View File

@ -96,6 +96,7 @@ async function seeded(profile: AgentProfile): Promise<Gateways> {
const profileGateway = new MockProfileGateway(); const profileGateway = new MockProfileGateway();
const terminal = new MockTerminalGateway(); const terminal = new MockTerminalGateway();
const system = new MockSystemGateway(); const system = new MockSystemGateway();
const workState = new MockWorkStateGateway();
await profileGateway.configureProfiles([profile]); await profileGateway.configureProfiles([profile]);
const created = await agent.createAgent("p1", { const created = await agent.createAgent("p1", {
name: "Worker", name: "Worker",
@ -114,6 +115,7 @@ async function seeded(profile: AgentProfile): Promise<Gateways> {
profile: profileGateway, profile: profileGateway,
terminal, terminal,
system, system,
workState,
} as unknown as Gateways; } as unknown as Gateways;
} }
@ -180,6 +182,98 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
expect(screen.getAllByText(/mock-attachment.txt/).length).toBeGreaterThan(0); expect(screen.getAllByText(/mock-attachment.txt/).length).toBeGreaterThan(0);
}); });
it("keeps the target announcement overlay visible in custom mode while busy (#232)", async () => {
const gateways = await seeded(structuredProfile);
const layout = gateways.layout as MockLayoutGateway;
const workState = gateways.workState as MockWorkStateGateway;
const tree = await layout.loadLayout("p1");
const leaf = leaves(tree)[0];
const agentId = leaf.agent;
if (!agentId) throw new Error("expected seeded agent");
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leaf.id}`, "custom");
workState._setProjectWorkState("p1", {
agents: [
{
agentId,
name: "Worker",
profileId: structuredProfile.id,
busy: { state: "busy", ticket: "ticket-232", sinceMs: 1 },
tickets: [
{
ticketId: "ticket-232",
conversationId: "conversation-232",
position: 0,
status: "inProgress",
source: { kind: "agent", agentId: "main" },
requesterLabel: "Main",
taskPreview: "Analyse",
taskLen: 7,
},
],
},
],
conversations: [],
});
renderGrid(gateways);
expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy();
expect(await screen.findByTestId("target-announcements-overlay")).toBeTruthy();
expect(screen.getByText("Travaille sur une demande de Main.")).toBeTruthy();
});
it("renders requester-side delegation fallback in custom mode after remount (#232)", async () => {
const gateways = await seeded(structuredProfile);
const layout = gateways.layout as MockLayoutGateway;
const workState = gateways.workState as MockWorkStateGateway;
const tree = await layout.loadLayout("p1");
const leaf = leaves(tree)[0];
const requesterId = leaf.agent;
if (!requesterId) throw new Error("expected seeded requester");
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leaf.id}`, "custom");
workState._setProjectWorkState("p1", {
agents: [
{
agentId: requesterId,
name: "Worker",
profileId: structuredProfile.id,
busy: { state: "idle" },
tickets: [],
},
{
agentId: "target-agent",
name: "Architect",
profileId: structuredProfile.id,
busy: { state: "busy", ticket: "ticket-232", sinceMs: 1 },
tickets: [
{
ticketId: "ticket-232",
conversationId: "conversation-232",
position: 0,
status: "inProgress",
source: { kind: "agent", agentId: requesterId },
requesterLabel: "Worker",
taskPreview: "Répondre",
taskLen: 8,
},
],
},
],
conversations: [],
});
const view = renderGrid(gateways);
expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy();
expect(await screen.findByText("Délégation en cours.")).toBeTruthy();
view.unmount();
renderGrid(gateways);
expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy();
expect(await screen.findByText("Délégation en cours.")).toBeTruthy();
});
it("persists custom CLI composer height per layout cell", async () => { it("persists custom CLI composer height per layout cell", async () => {
const rectSpy = vi const rectSpy = vi
.spyOn(HTMLElement.prototype, "getBoundingClientRect") .spyOn(HTMLElement.prototype, "getBoundingClientRect")

View File

@ -625,7 +625,7 @@ function LeafView({
// F3 lifecycle state (busy) for this cell's agent — shared here so the // F3 lifecycle state (busy) for this cell's agent — shared here so the
// write-portal veil can be mutually excluded with the F3 overlay (exactly one // write-portal veil can be mutually excluded with the F3 overlay (exactly one
// full-cell veil at a time, F3 first). Same source the overlay itself reads. // full-cell veil at a time, F3 first). Same source the overlay itself reads.
const { active: busyActive } = useTargetAnnouncements(agentId ?? ""); const { active: announcementBusyActive } = useTargetAnnouncements(agentId ?? "");
// Ticket #54 — local model-server launch state for this cell's pinned agent. // Ticket #54 — local model-server launch state for this cell's pinned agent.
// Correlated `agentId → profileId → opencode.localModelServerId → status` via // Correlated `agentId → profileId → opencode.localModelServerId → status` via
// the shared hook (same reduction as `useAgents`). While the bound server is // the shared hook (same reduction as `useAgents`). While the bound server is
@ -768,6 +768,10 @@ function LeafView({
) ?? []; ) ?? [];
const busyByWorkState = agentWork?.busy.state === "busy" || Boolean(activeTicket); const busyByWorkState = agentWork?.busy.state === "busy" || Boolean(activeTicket);
const delegatedTicket = activeTicket?.source.kind === "agent" ? activeTicket : undefined; const delegatedTicket = activeTicket?.source.kind === "agent" ? activeTicket : undefined;
const busyActive = announcementBusyActive || busyByWorkState;
const targetAnnouncementsFallback = delegatedTicket?.requesterLabel
? `Travaille sur une demande de ${delegatedTicket.requesterLabel}.`
: "Travaille sur une demande en cours.";
const [restartPending, setRestartPending] = useState(false); const [restartPending, setRestartPending] = useState(false);
const [restartError, setRestartError] = useState(false); const [restartError, setRestartError] = useState(false);
const [freshRestartedSession, setFreshRestartedSession] = const [freshRestartedSession, setFreshRestartedSession] =
@ -1561,8 +1565,13 @@ function LeafView({
busy state (agentBusyChanged + read-model hydration), never by the raw busy state (agentBusyChanged + read-model hydration), never by the raw
PTY — it retracts at idle even when a turn ends without a completion PTY — it retracts at idle even when a turn ends without a completion
event. Self-guards on `active` (busy) and renders null otherwise. */} event. Self-guards on `active` (busy) and renders null otherwise. */}
{!modelServerOverlay && cellMode !== "custom" && agentId != null && ( {!modelServerOverlay && agentId != null && (
<TargetAnnouncementsOverlay projectId={projectId} agentId={agentId} /> <TargetAnnouncementsOverlay
projectId={projectId}
agentId={agentId}
busyFallbackActive={busyByWorkState}
fallbackText={targetAnnouncementsFallback}
/>
)} )}
{/* Ticket #54 — model-server launch veil. Top-priority full-cell overlay {/* Ticket #54 — model-server launch veil. Top-priority full-cell overlay
(suppresses the write-portal + F3 veils above): while the pinned (suppresses the write-portal + F3 veils above): while the pinned