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>
175 lines
5.5 KiB
TypeScript
175 lines
5.5 KiB
TypeScript
/**
|
|
* 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();
|
|
});
|
|
});
|