merge(layout): intègre la ré-attache de flux au réveil arrière-plan d'un agent

Correctif UI autonome (surface humaine), vert (tsc 0, vitest 444 tests),
sans lien avec le chantier backend rendez-vous.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 13:53:14 +02:00
2 changed files with 188 additions and 1 deletions

View File

@ -329,6 +329,47 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
const [pendingResume, setPendingResume] = useState<PendingResume | null>(null); const [pendingResume, setPendingResume] = useState<PendingResume | null>(null);
const [resumeDetails, setResumeDetails] = useState<ConversationDetails | null>(null); const [resumeDetails, setResumeDetails] = useState<ConversationDetails | null>(null);
// ── Live re-attach on background wake (inter-agent delegation) ─────────────
// Bumped to re-key the TerminalView and force a re-attach to the cell's
// pinned agent when that agent becomes live in the *background* AFTER this
// cell has mounted — the classic case being an inter-agent delegation: the
// orchestrator wakes a cold target with `node_id = None` (background) and
// publishes `AgentLaunched`. Without this, the cell keeps showing its
// previous (often dead) session and only re-subscribes to the live output
// once the user toggles the view (Plain↔agent), which remounts the terminal.
// We do that re-attach declaratively instead, so the delegated turn is
// visible live with no manual toggle. The counter is bumped ONLY here (the
// background-wake path), never on a normal fresh launch — so ordinary mounts
// are untouched and never thrash.
const [attachGen, setAttachGen] = useState(0);
useEffect(() => {
if (!agentId || !agentGateway?.attachLiveAgent) return;
// Mid resume decision: let the popup own the (re)launch — don't race it.
if (pendingResume) return;
// Only act when the pinned agent is live in ANOTHER (non-visible) cell and
// this cell is not already bound to that session. `backgroundLive` is the
// same guard `doLaunch` and the dropdown use; crucially it ignores a session
// hosted by THIS cell (nodeId === id), so a fresh self-launch never triggers
// a re-attach loop.
const bg = backgroundLive(agentId);
if (!bg?.sessionId || bg.sessionId === session) return;
let cancelled = false;
void (async () => {
const attached = await agentGateway.attachLiveAgent!(projectId, agentId, id);
if (cancelled) return;
await vm.attachLiveAgentToCell(id, agentId, attached.sessionId ?? bg.sessionId);
if (!cancelled) setAttachGen((g) => g + 1);
})().catch(() => {
/* ignore — the view toggle remains the manual fallback */
});
return () => {
cancelled = true;
};
// `liveAgents` is the trigger (refreshed by the agentLaunched event below);
// `session` guards against re-firing once attached.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [liveAgents, agentId, session]);
// ── Cell routing (Option 1 — Terminal + MCP) ────────────────────────────── // ── Cell routing (Option 1 — Terminal + MCP) ──────────────────────────────
// Every agent cell renders the raw {@link TerminalView}: the human view is the // Every agent cell renders the raw {@link TerminalView}: the human view is the
// native interactive PTY (live reasoning + Échap are native CLI behaviours, // native interactive PTY (live reasoning + Échap are native CLI behaviours,
@ -571,7 +612,7 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
}} }}
> >
<TerminalView <TerminalView
key={`${id}-${agentId ?? "plain"}`} key={`${id}-${agentId ?? "plain"}-${attachGen}`}
cwd={cwd} cwd={cwd}
open={terminalOpener} open={terminalOpener}
reattach={reattachOpener} reattach={reattachOpener}

View File

