/** * 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( , ); } 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); }); }); });