From 4220416ae5b0fb1f2c003666f4e5740da3b7e431 Mon Sep 17 00:00:00 2001 From: dev-guard Date: Sun, 1 Nov 2026 13:45:30 +0000 Subject: [PATCH] test: preserve drafts at managed compaction input boundaries --- .../test/compaction-draft-aware.test.ts | 82 +++++++++++++++++-- 1 file changed, 75 insertions(+), 7 deletions(-) diff --git a/packages/daemon/test/compaction-draft-aware.test.ts b/packages/daemon/test/compaction-draft-aware.test.ts index d3853d0a1..25ecd284d 100644 --- a/packages/daemon/test/compaction-draft-aware.test.ts +++ b/packages/daemon/test/compaction-draft-aware.test.ts @@ -27,7 +27,7 @@ function frame(body: string): ComposerSnapshot { cursor: { x: 2 + stringWidth(body), y: 2, width: 100000, height: 60 }, inMode: false }; } -async function fixture(opts: { pane: string | null } = { pane: "%1" }) { +async function fixture(opts: { pane: string | null; mode?: "off" | "draft-aware"; footer?: boolean } = { pane: "%1" }) { const db = new Database(":memory:"); migrate(db, ALL_MIGRATIONS); db.exec(`INSERT INTO rigs(id,name) VALUES ('rig','test'); @@ -38,25 +38,32 @@ async function fixture(opts: { pane: string | null } = { pane: "%1" }) { const guard = new SeatDeliveryGuard(db, name => resolveGuardTarget(db, name)); guard.recoverActivation(); let body = "", staged = ""; + let onStage: (() => void) | undefined; + const snapshot = () => { + const s = frame(body); + if (opts.footer) s.screen += "\n✔ Update installed · /restart to apply"; + return s; + }; const writes: string[] = []; const tmux = new TmuxAdapter(async command => { - if (command.includes("paste-buffer")) { writes.push(`paste:${staged}`); body = staged; } + if (command.includes("paste-buffer")) { writes.push(`paste:${staged}`); body += staged; } if (command.includes("send-keys") && command.includes("Enter")) { writes.push("enter"); body = ""; } return ""; - }, { writeFile: async (_path, text) => { staged = text; }, unlink: async () => {}, tmpName: () => "/fixture/compact-input", bufferName: () => "compact-input" }); + }, { writeFile: async (_path, text) => { staged = text; onStage?.(); }, unlink: async () => {}, tmpName: () => "/fixture/compact-input", bufferName: () => "compact-input" }); tmux.deliveryGuard = guard; vi.spyOn(tmux, "probeSession").mockResolvedValue({ state: "present" }); vi.spyOn(tmux, "listPanes").mockImplementation(async () => [{ id: "%1" } as never]); vi.spyOn(tmux, "getPaneCommand").mockResolvedValue("node"); - vi.spyOn(tmux, "captureComposerSnapshot").mockImplementation(async () => frame(body)); - vi.spyOn(tmux, "capturePaneContent").mockImplementation(async () => frame(body).screen); + vi.spyOn(tmux, "captureComposerSnapshot").mockImplementation(async () => snapshot()); + vi.spyOn(tmux, "capturePaneContent").mockImplementation(async () => snapshot().screen); + vi.spyOn(tmux, "getPaneCursorPosition").mockImplementation(async () => snapshot().cursor); const transport = new SessionTransport({ db, tmuxAdapter: tmux, rigRepo: new RigRepository(db), sessionRegistry: new SessionRegistry(db), sleep: (ms) => new Promise((r) => setTimeout(r, Math.min(ms, 5))), waitForIdlePollMs: 5 }); // Readiness is the variable under test: the pane says idle or working. let activity = "idle"; (transport as any).classifySendReadiness = async () => ({ state: activity, reason: "fixture", evidenceSource: "pane_heuristic" }); (transport as any).claudeDeliveryObservation = async () => ({ state: "unknown", detail: "fixture" }); - await guard.set("a", { mode: "draft-aware" }, "human@test", "draft-aware seat"); + await guard.set("a", { mode: opts.mode ?? "draft-aware" }, "human@test", "fixture input policy"); const home = mkdtempSync(join(tmpdir(), "compaction-draft-aware-")); let clock = 10_000; const policy = { enabled: true, thresholdPercent: 80, preCompactInstruction: "Write a restore map.", compactInstruction: "", messageInline: "", messageFilePath: "", postRestoreAuditInstruction: "" }; @@ -74,9 +81,70 @@ async function fixture(opts: { pane: string | null } = { pane: "%1" }) { const effects = () => ({ compactPastes: writes.filter((w) => w.startsWith("paste:/compact")).length, prepPastes: writes.filter((w) => w.startsWith("paste:") && !w.startsWith("paste:/compact")).length, enters: writes.filter((w) => w === "enter").length }); const compactRows = (state?: string) => (db.prepare("SELECT outbox_id AS id, delivery_state AS state FROM outbox_entries WHERE body LIKE '/compact%'").all() as Array<{ id: string; state: string }>) .filter((r) => !state || r.state === state); - return { db, e, transport, writes, compactRows, rows, effects, stageMap, activity: (s: string) => { activity = s; }, draft: (text: string) => { body = text; } }; + return { db, e, transport, tmux, writes, compactRows, rows, effects, stageMap, body: () => body, + onStage: (fn: () => void) => { onStage = fn; }, activity: (s: string) => { activity = s; }, draft: (text: string) => { body = text; } }; } +it.each([ + { footer: false, skipMap: false }, { footer: false, skipMap: true }, + { footer: true, skipMap: false }, { footer: true, skipMap: true }, +])("managed preparation preserves a draft with typing guard off (footer=$footer, skipMap=$skipMap)", async ({ footer, skipMap }) => { + const f = await fixture({ pane: "%1", mode: "off", footer }); + f.draft("an unsent message"); + const outcome = await f.e.triggerManualCompact(input, { operatorInitiated: true, skipMap }); + f.e.cancelPreparation(seat); + expect(outcome).toMatchObject({ triggered: false, reason: "draft_input_busy", preparation: { delivery: "not_sent", compact: "not_sent" } }); + expect(f.body()).toBe("an unsent message"); + expect(f.effects()).toEqual({ compactPastes: 0, prepPastes: 0, enters: 0 }); +}); + +it("checks the composer after buffer setup, immediately before the preparation paste", async () => { + const f = await fixture({ pane: "%1", mode: "off", footer: true }); + f.onStage(() => f.draft("typed while the buffer was prepared")); + const outcome = await f.e.triggerManualCompact(input, { operatorInitiated: true, skipMap: true }); + expect(outcome).toMatchObject({ reason: "draft_input_busy", preparation: { delivery: "not_sent" } }); + expect(f.body()).toBe("typed while the buffer was prepared"); + expect(f.writes).toEqual([]); +}); + +it("an unreadable composer holds managed preparation without input", async () => { + const f = await fixture({ pane: "%1", mode: "off" }); + vi.spyOn(f.tmux, "getPaneCursorPosition").mockResolvedValue(null); + const outcome = await f.e.triggerManualCompact(input, { operatorInitiated: true, skipMap: true }); + expect(outcome).toMatchObject({ reason: "draft_input_unknown", preparation: { delivery: "not_sent" } }); + expect(f.writes).toEqual([]); +}); + +it("a draft arriving after preparation prevents the managed command paste", async () => { + const f = await fixture({ pane: "%1", mode: "off" }); + const send = f.transport.send.bind(f.transport); + vi.spyOn(f.transport, "send").mockImplementation(async (s, text, opts) => { + if (text.startsWith("/compact")) f.draft("draft before command"); + return send(s, text, opts); + }); + const outcome = await f.e.triggerManualCompact(input, { operatorInitiated: true, skipMap: true }); + expect(outcome).toMatchObject({ reason: "draft_input_busy", preparation: { compact: "not_sent" } }); + expect(f.body()).toBe("draft before command"); + expect(f.effects()).toEqual({ compactPastes: 0, prepPastes: 1, enters: 1 }); +}); + +it("an owed restore stage holds its place while the composer has a draft", async () => { + const f = await fixture({ pane: "%1", mode: "off" }); + expect(await f.e.triggerManualCompact(input, { operatorInitiated: true, skipMap: true })).toMatchObject({ triggered: true }); + f.draft("draft before restore"); + const before = f.writes.length; + await f.e.maybeAutoCompact(input); + expect(f.body()).toBe("draft before restore"); + expect(f.writes.length).toBe(before); + expect(f.e.getOwedRestore(seat)?.stage).toBe("turn_boundary"); +}); + +it.each([false, true])("empty composer still permits managed preparation and command (footer=%s)", async footer => { + const f = await fixture({ pane: "%1", mode: "off", footer }); + expect(await f.e.triggerManualCompact(input, { operatorInitiated: true, skipMap: true })).toMatchObject({ triggered: true }); + expect(f.effects()).toEqual({ compactPastes: 1, prepPastes: 1, enters: 2 }); +}); + it("automatic, recorded pane (existing-behaviour control, green before this PR's fixes): busy then idle, the lifecycle lease refuses before any send, so no /compact and no held row", async () => { const f = await fixture(); expect(f.transport.guardedDelivery).toBeDefined();