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>
189 lines
6.2 KiB
TypeScript
189 lines
6.2 KiB
TypeScript
/**
|
|
* L3 — behavioural tests for {@link MockTerminalGateway}: it greets on open,
|
|
* echoes writes back through `onData` (translating CR→CRLF like a cooked
|
|
* terminal), behaves on resize/close, and mints distinct session ids.
|
|
*/
|
|
import { describe, it, expect, vi } from "vitest";
|
|
|
|
import { MockTerminalGateway } from "./index";
|
|
|
|
/** Flush queued microtasks (the greeting is delivered via `queueMicrotask`). */
|
|
async function flushMicrotasks() {
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
}
|
|
|
|
function decode(chunks: Uint8Array[]): string {
|
|
const dec = new TextDecoder();
|
|
return chunks.map((c) => dec.decode(c)).join("");
|
|
}
|
|
|
|
describe("MockTerminalGateway", () => {
|
|
it("greets on open with the cwd via onData", async () => {
|
|
const gw = new MockTerminalGateway();
|
|
const received: Uint8Array[] = [];
|
|
await gw.openTerminal({ cwd: "/work/dir", rows: 24, cols: 80 }, (b) =>
|
|
received.push(b),
|
|
);
|
|
|
|
await flushMicrotasks();
|
|
expect(decode(received)).toContain("/work/dir");
|
|
});
|
|
|
|
it("returns a handle with a sessionId and write/resize/close methods", async () => {
|
|
const gw = new MockTerminalGateway();
|
|
const handle = await gw.openTerminal(
|
|
{ cwd: "/c", rows: 24, cols: 80 },
|
|
() => {},
|
|
);
|
|
expect(typeof handle.sessionId).toBe("string");
|
|
expect(typeof handle.write).toBe("function");
|
|
expect(typeof handle.resize).toBe("function");
|
|
expect(typeof handle.close).toBe("function");
|
|
});
|
|
|
|
it("echoes written bytes back through onData", async () => {
|
|
const gw = new MockTerminalGateway();
|
|
const received: Uint8Array[] = [];
|
|
const handle = await gw.openTerminal(
|
|
{ cwd: "/c", rows: 24, cols: 80 },
|
|
(b) => received.push(b),
|
|
);
|
|
await flushMicrotasks();
|
|
received.length = 0; // drop the greeting
|
|
|
|
await handle.write(new TextEncoder().encode("hi"));
|
|
expect(decode(received)).toBe("hi");
|
|
});
|
|
|
|
it("translates a CR keystroke to CRLF on echo", async () => {
|
|
const gw = new MockTerminalGateway();
|
|
const received: Uint8Array[] = [];
|
|
const handle = await gw.openTerminal(
|
|
{ cwd: "/c", rows: 24, cols: 80 },
|
|
(b) => received.push(b),
|
|
);
|
|
await flushMicrotasks();
|
|
received.length = 0;
|
|
|
|
// 0x0d == CR
|
|
await handle.write(Uint8Array.from([0x61, 0x0d])); // 'a' + CR
|
|
const out = received.flatMap((c) => Array.from(c));
|
|
expect(out).toEqual([0x61, 0x0d, 0x0a]); // 'a', CR, LF
|
|
});
|
|
|
|
it("stops echoing once closed", async () => {
|
|
const gw = new MockTerminalGateway();
|
|
const received: Uint8Array[] = [];
|
|
const handle = await gw.openTerminal(
|
|
{ cwd: "/c", rows: 24, cols: 80 },
|
|
(b) => received.push(b),
|
|
);
|
|
await flushMicrotasks();
|
|
received.length = 0;
|
|
|
|
await handle.close();
|
|
await handle.write(new TextEncoder().encode("ignored"));
|
|
expect(received).toHaveLength(0);
|
|
});
|
|
|
|
it("resize resolves without throwing", async () => {
|
|
const gw = new MockTerminalGateway();
|
|
const handle = await gw.openTerminal(
|
|
{ cwd: "/c", rows: 24, cols: 80 },
|
|
() => {},
|
|
);
|
|
await expect(handle.resize(40, 120)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("mints distinct session ids for two opens", async () => {
|
|
const gw = new MockTerminalGateway();
|
|
const a = await gw.openTerminal({ cwd: "/c", rows: 24, cols: 80 }, () => {});
|
|
const b = await gw.openTerminal({ cwd: "/c", rows: 24, cols: 80 }, () => {});
|
|
expect(a.sessionId).not.toEqual(b.sessionId);
|
|
});
|
|
|
|
it("does not echo before any write (only the greeting)", async () => {
|
|
const gw = new MockTerminalGateway();
|
|
const onData = vi.fn();
|
|
await gw.openTerminal({ cwd: "/c", rows: 24, cols: 80 }, onData);
|
|
await flushMicrotasks();
|
|
// Exactly one delivery so far: the greeting.
|
|
expect(onData).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("detach stops delivery to the old view but keeps the session alive", async () => {
|
|
const gw = new MockTerminalGateway();
|
|
const first: Uint8Array[] = [];
|
|
const handle = await gw.openTerminal(
|
|
{ cwd: "/c", rows: 24, cols: 80 },
|
|
(b) => first.push(b),
|
|
);
|
|
await flushMicrotasks();
|
|
first.length = 0;
|
|
|
|
handle.detach();
|
|
// Output produced after detach must NOT reach the detached view.
|
|
await handle.write(new TextEncoder().encode("after-detach"));
|
|
expect(first).toHaveLength(0);
|
|
|
|
// But the session is still alive: reattach succeeds.
|
|
await expect(
|
|
gw.reattach(handle.sessionId, () => {}),
|
|
).resolves.toBeDefined();
|
|
});
|
|
|
|
it("reattach replays scrollback and resumes live output", async () => {
|
|
const gw = new MockTerminalGateway();
|
|
const handle = await gw.openTerminal(
|
|
{ cwd: "/work", rows: 24, cols: 80 },
|
|
() => {},
|
|
);
|
|
await flushMicrotasks();
|
|
await handle.write(new TextEncoder().encode("typed"));
|
|
handle.detach();
|
|
|
|
const fresh: Uint8Array[] = [];
|
|
const { handle: h2, scrollback } = await gw.reattach(
|
|
handle.sessionId,
|
|
(b) => fresh.push(b),
|
|
);
|
|
// Scrollback carries the prior greeting + echoed input.
|
|
const sb = decode([scrollback]);
|
|
expect(sb).toContain("/work");
|
|
expect(sb).toContain("typed");
|
|
// New output now flows to the re-attached view.
|
|
await h2.write(new TextEncoder().encode("more"));
|
|
expect(decode(fresh)).toBe("more");
|
|
});
|
|
|
|
it("reattach to a closed session rejects (PTY is gone)", async () => {
|
|
const gw = new MockTerminalGateway();
|
|
const handle = await gw.openTerminal(
|
|
{ cwd: "/c", rows: 24, cols: 80 },
|
|
() => {},
|
|
);
|
|
await handle.close();
|
|
await expect(gw.reattach(handle.sessionId, () => {})).rejects.toMatchObject({
|
|
code: "NOT_FOUND",
|
|
});
|
|
});
|
|
|
|
it("closeTerminal(sessionId) kills the PTY so a later reattach fails (Bug #3)", async () => {
|
|
const gw = new MockTerminalGateway();
|
|
const handle = await gw.openTerminal(
|
|
{ cwd: "/c", rows: 24, cols: 80 },
|
|
() => {},
|
|
);
|
|
await gw.closeTerminal(handle.sessionId);
|
|
await expect(gw.reattach(handle.sessionId, () => {})).rejects.toMatchObject({
|
|
code: "NOT_FOUND",
|
|
});
|
|
});
|
|
|
|
it("closeTerminal on an unknown session is a no-op (best-effort)", async () => {
|
|
const gw = new MockTerminalGateway();
|
|
await expect(gw.closeTerminal("does-not-exist")).resolves.toBeUndefined();
|
|
});
|
|
});
|