merge(layout): intègre la ré-attache de flux au réveil arrière-plan d'un agent
Correctif UI autonome (surface humaine), vert (tsc 0, vitest 444 tests), sans lien avec le chantier backend rendez-vous. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -329,6 +329,47 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
|
||||
const [pendingResume, setPendingResume] = useState<PendingResume | null>(null);
|
||||
const [resumeDetails, setResumeDetails] = useState<ConversationDetails | null>(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
|
||||
}}
|
||||
>
|
||||
<TerminalView
|
||||
key={`${id}-${agentId ?? "plain"}`}
|
||||
key={`${id}-${agentId ?? "plain"}-${attachGen}`}
|
||||
cwd={cwd}
|
||||
open={terminalOpener}
|
||||
reattach={reattachOpener}
|
||||
|
||||
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