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>
This commit is contained in:
@ -28,11 +28,11 @@
|
|||||||
* - getByRole("alert") — error display
|
* - getByRole("alert") — error display
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import type { LayoutInfo } from "@/domain";
|
import type { LayoutInfo } from "@/domain";
|
||||||
import { LayoutGrid, LayoutTabs } from "@/features/layout";
|
import { LayoutGrid, LayoutTabs } from "@/features/layout";
|
||||||
import { AgentsPanel, ResumeProjectPanel } from "@/features/agents";
|
import { AgentsPanel } from "@/features/agents";
|
||||||
import { TemplatesPanel } from "@/features/templates";
|
import { TemplatesPanel } from "@/features/templates";
|
||||||
import { SkillsPanel } from "@/features/skills";
|
import { SkillsPanel } from "@/features/skills";
|
||||||
import { MemoryPanel } from "@/features/memory";
|
import { MemoryPanel } from "@/features/memory";
|
||||||
@ -79,6 +79,15 @@ export function ProjectsView() {
|
|||||||
|
|
||||||
const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null;
|
const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null;
|
||||||
|
|
||||||
|
// Reset the active layout whenever the active project changes. `activeLayout`
|
||||||
|
// is only repopulated asynchronously by `LayoutTabs` (which re-fetches the new
|
||||||
|
// project's layouts). Without this reset, the stale id of the *previous*
|
||||||
|
// project would be handed to `LayoutGrid`/`GitGraphView` during the gap, and
|
||||||
|
// loading it against the new project's store fails with "not found: layout X".
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveLayout(null);
|
||||||
|
}, [active?.id]);
|
||||||
|
|
||||||
const activeLayoutKind = activeLayout?.kind ?? "terminal";
|
const activeLayoutKind = activeLayout?.kind ?? "terminal";
|
||||||
|
|
||||||
const canCreate = name.trim().length > 0 && root.trim().length > 0 && !vm.busy;
|
const canCreate = name.trim().length > 0 && root.trim().length > 0 && !vm.busy;
|
||||||
@ -355,17 +364,6 @@ export function ProjectsView() {
|
|||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Reopen resume panel (§15.2) ──
|
|
||||||
Mounted per active project (keyed by id) so it re-pulls the resumable
|
|
||||||
inventory on every open/switch; it renders nothing when empty. */}
|
|
||||||
{active && (
|
|
||||||
<ResumeProjectPanel
|
|
||||||
key={`resume-${active.id}`}
|
|
||||||
projectId={active.id}
|
|
||||||
cwd={active.root}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -120,6 +120,25 @@ describe("useWritePortal (§20)", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 () => {
|
it("K=2 race → exactly two \\x7f backspaces before the text (never Ctrl-U)", async () => {
|
||||||
const { system, view } = setup();
|
const { system, view } = setup();
|
||||||
const { handle, writes } = makeHandle();
|
const { handle, writes } = makeHandle();
|
||||||
@ -255,6 +274,26 @@ describe("useWritePortal (§20)", () => {
|
|||||||
expect(input.delivered.length).toBe(1);
|
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 () => {
|
it("is inert for a plain (agent-less) cell", async () => {
|
||||||
const { system, view } = setup(null);
|
const { system, view } = setup(null);
|
||||||
const { handle, writes } = makeHandle();
|
const { handle, writes } = makeHandle();
|
||||||
|
|||||||
@ -44,10 +44,19 @@ import { useGateways } from "@/app/di";
|
|||||||
|
|
||||||
/** Default submit sequence when the profile omits one (paste-detection esquive). */
|
/** Default submit sequence when the profile omits one (paste-detection esquive). */
|
||||||
const DEFAULT_SUBMIT_SEQUENCE = "\r";
|
const DEFAULT_SUBMIT_SEQUENCE = "\r";
|
||||||
/** Default delay (ms) between text and submit-sequence writes. */
|
/**
|
||||||
const DEFAULT_SUBMIT_DELAY_MS = 60;
|
* Default delay (ms) between text and submit-sequence writes.
|
||||||
|
*
|
||||||
|
* Keep this conservative: Codex's interactive TUI can otherwise keep a delegated
|
||||||
|
* prompt in the input editor until the user presses Enter manually.
|
||||||
|
*/
|
||||||
|
const DEFAULT_SUBMIT_DELAY_MS = 350;
|
||||||
/** Anti-flash overlay floor (ms) from step (b). */
|
/** Anti-flash overlay floor (ms) from step (b). */
|
||||||
const OVERLAY_FLOOR_MS = 2000;
|
const OVERLAY_FLOOR_MS = 2000;
|
||||||
|
/** Max UTF-8 bytes per delegation write chunk. */
|
||||||
|
const DELEGATION_WRITE_CHUNK_BYTES = 512;
|
||||||
|
/** Delay between delegation chunks, to avoid TUI paste/drop behaviour on cold agents. */
|
||||||
|
const DELEGATION_WRITE_CHUNK_DELAY_MS = 8;
|
||||||
|
|
||||||
/** A pending delegation kept in the local FIFO. */
|
/** A pending delegation kept in the local FIFO. */
|
||||||
interface PendingDelegation {
|
interface PendingDelegation {
|
||||||
@ -99,6 +108,8 @@ export function useWritePortal(
|
|||||||
const handleRef = useRef<TerminalHandle | null>(null);
|
const handleRef = useRef<TerminalHandle | null>(null);
|
||||||
const suspendedRef = useRef(false); // relay suspended while injecting
|
const suspendedRef = useRef(false); // relay suspended while injecting
|
||||||
const injectingRef = useRef(false); // a handshake is in flight
|
const injectingRef = useRef(false); // a handshake is in flight
|
||||||
|
const subscribedAgentRef = useRef<string | null>(null);
|
||||||
|
const frontAttachedAgentRef = useRef<string | null>(null);
|
||||||
|
|
||||||
// Stable refs to the gateways used inside the (non-reactive) handshake.
|
// Stable refs to the gateways used inside the (non-reactive) handshake.
|
||||||
const inputRef = useRef(input);
|
const inputRef = useRef(input);
|
||||||
@ -110,6 +121,28 @@ export function useWritePortal(
|
|||||||
const projectIdRef = useRef(projectId);
|
const projectIdRef = useRef(projectId);
|
||||||
projectIdRef.current = projectId;
|
projectIdRef.current = projectId;
|
||||||
|
|
||||||
|
// Backend routing must only switch to the frontend path once this cell can
|
||||||
|
// actually consume DelegationReady events. If we report "front attached"
|
||||||
|
// immediately at bindHandle time, a delegation emitted before the async event
|
||||||
|
// subscription is installed is lost: the backend publishes an event nobody is
|
||||||
|
// listening to, and it also skips the headless PTY write fallback.
|
||||||
|
const reconcileFrontAttachment = useRef<() => void>(() => {});
|
||||||
|
reconcileFrontAttachment.current = () => {
|
||||||
|
const desired = handleRef.current && subscribedAgentRef.current
|
||||||
|
? subscribedAgentRef.current
|
||||||
|
: null;
|
||||||
|
const previous = frontAttachedAgentRef.current;
|
||||||
|
if (previous === desired) return;
|
||||||
|
|
||||||
|
if (previous) {
|
||||||
|
void inputRef.current?.setFrontAttached(previous, false).catch(() => {});
|
||||||
|
}
|
||||||
|
if (desired) {
|
||||||
|
void inputRef.current?.setFrontAttached(desired, true).catch(() => {});
|
||||||
|
}
|
||||||
|
frontAttachedAgentRef.current = desired;
|
||||||
|
};
|
||||||
|
|
||||||
// ── (3)+(4): the handshake, attempted whenever a boundary may have opened ──
|
// ── (3)+(4): the handshake, attempted whenever a boundary may have opened ──
|
||||||
const tryInject = useRef<() => void>(() => {});
|
const tryInject = useRef<() => void>(() => {});
|
||||||
tryInject.current = () => {
|
tryInject.current = () => {
|
||||||
@ -147,7 +180,10 @@ export function useWritePortal(
|
|||||||
|
|
||||||
// (d) write the text WITHOUT a trailing newline, then the submit
|
// (d) write the text WITHOUT a trailing newline, then the submit
|
||||||
// sequence after the profile's delay (esquive de la paste-detection).
|
// sequence after the profile's delay (esquive de la paste-detection).
|
||||||
await handle.write(encoder.encode(head.text));
|
// Long delegated prompts are chunked: Codex's TUI can otherwise treat a
|
||||||
|
// large single write like a paste and occasionally keep/drop the tail on
|
||||||
|
// cold-started agents before Enter is received.
|
||||||
|
await writeDelegationText(handle, head.text);
|
||||||
const delay = head.submitDelayMs ?? DEFAULT_SUBMIT_DELAY_MS;
|
const delay = head.submitDelayMs ?? DEFAULT_SUBMIT_DELAY_MS;
|
||||||
await sleep(delay);
|
await sleep(delay);
|
||||||
const submit = head.submitSequence ?? DEFAULT_SUBMIT_SEQUENCE;
|
const submit = head.submitSequence ?? DEFAULT_SUBMIT_SEQUENCE;
|
||||||
@ -176,14 +212,19 @@ export function useWritePortal(
|
|||||||
|
|
||||||
// ── (2): subscribe to delegationReady for THIS agent and enqueue ───────────
|
// ── (2): subscribe to delegationReady for THIS agent and enqueue ───────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!system || !agentId) return;
|
if (!system || !agentId) {
|
||||||
|
subscribedAgentRef.current = null;
|
||||||
|
reconcileFrontAttachment.current();
|
||||||
|
return;
|
||||||
|
}
|
||||||
let unsubscribe: (() => void) | undefined;
|
let unsubscribe: (() => void) | undefined;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
const subscribedAgent = agentId;
|
||||||
|
|
||||||
void system
|
void system
|
||||||
.onDomainEvent((event: DomainEvent) => {
|
.onDomainEvent((event: DomainEvent) => {
|
||||||
if (event.type !== "delegationReady") return;
|
if (event.type !== "delegationReady") return;
|
||||||
if (event.agentId !== agentId) return;
|
if (event.agentId !== subscribedAgent) return;
|
||||||
queueRef.current.push({
|
queueRef.current.push({
|
||||||
ticket: event.ticket,
|
ticket: event.ticket,
|
||||||
text: event.text,
|
text: event.text,
|
||||||
@ -195,12 +236,25 @@ export function useWritePortal(
|
|||||||
})
|
})
|
||||||
.then((un) => {
|
.then((un) => {
|
||||||
if (cancelled) un();
|
if (cancelled) un();
|
||||||
else unsubscribe = un;
|
else {
|
||||||
|
unsubscribe = un;
|
||||||
|
subscribedAgentRef.current = subscribedAgent;
|
||||||
|
reconcileFrontAttachment.current();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
unsubscribe?.();
|
unsubscribe?.();
|
||||||
|
if (subscribedAgentRef.current === subscribedAgent) {
|
||||||
|
subscribedAgentRef.current = null;
|
||||||
|
}
|
||||||
|
if (frontAttachedAgentRef.current === subscribedAgent) {
|
||||||
|
void inputRef.current?.setFrontAttached(subscribedAgent, false).catch(() => {});
|
||||||
|
frontAttachedAgentRef.current = null;
|
||||||
|
} else {
|
||||||
|
reconcileFrontAttachment.current();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [system, agentId]);
|
}, [system, agentId]);
|
||||||
|
|
||||||
@ -225,11 +279,10 @@ export function useWritePortal(
|
|||||||
},
|
},
|
||||||
bindHandle(handle: TerminalHandle) {
|
bindHandle(handle: TerminalHandle) {
|
||||||
handleRef.current = handle;
|
handleRef.current = handle;
|
||||||
// Tell the backend a frontend cell is now mounted for this agent, so the
|
// Tell the backend a frontend cell is mounted only after the event
|
||||||
// mediator routes its turns through `delegationReady` (this portal writes)
|
// subscription is ready. Until then, the backend keeps the headless PTY
|
||||||
// rather than writing the PTY itself (the headless path for cell-less agents).
|
// write fallback, so a delegation cannot disappear during mount.
|
||||||
const agent = agentIdRef.current;
|
reconcileFrontAttachment.current();
|
||||||
if (agent) void inputRef.current.setFrontAttached(agent, true).catch(() => {});
|
|
||||||
// The PTY just became available — a queued delegation may be injectable.
|
// The PTY just became available — a queued delegation may be injectable.
|
||||||
tryInject.current();
|
tryInject.current();
|
||||||
},
|
},
|
||||||
@ -237,8 +290,7 @@ export function useWritePortal(
|
|||||||
handleRef.current = null;
|
handleRef.current = null;
|
||||||
// The cell is gone — let the backend fall back to headless delivery so a
|
// The cell is gone — let the backend fall back to headless delivery so a
|
||||||
// delegation arriving while this agent has no live cell is not lost.
|
// delegation arriving while this agent has no live cell is not lost.
|
||||||
const agent = agentIdRef.current;
|
reconcileFrontAttachment.current();
|
||||||
if (agent) void inputRef.current.setFrontAttached(agent, false).catch(() => {});
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[],
|
[],
|
||||||
@ -251,3 +303,37 @@ export function useWritePortal(
|
|||||||
function sleep(ms: number): Promise<void> {
|
function sleep(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function writeDelegationText(
|
||||||
|
handle: TerminalHandle,
|
||||||
|
text: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const chunks = splitUtf8Chunks(text, DELEGATION_WRITE_CHUNK_BYTES);
|
||||||
|
for (let i = 0; i < chunks.length; i += 1) {
|
||||||
|
await handle.write(encoder.encode(chunks[i]));
|
||||||
|
if (i + 1 < chunks.length) {
|
||||||
|
await sleep(DELEGATION_WRITE_CHUNK_DELAY_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitUtf8Chunks(text: string, maxBytes: number): string[] {
|
||||||
|
if (text.length === 0) return [""];
|
||||||
|
const chunks: string[] = [];
|
||||||
|
let current = "";
|
||||||
|
let currentBytes = 0;
|
||||||
|
|
||||||
|
for (const ch of text) {
|
||||||
|
const bytes = encoder.encode(ch).byteLength;
|
||||||
|
if (current.length > 0 && currentBytes + bytes > maxBytes) {
|
||||||
|
chunks.push(current);
|
||||||
|
current = "";
|
||||||
|
currentBytes = 0;
|
||||||
|
}
|
||||||
|
current += ch;
|
||||||
|
currentBytes += bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.length > 0) chunks.push(current);
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user