feat(mcp,orchestrator,frontend): tool idea_restart_self, redemarrage autonome d'un agent — #288 (tool core self-only via requester MCP handshake, retour immediat {restartId, status: scheduled} puis orchestration detachee: snapshot reprise, stop PTY/structured, liberation Busy/mailbox, relance LaunchAgent meme conversation_id fresh_start=false, injection interne invisible; events domain/wire agentRestartRequested/PhaseChanged/Completed/Failed phases stopping/releasingInput/launching/resuming; CLI custom: separateurs exacts redemarrage de l'agent + etat termine/echoue, deduplication (restartId, event), verrouillage composer; TUI native: overlay AgentRestartOverlay sans injection xterm, progress/annonces projetes jusqu'a idle; QA verte: mcp_server restart_self 1/1, restart-self 3/3, backend self_restart_contract + events wire 2/2, frontend 3 suites 92/92, npm build, cargo check --workspace, fmt --check, diff --check; reserves hors perimetre: web-server 103/110 fixture PTY preexistante, 34 echecs npm test globaux listProjectSettings/web hors #288; scripts/ hors perimetre)
This commit is contained in:
@ -280,6 +280,12 @@ export interface EmbeddedServerStatus {
|
||||
}
|
||||
|
||||
/** A domain event relayed from the backend (tagged union on `type`). */
|
||||
export type AgentRestartPhase =
|
||||
| "stopping"
|
||||
| "releasingInput"
|
||||
| "launching"
|
||||
| "resuming";
|
||||
|
||||
export type DomainEvent =
|
||||
| { type: "projectCreated"; projectId: string }
|
||||
| { type: "pluginInstalled"; pluginId: string; version: string }
|
||||
@ -317,6 +323,26 @@ export type DomainEvent =
|
||||
}
|
||||
| { type: "agentProfileChanged"; agentId: string; profileId: string }
|
||||
| { type: "agentBusyChanged"; agentId: string; busy: boolean }
|
||||
| {
|
||||
type: "agentRestartRequested";
|
||||
agentId: string;
|
||||
restartId: string;
|
||||
reason: string | null;
|
||||
}
|
||||
| {
|
||||
type: "agentRestartPhaseChanged";
|
||||
agentId: string;
|
||||
restartId: string;
|
||||
phase: AgentRestartPhase;
|
||||
}
|
||||
| { type: "agentRestartCompleted"; agentId: string; restartId: string }
|
||||
| {
|
||||
type: "agentRestartFailed";
|
||||
agentId: string;
|
||||
restartId: string;
|
||||
phase: AgentRestartPhase;
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
type: "backgroundTaskChanged";
|
||||
projectId: string;
|
||||
|
||||
@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import type { AgentProfile, ReplyChunk } from "@/domain";
|
||||
import { MockSystemGateway, MockWorkStateGateway } from "@/adapters/mock";
|
||||
import { AnnouncementsProvider } from "@/features/announcements";
|
||||
import { PluginRuntimeProvider } from "@/features/plugins";
|
||||
import {
|
||||
__resetProjectCliInteractionActivityForTests,
|
||||
@ -145,6 +146,91 @@ describe("CustomAgentChatView", () => {
|
||||
__resetProjectCliInteractionActivityForTests();
|
||||
});
|
||||
|
||||
it("projects self-restart lifecycle separators in the custom CLI only once", async () => {
|
||||
const system = new MockSystemGateway();
|
||||
const agent = {
|
||||
launchAgentChat: vi.fn(async () => ({ sessionId: "chat-session-restart" })),
|
||||
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||||
sessionId,
|
||||
scrollback: [],
|
||||
})),
|
||||
sendAgentChat: vi.fn(async () => {}),
|
||||
cancelAgentChat: vi.fn(async () => {}),
|
||||
closeAgentChat: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
render(
|
||||
<DIProvider gateways={{ agent, system } as unknown as Gateways}>
|
||||
<AnnouncementsProvider projectId="project-1">
|
||||
<CustomAgentChatView
|
||||
projectId="project-1"
|
||||
agentId="agent-1"
|
||||
agentName="Worker"
|
||||
profile={profile}
|
||||
cwd="/repo"
|
||||
nodeId="node-1"
|
||||
sessionId={null}
|
||||
conversationId="conversation-1"
|
||||
onSessionId={vi.fn()}
|
||||
onConversationId={vi.fn()}
|
||||
/>
|
||||
</AnnouncementsProvider>
|
||||
</DIProvider>,
|
||||
);
|
||||
await waitFor(() => expect(agent.launchAgentChat).toHaveBeenCalled());
|
||||
|
||||
act(() => {
|
||||
system.emit({
|
||||
type: "agentRestartRequested",
|
||||
agentId: "agent-1",
|
||||
restartId: "restart-1",
|
||||
reason: null,
|
||||
});
|
||||
system.emit({
|
||||
type: "agentRestartCompleted",
|
||||
agentId: "agent-1",
|
||||
restartId: "restart-1",
|
||||
});
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByText("------- redemarrage de l'agent -------"),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
screen.getByText("------- redemarrage de l'agent termine -------"),
|
||||
).not.toBeNull();
|
||||
expect(screen.getAllByTestId("agent-restart-separator")).toHaveLength(2);
|
||||
|
||||
act(() => {
|
||||
system.emit({
|
||||
type: "agentRestartCompleted",
|
||||
agentId: "agent-1",
|
||||
restartId: "restart-1",
|
||||
});
|
||||
});
|
||||
expect(screen.getAllByTestId("agent-restart-separator")).toHaveLength(2);
|
||||
|
||||
act(() => {
|
||||
system.emit({
|
||||
type: "agentRestartRequested",
|
||||
agentId: "agent-1",
|
||||
restartId: "restart-2",
|
||||
reason: "new capability",
|
||||
});
|
||||
system.emit({
|
||||
type: "agentRestartFailed",
|
||||
agentId: "agent-1",
|
||||
restartId: "restart-2",
|
||||
phase: "launching",
|
||||
message: "runtime unavailable",
|
||||
});
|
||||
});
|
||||
expect(
|
||||
screen.getByText("------- redemarrage de l'agent echoue -------"),
|
||||
).not.toBeNull();
|
||||
expect(screen.getByText("runtime unavailable")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("shows the technical open failure on mobile with retry and native TUI fallback", async () => {
|
||||
const openNativeTui = vi.fn();
|
||||
const agent = {
|
||||
|
||||
@ -31,7 +31,11 @@ import type {
|
||||
SlashCommand,
|
||||
} from "@/domain";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { AnnouncementsPreview } from "@/features/announcements";
|
||||
import {
|
||||
AnnouncementsPreview,
|
||||
shortRestartFailure,
|
||||
useAgentRestart,
|
||||
} from "@/features/announcements";
|
||||
import { usePluginRuntime } from "@/features/plugins/PluginRuntimeProvider";
|
||||
import {
|
||||
markProjectCliInteractionFinished,
|
||||
@ -68,6 +72,7 @@ type ChatTurn =
|
||||
| { role: "progress"; progress: ReplyProgress; settled?: boolean; progressKey?: string }
|
||||
| { role: "final"; text: string; receivedAtMs?: number }
|
||||
| { role: "error"; text: string; receivedAtMs?: number }
|
||||
| { role: "restart"; key: string; text: string; message?: string; failed?: boolean }
|
||||
| { role: "unknown"; text: string; receivedAtMs?: number };
|
||||
|
||||
interface CachedChatView {
|
||||
@ -1127,9 +1132,14 @@ export function CustomAgentChatView({
|
||||
currentAgentBusy: false,
|
||||
delegationTargetName: null,
|
||||
});
|
||||
const { restart, events: restartEvents } = useAgentRestart(agentId);
|
||||
const delegationActive = requesterDelegation.delegationTargetName !== null;
|
||||
const effectiveBusy =
|
||||
busy || stopping || requesterDelegation.currentAgentBusy || delegationActive;
|
||||
busy ||
|
||||
stopping ||
|
||||
Boolean(restart && restart.outcome !== "failed") ||
|
||||
requesterDelegation.currentAgentBusy ||
|
||||
delegationActive;
|
||||
const delegationFallbackText = requesterDelegation.delegationTargetName
|
||||
? `${agentName} attend ${requesterDelegation.delegationTargetName}`
|
||||
: "Délégation en cours.";
|
||||
@ -1303,6 +1313,45 @@ export function CustomAgentChatView({
|
||||
};
|
||||
}, [agentId, projectId, refreshRequesterDelegation, system]);
|
||||
|
||||
useEffect(() => {
|
||||
if (restartEvents.length === 0) return;
|
||||
updateChatViewState((previous) => {
|
||||
const nextTurns = [...previous.turns];
|
||||
let changed = false;
|
||||
for (const event of restartEvents) {
|
||||
const key = `${event.restartId}:${event.type}`;
|
||||
if (nextTurns.some((turn) => turn.role === "restart" && turn.key === key)) {
|
||||
continue;
|
||||
}
|
||||
if (event.type === "agentRestartRequested") {
|
||||
changed = true;
|
||||
nextTurns.push({
|
||||
role: "restart",
|
||||
key,
|
||||
text: "------- redemarrage de l'agent -------",
|
||||
});
|
||||
} else if (event.type === "agentRestartCompleted") {
|
||||
changed = true;
|
||||
nextTurns.push({
|
||||
role: "restart",
|
||||
key,
|
||||
text: "------- redemarrage de l'agent termine -------",
|
||||
});
|
||||
} else {
|
||||
changed = true;
|
||||
nextTurns.push({
|
||||
role: "restart",
|
||||
key,
|
||||
text: "------- redemarrage de l'agent echoue -------",
|
||||
message: shortRestartFailure(event.message),
|
||||
failed: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return changed ? { ...previous, turns: nextTurns } : previous;
|
||||
});
|
||||
}, [restartEvents, updateChatViewState]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
@ -2577,6 +2626,20 @@ function ChatBubble({
|
||||
expandedProgressKeys: Set<string>;
|
||||
onToggleProgressExpanded: (key: string) => void;
|
||||
}) {
|
||||
if (turn.role === "restart") {
|
||||
return (
|
||||
<div
|
||||
data-testid="agent-restart-separator"
|
||||
className={cn(
|
||||
"flex min-w-0 flex-col items-center gap-1 py-1 text-xs",
|
||||
turn.failed ? "text-danger" : "text-muted",
|
||||
)}
|
||||
>
|
||||
<span className="whitespace-pre-wrap text-center">{turn.text}</span>
|
||||
{turn.message && <span className="max-w-[86%] text-center">{turn.message}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (turn.role === "tool") {
|
||||
return (
|
||||
<div className="flex min-w-0 justify-center">
|
||||
|
||||
80
frontend/src/features/announcements/AgentRestartOverlay.tsx
Normal file
80
frontend/src/features/announcements/AgentRestartOverlay.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { Spinner } from "@/shared";
|
||||
import { useAgentRestart, useTargetAnnouncements } from "./AnnouncementsProvider";
|
||||
|
||||
export interface AgentRestartOverlayProps {
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
export function restartOverlayLabel(
|
||||
phase: "requested" | "stopping" | "releasingInput" | "launching" | "resuming",
|
||||
): string {
|
||||
switch (phase) {
|
||||
case "stopping":
|
||||
case "releasingInput":
|
||||
return "Arret de l'agent en cours...";
|
||||
case "launching":
|
||||
return "Relance de l'agent...";
|
||||
case "resuming":
|
||||
return "Reprise du travail...";
|
||||
default:
|
||||
return "Redemarrage de l'agent...";
|
||||
}
|
||||
}
|
||||
|
||||
export function shortRestartFailure(message: string): string {
|
||||
const normalized = message.trim().replace(/\s+/g, " ");
|
||||
if (!normalized) return "Cause technique non disponible.";
|
||||
return normalized.length <= 160 ? normalized : `${normalized.slice(0, 157)}...`;
|
||||
}
|
||||
|
||||
export function AgentRestartOverlay({ agentId }: AgentRestartOverlayProps) {
|
||||
const { restart } = useAgentRestart(agentId);
|
||||
const { announcements } = useTargetAnnouncements(agentId);
|
||||
const feedRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const element = feedRef.current;
|
||||
if (element) element.scrollTop = element.scrollHeight;
|
||||
}, [announcements]);
|
||||
|
||||
if (!restart) return null;
|
||||
|
||||
const failed = restart.outcome === "failed";
|
||||
return (
|
||||
<div
|
||||
data-testid="agent-restart-overlay"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="pointer-events-none absolute inset-0 z-[4] flex flex-col bg-canvas/90 backdrop-blur-sm"
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border/60 bg-surface/85 px-4 py-2">
|
||||
{!failed && <Spinner size={14} />}
|
||||
<span className={failed ? "text-sm font-medium text-danger" : "text-sm font-medium text-content"}>
|
||||
{failed ? "Redemarrage de l'agent echoue" : restartOverlayLabel(restart.phase)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
ref={feedRef}
|
||||
className="flex flex-1 flex-col justify-end gap-1.5 overflow-hidden px-6 py-4"
|
||||
>
|
||||
{failed ? (
|
||||
<div className="rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger">
|
||||
{shortRestartFailure(restart.message ?? "")}
|
||||
</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">
|
||||
{restartOverlayLabel(restart.phase)}
|
||||
</div>
|
||||
) : (
|
||||
announcements.map((announcement) => (
|
||||
<div key={announcement.seq} className="text-sm text-content/90">
|
||||
<span className="whitespace-pre-wrap break-words">{announcement.text}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -21,6 +21,7 @@ import { DIProvider } from "@/app/di";
|
||||
import { AnnouncementsProvider } from "./AnnouncementsProvider";
|
||||
import { AnnouncementsPreview } from "./AnnouncementsPreview";
|
||||
import { TargetAnnouncementsOverlay } from "./TargetAnnouncementsOverlay";
|
||||
import { AgentRestartOverlay } from "./AgentRestartOverlay";
|
||||
|
||||
function setup(
|
||||
node: React.ReactNode,
|
||||
@ -304,6 +305,91 @@ describe("F3 — overlay lifecycle is the target's busy state", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("#288 — native TUI restart overlay", () => {
|
||||
it("tracks restart phases, keeps progress after resume, then retracts at idle", async () => {
|
||||
const { system } = setup(<AgentRestartOverlay agentId="agent-T" />);
|
||||
await flush();
|
||||
|
||||
act(() => {
|
||||
system.emit({
|
||||
type: "agentRestartRequested",
|
||||
agentId: "agent-T",
|
||||
restartId: "restart-1",
|
||||
reason: null,
|
||||
});
|
||||
});
|
||||
expect(screen.getAllByText("Redemarrage de l'agent...").length).toBeGreaterThan(0);
|
||||
|
||||
act(() => {
|
||||
system.emit({
|
||||
type: "agentRestartPhaseChanged",
|
||||
agentId: "agent-T",
|
||||
restartId: "restart-1",
|
||||
phase: "launching",
|
||||
});
|
||||
});
|
||||
expect(screen.getAllByText("Relance de l'agent...").length).toBeGreaterThan(0);
|
||||
|
||||
act(() => {
|
||||
system.emit(busy("agent-T", true));
|
||||
system.emit({
|
||||
type: "agentRestartCompleted",
|
||||
agentId: "agent-T",
|
||||
restartId: "restart-1",
|
||||
});
|
||||
system.emit(announcement({ target: "agent-T", text: "travail repris" }));
|
||||
});
|
||||
expect(screen.getByText("Reprise du travail...")).not.toBeNull();
|
||||
expect(screen.getByText("travail repris")).not.toBeNull();
|
||||
|
||||
act(() => system.emit(busy("agent-T", false)));
|
||||
expect(screen.queryByTestId("agent-restart-overlay")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a short failure without CLI separators", async () => {
|
||||
const { system } = setup(<AgentRestartOverlay agentId="agent-T" />);
|
||||
await flush();
|
||||
act(() => {
|
||||
system.emit({
|
||||
type: "agentRestartRequested",
|
||||
agentId: "agent-T",
|
||||
restartId: "restart-2",
|
||||
reason: "new plugin",
|
||||
});
|
||||
system.emit({
|
||||
type: "agentRestartFailed",
|
||||
agentId: "agent-T",
|
||||
restartId: "restart-2",
|
||||
phase: "launching",
|
||||
message: "runtime unavailable",
|
||||
});
|
||||
});
|
||||
expect(screen.getByText("Redemarrage de l'agent echoue")).not.toBeNull();
|
||||
expect(screen.getByText("runtime unavailable")).not.toBeNull();
|
||||
expect(screen.queryByText(/-------/)).toBeNull();
|
||||
});
|
||||
|
||||
it("does not stick when the structured resumed turn becomes idle before completed", async () => {
|
||||
const { system } = setup(<AgentRestartOverlay agentId="agent-T" />);
|
||||
await flush();
|
||||
act(() => {
|
||||
system.emit({
|
||||
type: "agentRestartRequested",
|
||||
agentId: "agent-T",
|
||||
restartId: "restart-structured",
|
||||
reason: null,
|
||||
});
|
||||
system.emit(busy("agent-T", false));
|
||||
system.emit({
|
||||
type: "agentRestartCompleted",
|
||||
agentId: "agent-T",
|
||||
restartId: "restart-structured",
|
||||
});
|
||||
});
|
||||
expect(screen.queryByTestId("agent-restart-overlay")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("F2 — AnnouncementsPreview (requester filter)", () => {
|
||||
it("shows a requester-side fallback while waiting without live text", async () => {
|
||||
setup(
|
||||
|
||||
@ -11,6 +11,10 @@
|
||||
* 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}).
|
||||
* 3. **Self-restart presentation** — the live restart phase plus a small,
|
||||
* deduplicated journal used by the native-TUI overlay and custom-CLI
|
||||
* separators. It is deliberately presentation-only and never persisted to
|
||||
* the human conversation transcript.
|
||||
*
|
||||
* Why busy — not "there are announcements" — drives F3: a turn can end **without**
|
||||
* any completion event (interruption / error / crash / rate-limit). The mediator's
|
||||
@ -33,11 +37,13 @@ import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import { useGateways } from "@/app/di";
|
||||
import type { AgentRestartPhase, DomainEvent } from "@/domain";
|
||||
import {
|
||||
announcementsForRequester,
|
||||
announcementsForTarget,
|
||||
@ -51,11 +57,33 @@ import {
|
||||
/** Per-agent busy map: `true` while the agent owns a turn (drives the overlay). */
|
||||
type BusyMap = Record<string, boolean>;
|
||||
|
||||
export type AgentRestartJournalEvent = Extract<
|
||||
DomainEvent,
|
||||
{
|
||||
type:
|
||||
| "agentRestartRequested"
|
||||
| "agentRestartCompleted"
|
||||
| "agentRestartFailed";
|
||||
}
|
||||
>;
|
||||
|
||||
export interface AgentRestartPresentation {
|
||||
restartId: string;
|
||||
phase: AgentRestartPhase | "requested";
|
||||
outcome: "running" | "completed" | "failed";
|
||||
message?: string;
|
||||
}
|
||||
|
||||
type RestartMap = Record<string, AgentRestartPresentation>;
|
||||
type RestartJournal = Record<string, AgentRestartJournalEvent[]>;
|
||||
|
||||
interface AnnouncementsStore {
|
||||
/** Bounded announcement content, indexed by `(target, ticketId)`. */
|
||||
index: AnnouncementIndex;
|
||||
/** Per-agent busy state — the overlay's lifecycle authority. */
|
||||
busy: BusyMap;
|
||||
restarts: RestartMap;
|
||||
restartJournal: RestartJournal;
|
||||
/**
|
||||
* 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
|
||||
@ -67,11 +95,15 @@ interface AnnouncementsStore {
|
||||
const noopSeed = () => {};
|
||||
const EMPTY_INDEX = emptyAnnouncementIndex();
|
||||
const EMPTY_BUSY: BusyMap = {};
|
||||
const EMPTY_RESTARTS: RestartMap = {};
|
||||
const EMPTY_RESTART_JOURNAL: RestartJournal = {};
|
||||
|
||||
/** Store shape read outside a provider — a silent, inert default (never throws). */
|
||||
const DEFAULT_STORE: AnnouncementsStore = {
|
||||
index: EMPTY_INDEX,
|
||||
busy: EMPTY_BUSY,
|
||||
restarts: EMPTY_RESTARTS,
|
||||
restartJournal: EMPTY_RESTART_JOURNAL,
|
||||
seedBusy: noopSeed,
|
||||
};
|
||||
|
||||
@ -87,16 +119,24 @@ export function AnnouncementsProvider({
|
||||
const { system } = useGateways();
|
||||
const [index, setIndex] = useState<AnnouncementIndex>(emptyAnnouncementIndex);
|
||||
const [busy, setBusy] = useState<BusyMap>({});
|
||||
const busyRef = useRef<BusyMap>({});
|
||||
const [restarts, setRestarts] = useState<RestartMap>({});
|
||||
const [restartJournal, setRestartJournal] = useState<RestartJournal>({});
|
||||
|
||||
// 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 }));
|
||||
if (agentId in busyRef.current) return;
|
||||
busyRef.current = { ...busyRef.current, [agentId]: value };
|
||||
setBusy((prev) => ({ ...prev, [agentId]: value }));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setIndex(emptyAnnouncementIndex());
|
||||
setBusy({});
|
||||
busyRef.current = {};
|
||||
setRestarts({});
|
||||
setRestartJournal({});
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -124,8 +164,71 @@ export function AnnouncementsProvider({
|
||||
// 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;
|
||||
busyRef.current = { ...busyRef.current, [agentId]: isBusy };
|
||||
setBusy((prev) => ({ ...prev, [agentId]: isBusy }));
|
||||
if (!isBusy) setIndex((prev) => purgeAllForTarget(prev, agentId));
|
||||
if (!isBusy) {
|
||||
setRestarts((prev) => {
|
||||
const restart = prev[agentId];
|
||||
if (!restart || restart.outcome !== "completed") return prev;
|
||||
const next = { ...prev };
|
||||
delete next[agentId];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} else if (event.type === "agentRestartRequested") {
|
||||
setRestarts((prev) => ({
|
||||
...prev,
|
||||
[event.agentId]: {
|
||||
restartId: event.restartId,
|
||||
phase: "requested",
|
||||
outcome: "running",
|
||||
},
|
||||
}));
|
||||
setRestartJournal((prev) => appendRestartJournal(prev, event));
|
||||
} else if (event.type === "agentRestartPhaseChanged") {
|
||||
setRestarts((prev) => {
|
||||
const current = prev[event.agentId];
|
||||
if (!current || current.restartId !== event.restartId) return prev;
|
||||
return {
|
||||
...prev,
|
||||
[event.agentId]: { ...current, phase: event.phase },
|
||||
};
|
||||
});
|
||||
} else if (event.type === "agentRestartCompleted") {
|
||||
setRestarts((prev) => {
|
||||
const current = prev[event.agentId];
|
||||
if (!current || current.restartId !== event.restartId) return prev;
|
||||
if (busyRef.current[event.agentId] === false) {
|
||||
const next = { ...prev };
|
||||
delete next[event.agentId];
|
||||
return next;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
[event.agentId]: {
|
||||
...current,
|
||||
phase: "resuming",
|
||||
outcome: "completed",
|
||||
},
|
||||
};
|
||||
});
|
||||
setRestartJournal((prev) => appendRestartJournal(prev, event));
|
||||
} else if (event.type === "agentRestartFailed") {
|
||||
setRestarts((prev) => {
|
||||
const current = prev[event.agentId];
|
||||
if (!current || current.restartId !== event.restartId) return prev;
|
||||
return {
|
||||
...prev,
|
||||
[event.agentId]: {
|
||||
...current,
|
||||
phase: event.phase,
|
||||
outcome: "failed",
|
||||
message: event.message,
|
||||
},
|
||||
};
|
||||
});
|
||||
setRestartJournal((prev) => appendRestartJournal(prev, event));
|
||||
}
|
||||
})
|
||||
.then((un) => {
|
||||
@ -143,8 +246,8 @@ export function AnnouncementsProvider({
|
||||
}, [system, projectId]);
|
||||
|
||||
const store = useMemo<AnnouncementsStore>(
|
||||
() => ({ index, busy, seedBusy }),
|
||||
[index, busy, seedBusy],
|
||||
() => ({ index, busy, restarts, restartJournal, seedBusy }),
|
||||
[index, busy, restarts, restartJournal, seedBusy],
|
||||
);
|
||||
|
||||
return (
|
||||
@ -154,6 +257,19 @@ export function AnnouncementsProvider({
|
||||
);
|
||||
}
|
||||
|
||||
function appendRestartJournal(
|
||||
journal: RestartJournal,
|
||||
event: AgentRestartJournalEvent,
|
||||
): RestartJournal {
|
||||
const key = `${event.restartId}:${event.type}`;
|
||||
const previous = journal[event.agentId] ?? [];
|
||||
if (previous.some((item) => `${item.restartId}:${item.type}` === key)) return journal;
|
||||
return {
|
||||
...journal,
|
||||
[event.agentId]: [...previous, event].slice(-12),
|
||||
};
|
||||
}
|
||||
|
||||
/** The store, or an inert default when read outside a provider. */
|
||||
function useStore(): AnnouncementsStore {
|
||||
return useContext(AnnouncementsContext) ?? DEFAULT_STORE;
|
||||
@ -183,6 +299,20 @@ export function useTargetAnnouncements(target: string): {
|
||||
);
|
||||
}
|
||||
|
||||
export function useAgentRestart(agentId: string): {
|
||||
restart: AgentRestartPresentation | null;
|
||||
events: AgentRestartJournalEvent[];
|
||||
} {
|
||||
const { restarts, restartJournal } = useStore();
|
||||
return useMemo(
|
||||
() => ({
|
||||
restart: restarts[agentId] ?? null,
|
||||
events: restartJournal[agentId] ?? [],
|
||||
}),
|
||||
[agentId, restartJournal, restarts],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@ -7,7 +7,18 @@ export {
|
||||
AnnouncementsProvider,
|
||||
useTargetAnnouncements,
|
||||
useRequesterAnnouncements,
|
||||
useAgentRestart,
|
||||
} from "./AnnouncementsProvider";
|
||||
export type {
|
||||
AgentRestartJournalEvent,
|
||||
AgentRestartPresentation,
|
||||
} from "./AnnouncementsProvider";
|
||||
export {
|
||||
AgentRestartOverlay,
|
||||
restartOverlayLabel,
|
||||
shortRestartFailure,
|
||||
type AgentRestartOverlayProps,
|
||||
} from "./AgentRestartOverlay";
|
||||
export {
|
||||
TargetAnnouncementsOverlay,
|
||||
type TargetAnnouncementsOverlayProps,
|
||||
|
||||
@ -367,4 +367,26 @@ describe("LayoutGrid — ticket #48 cell control layering", () => {
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("gives the native TUI restart overlay priority over other cell veils", async () => {
|
||||
const setup = await makeSplitAgentGrid();
|
||||
renderGrid(setup.gateways);
|
||||
await flush();
|
||||
|
||||
act(() => {
|
||||
setup.system.emit(busy(setup.agentId, true));
|
||||
setup.system.emit({
|
||||
type: "agentRestartRequested",
|
||||
agentId: setup.agentId,
|
||||
restartId: "restart-1",
|
||||
reason: null,
|
||||
});
|
||||
});
|
||||
|
||||
const overlay = await screen.findByTestId("agent-restart-overlay");
|
||||
expect(overlay.className).toContain("z-[4]");
|
||||
expect(screen.queryByTestId("target-announcements-overlay")).toBeNull();
|
||||
expect(screen.queryByText("------- redemarrage de l'agent -------")).toBeNull();
|
||||
expect(controlsFor(setup.bId).controls.style.zIndex).toBe("5");
|
||||
});
|
||||
});
|
||||
|
||||
@ -30,7 +30,9 @@ import { TerminalView, useWritePortal } from "@/features/terminals";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { useProjectWorkState } from "@/features/workstate/useProjectWorkState";
|
||||
import {
|
||||
AgentRestartOverlay,
|
||||
TargetAnnouncementsOverlay,
|
||||
useAgentRestart,
|
||||
useTargetAnnouncements,
|
||||
} from "@/features/announcements";
|
||||
import { PluginLayoutCellView } from "@/features/plugins";
|
||||
@ -623,6 +625,7 @@ function LeafView({
|
||||
// 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.
|
||||
const { active: announcementBusyActive } = useTargetAnnouncements(agentId ?? "");
|
||||
const { restart: agentRestart } = useAgentRestart(agentId ?? "");
|
||||
// Ticket #54 — local model-server launch state for this cell's pinned agent.
|
||||
// Correlated `agentId → profileId → opencode.localModelServerId → status` via
|
||||
// the shared hook (same reduction as `useAgents`). While the bound server is
|
||||
@ -1466,7 +1469,8 @@ function LeafView({
|
||||
is being injected into the agent's PTY, a grey veil with a centred
|
||||
message sits above the terminal. Only ever shown for an agent cell —
|
||||
and never together with the F3 overlay (exactly one veil, F3 first). */}
|
||||
{!modelServerOverlay &&
|
||||
{!agentRestart &&
|
||||
!modelServerOverlay &&
|
||||
cellMode !== "custom" &&
|
||||
shouldShowWritePortalVeil(agentId != null, Boolean(overlay), busyActive) && (
|
||||
<div
|
||||
@ -1504,7 +1508,7 @@ function LeafView({
|
||||
an availability veil showing its live réflexions. Native TUI cells
|
||||
need the full-cell guard; custom CLI cells keep the canonical busy
|
||||
badge and composer lock only, matching their normal chat turn flow. */}
|
||||
{!modelServerOverlay && cellMode !== "custom" && agentId != null && (
|
||||
{!agentRestart && !modelServerOverlay && cellMode !== "custom" && agentId != null && (
|
||||
<TargetAnnouncementsOverlay
|
||||
projectId={projectId}
|
||||
agentId={agentId}
|
||||
@ -1517,7 +1521,7 @@ function LeafView({
|
||||
agent's bound local server is downloading its model / starting /
|
||||
probing, the empty terminal is covered rather than left to time out.
|
||||
Retracts at `ready`/`failed` (the predicate returns null then). */}
|
||||
{modelServerOverlay && (
|
||||
{!agentRestart && modelServerOverlay && (
|
||||
<div
|
||||
data-testid="model-server-overlay"
|
||||
role="status"
|
||||
@ -1634,6 +1638,9 @@ function LeafView({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{cellMode !== "custom" && agentId != null && agentRestart && (
|
||||
<AgentRestartOverlay agentId={agentId} />
|
||||
)}
|
||||
</div>
|
||||
{busyNotice && (
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user