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:
174
frontend/src/features/terminals/TerminalView.portal.test.tsx
Normal file
174
frontend/src/features/terminals/TerminalView.portal.test.tsx
Normal file
@ -0,0 +1,174 @@
|
||||
/**
|
||||
* L3 — TerminalView agent cell is a **native terminal** wired to a
|
||||
* {@link WritePortal} (ARCHITECTURE §20).
|
||||
*
|
||||
* Contracts exercised (xterm stubbed so `term.open` succeeds under jsdom and the
|
||||
* keystroke wiring genuinely runs; the stub captures the `onData` handler):
|
||||
* - In agent mode a keystroke is written to the PTY (native), AND reported to
|
||||
* the portal for line counting.
|
||||
* - The portal's `isSuspended()` gates the relay: while suspended, keystrokes
|
||||
* are NOT forwarded to the PTY (but are still reported for counting).
|
||||
* - The live handle is bound to the portal on adopt, unbound on unmount.
|
||||
*/
|
||||
import { beforeEach, describe, it, expect, vi } from "vitest";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
|
||||
const xtermState: { keyHandler: ((data: string) => void) | null } = {
|
||||
keyHandler: null,
|
||||
};
|
||||
|
||||
vi.mock("@xterm/xterm", () => ({
|
||||
Terminal: class {
|
||||
loadAddon() {}
|
||||
open() {}
|
||||
onData(cb: (data: string) => void) {
|
||||
xtermState.keyHandler = cb;
|
||||
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, TerminalHandle, WritePortal } from "@/ports";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { TerminalView } from "./TerminalView";
|
||||
|
||||
function makeHandle(write = vi.fn().mockResolvedValue(undefined)): TerminalHandle {
|
||||
return {
|
||||
sessionId: "s1",
|
||||
write,
|
||||
resize: vi.fn().mockResolvedValue(undefined),
|
||||
detach: vi.fn(),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
function makePortal(overrides: Partial<WritePortal> = {}): WritePortal {
|
||||
return {
|
||||
onHumanData: vi.fn(),
|
||||
isSuspended: vi.fn(() => false),
|
||||
bindHandle: vi.fn(),
|
||||
unbindHandle: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function decode(b: Uint8Array): string {
|
||||
return new TextDecoder().decode(b);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
xtermState.keyHandler = null;
|
||||
});
|
||||
|
||||
describe("TerminalView native agent terminal + portal (§20)", () => {
|
||||
it("writes keystrokes to the PTY in agent mode and reports them to the portal", async () => {
|
||||
const write = vi.fn().mockResolvedValue(undefined);
|
||||
const handle = makeHandle(write);
|
||||
const open = vi.fn(async () => handle);
|
||||
const portal = makePortal();
|
||||
const gateways = { terminal: { reattach: vi.fn() } } as unknown as Gateways;
|
||||
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<TerminalView cwd="/c" open={open} agentMode portal={portal} />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(xtermState.keyHandler).not.toBeNull());
|
||||
await waitFor(() => expect(portal.bindHandle).toHaveBeenCalledWith(handle));
|
||||
|
||||
xtermState.keyHandler!("a");
|
||||
|
||||
expect(portal.onHumanData).toHaveBeenCalledWith("a");
|
||||
await waitFor(() => expect(write).toHaveBeenCalled());
|
||||
expect(decode(write.mock.calls[0][0])).toBe("a");
|
||||
});
|
||||
|
||||
it("does NOT forward keystrokes while the portal is suspended (but still counts them)", async () => {
|
||||
const write = vi.fn().mockResolvedValue(undefined);
|
||||
const handle = makeHandle(write);
|
||||
const open = vi.fn(async () => handle);
|
||||
const onHumanData = vi.fn();
|
||||
const portal = makePortal({ isSuspended: () => true, onHumanData });
|
||||
const gateways = { terminal: { reattach: vi.fn() } } as unknown as Gateways;
|
||||
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<TerminalView cwd="/c" open={open} agentMode portal={portal} />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(xtermState.keyHandler).not.toBeNull());
|
||||
await waitFor(() => expect(portal.bindHandle).toHaveBeenCalled());
|
||||
|
||||
xtermState.keyHandler!("x");
|
||||
|
||||
// Reported for counting, but the relay is suspended ⇒ not written.
|
||||
expect(onHumanData).toHaveBeenCalledWith("x");
|
||||
expect(write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("unbinds the handle from the portal on unmount", async () => {
|
||||
const handle = makeHandle();
|
||||
const open = vi.fn(async () => handle);
|
||||
const portal = makePortal();
|
||||
const gateways = { terminal: { reattach: vi.fn() } } as unknown as Gateways;
|
||||
|
||||
const { unmount } = render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<TerminalView cwd="/c" open={open} agentMode portal={portal} />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(portal.bindHandle).toHaveBeenCalled());
|
||||
unmount();
|
||||
expect(portal.unbindHandle).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a PLAIN cell ignores the portal and writes keystrokes natively", async () => {
|
||||
const write = vi.fn().mockResolvedValue(undefined);
|
||||
const handle = makeHandle(write);
|
||||
const open = vi.fn(async () => handle);
|
||||
const portal = makePortal();
|
||||
const gateways = { terminal: { reattach: vi.fn() } } as unknown as Gateways;
|
||||
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
{/* agentMode false: portal must never be touched */}
|
||||
<TerminalView cwd="/c" open={open} portal={portal} />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(xtermState.keyHandler).not.toBeNull());
|
||||
xtermState.keyHandler!("l");
|
||||
|
||||
await waitFor(() => expect(write).toHaveBeenCalled());
|
||||
expect(portal.onHumanData).not.toHaveBeenCalled();
|
||||
expect(portal.bindHandle).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user