fix(layout): ré-attacher le flux d'une cellule au réveil arrière-plan d'un agent

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 13:52:25 +02:00
parent 77e62e54c1
commit db19ef35f5
2 changed files with 188 additions and 1 deletions

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);
});
});
});