Files
IdeaSDK/frontend/src/features/terminals/useWritePortal.test.tsx
Blomios e462136df2 feat(terminals): durcit le portail d'écriture de délégation
Fiabilise la livraison des délégations inter-agents dans le terminal :
écriture par chunks UTF-8 bornés (512 o, délai 8 ms) pour éviter les
comportements de paste/drop des TUI sur agents froids, et réconciliation
de l'attachement front (frontAttachedAgentRef / reconcileFrontAttachment)
pour ne reporter « front attaché » qu'une fois les DelegationReady
réellement consommés. Tests vitest associés.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 08:56:39 +02:00

310 lines
10 KiB
TypeScript

/**
* L4 — the write-portal hook {@link useWritePortal} (ARCHITECTURE §20).
*
* Driven with fake timers + a fake handle recording writes, and the real
* {@link DIProvider} wiring the in-memory {@link MockSystemGateway} (to emit
* `delegationReady`) and {@link MockInputGateway} (to record the ack).
*
* Cases (cadrage §20.6):
* - injects NOTHING while the human line is non-empty (counter > 0);
* - at an empty line → writes `text` WITHOUT `\n`, then `\r` after the delay;
* - K=2 race → exactly two `\x7f` before the text;
* - 2 s floor → overlay/suspension held until 2000 ms after step (b);
* - the relay is suspended during the overlay;
* - `delegationDelivered` is acked exactly once after (d);
* - default submitSequence/delay applied when the profile omits them.
*/
import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import type { Gateways, TerminalHandle } from "@/ports";
import {
MockInputGateway,
MockSystemGateway,
} from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import { useWritePortal } from "./useWritePortal";
function makeHandle(): { handle: TerminalHandle; writes: string[] } {
const writes: string[] = [];
const handle: TerminalHandle = {
sessionId: "s1",
write: vi.fn(async (b: Uint8Array) => {
writes.push(new TextDecoder().decode(b));
}),
resize: vi.fn().mockResolvedValue(undefined),
detach: vi.fn(),
close: vi.fn().mockResolvedValue(undefined),
};
return { handle, writes };
}
function setup(agentId: string | null = "ag1") {
const system = new MockSystemGateway();
const input = new MockInputGateway();
const gateways = { system, input } as unknown as Gateways;
const wrapper = ({ children }: { children: React.ReactNode }) => (
<DIProvider gateways={gateways}>{children}</DIProvider>
);
const view = renderHook(() => useWritePortal("p1", agentId), { wrapper });
return { system, input, view };
}
const PROFILELESS = (ticket: string, text: string) => ({
type: "delegationReady" as const,
agentId: "ag1",
ticket,
text,
});
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe("useWritePortal (§20)", () => {
it("injects NOTHING while the human line is non-empty, releases at empty line", async () => {
const { system, view } = setup();
const { handle, writes } = makeHandle();
// Bind a live handle and type a printable char (line non-empty).
act(() => view.result.current.portal.bindHandle(handle));
act(() => view.result.current.portal.onHumanData("a")); // K = 1
// A delegation arrives while the line is non-empty.
await act(async () => {
system.emit(PROFILELESS("t1", "hello"));
});
// Nothing written: the delegation waits.
expect(writes.length).toBe(0);
// The human presses Enter → line empty → injection fires.
await act(async () => {
view.result.current.portal.onHumanData("\r");
await vi.runAllTimersAsync();
});
// text written without trailing newline, then the submit sequence.
expect(writes).toContain("hello");
expect(writes[writes.length - 1]).toBe("\r");
expect(writes.join("")).not.toContain("\n");
});
it("at an empty line writes text then \\r after the delay, acking once", async () => {
const { system, input, view } = setup();
const { handle, writes } = makeHandle();
act(() => view.result.current.portal.bindHandle(handle));
await act(async () => {
system.emit(PROFILELESS("t1", "do it"));
// Flush the microtask that defers step (c)/(d) but NOT the submit delay.
await Promise.resolve();
await Promise.resolve();
});
// Line is empty (counter 0) ⇒ handshake started; overlay raised.
expect(view.result.current.overlay).toBe(true);
// Before the delay elapses, only the text has been written (no submit yet).
expect(writes).toEqual(["do it"]);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(writes).toEqual(["do it", "\r"]);
// Acked exactly once with the right shape.
expect(input.delivered).toEqual([
{ projectId: "p1", agentId: "ag1", ticket: "t1" },
]);
});
it("chunks long delegated text before submit and acks after the submit", async () => {
const { system, input, view } = setup();
const { handle, writes } = makeHandle();
act(() => view.result.current.portal.bindHandle(handle));
const text = `début ${"x".repeat(1200)} fin`;
await act(async () => {
system.emit(PROFILELESS("t1", text));
await vi.runAllTimersAsync();
});
expect(writes.length).toBeGreaterThan(2);
expect(writes[writes.length - 1]).toBe("\r");
expect(writes.slice(0, -1).join("")).toBe(text);
expect(input.delivered).toEqual([
{ projectId: "p1", agentId: "ag1", ticket: "t1" },
]);
});
it("K=2 race → exactly two \\x7f backspaces before the text (never Ctrl-U)", async () => {
const { system, view } = setup();
const { handle, writes } = makeHandle();
act(() => view.result.current.portal.bindHandle(handle));
// The delegation arrives at an EMPTY line, so the handshake starts and
// raises suspension. The §20.6 race: in the micro-window between "empty
// observed" (b) and step (c) re-checking the counter, the human types K=2
// printables. TerminalView still reports them while suspended, so the
// counter is 2 when step (c) reads it ⇒ exactly two backspaces.
await act(async () => {
system.emit(PROFILELESS("t1", "TASK"));
// Race two keystrokes in before the timers (delay) flush step (c)/(d).
view.result.current.portal.onHumanData("x");
view.result.current.portal.onHumanData("y");
await vi.runAllTimersAsync();
});
const idxText = writes.indexOf("TASK");
expect(idxText).toBeGreaterThan(0);
expect(writes[idxText - 1]).toBe("\x7f\x7f");
expect(writes.join("")).not.toContain("\x15"); // never Ctrl-U
});
it("holds the overlay until the 2 s floor after step (b)", async () => {
const { system, view } = setup();
const { handle } = makeHandle();
act(() => view.result.current.portal.bindHandle(handle));
await act(async () => {
system.emit(PROFILELESS("t1", "hi"));
});
expect(view.result.current.overlay).toBe(true);
// Finish the write phase (default 60 ms delay) — still well under 2 s.
await act(async () => {
await vi.advanceTimersByTimeAsync(100);
});
// The relay is still suspended and the overlay still up (2 s floor).
expect(view.result.current.portal.isSuspended()).toBe(true);
expect(view.result.current.overlay).toBe(true);
// Cross the 2 s floor.
await act(async () => {
await vi.advanceTimersByTimeAsync(2000);
});
expect(view.result.current.overlay).toBe(false);
expect(view.result.current.portal.isSuspended()).toBe(false);
});
it("suspends the keystroke relay during the overlay", async () => {
const { system, view } = setup();
const { handle } = makeHandle();
act(() => view.result.current.portal.bindHandle(handle));
expect(view.result.current.portal.isSuspended()).toBe(false);
await act(async () => {
system.emit(PROFILELESS("t1", "hi"));
});
// During injection the relay is suspended.
expect(view.result.current.portal.isSuspended()).toBe(true);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(view.result.current.portal.isSuspended()).toBe(false);
});
it("applies the profile submitSequence/submitDelayMs when provided", async () => {
const { system, view } = setup();
const { handle, writes } = makeHandle();
act(() => view.result.current.portal.bindHandle(handle));
await act(async () => {
system.emit({
type: "delegationReady",
agentId: "ag1",
ticket: "t1",
text: "msg",
submitSequence: "\n",
submitDelayMs: 200,
});
});
// After 100 ms (< 200) the submit sequence has not been written yet.
await act(async () => {
await vi.advanceTimersByTimeAsync(100);
});
expect(writes).toEqual(["msg"]);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(writes).toEqual(["msg", "\n"]);
});
it("ignores delegationReady for a different agent", async () => {
const { system, view } = setup();
const { handle, writes } = makeHandle();
act(() => view.result.current.portal.bindHandle(handle));
await act(async () => {
system.emit({
type: "delegationReady",
agentId: "OTHER",
ticket: "t1",
text: "nope",
});
await vi.runAllTimersAsync();
});
expect(writes.length).toBe(0);
expect(view.result.current.overlay).toBe(false);
});
it("waits for a live handle before injecting", async () => {
const { system, input, view } = setup();
const { handle, writes } = makeHandle();
// No handle yet: an arriving delegation must NOT write or ack.
await act(async () => {
system.emit(PROFILELESS("t1", "later"));
await vi.runAllTimersAsync();
});
expect(writes.length).toBe(0);
expect(input.delivered.length).toBe(0);
// Handle becomes live (PTY opened) → injection proceeds.
await act(async () => {
view.result.current.portal.bindHandle(handle);
await vi.runAllTimersAsync();
});
expect(writes).toEqual(["later", "\r"]);
expect(input.delivered.length).toBe(1);
});
it("reports front attachment only after the delegation subscription is ready", async () => {
const { input, view } = setup();
const { handle } = makeHandle();
act(() => view.result.current.portal.bindHandle(handle));
expect(input.frontAttached).toEqual([]);
await act(async () => {
await Promise.resolve();
});
expect(input.frontAttached).toEqual([{ agentId: "ag1", attached: true }]);
act(() => view.result.current.portal.unbindHandle());
expect(input.frontAttached).toEqual([
{ agentId: "ag1", attached: true },
{ agentId: "ag1", attached: false },
]);
});
it("is inert for a plain (agent-less) cell", async () => {
const { system, view } = setup(null);
const { handle, writes } = makeHandle();
act(() => view.result.current.portal.bindHandle(handle));
await act(async () => {
system.emit(PROFILELESS("t1", "x"));
await vi.runAllTimersAsync();
});
expect(writes.length).toBe(0);
expect(view.result.current.overlay).toBe(false);
});
});