From db19ef35f5778fb528a26ba813b072363ef4699a Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 23 Jun 2026 13:52:25 +0200 Subject: [PATCH] =?UTF-8?q?fix(layout):=20r=C3=A9-attacher=20le=20flux=20d?= =?UTF-8?q?'une=20cellule=20au=20r=C3=A9veil=20arri=C3=A8re-plan=20d'un=20?= =?UTF-8?q?agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une cellule ne ré-attachait pas le flux de sortie de son agent lorsque celui-ci était réveillé en arrière-plan par une délégation : l'utilisateur devait basculer la vue Plain↔agent pour voir l'agent travailler. Ajout d'un effect de ré-attache déclenché sur le réveil arrière-plan + bump de la key pour forcer le remontage de la vue. Test de régression liveReattachOnDelegation : rouge sans le fix, vert avec. Vérifs : tsc --noEmit exit 0 ; vitest run 48 fichiers / 444 tests OK. Co-Authored-By: Claude Opus 4.8 --- frontend/src/features/layout/LayoutGrid.tsx | 43 +++++- .../layout/liveReattachOnDelegation.test.tsx | 146 ++++++++++++++++++ 2 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 frontend/src/features/layout/liveReattachOnDelegation.test.tsx diff --git a/frontend/src/features/layout/LayoutGrid.tsx b/frontend/src/features/layout/LayoutGrid.tsx index 8c66fb5..e48af00 100644 --- a/frontend/src/features/layout/LayoutGrid.tsx +++ b/frontend/src/features/layout/LayoutGrid.tsx @@ -329,6 +329,47 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm const [pendingResume, setPendingResume] = useState(null); const [resumeDetails, setResumeDetails] = useState(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) ────────────────────────────── // Every agent cell renders the raw {@link TerminalView}: the human view is the // native interactive PTY (live reasoning + Échap are native CLI behaviours, @@ -571,7 +612,7 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm }} > ({ + 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( + + + , + ); +} + +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); + }); + }); +});