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:
146
frontend/src/features/layout/liveReattachOnDelegation.test.tsx
Normal file
146
frontend/src/features/layout/liveReattachOnDelegation.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user