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:
270
frontend/src/features/terminals/useWritePortal.test.tsx
Normal file
270
frontend/src/features/terminals/useWritePortal.test.tsx
Normal file
@ -0,0 +1,270 @@
|
||||
/**
|
||||
* 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("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("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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user