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>
147 lines
4.8 KiB
TypeScript
147 lines
4.8 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|
|
});
|