@ -0,0 +1,146 @@
/**
* Live re-attach on background wake (inter-agent delegation UX bug).
*
* Symptom reported by the user: when an agent talks to another agent (a turn
* delegated through `idea_ask_agent`), the target agent's cell does NOT show it
* working live — the user has to toggle the cell view (Plain ↔ agent) and back
* to see it. Root cause: the orchestrator wakes a cold target in the BACKGROUND
* (`node_id = None`, a fresh PTY session) and publishes `AgentLaunched`, but the
* cell pinned to that agent only refreshed its dropdown — it never re-attached
* its terminal to the newly-live session. TerminalView's output subscription is
* established once at mount and only re-bound on a `key` change, so the live
* output of the delegated turn never reached the cell until the manual toggle
* (which remounts the terminal and runs the background-attach path).
*
* The fix re-attaches declaratively: when the pinned agent becomes live in
* another (non-visible) cell after mount, the cell binds to that session and
* re-keys the terminal. This test drives exactly that sequence and asserts the
* cell's session follows the background one — no view toggle.
*
* Like the sibling layout tests, we stub xterm (the headless obstacle under
* jsdom) so the leaf's launch/attach logic runs untouched.
*/
import { describe, it, expect } from "vitest";
import { render, waitFor } from "@testing-library/react";
import { vi } from "vitest";
// Minimal xterm stub so `term.open` succeeds and the opener (→ launchAgent) runs.
vi.mock("@xterm/xterm", () => ({
Terminal: class {
loadAddon() {}
open() {}
onData() {
return { dispose() {} };
}
onResize() {
return { dispose() {} };
}
write() {}
dispose() {}
get cols() {
return 80;
}
get rows() {
return 24;
}
},
}));
vi.mock("@xterm/addon-fit", () => ({
FitAddon: class {
fit() {}
},
}));
vi.mock("@xterm/xterm/css/xterm.css", () => ({}));
if (typeof globalThis.ResizeObserver === "undefined") {
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
}
import type { Gateways } from "@/ports";
import {
MockAgentGateway,
MockLayoutGateway,
MockSystemGateway,
MockTerminalGateway,
} from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import { leaves } from "./layout";
import { LayoutGrid } from "./LayoutGrid";
function renderGrid(gateways: Gateways) {
return render(
<DIProvider gateways={gateways}>
<LayoutGrid projectId="p1" cwd="/home/me/proj" />
</DIProvider>,
);
}
describe("live re-attach on background wake (inter-agent delegation)", () => {
it("re-attaches the cell to the agent's background session without a view toggle", async () => {
const layout = new MockLayoutGateway();
const agentGateway = new MockAgentGateway();
const terminal = new MockTerminalGateway();
const system = new MockSystemGateway();
const agent = await agentGateway.createAgent("p1", {
name: "Git",
profileId: "claude",
});
// Pin the agent on the single root leaf.
const initial = await layout.loadLayout("p1");
const leafId = leaves(initial)[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: agent.id,
});
const gateways = {
layout,
agent: agentGateway,
terminal,
system,
} as unknown as Gateways;
renderGrid(gateways);
// The cell launches the agent at mount and persists its (own-cell) session.
let firstSession = "";
await waitFor(async () => {
const leaf = leaves(await layout.loadLayout("p1"))[0];
expect(leaf.session).toBeTruthy();
firstSession = leaf.session!;
});
// Now simulate an inter-agent delegation waking the agent in the BACKGROUND:
// its own-cell session ends, then the orchestrator relaunches it on a fresh
// session pinned to a non-visible node (`node_id = None` on the backend),
// and publishes `AgentLaunched`.
await agentGateway.stopLiveAgent("p1", agent.id);
const bgHandle = await agentGateway.launchAgent(
"p1",
agent.id,
{ cwd: "/home/me/proj", rows: 24, cols: 80, nodeId: "background-node" },
() => {},
);
expect(bgHandle.sessionId).not.toBe(firstSession);
system.emit({
type: "agentLaunched",
agentId: agent.id,
sessionId: bgHandle.sessionId,
});
// Without the fix the cell keeps its (now dead) session; with it, the cell
// re-binds to the live background session — proving it re-subscribed to the
// delegated turn's output with no manual Plain↔agent toggle.
await waitFor(async () => {
const leaf = leaves(await layout.loadLayout("p1"))[0];
expect(leaf.session).toBe(bgHandle.sessionId);
});
});
});