Permet de recharger la conversation CLI précédente de chaque cellule à la
réouverture du projet, de façon universelle (indépendant du modèle/CLI).
- profil AgentRuntime: bloc déclaratif optionnel `session { assignFlag, resumeFlag }`
- LeafCell: `conversationId` (persistant, distinct du SessionId PTY) + `agentWasRunning`
- runtime: SessionPlan (None/Assign/Resume) + composition pure des args
- LaunchAgent: décide Assign vs Resume, génère l'UUID, remonte l'id assigné
(persistance par l'appelant via setCellConversation — découplage SRP)
- close: SnapshotRunningAgents fige `agentWasRunning` avant le kill-all
(statut clot/en cours universel, sans parsing CLI)
- SessionInspector: port optionnel best-effort + adapter ClaudeTranscriptInspector
- popup de reprise par cellule (statut + sujet/tokens si dispo), intercalée
avant le Resume auto, jamais sur le chemin reattach
fix(terminals): sérialise les écritures PTY (file FIFO par handle) — corrige
les caractères mélangés/accents dus au réordonnancement des invoke Tauri concurrents
fix(layout): l'opération `move` préservait mal les champs du leaf (perdait `agent`)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
110 lines
3.9 KiB
TypeScript
110 lines
3.9 KiB
TypeScript
/**
|
|
* Contract / regression tests for the Tauri terminal adapter.
|
|
*
|
|
* The headline test guards the keystroke-ordering bug: `write` must serialise
|
|
* per handle so bytes reach the backend in call order even when the underlying
|
|
* `invoke`s resolve out of order (Tauri's IPC gives no ordering guarantee for
|
|
* concurrent calls). Without serialisation, fast typing/pasting garbles the CLI
|
|
* input.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
const invoke = vi.fn();
|
|
vi.mock("@tauri-apps/api/core", () => ({
|
|
invoke: (...args: unknown[]) => invoke(...args),
|
|
Channel: class {
|
|
onmessage: ((c: number[]) => void) | null = null;
|
|
},
|
|
}));
|
|
|
|
import { Channel } from "@tauri-apps/api/core";
|
|
|
|
import { makeTerminalHandle } from "./terminal";
|
|
|
|
const dec = new TextDecoder();
|
|
|
|
function handle() {
|
|
return makeTerminalHandle("sess-1", new Channel<number[]>());
|
|
}
|
|
|
|
describe("TauriTerminalGateway write ordering (regression)", () => {
|
|
beforeEach(() => invoke.mockReset());
|
|
|
|
it("delivers bytes to the backend in call order despite out-of-order resolution", async () => {
|
|
// Each `write_terminal` invoke resolves after a delay that is the INVERSE of
|
|
// its arrival order, so the *first* call resolves last. If writes were
|
|
// fire-and-forget (no chaining), the backend would observe them reversed.
|
|
const arrivals: string[] = [];
|
|
let order = 0;
|
|
invoke.mockImplementation((cmd: string, payload: { request: { data: number[] } }) => {
|
|
if (cmd !== "write_terminal") return Promise.resolve();
|
|
const callIndex = order++;
|
|
const delay = (4 - callIndex) * 10; // 1st call → longest delay
|
|
return new Promise<void>((resolve) => {
|
|
setTimeout(() => {
|
|
arrivals.push(dec.decode(Uint8Array.from(payload.request.data)));
|
|
resolve();
|
|
}, delay);
|
|
});
|
|
});
|
|
|
|
const h = handle();
|
|
const enc = new TextEncoder();
|
|
// Fire five writes back-to-back, as fast typing would.
|
|
const writes = ["a", "b", "c", "d", "e"].map((c) => h.write(enc.encode(c)));
|
|
await Promise.all(writes);
|
|
|
|
// Backend stdin order MUST equal the order `write` was called.
|
|
expect(arrivals).toEqual(["a", "b", "c", "d", "e"]);
|
|
});
|
|
|
|
it("a rejected write does not block subsequent writes (chain survives errors)", async () => {
|
|
const arrivals: string[] = [];
|
|
let call = 0;
|
|
invoke.mockImplementation(
|
|
(_cmd: string, payload?: { request: { data: number[] } }) => {
|
|
// Only `write_terminal` calls carry a payload; ignore any bare probe.
|
|
if (!payload) return Promise.resolve();
|
|
call++;
|
|
if (call === 1) return Promise.reject(new Error("boom"));
|
|
arrivals.push(dec.decode(Uint8Array.from(payload.request.data)));
|
|
return Promise.resolve();
|
|
},
|
|
);
|
|
|
|
const h = handle();
|
|
const enc = new TextEncoder();
|
|
const first = h.write(enc.encode("x")); // rejects
|
|
const second = h.write(enc.encode("y")); // must still run, in order
|
|
|
|
// The rejected write surfaces its error to its own caller…
|
|
let firstErr: unknown;
|
|
await first.catch((e) => {
|
|
firstErr = e;
|
|
});
|
|
expect(firstErr).toBeInstanceOf(Error);
|
|
// …but the chain survives, so the next write still reaches the backend.
|
|
await second;
|
|
expect(arrivals).toEqual(["y"]);
|
|
});
|
|
|
|
it("write nests sessionId + data array inside the request DTO", async () => {
|
|
invoke.mockResolvedValue(undefined);
|
|
const h = handle();
|
|
await h.write(Uint8Array.from([104, 105])); // "hi"
|
|
expect(invoke).toHaveBeenCalledWith("write_terminal", {
|
|
request: { sessionId: "sess-1", data: [104, 105] },
|
|
});
|
|
});
|
|
|
|
it("resize forwards rows/cols inside the request DTO", async () => {
|
|
invoke.mockResolvedValue(undefined);
|
|
const h = handle();
|
|
await h.resize(40, 120);
|
|
expect(invoke).toHaveBeenCalledWith("resize_terminal", {
|
|
request: { sessionId: "sess-1", rows: 40, cols: 120 },
|
|
});
|
|
});
|
|
});
|