chore(wip): checkpoint P8/C avant chantier Codex inter-agents

Sauvegarde de l'arbre de travail en cours (persistance P8, conversations
C-series, write-portal frontend, médiation d'entrée) avant d'attaquer le
support de la délégation inter-agents pour les profils Codex.

Le round-trip inter-agent question/réponse est couvert sans tokens par
les tests loopback existants (state::mcp_e2e_loopback_tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 21:42:53 +02:00
parent 4509f0db9d
commit fdcf16c387
76 changed files with 3783 additions and 1404 deletions

View File

@ -1,22 +1,20 @@
/**
* F2 — mediated input for agent cells (cadrage §4.2/§7).
* Agent cell = NATIVE terminal (ARCHITECTURE §20) — supersedes the F2 mediated
* input contract.
*
* Two contracts are exercised here, with the real {@link DIProvider} and the
* in-memory mocks:
* The agent cell no longer mediates keystrokes through a separate strip. It is a
* real terminal:
*
* 1. **Keystrokes are mediated in agent mode.** A cell that pins an agent puts
* `TerminalView` in `agentMode`: human keystrokes (`term.onData`) MUST NOT be
* written to the PTY (input flows through {@link MediatedInput}). A plain
* (agent-less) cell keeps the raw-shell behaviour (keystrokes → PTY).
* 2. **The PTY *output* is always painted** (both modes) — xterm stays the raw
* output view, unchanged by F2.
* 3. **`MediatedInput` is mounted under the terminal** for an agent cell, and
* **never** an `AgentChatView` (the structured chat surface stays removed).
* 1. **Keystrokes reach the PTY in BOTH modes.** An agent cell and a plain cell
* alike forward `term.onData` keystrokes straight to `handle.write`.
* 2. **The PTY *output* is always painted** — xterm stays the raw output view.
* 3. **No `MediatedInput` strip and no `AgentChatView`** are ever mounted; the
* agent cell renders only the raw {@link TerminalView}.
*
* xterm is stubbed (as in the sibling chat test) so `term.open` succeeds under
* jsdom and the opener/keystroke wiring genuinely runs. The stub captures the
* `onData` keystroke handler so the test can fire a keystroke and assert whether
* it reached the PTY handle (`handle.write`).
* `onData` keystroke handler so the test can fire a keystroke and assert it
* reached the PTY handle (`handle.write`).
*/
import { beforeEach, describe, it, expect, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
@ -79,7 +77,7 @@ import { DIProvider } from "@/app/di";
import { leaves } from "./layout";
import { LayoutGrid } from "./LayoutGrid";
/** Fresh mock set (input gateway included so MediatedInput is fully wired). */
/** Fresh mock set (input gateway included so the write-portal is fully wired). */
function makeGateways(agentGateway: MockAgentGateway): Gateways {
return {
layout: new MockLayoutGateway(),
@ -123,42 +121,43 @@ function spyTerminalWrite(gw: MockTerminalGateway): ReturnType<typeof vi.fn> {
return writeSpy;
}
async function pinAgent(gateways: Gateways, agentId: string): Promise<void> {
const layout = gateways.layout as MockLayoutGateway;
const leafId = leaves(await layout.loadLayout("p1"))[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: agentId,
});
}
beforeEach(() => {
xtermState.keyHandler = null;
xtermState.writes = [];
});
describe("LayoutGrid F2 — mediated input for agent cells", () => {
it("an AGENT cell does NOT write keystrokes to the PTY (input is mediated)", async () => {
describe("LayoutGrid — agent cell is a native terminal (§20)", () => {
it("an AGENT cell DOES write keystrokes to the PTY (native terminal)", async () => {
const agentGateway = new MockAgentGateway();
const agent = await agentGateway.createAgent("p1", {
name: "Worker",
profileId: "claude",
});
const gateways = makeGateways(agentGateway);
// Pin the agent onto the leaf in this run's layout gateway.
const layout = gateways.layout as MockLayoutGateway;
const leafId = leaves(await layout.loadLayout("p1"))[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: agent.id,
});
await pinAgent(gateways, agent.id);
const writeSpy = spyAgentWrite(agentGateway);
renderGrid(gateways);
// Wait for the launch to settle (the opener ran and adopted a handle).
await waitFor(() => expect(agentGateway.launchAgent).toHaveBeenCalled());
await waitFor(() => expect(xtermState.keyHandler).not.toBeNull());
// Simulate a human keystroke into xterm.
// Simulate human keystrokes into xterm; in native mode they reach the PTY.
xtermState.keyHandler!("h");
xtermState.keyHandler!("i");
// In agent mode the keystroke must be swallowed — never forwarded to the PTY.
expect(writeSpy).not.toHaveBeenCalled();
await waitFor(() => expect(writeSpy).toHaveBeenCalled());
});
it("a PLAIN cell still writes keystrokes to the PTY (raw shell, unchanged)", async () => {
@ -176,64 +175,48 @@ describe("LayoutGrid F2 — mediated input for agent cells", () => {
xtermState.keyHandler!("l");
xtermState.keyHandler!("s");
// Plain shell: keystrokes flow straight to the PTY (current behaviour).
await waitFor(() => expect(writeSpy).toHaveBeenCalled());
});
it("paints PTY output in BOTH modes (xterm output view unchanged)", async () => {
// Agent cell: the mock greets on open → output must reach term.write.
const agentGateway = new MockAgentGateway();
const agent = await agentGateway.createAgent("p1", {
name: "Worker",
profileId: "claude",
});
const gateways = makeGateways(agentGateway);
const layout = gateways.layout as MockLayoutGateway;
const leafId = leaves(await layout.loadLayout("p1"))[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: agent.id,
});
await pinAgent(gateways, agent.id);
renderGrid(gateways);
// The mock agent greets on open; that output must be painted by xterm even
// though keystroke input is mediated.
await waitFor(() => expect(xtermState.writes.length).toBeGreaterThan(0));
});
it("mounts MediatedInput under an agent cell, never an AgentChatView", async () => {
it("renders only the raw TerminalView — no MediatedInput, no AgentChatView", async () => {
const agentGateway = new MockAgentGateway();
const agent = await agentGateway.createAgent("p1", {
name: "Worker",
profileId: "claude",
});
const gateways = makeGateways(agentGateway);
const layout = gateways.layout as MockLayoutGateway;
const leafId = leaves(await layout.loadLayout("p1"))[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: agent.id,
});
renderGrid(gateways);
await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
expect(screen.getByTestId("terminal-view")).toBeTruthy();
expect(screen.getByTestId("mediated-input")).toBeTruthy();
expect(screen.queryByTestId("agent-chat-view")).toBeNull();
});
it("a PLAIN cell has NO mediated input strip", async () => {
const agentGateway = new MockAgentGateway();
const gateways = makeGateways(agentGateway);
await pinAgent(gateways, agent.id);
renderGrid(gateways);
await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
expect(screen.getByTestId("terminal-view")).toBeTruthy();
expect(screen.queryByTestId("mediated-input")).toBeNull();
expect(screen.queryByTestId("agent-chat-view")).toBeNull();
});
it("a PLAIN cell has no portal overlay", async () => {
const agentGateway = new MockAgentGateway();
const gateways = makeGateways(agentGateway);
renderGrid(gateways);
await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
expect(screen.getByTestId("terminal-view")).toBeTruthy();
expect(screen.queryByTestId("write-portal-overlay")).toBeNull();
});
});

View File

@ -27,10 +27,9 @@ import type {
TerminalHandle,
} from "@/ports";
import {
MediatedInput,
ResumeConversationPopup,
TerminalView,
useAgentBusy,
useWritePortal,
} from "@/features/terminals";
import { useGateways } from "@/app/di";
import { leaves, normalizeWeights, resizeAdjacent } from "./layout";
@ -203,10 +202,12 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
const siblingIndex = parentSplit ? (parentSplit.index === 0 ? 1 : 0) : 0;
const { agent: agentGateway, system } = useGateways();
// Per-agent busy map (lot F1) feeds the mediated input strip: while the agent
// is processing a turn, "Envoyer" is dimmed (but the enqueue stays open) and
// "Interrompre" is active. Absent key ⇒ idle.
const busyMap = useAgentBusy();
// The single write-portal of this cell (ARCHITECTURE §20). It owns the human
// line counter, the local delegation FIFO, the handshake (b→e) and the overlay
// shown while a delegation is being injected. For a plain (agent-less) cell
// `agent` is null: the portal stays inert (no subscription, no overlay) and is
// simply not passed to the terminal.
const { portal, overlay } = useWritePortal(projectId, agent ?? null);
// Load the project's agents for the dropdown.
const [agents, setAgents] = useState<Agent[]>([]);
@ -552,44 +553,66 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
)}
</div>
{/* Option 1 (Terminal + MCP): every cell — plain or agent — renders the
raw xterm {@link TerminalView}. Agent cells are interactive PTYs; their
cross-model delegation happens through MCP tools, not a chat view.
Re-key on the agent so the right opener is captured at mount; the
persisted session drives re-attach (never a re-spawn) when navigating.
Lot F2 (cadrage §4.2): in an **agent** cell the terminal is output-only
for the human (`agentMode`) and the {@link MediatedInput} strip is
mounted **under** it — keystrokes route through IdeA, not the PTY. A
**plain** cell keeps the raw-shell behaviour (keystrokes → PTY) and has
no input strip. We stack terminal + strip in a column so the strip sits
beneath the live output. */}
raw xterm {@link TerminalView}. Agent cells are **native terminals**
(ARCHITECTURE §20): every human keystroke (Enter included) reaches the
PTY. There is no mediated-input strip; cross-model delegation flows
through the write-portal (which injects at a clean line boundary) and
MCP tools. Re-key on the agent so the right opener is captured at mount;
the persisted session drives re-attach (never a re-spawn) when
navigating. The agent cell passes its {@link WritePortal} so keystroke
counting + injection suspension are wired; a plain cell passes none. */}
<div
style={{
display: "flex",
flexDirection: "column",
position: "relative",
width: "100%",
height: "100%",
minHeight: 0,
minWidth: 0,
}}
>
<div style={{ flex: "1 1 auto", minHeight: 0, minWidth: 0 }}>
<TerminalView
key={`${id}-${agentId ?? "plain"}`}
cwd={cwd}
open={terminalOpener}
reattach={reattachOpener}
sessionId={session}
onSessionId={(sid) => void vm.setSession(id, sid)}
agentMode={agentId != null}
/>
</div>
{agentId != null && (
<MediatedInput
projectId={projectId}
agentId={agentId}
busy={busyMap[agentId] ?? false}
/>
<TerminalView
key={`${id}-${agentId ?? "plain"}`}
cwd={cwd}
open={terminalOpener}
reattach={reattachOpener}
sessionId={session}
onSessionId={(sid) => void vm.setSession(id, sid)}
agentMode={agentId != null}
portal={agentId != null ? portal : undefined}
/>
{/* Write-portal overlay (ARCHITECTURE §20.3 step b/e): while a delegation
is being injected into the agent's PTY, a grey veil with a centred
message sits above the terminal. Only ever shown for an agent cell. */}
{agentId != null && overlay && (
<div
data-testid="write-portal-overlay"
role="status"
aria-live="polite"
style={{
position: "absolute",
inset: 0,
zIndex: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(0, 0, 0, 0.45)",
color: "var(--color-content, #e0e0e0)",
fontSize: 13,
pointerEvents: "none",
userSelect: "none",
}}
>
<span
style={{
background: "var(--color-surface, #1e1e1e)",
border: "1px solid var(--color-border, #3a3a3a)",
borderRadius: 4,
padding: "6px 12px",
}}
>
Un agent est en train de parler
</span>
</div>
)}
</div>
{busyNotice && (