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>
310 lines
10 KiB
TypeScript
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);
|
|
});
|
|
});
|