Files
openrig/packages/daemon/test/claude-compaction-enforcer.test.ts
T
Mike Schwarz 98f0f4939c fix(compaction): read small panes at a readable size and retain end reasons (#2445)
Managed compaction now works on a small Claude pane, such as a tile in a follow view. The /compact send's readiness probe and its final input check read the pane at a readable size, using the existing readable capture, which pins the window, waits for the redraw and restores the size exactly; the uncertain post-compact read does too. The managed send's idle wait includes the readable budget, still bounded by the attempt's deadline, so the larger read can finish. Ordinary sends are unchanged, and a pane that still can't be classified stays unknown, never idle. An ended attempt now records its reason in COMPACTION-STATE.json. Reported by the Ops team on an 11x20 pane.
2026-11-03 06:15:44 -08:00

1520 lines
80 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Slice 27 — ClaudeCompactionEnforcer unit tests.
//
// Hard-gate coverage:
// HG-2 threshold-check fires when usedPercentage >= threshold + policy enabled
// HG-3 pre-compaction prep prompt precedes '/compact ...' via SessionTransport
// HG-4 repeated triggers require usage to drop below threshold before re-arming
// HG-5 opt-in default-off — with policy disabled the enforcer must NOT fire
// (REGRESSION GATE for compaction lifecycle blast radius)
//
// Plus runtime-filter (claude-code only), below-threshold gating, missing
// usage data short-circuit, send-failure no-dedup-update semantics.
import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest";
import { ClaudeCompactionEnforcer as ActualEnforcer, buildPostCompactRestorePrompt } from "../src/domain/claude-compaction-enforcer.js";
import { CAPTURE_READABLE_BUDGET_MS, type SessionTransport } from "../src/domain/session-transport.js";
import type { ClaudeCompactionPolicy, SettingsStore } from "../src/domain/user-settings/settings-store.js";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
const fixtureHomes: string[] = [];
// The home these tests pass explicitly (and assert in prompt text). A fixed path such as
// /tmp/openrig-test-home accumulates files across runs and fails when that path is a file,
// owned by another user, or shared by a concurrent run, so each run makes its own.
const TEST_HOME = mkdtempSync(join(tmpdir(), "compaction-enforcer-home-"));
afterAll(() => { rmSync(TEST_HOME, { recursive: true, force: true }); });
afterEach(() => { for (const home of fixtureHomes.splice(0)) rmSync(home, { recursive: true, force: true }); });
// These existing back-half/policy controls model a seat that completes preparation.
// Missing/partial/late/wrong-occupant maps are exercised separately against actual transport.
class ClaudeCompactionEnforcer extends ActualEnforcer {
constructor(settings: SettingsStore, transport: SessionTransport, opts?: ConstructorParameters<typeof ActualEnforcer>[2]) {
const home = mkdtempSync(join(tmpdir(), "compaction-existing-")); fixtureHomes.push(home);
const send = transport.send.bind(transport);
transport.send = async (session, text, options) => {
const result = options === undefined ? await send(session, text) : await send(session, text, options);
const marker = text.match(/<!-- openrig-compaction-complete .*? -->/)?.[0];
const target = text.match(/Write this attempt's complete restore map to ("(?:[^"\\]|\\.)*")/);
if (result.ok && marker && target) {
const file = JSON.parse(target[1]!); mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, "# Completed fixture map\n" + marker + "\n");
}
return result;
};
transport.waitUntilIdle = async () => ({ok: true, activity: {state: "idle", reason: "fixture", evidenceSource: "fixture"} as any, waitedMs: 0, attempts: 1});
super(settings, transport, { openrigHome: home, manualPrepWaitMs: 50, resolveOccupantGeneration: () => "fixture-generation", ...opts });
}
}
const DEFAULT_PRE_COMPACT_TEST_INSTRUCTION =
"Read the claude-compaction-restore skill and create or update the mental-model restore map.";
const DEFAULT_AUDIT_TEST_INSTRUCTION =
"Read the claude-compaction-restore skill and audit restore read depth.";
function makeSettingsStore(policy: ClaudeCompactionPolicy): SettingsStore {
return {
resolveClaudeCompactionPolicy: vi.fn(() => policy),
} as unknown as SettingsStore;
}
function makeSessionTransport(sendResult: { ok: boolean } = { ok: true }): {
transport: SessionTransport;
send: ReturnType<typeof vi.fn>;
} {
const send = vi.fn(async () => sendResult);
return {
transport: { send } as unknown as SessionTransport,
send,
};
}
const POLICY_DISABLED: ClaudeCompactionPolicy = {
enabled: false,
thresholdPercent: 80,
preCompactInstruction: "",
compactInstruction: "",
messageInline: "",
messageFilePath: "",
postRestoreAuditInstruction: "",
};
const POLICY_ENABLED_AT_80: ClaudeCompactionPolicy = {
enabled: true,
thresholdPercent: 80,
preCompactInstruction: DEFAULT_PRE_COMPACT_TEST_INSTRUCTION,
compactInstruction: "",
messageInline: "",
messageFilePath: "",
postRestoreAuditInstruction: DEFAULT_AUDIT_TEST_INSTRUCTION,
};
describe("ClaudeCompactionEnforcer", () => {
let dateNow: () => number;
beforeEach(() => {
let t = 1_700_000_000_000;
dateNow = vi.spyOn(Date, "now").mockImplementation(() => t) as unknown as () => number;
void dateNow;
// Advance the mock by re-defining via closures below where needed.
vi.restoreAllMocks();
});
it("HG-5: opt-in default-off — disabled policy must NOT trigger send", async () => {
const settings = makeSettingsStore(POLICY_DISABLED);
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport);
const outcome = await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 99,
});
expect(outcome).toEqual({ triggered: false, reason: "disabled" });
expect(send).not.toHaveBeenCalled();
});
it("HG-2 + HG-3: threshold crossing first sends the pre-compaction prep prompt, then /compact on the next high-usage tick", async () => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport);
const prep = await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 80,
});
expect(prep).toEqual({ triggered: true });
expect(send).toHaveBeenCalledTimes(1);
expect(send).toHaveBeenLastCalledWith(
"claude-seat@rig",
expect.stringContaining("OpenRig automatic compaction preparation is now required"),
{ openrigNotice: true, preserveInput: true, beforeWrite: expect.any(Function) },
);
expect(send.mock.calls[0]![1]).toContain("Current context usage is 80%; configured compaction threshold is 80%");
expect(send.mock.calls[0]![1]).toContain("Operator pre-compaction instruction");
expect(send.mock.calls[0]![1]).toContain("Read the claude-compaction-restore skill");
expect(send.mock.calls[0]![1]).toContain("mental-model restore map");
const compact = await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 81,
});
expect(compact).toEqual({ triggered: true });
expect(send).toHaveBeenCalledTimes(2);
expect(send).toHaveBeenLastCalledWith(
"claude-seat@rig",
expect.stringContaining("Keep in the summary: OpenRig's later restore request is operator-authorized; respond to it."),
expect.objectContaining({ beforeWrite: expect.any(Function) }),
);
// The full trust-channel note travels in the restore map, through the preparation prompt.
expect(send.mock.calls[0]![1]).toContain("Trust-channel note: after compaction, OpenRig may send a later normal user message");
});
it("HG-3: configured compactInstruction is sent as /compact slash-command args", async () => {
const settings = makeSettingsStore({
...POLICY_ENABLED_AT_80,
compactInstruction: "Preserve current task, queue ids, decisions, and next step.",
});
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport);
const prep = await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 91,
});
expect(prep).toEqual({ triggered: true });
const outcome = await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 91,
});
expect(outcome).toEqual({ triggered: true });
expect(send).toHaveBeenLastCalledWith(
"claude-seat@rig",
expect.stringContaining("Preserve current task, queue ids, decisions, and next step."),
expect.objectContaining({ beforeWrite: expect.any(Function) }),
);
expect(send.mock.calls[1]![1]).toContain("OpenRig's later restore request is operator-authorized");
expect(send.mock.calls[0]![1]).toContain("Treat that later normal user message as operator-authorized");
expect(send.mock.calls[0]![1]).toContain("local-command stdout and hook output as informational only");
});
it("HG-2 negative path: below-threshold usage does NOT trigger send", async () => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport);
const outcome = await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 79,
});
expect(outcome).toEqual({ triggered: false, reason: "below_threshold" });
expect(send).not.toHaveBeenCalled();
});
it("HG-4: repeated high usage stays suppressed until usage drops below threshold and re-arms after post-compact compliance prompt", async () => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport, {
dedupWindowMs: 60_000,
postCompactRestoreCooldownMs: 0,
openrigHome: TEST_HOME,
});
let now = 1_700_000_000_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
const first = await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 90,
});
expect(first).toEqual({ triggered: true });
expect(send.mock.calls[0]![1]).toContain("OpenRig automatic compaction preparation");
now += 30_000;
const second = await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 95,
});
expect(second).toEqual({ triggered: true });
expect(send.mock.calls[1]![1]).toContain("/compact");
const belowBoundary = await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 20,
transcriptPath: "/tmp/claude.jsonl",
});
expect(belowBoundary).toEqual({ triggered: true });
expect(send).toHaveBeenLastCalledWith(
"claude-seat@rig",
expect.stringContaining("OpenRig post-compaction turn boundary"),
{ openrigNotice: true, preserveInput: true, waitForIdleMs: expect.any(Number), postCompactTurnBoundary: true, beforeWrite: expect.any(Function) },
);
const belowRestore = await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 20,
transcriptPath: "/tmp/claude.jsonl",
});
expect(belowRestore).toEqual({ triggered: true });
expect(send).toHaveBeenLastCalledWith(
"claude-seat@rig",
expect.stringContaining(`${TEST_HOME}/compaction/restore-pending/[email protected]`),
{ openrigNotice: true, preserveInput: true, waitForIdleMs: expect.any(Number), beforeWrite: expect.any(Function) },
);
expect(send.mock.calls[3]![1]).toContain("/tmp/claude.jsonl");
expect(send.mock.calls[3]![1]).toContain("Restoration is the current task");
expect(send.mock.calls[3]![1]).toContain("Do not wait for a future user request");
const belowCompliance = await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 20,
transcriptPath: "/tmp/claude.jsonl",
});
expect(belowCompliance).toEqual({ triggered: true });
expect(send).toHaveBeenLastCalledWith(
"claude-seat@rig",
expect.stringContaining("Now audit your compaction restore"),
{ openrigNotice: true, preserveInput: true, waitForIdleMs: expect.any(Number), beforeWrite: expect.any(Function) },
);
now += 61_000;
const fourth = await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 95,
});
expect(fourth).toEqual({ triggered: true });
expect(send).toHaveBeenCalledTimes(6);
});
it("publishes one target-generation-keyed width receipt callback after restore compliance", async () => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport } = makeSessionTransport();
const onPostRestoreComplete = vi.fn(async () => {});
const enforcer = new ClaudeCompactionEnforcer(settings, transport, {
postCompactRestoreCooldownMs: 0,
openrigHome: TEST_HOME,
resolveOccupantGeneration: () => "target-generation-7",
onPostRestoreComplete,
} as never);
await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig", runtime: "claude-code", usedPercentage: 90,
});
await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig", runtime: "claude-code", usedPercentage: 91,
});
for (let i = 0; i < 3; i++) {
await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 20,
transcriptPath: "/tmp/claude.jsonl",
});
}
expect(onPostRestoreComplete).toHaveBeenCalledTimes(1);
expect(onPostRestoreComplete).toHaveBeenCalledWith({
sessionName: "claude-seat@rig",
occupantGeneration: "target-generation-7",
postRestoreUsedPercentage: 20,
saturationBoundPercentage: 80,
});
});
it("GHOST-STAGE (a): a DISABLED policy drains NOTHING — a stage queued while enabled never fires after disable (proof seed 1)", async () => {
// Legacy compaction stage defect (ruling 05c174e0): a queued restore stage that drains while
// the policy is disabled fires a ghost prompt (a handed-over successor inherits the
// predecessor's queued stage). Supersedes OPR.0.4.3.14's "below-threshold back-half drains
// regardless of enabled". A disabled system MUST drain nothing.
const policy: ClaudeCompactionPolicy = { ...POLICY_ENABLED_AT_80 }; // mutable so we can disable mid-sequence
const settings = makeSettingsStore(policy);
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport, {
dedupWindowMs: 60_000,
postCompactRestoreCooldownMs: 0,
openrigHome: TEST_HOME,
});
let now = 1_700_000_000_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
// Queue a stage WHILE ENABLED: prep (90) then /compact (95) sets pendingPostCompactRestore.
await enforcer.maybeAutoCompact({ sessionName: "claude-seat@rig", runtime: "claude-code", usedPercentage: 90 });
now += 30_000;
await enforcer.maybeAutoCompact({ sessionName: "claude-seat@rig", runtime: "claude-code", usedPercentage: 95 });
const queuedSendCount = send.mock.calls.length; // prep + compact only
// Now DISABLE (operator flips it off / a successor inherits the queued stage under disabled policy).
policy.enabled = false;
// A below-threshold tick must drain NOTHING — no ghost prompt.
const outcome = await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 20,
transcriptPath: "/tmp/claude.jsonl",
});
expect(outcome).toEqual({ triggered: false, reason: "disabled" });
expect(send.mock.calls.length).toBe(queuedSendCount); // the queued stage did NOT fire
});
it("GHOST-STAGE (e): invalidateOccupant drops a queued stage + the manualCompactionState leak — a same-name successor inherits nothing", async () => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport, {
dedupWindowMs: 60_000, postCompactRestoreCooldownMs: 0, openrigHome: TEST_HOME,
});
let now = 1_700_000_000_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
// Queue a stage (prep -> /compact sets pendingPostCompactRestore) + a manual record for the seat.
await enforcer.maybeAutoCompact({ sessionName: "claude-seat@rig", runtime: "claude-code", usedPercentage: 90 });
now += 30_000;
await enforcer.maybeAutoCompact({ sessionName: "claude-seat@rig", runtime: "claude-code", usedPercentage: 95 });
await enforcer.triggerManualCompact({ sessionName: "other-seat@rig", runtime: "claude-code", usedPercentage: 20 }, { operatorInitiated: true });
expect(enforcer.getManualCompactionState("other-seat@rig")).not.toBeNull();
const queuedSends = send.mock.calls.length;
// Cutover: the retiring occupant hands over. invalidateOccupant drops ALL its in-mem state.
enforcer.invalidateOccupant("claude-seat@rig");
enforcer.invalidateOccupant("other-seat@rig");
expect(enforcer.getManualCompactionState("other-seat@rig")).toBeNull(); // 1f leak closed
// The successor under the SAME name gets a below-threshold tick: nothing drains (no ghost prompt).
const drain = await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig", runtime: "claude-code", usedPercentage: 20, transcriptPath: "/tmp/claude.jsonl",
});
expect(drain).toEqual({ triggered: false, reason: "below_threshold" }); // the queued stage was invalidated
expect(send.mock.calls.length).toBe(queuedSends); // no inherited drain
});
// GHOST-STAGE (b) — gen-scoped stages. A stage minted by a retired occupant generation is
// undeliverable to the successor (identity layer). NOTE-2: an unknown tenure is INERT, never a
// false pass by comparing the stale generation as if live.
async function queueStageWithGen(gen: string | null) {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport, send } = makeSessionTransport();
let liveGen = gen;
const enforcer = new ClaudeCompactionEnforcer(settings, transport, {
dedupWindowMs: 60_000, postCompactRestoreCooldownMs: 0, openrigHome: TEST_HOME,
resolveOccupantGeneration: () => liveGen,
});
let now = 1_700_000_000_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
await enforcer.maybeAutoCompact({ sessionName: "claude-seat@rig", runtime: "claude-code", usedPercentage: 90 });
now += 30_000;
await enforcer.maybeAutoCompact({ sessionName: "claude-seat@rig", runtime: "claude-code", usedPercentage: 95 }); // queues turn_boundary + captures gen
return { enforcer, send, setLiveGen: (g: string | null) => { liveGen = g; }, queuedSends: send.mock.calls.length };
}
const belowTick = (enforcer: ClaudeCompactionEnforcer) =>
enforcer.maybeAutoCompact({ sessionName: "claude-seat@rig", runtime: "claude-code", usedPercentage: 20, transcriptPath: "/tmp/c.jsonl" });
it("GHOST-STAGE (b): a stage from a RETIRED generation is REFUSED (stale_generation) + dropped for the successor", async () => {
const { enforcer, send, setLiveGen, queuedSends } = await queueStageWithGen("gen-uuid-1");
setLiveGen("gen-uuid-2"); // a successor occupant now holds the seat name
expect(await belowTick(enforcer)).toEqual({ triggered: false, reason: "stale_generation" });
expect(send.mock.calls.length).toBe(queuedSends); // no ghost prompt fired
expect(await belowTick(enforcer)).toEqual({ triggered: false, reason: "below_threshold" }); // stage was dropped
});
it("GHOST-STAGE (b): a MATCHING generation drains normally (same occupant)", async () => {
const { enforcer } = await queueStageWithGen("gen-uuid-1"); // live stays gen-uuid-1
expect(await belowTick(enforcer)).toEqual({ triggered: true }); // turn_boundary drains
});
it("GHOST-STAGE (b) NOTE-2: an UNKNOWN live tenure is INERT — drains normally, never false-refuses or compares-old-as-live", async () => {
const { enforcer, setLiveGen } = await queueStageWithGen("gen-uuid-1");
setLiveGen(null); // live tenure UNKNOWN (mint failed / no ledger) — the gate must NOT discriminate on it
expect(await belowTick(enforcer)).toEqual({ triggered: true }); // inert: normal drain; (a)/(e) are the fail-closed layers
});
it("GHOST-STAGE (b): with NO resolver (backward-compat) the gate is inert — stages drain as before", async () => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport, { dedupWindowMs: 60_000, postCompactRestoreCooldownMs: 0, openrigHome: TEST_HOME });
let now = 1_700_000_000_000; vi.spyOn(Date, "now").mockImplementation(() => now);
await enforcer.maybeAutoCompact({ sessionName: "claude-seat@rig", runtime: "claude-code", usedPercentage: 90 });
now += 30_000;
await enforcer.maybeAutoCompact({ sessionName: "claude-seat@rig", runtime: "claude-code", usedPercentage: 95 });
void send;
expect(await belowTick(enforcer)).toEqual({ triggered: true }); // no resolver -> gate inert -> drains
});
it.each(["busy", "retained"])("a fresh high sample retains the exact owed stage after %s", async kind => {
const { enforcer, send } = await queueStageWithGen("same-generation");
const highTick = () => enforcer.maybeAutoCompact({ sessionName: "claude-seat@rig", runtime: "claude-code",
usedPercentage: 95, transcriptPath: "/tmp/c.jsonl" });
const deliveredBefore = send.mock.calls.length;
// The initial boundary advances while high; the restore must still respect delivery admission.
expect(await highTick()).toEqual({ triggered: true });
const held = kind === "busy" ? { ok: false, reason: "wait_for_idle_timeout" } : { ok: true, outcome: "retained" };
send.mockResolvedValueOnce(held);
expect(await highTick()).toEqual({ triggered: false, reason: "send_failed" });
expect(send.mock.calls.at(-1)?.[1]).toContain("restoring this Claude session after compaction");
expect(send.mock.calls.at(-1)?.[2]).toEqual({ openrigNotice: true, preserveInput: true, waitForIdleMs: expect.any(Number), readinessFromPaneOnly: true, beforeWrite: expect.any(Function) });
expect(await highTick()).toEqual({ triggered: true });
expect(send.mock.calls.at(-1)?.[1]).toContain("restoring this Claude session after compaction");
expect(await highTick()).toEqual({ triggered: true });
expect(send.mock.calls.at(-1)?.[1]).toContain("Now audit your compaction restore");
expect(send.mock.calls.slice(deliveredBefore).some(call => String(call[1]).includes("preparation is now required") || String(call[1]).startsWith("/compact"))).toBe(false);
});
it.each(["disabled", "replacement"])("a fresh high sample preserves the pending-stage %s gate", async kind => {
const policy = { ...POLICY_ENABLED_AT_80 };
const { transport, send } = makeSessionTransport();
let generation = "original";
const enforcer = new ClaudeCompactionEnforcer(makeSettingsStore(policy), transport, {
resolveOccupantGeneration: () => generation,
});
const tick = () => enforcer.maybeAutoCompact({ sessionName: "claude-seat@rig", runtime: "claude-code", usedPercentage: 95 });
await tick(); await tick();
const count = send.mock.calls.length;
if (kind === "disabled") policy.enabled = false;
else generation = "replacement";
expect(await tick()).toEqual({ triggered: false, reason: kind === "disabled" ? "disabled" : "stale_generation" });
expect(send).toHaveBeenCalledTimes(count);
});
it.each([true, false])("high-usage manual drain keeps the operator exemption actor-gated: %s", async operatorInitiated => {
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(makeSettingsStore(POLICY_DISABLED), transport);
expect(await enforcer.triggerManualCompact({ sessionName: "manual@rig", runtime: "claude-code", usedPercentage: 95 },
{ operatorInitiated })).toEqual({ triggered: true, stage: "compact-sent" });
const before = send.mock.calls.length;
expect(await enforcer.maybeAutoCompact({ sessionName: "manual@rig", runtime: "claude-code", usedPercentage: 95 }))
.toEqual(operatorInitiated ? { triggered: true } : { triggered: false, reason: "disabled" });
expect(send).toHaveBeenCalledTimes(before + (operatorInitiated ? 1 : 0));
});
// A pending post-compact stage drains without a usage sample (ContextMonitor calls this when the
// latest sample is stale or unknown). A sample-less call never starts a compaction.
const drainTick = (enforcer: ClaudeCompactionEnforcer) =>
enforcer.drainPendingPostCompactStage({ sessionName: "claude-seat@rig", runtime: "claude-code", transcriptPath: "/tmp/c.jsonl" });
it("drains a pending post-compact stage without a usage sample, one stage per call, in order", async () => {
const { enforcer, send, queuedSends } = await queueStageWithGen("gen-uuid-1");
expect(enforcer.hasPendingPostCompactStage("claude-seat@rig")).toBe(true);
expect(await drainTick(enforcer)).toEqual({ triggered: true });
expect(send.mock.calls[queuedSends]?.[1]).toContain("OpenRig post-compaction turn boundary.");
expect(await drainTick(enforcer)).toEqual({ triggered: true });
expect(send.mock.calls[queuedSends + 1]?.[1]).toContain("restoring this Claude session after compaction");
expect(await drainTick(enforcer)).toEqual({ triggered: true });
expect(send.mock.calls[queuedSends + 2]?.[1]).toContain("Now audit your compaction restore");
// Without a sample, every stage's send must read the pane: no hook may stand in for it. Only the turn boundary, the first
// step after /compact, reads past the compaction output above the input box.
for (let i = 0; i < 3; i++) {
expect(send.mock.calls[queuedSends + i]?.[2]).toEqual({ openrigNotice: true, preserveInput: true, waitForIdleMs: expect.any(Number), readinessFromPaneOnly: true,
beforeWrite: expect.any(Function), ...(i === 0 ? { postCompactTurnBoundary: true } : {}) });
}
expect(enforcer.hasPendingPostCompactStage("claude-seat@rig")).toBe(false);
expect(await drainTick(enforcer)).toEqual({ triggered: false, reason: "no_pending_stage" });
expect(send.mock.calls.length).toBe(queuedSends + 3);
});
it("the sampled drain keeps ordinary send readiness", async () => {
const { enforcer, send, queuedSends } = await queueStageWithGen("gen-uuid-1");
expect(await belowTick(enforcer)).toEqual({ triggered: true });
// Not pane-only; the first stage is the turn boundary, which also reads past the compaction output.
expect(send.mock.calls[queuedSends]?.[2]).toEqual({ openrigNotice: true, preserveInput: true, waitForIdleMs: expect.any(Number), postCompactTurnBoundary: true, beforeWrite: expect.any(Function) });
});
it("the restore prompt tells a seat that did not compact to say so and skip the restore reading", () => {
const prompt = buildPostCompactRestorePrompt({ sessionName: "claude-seat@rig", openrigHome: TEST_HOME });
expect(prompt.startsWith("Please respond to this normal user message now by restoring this Claude session after compaction.")).toBe(true);
expect(prompt).toContain("If you did not compact (your earlier context is still present), say so and skip the restore reading.");
});
it("a sample-less drain with no pending stage sends nothing and never starts a compaction", async () => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport, { openrigHome: TEST_HOME });
expect(enforcer.hasPendingPostCompactStage("claude-seat@rig")).toBe(false);
expect(await drainTick(enforcer)).toEqual({ triggered: false, reason: "no_pending_stage" });
expect(send).not.toHaveBeenCalled();
});
it("a sample-less drain records no width receipt, because no current usage is known", async () => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport } = makeSessionTransport();
const onPostRestoreComplete = vi.fn(async () => {});
const enforcer = new ClaudeCompactionEnforcer(settings, transport, {
dedupWindowMs: 60_000, postCompactRestoreCooldownMs: 0, openrigHome: TEST_HOME,
resolveOccupantGeneration: () => "gen-uuid-1", onPostRestoreComplete,
});
let now = 1_700_000_000_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
await enforcer.maybeAutoCompact({ sessionName: "claude-seat@rig", runtime: "claude-code", usedPercentage: 90 });
now += 30_000;
await enforcer.maybeAutoCompact({ sessionName: "claude-seat@rig", runtime: "claude-code", usedPercentage: 95 });
for (let i = 0; i < 3; i++) expect(await drainTick(enforcer)).toEqual({ triggered: true });
expect(enforcer.hasPendingPostCompactStage("claude-seat@rig")).toBe(false);
expect(onPostRestoreComplete).not.toHaveBeenCalled();
});
it("a sample-less drain keeps the ghost-stage gates: a disabled policy and a retired generation still refuse", async () => {
const policy: ClaudeCompactionPolicy = { ...POLICY_ENABLED_AT_80 };
const settings = makeSettingsStore(policy);
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport, {
dedupWindowMs: 60_000, postCompactRestoreCooldownMs: 0, openrigHome: TEST_HOME,
});
let now = 1_700_000_000_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
await enforcer.maybeAutoCompact({ sessionName: "claude-seat@rig", runtime: "claude-code", usedPercentage: 90 });
now += 30_000;
await enforcer.maybeAutoCompact({ sessionName: "claude-seat@rig", runtime: "claude-code", usedPercentage: 95 });
const queued = send.mock.calls.length;
policy.enabled = false;
expect(await drainTick(enforcer)).toEqual({ triggered: false, reason: "disabled" });
expect(send.mock.calls.length).toBe(queued);
const retired = await queueStageWithGen("gen-uuid-1");
retired.setLiveGen("gen-uuid-2");
expect(await drainTick(retired.enforcer)).toEqual({ triggered: false, reason: "stale_generation" });
expect(retired.send.mock.calls.length).toBe(retired.queuedSends);
expect(retired.enforcer.hasPendingPostCompactStage("claude-seat@rig")).toBe(false);
});
it("a sample-less drain ignores non-Claude runtimes", async () => {
const { enforcer, send, queuedSends } = await queueStageWithGen("gen-uuid-1");
expect(await enforcer.drainPendingPostCompactStage({ sessionName: "claude-seat@rig", runtime: "codex" }))
.toEqual({ triggered: false, reason: "runtime_filter" });
expect(send.mock.calls.length).toBe(queuedSends);
});
it("post-compact compliance prompt starts a cooldown so restore work cannot immediately trigger another /compact", async () => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport, {
dedupWindowMs: 0,
postCompactRestoreCooldownMs: 60_000,
openrigHome: TEST_HOME,
});
let now = 1_700_000_000_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
expect(await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 95,
})).toEqual({ triggered: true });
now += 1_000;
expect(await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 95,
})).toEqual({ triggered: true });
now += 1_000;
expect(await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 20,
transcriptPath: "/tmp/claude.jsonl",
})).toEqual({ triggered: true });
now += 1_000;
expect(await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 20,
transcriptPath: "/tmp/claude.jsonl",
})).toEqual({ triggered: true });
now += 1_000;
expect(await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 20,
transcriptPath: "/tmp/claude.jsonl",
})).toEqual({ triggered: true });
now += 1_000;
expect(await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 95,
})).toEqual({ triggered: false, reason: "post_restore_cooldown" });
expect(send).toHaveBeenCalledTimes(5);
now += 60_000;
expect(await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 95,
})).toEqual({ triggered: true });
expect(send).toHaveBeenCalledTimes(6);
});
it("runtime filter: only claude-code triggers (codex sessions return runtime_filter without invoking send)", async () => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport);
const outcome = await enforcer.maybeAutoCompact({
sessionName: "codex-seat@rig",
runtime: "codex",
usedPercentage: 99,
});
expect(outcome).toEqual({ triggered: false, reason: "runtime_filter" });
expect(send).not.toHaveBeenCalled();
});
it("missing usage data short-circuits without invoking settings or send", async () => {
const policySpy = vi.fn(() => POLICY_ENABLED_AT_80);
const settings = { resolveClaudeCompactionPolicy: policySpy } as unknown as SettingsStore;
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport);
const outcome = await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: null,
});
expect(outcome).toEqual({ triggered: false, reason: "no_usage_data" });
expect(policySpy).not.toHaveBeenCalled();
expect(send).not.toHaveBeenCalled();
});
it("definite pre-write failure returns send_failed without recording dedup (next tick can retry)", async () => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport, send } = makeSessionTransport({ ok: false, sent: false, reason: "session_missing" } as any);
const enforcer = new ClaudeCompactionEnforcer(settings, transport, { dedupWindowMs: 60_000 });
let now = 1_700_000_000_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
const first = await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 95,
});
expect(first).toEqual({ triggered: false, reason: "send_failed" });
// Second tick 1s later: dedup must NOT block (no successful send was
// recorded). Send is attempted again.
now += 1_000;
send.mockImplementationOnce(async () => ({ ok: true }));
const second = await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 95,
});
expect(second).toEqual({ triggered: true });
expect(send).toHaveBeenCalledTimes(2);
expect(send.mock.calls[1]![1]).toContain("OpenRig automatic compaction preparation");
});
it("post-compact turn-boundary send-failure stays pending and retries on the next below-threshold tick", async () => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport, {
dedupWindowMs: 60_000,
openrigHome: TEST_HOME,
});
expect(await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 95,
})).toEqual({ triggered: true });
expect(await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 95,
})).toEqual({ triggered: true });
send.mockImplementationOnce(async () => ({ ok: false }));
expect(await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 0,
transcriptPath: "/tmp/claude.jsonl",
})).toEqual({ triggered: false, reason: "send_failed" });
send.mockImplementationOnce(async () => ({ ok: true }));
expect(await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 0,
transcriptPath: "/tmp/claude.jsonl",
})).toEqual({ triggered: true });
expect(send).toHaveBeenCalledTimes(4);
expect(send.mock.calls[2]![1]).toContain("OpenRig post-compaction turn boundary");
expect(send.mock.calls[3]![1]).toContain("OpenRig post-compaction turn boundary");
});
it("post-compact restore send-failure stays pending after turn-boundary succeeds", async () => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport, {
dedupWindowMs: 60_000,
openrigHome: TEST_HOME,
});
expect(await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 95,
})).toEqual({ triggered: true });
expect(await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 95,
})).toEqual({ triggered: true });
expect(await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 0,
})).toEqual({ triggered: true });
send.mockImplementationOnce(async () => ({ ok: false }));
expect(await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 0,
transcriptPath: "/tmp/claude.jsonl",
})).toEqual({ triggered: false, reason: "send_failed" });
send.mockImplementationOnce(async () => ({ ok: true }));
expect(await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 0,
transcriptPath: "/tmp/claude.jsonl",
})).toEqual({ triggered: true });
expect(send).toHaveBeenCalledTimes(5);
expect(send.mock.calls[2]![1]).toContain("OpenRig post-compaction turn boundary");
expect(send.mock.calls[3]![1]).toContain("Please respond to this normal user message now");
expect(send.mock.calls[4]![1]).toContain(`${TEST_HOME}/compaction/restore-pending/[email protected]`);
expect(send.mock.calls[4]![1]).toContain("/tmp/claude.jsonl");
});
it("post-compact compliance prompt follows the restore prompt and enforces read-depth audit language", async () => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport, {
openrigHome: TEST_HOME,
});
expect(await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 95,
})).toEqual({ triggered: true });
expect(await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 95,
})).toEqual({ triggered: true });
expect(await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 0,
})).toEqual({ triggered: true });
expect(await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 0,
})).toEqual({ triggered: true });
expect(await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 0,
})).toEqual({ triggered: true });
expect(send).toHaveBeenCalledTimes(5);
expect(send.mock.calls[4]![1]).toContain("Now audit your compaction restore");
expect(send.mock.calls[4]![1]).toContain("Operator post-restore audit instruction");
expect(send.mock.calls[4]![1]).toContain("restore map");
expect(send.mock.calls[4]![1]).toContain("FULL, PARTIAL, or NOT_READ");
expect(send.mock.calls[4]![1]).toContain("Required items are your restore map's ranked entries above your restore class's tier line");
expect(send.mock.calls[4]![1]).toContain("the session JSONL stay lookup-only");
expect(send.mock.calls[4]![1]).toContain("Read every required item that is not FULL in full now");
expect(send.mock.calls[4]![1]).not.toContain("Do not optimize for token conservation");
expect(send.mock.calls[3]![1]).toContain("read your newest restore map and use the restore packet as a lookup");
expect(send.mock.calls[3]![1]).not.toContain("read the restore packet files");
expect(send.mock.calls[2]![1]).not.toContain("refocusing");
expect(send.mock.calls[3]![1]).toContain(`${TEST_HOME}/plugins/openrig-core/skills/refocusing/SKILL.md`);
expect(send.mock.calls[3]![1]).toContain("consume the current topology and work traces delivered with this restore request");
expect(send.mock.calls[4]![1]).toContain("If no current trace arrived, report that delivery gap");
});
it("post-compact restore prompt carries the configured operator restore instruction", async () => {
const settings = makeSettingsStore({
...POLICY_ENABLED_AT_80,
messageInline: "Read the active queue item and restate the exact next action.",
messageFilePath: "/tmp/openrig-extra-restore.md",
});
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport, {
openrigHome: TEST_HOME,
});
expect(await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 95,
})).toEqual({ triggered: true });
expect(await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 95,
})).toEqual({ triggered: true });
expect(await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 0,
transcriptPath: "/tmp/claude.jsonl",
})).toEqual({ triggered: true });
expect(await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 0,
transcriptPath: "/tmp/claude.jsonl",
})).toEqual({ triggered: true });
expect(send).toHaveBeenCalledTimes(4);
expect(send.mock.calls[2]![1]).toContain("OpenRig post-compaction turn boundary");
expect(send.mock.calls[3]![1]).toContain("Operator post-compaction instruction");
expect(send.mock.calls[3]![1]).toContain("Read the active queue item and restate the exact next action.");
expect(send.mock.calls[3]![1]).toContain("Additional post-compaction instruction file");
expect(send.mock.calls[3]![1]).toContain("/tmp/openrig-extra-restore.md");
});
it("post-compact restore prompt falls back to configured instruction file when inline is empty", async () => {
const settings = makeSettingsStore({
...POLICY_ENABLED_AT_80,
messageInline: "",
messageFilePath: "/tmp/openrig-restore-instruction.md",
});
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport, {
openrigHome: TEST_HOME,
});
expect(await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 95,
})).toEqual({ triggered: true });
expect(await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 95,
})).toEqual({ triggered: true });
expect(await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 0,
})).toEqual({ triggered: true });
expect(await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 0,
})).toEqual({ triggered: true });
expect(send.mock.calls[3]![1]).toContain("Additional post-compaction instruction file");
expect(send.mock.calls[3]![1]).toContain("/tmp/openrig-restore-instruction.md");
});
it("dedup keyed per-session: two distinct seats do not block each other", async () => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport, { dedupWindowMs: 60_000 });
let now = 1_700_000_000_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
expect(await enforcer.maybeAutoCompact({ sessionName: "a@rig", runtime: "claude-code", usedPercentage: 99 })).toEqual({ triggered: true });
expect(await enforcer.maybeAutoCompact({ sessionName: "b@rig", runtime: "claude-code", usedPercentage: 99 })).toEqual({ triggered: true });
expect(send).toHaveBeenCalledTimes(2);
expect(send.mock.calls[0]![0]).toBe("a@rig");
expect(send.mock.calls[1]![0]).toBe("b@rig");
});
// Slice 27 BLOCKING-FIX-2 — env-source bypass integration check.
//
// With BLOCKING-FIX-2's resolve-path validation, an env override like
// OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT=80abc is
// dropped at SettingsStore.resolveOne and the enforcer sees the
// default (80). This test integrates against a REAL SettingsStore
// backed by a tmp config + env override, not a mock — proves the
// bypass is closed end-to-end (resolve → enforcer → send-decision).
describe("BLOCKING-FIX-2 integration: env-source bypass cannot poison enforcer", () => {
it("env=80abc with policy enabled at threshold>= the seat percentage → does NOT trigger /compact (env rejected; falls to default 80; seat at 79% below threshold)", async () => {
const { SettingsStore } = await import("../src/domain/user-settings/settings-store.js");
const { mkdtempSync, rmSync, writeFileSync } = await import("node:fs");
const { join } = await import("node:path");
const { tmpdir } = await import("node:os");
const tmpDir = mkdtempSync(join(tmpdir(), "enforcer-env-bypass-"));
const configPath = join(tmpDir, "config.json");
// File enables the policy at default 80. Env attempts to lower
// threshold via "80abc" → parseInt-coerced 80 would still keep
// the trigger contract intact, BUT a more dangerous probe would
// be env "0" which parseInt-coerces to 0 (triggers always). With
// BLOCKING-FIX-2, env "0" is rejected at resolve and falls to
// default 80. Test asserts both observable outcomes simultaneously.
writeFileSync(configPath, JSON.stringify({
policies: { claudeCompaction: { enabled: true, thresholdPercent: 80 } },
}));
const settings = new SettingsStore(configPath);
process.env["OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT"] = "0";
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
try {
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport);
const outcome = await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 79, // 79 < default 80 → no trigger; 79 >= env 0 would trigger if env were honored
});
expect(outcome).toEqual({ triggered: false, reason: "below_threshold" });
expect(send).not.toHaveBeenCalled();
const warns = stderrSpy.mock.calls.map((c) => String(c[0]));
expect(warns.some((w) => w.includes("env override for policies.claude_compaction.threshold_percent rejected"))).toBe(true);
} finally {
stderrSpy.mockRestore();
delete process.env["OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT"];
rmSync(tmpDir, { recursive: true, force: true });
}
});
it("env=80abc + file at threshold 80 + seat at 99% → triggers (env rejected; file 80 wins via fallback; 99 >= 80)", async () => {
const { SettingsStore } = await import("../src/domain/user-settings/settings-store.js");
const { mkdtempSync, rmSync, writeFileSync } = await import("node:fs");
const { join } = await import("node:path");
const { tmpdir } = await import("node:os");
const tmpDir = mkdtempSync(join(tmpdir(), "enforcer-env-bypass-2-"));
const configPath = join(tmpDir, "config.json");
writeFileSync(configPath, JSON.stringify({
policies: { claudeCompaction: { enabled: true, thresholdPercent: 80 } },
}));
const settings = new SettingsStore(configPath);
process.env["OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT"] = "80abc";
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
try {
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport);
const prep = await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 99,
});
// Env was rejected; file (threshold=80) wins; 99 >= 80 → prep trigger
expect(prep).toEqual({ triggered: true });
expect(send).toHaveBeenCalledTimes(1);
expect(send).toHaveBeenCalledWith(
"claude-seat@rig",
expect.stringContaining("OpenRig automatic compaction preparation"),
{ openrigNotice: true, preserveInput: true, beforeWrite: expect.any(Function) },
);
const compact = await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 99,
});
expect(compact).toEqual({ triggered: true });
expect(send).toHaveBeenCalledTimes(2);
expect(send).toHaveBeenLastCalledWith(
"claude-seat@rig",
expect.stringContaining("Keep in the summary: OpenRig's later restore request is operator-authorized"),
expect.objectContaining({ beforeWrite: expect.any(Function) }),
);
} finally {
stderrSpy.mockRestore();
delete process.env["OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT"];
rmSync(tmpDir, { recursive: true, force: true });
}
});
});
// Slice 27 BLOCKING-FIX — defense-in-depth at the enforcer.
//
// The CLI + daemon set() paths reject invalid threshold input, but a
// hand-edited ~/.openrig/config.json could still inject 0, 101, NaN,
// or a non-integer (the file read path passes the value through
// without re-validation by design). The enforcer is the last
// safety net before send: it MUST treat an out-of-contract
// thresholdPercent as disabled (returns `invalid_policy`, no send).
describe("BLOCKING-FIX: defense-in-depth on hand-edited bad policy values", () => {
const cases: Array<{ name: string; thresholdPercent: number }> = [
{ name: "0 (would trigger on every tick)", thresholdPercent: 0 },
{ name: "101 (above range)", thresholdPercent: 101 },
{ name: "-1 (below range)", thresholdPercent: -1 },
{ name: "80.5 (non-integer)", thresholdPercent: 80.5 },
{ name: "NaN", thresholdPercent: Number.NaN },
{ name: "Infinity", thresholdPercent: Number.POSITIVE_INFINITY },
];
for (const c of cases) {
it(`treats threshold=${c.name} as invalid_policy; never sends`, async () => {
const settings = makeSettingsStore({
enabled: true,
thresholdPercent: c.thresholdPercent,
preCompactInstruction: "",
compactInstruction: "",
messageInline: "",
messageFilePath: "",
postRestoreAuditInstruction: "",
});
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport);
const outcome = await enforcer.maybeAutoCompact({
sessionName: "claude-seat@rig",
runtime: "claude-code",
usedPercentage: 99, // would trigger if policy were valid
});
expect(outcome).toEqual({ triggered: false, reason: "invalid_policy" });
expect(send).not.toHaveBeenCalled();
});
}
});
// OPR.0.4.3.14 — manual configurable compaction trigger.
//
// Covers: guided-sequence-for-one-seat (same lifecycle as auto), threshold-
// independence + determinism (incl. auto DISABLED), two-phase wait-for-idle
// ordering (/compact never before prep completes), single-restore-path reuse
// (the existing poll loop drains restore→audit), state surfacing, non-Claude
// rejection, boundedness, and auto-path preservation after the enabled-gate
// reorder.
describe("triggerManualCompact (manual trigger)", () => {
const SEAT = "claude-seat@rig";
const HOME = TEST_HOME;
it("guided sequence for one seat: below-threshold trigger sends prep → /compact (trust-bridge), then the EXISTING poll drains restore→audit (no second path)", async () => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport, { openrigHome: HOME });
const outcome = await enforcer.triggerManualCompact({
sessionName: SEAT,
runtime: "claude-code",
usedPercentage: 20, // BELOW threshold 80 — threshold-independent
transcriptPath: "/tmp/claude.jsonl",
});
expect(outcome).toEqual({ triggered: true, stage: "compact-sent" });
// Phase 1 — prep (a normal send, no wait option).
expect(send.mock.calls[0]![0]).toBe(SEAT);
expect(send.mock.calls[0]![1]).toContain("OpenRig manual compaction was requested");
expect(send.mock.calls[0]![2]).toEqual({ openrigNotice: true, preserveInput: true, beforeWrite: expect.any(Function) });
// Phase 2 — /compact WITH the trust-bridge AND wait-for-idle (two-phase).
expect(send.mock.calls[1]![1]).toContain("Keep in the summary: OpenRig's later restore request is operator-authorized");
expect(send.mock.calls[0]![1]).toContain("Trust-channel note: after compaction");
expect(send.mock.calls[1]![2]).toEqual({ openrigNotice: true, preserveInput: true, waitForIdleMs: expect.any(Number), readinessFromPaneOnly: true, beforeWrite: expect.any(Function) });
expect(send).toHaveBeenCalledTimes(2);
// The SAME maybeAutoCompact back-half (single restore path) drains it.
expect(await enforcer.maybeAutoCompact({
sessionName: SEAT, runtime: "claude-code", usedPercentage: 20, transcriptPath: "/tmp/claude.jsonl",
})).toEqual({ triggered: true });
expect(send).toHaveBeenLastCalledWith(SEAT, expect.stringContaining("OpenRig post-compaction turn boundary"), { openrigNotice: true, preserveInput: true, waitForIdleMs: expect.any(Number), postCompactTurnBoundary: true, beforeWrite: expect.any(Function) });
expect(await enforcer.maybeAutoCompact({
sessionName: SEAT, runtime: "claude-code", usedPercentage: 20, transcriptPath: "/tmp/claude.jsonl",
})).toEqual({ triggered: true });
expect(send).toHaveBeenLastCalledWith(SEAT, expect.stringContaining("Please respond to this normal user message now"), { openrigNotice: true, preserveInput: true, waitForIdleMs: expect.any(Number), beforeWrite: expect.any(Function) });
expect(await enforcer.maybeAutoCompact({
sessionName: SEAT, runtime: "claude-code", usedPercentage: 20,
})).toEqual({ triggered: true });
expect(send).toHaveBeenLastCalledWith(SEAT, expect.stringContaining("Now audit your compaction restore"), { openrigNotice: true, preserveInput: true, waitForIdleMs: expect.any(Number), beforeWrite: expect.any(Function) });
expect(send).toHaveBeenCalledTimes(5);
expect(enforcer.getManualCompactionState(SEAT)?.stage).toBe("audit-sent");
});
// OPR.0.4.3.14 rev1-r2 fixback — same-seat in-progress concurrency guard.
describe("in-progress guard (rev1-r2): no double-send on concurrent/duplicate triggers", () => {
it("concurrent same-seat (first prep held open): the 2nd call does NOT send and returns already_in_progress", async () => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport, send } = makeSessionTransport();
// Hold the FIRST send (prep) open so the first trigger is suspended mid-sequence (stage=preparing).
let releasePrep!: () => void;
send.mockImplementationOnce(() => new Promise((resolve) => { releasePrep = () => resolve({ ok: true }); }));
// This control exercises the in-progress guard, not elapsed preparation time: holding the prep open (and a busy CI)
// must not consume its 50ms budget.
const enforcer = new ClaudeCompactionEnforcer(settings, transport, { openrigHome: HOME, now: () => 1_700_000_000_000 });
// Start (do NOT await) — runs synchronously up to the held prep send: stage=preparing, send #1 fired.
const first = enforcer.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20 });
expect(send).toHaveBeenCalledTimes(1);
// Second concurrent call while the first is still in preparing → guarded skip, NO send.
const second = await enforcer.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20 });
expect(second).toEqual({ triggered: false, stage: "skipped-or-failed", reason: "already_in_progress" });
expect(send).toHaveBeenCalledTimes(1); // still only the first prep — the 2nd never sent prep or /compact
releasePrep();
await first; // let the first finish its /compact (send #2)
expect(send).toHaveBeenCalledTimes(2);
});
it("rev1-r2 fixback B1: a DEGRADED duplicate (usedPercentage:null) does NOT clobber the first call's preparing marker", async () => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport, send } = makeSessionTransport();
let releasePrep!: () => void;
send.mockImplementationOnce(() => new Promise((resolve) => { releasePrep = () => resolve({ ok: true }); }));
// Holding the first prep open across the later calls (and a busy CI) must not consume its 50ms budget.
const enforcer = new ClaudeCompactionEnforcer(settings, transport, { openrigHome: HOME, now: () => 1_700_000_000_000 });
// First call suspended mid-prep → stage=preparing, send #1 fired.
const first = enforcer.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20 });
expect(send).toHaveBeenCalledTimes(1);
expect(enforcer.getManualCompactionState(SEAT)?.stage).toBe("preparing");
// DEGRADED duplicate: usedPercentage null (bad-sidecar projection). The guard
// now PRECEDES the no_usage_data recordManualFailure path, so it must NOT record
// skipped-or-failed / erase the preparing marker (the bug rev1-r2 caught).
const degraded = await enforcer.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: null });
expect(degraded).toEqual({ triggered: false, stage: "skipped-or-failed", reason: "already_in_progress" });
expect(send).toHaveBeenCalledTimes(1); // no send
expect(enforcer.getManualCompactionState(SEAT)?.stage).toBe("preparing"); // marker PRESERVED, not clobbered
// A third call with KNOWN usage while the first is still held → still guarded, no send.
const third = await enforcer.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 30 });
expect(third).toEqual({ triggered: false, stage: "skipped-or-failed", reason: "already_in_progress" });
expect(send).toHaveBeenCalledTimes(1);
releasePrep();
await first;
});
it("sequential call after compact-sent but before the back-half drains: no second prep, no second /compact", async () => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport, { openrigHome: HOME });
expect(await enforcer.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20 }))
.toEqual({ triggered: true, stage: "compact-sent" });
expect(send).toHaveBeenCalledTimes(2); // prep + /compact
// back-half (pendingPostCompactRestore=turn_boundary) NOT yet drained → guard holds.
const dup = await enforcer.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20 });
expect(dup).toEqual({ triggered: false, stage: "skipped-or-failed", reason: "already_in_progress" });
expect(send).toHaveBeenCalledTimes(2); // unchanged
});
it("after the sequence completes (audit-sent) OR fails (skipped-or-failed), a fresh re-trigger is allowed", async () => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport, send } = makeSessionTransport();
// This checks sequence/re-trigger behavior, not elapsed preparation time; CI work must not consume its 50ms budget.
const enforcer = new ClaudeCompactionEnforcer(settings, transport, { openrigHome: HOME, now: () => 1_700_000_000_000 });
// Drive one full sequence to audit-sent (the back-half clears the pending maps).
await enforcer.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20, transcriptPath: "/tmp/c.jsonl" });
await enforcer.maybeAutoCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20, transcriptPath: "/tmp/c.jsonl" });
await enforcer.maybeAutoCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20, transcriptPath: "/tmp/c.jsonl" });
await enforcer.maybeAutoCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20 });
expect(enforcer.getManualCompactionState(SEAT)?.stage).toBe("audit-sent");
const before = send.mock.calls.length;
// Re-trigger AFTER audit-sent → allowed (marker cleared): a new prep + /compact.
expect(await enforcer.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20 }))
.toEqual({ triggered: true, stage: "compact-sent" });
expect(send.mock.calls.length).toBe(before + 2);
// And after a FAILED trigger (prep send fails → skipped-or-failed), a retry is allowed too.
const { transport: t2, send: send2 } = makeSessionTransport();
send2.mockImplementationOnce(async () => ({ ok: false, reason: "send_failed", inputPhase: "not_reached" }));
const enf2 = new ClaudeCompactionEnforcer(settings, t2, { openrigHome: HOME, now: () => 1_700_000_000_000 });
expect((await enf2.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20 })).triggered).toBe(false);
expect(enf2.getManualCompactionState(SEAT)?.stage).toBe("skipped-or-failed");
// Retry proceeds (not blocked by the guard).
expect(await enf2.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20 }))
.toEqual({ triggered: true, stage: "compact-sent" });
});
});
it("threshold-independent + deterministic: runs the guided sequence even when auto-compaction is DISABLED, and the back-half still drains via the single path", async () => {
const settings = makeSettingsStore(POLICY_DISABLED);
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport, { openrigHome: HOME });
const outcome = await enforcer.triggerManualCompact({
sessionName: SEAT, runtime: "claude-code", usedPercentage: 20, transcriptPath: "/tmp/claude.jsonl",
}, { operatorInitiated: true }); // GHOST-STAGE (a): OPERATOR-initiated → drain-exempt while disabled
expect(outcome).toEqual({ triggered: true, stage: "compact-sent" });
expect(send.mock.calls[0]![1]).toContain("OpenRig manual compaction was requested");
expect(send.mock.calls[1]![1]).toContain("/compact");
expect(send).toHaveBeenCalledTimes(2);
// The below-threshold back-half must drain even though enabled=false (operator-initiated exemption).
expect(await enforcer.maybeAutoCompact({
sessionName: SEAT, runtime: "claude-code", usedPercentage: 20, transcriptPath: "/tmp/claude.jsonl",
})).toEqual({ triggered: true });
expect(send).toHaveBeenLastCalledWith(SEAT, expect.stringContaining("OpenRig post-compaction turn boundary"), { openrigNotice: true, preserveInput: true, waitForIdleMs: expect.any(Number), postCompactTurnBoundary: true, beforeWrite: expect.any(Function) });
expect(await enforcer.maybeAutoCompact({
sessionName: SEAT, runtime: "claude-code", usedPercentage: 20, transcriptPath: "/tmp/claude.jsonl",
})).toEqual({ triggered: true });
expect(await enforcer.maybeAutoCompact({
sessionName: SEAT, runtime: "claude-code", usedPercentage: 20,
})).toEqual({ triggered: true });
expect(send).toHaveBeenLastCalledWith(SEAT, expect.stringContaining("Now audit your compaction restore"), { openrigNotice: true, preserveInput: true, waitForIdleMs: expect.any(Number), beforeWrite: expect.any(Function) });
});
it("GHOST-STAGE (a) PM-pin: an AUTOMATION-initiated manual trigger is NOT drain-exempt — a disabled tick drains nothing (no laundering bypass)", async () => {
const settings = makeSettingsStore(POLICY_DISABLED);
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport, { openrigHome: HOME });
// Automation calls the manual verb WITHOUT operator initiation (fail-safe default). The verb
// still runs (prep + /compact), but the resulting stage must NOT be drain-exempt — otherwise
// the manual entrypoint launders exactly the drains fix (a) kills.
const outcome = await enforcer.triggerManualCompact({
sessionName: SEAT, runtime: "claude-code", usedPercentage: 20, transcriptPath: "/tmp/claude.jsonl",
}); // NO { operatorInitiated: true } — automation actor
expect(outcome).toEqual({ triggered: true, stage: "compact-sent" });
const afterTriggerSends = send.mock.calls.length; // prep + /compact only
// A disabled below-threshold tick MUST refuse to drain the automation-seeded stage.
const drain = await enforcer.maybeAutoCompact({
sessionName: SEAT, runtime: "claude-code", usedPercentage: 20, transcriptPath: "/tmp/claude.jsonl",
});
expect(drain).toEqual({ triggered: false, reason: "disabled" });
expect(send.mock.calls.length).toBe(afterTriggerSends); // no ghost drain — not exempt
});
it("auto path preserved after the enabled-gate reorder: a disabled policy still does NOT auto-trigger above threshold", async () => {
const settings = makeSettingsStore(POLICY_DISABLED);
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport);
expect(await enforcer.maybeAutoCompact({
sessionName: SEAT, runtime: "claude-code", usedPercentage: 99,
})).toEqual({ triggered: false, reason: "disabled" });
expect(send).not.toHaveBeenCalled();
});
it("two-phase ordering: the long idle wait precedes the short guarded compact send", async () => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport, send } = makeSessionTransport();
const callOrder: string[] = [];
send.mockImplementation(async (_session: string, text: string, opts?: unknown) => {
callOrder.push(text.startsWith("/compact") ? `compact:${JSON.stringify(opts)}` : "prep");
return { ok: true };
});
const enforcer = new ClaudeCompactionEnforcer(settings, transport, { manualPrepWaitMs: 90_000 });
transport.waitUntilIdle = async (_session, timeoutMs) => {
expect(timeoutMs).toBeGreaterThan(89_000);
expect(timeoutMs).toBeLessThanOrEqual(90_000);
callOrder.push("idle");
return { ok: true, activity: { state: "idle" } as any, attempts: 1, waitedMs: 0 };
};
await enforcer.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20 });
expect(callOrder).toEqual(["prep", "idle", `compact:${JSON.stringify({ openrigNotice: true, preserveInput: true, waitForIdleMs: 1_000 + CAPTURE_READABLE_BUDGET_MS, readinessFromPaneOnly: true })}`]);
});
it.each([0, 75])("wait-for-idle failure means /compact never landed: the back-half is NOT seeded (preparation delay=%ims)", async (delayMs) => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport, send } = makeSessionTransport();
send.mockImplementationOnce(async () => { await new Promise(resolve => setTimeout(resolve, delayMs)); return { ok: true }; }); // prep lands
send.mockImplementationOnce(async () => ({ ok: false, sent: false, reason: "wait_for_idle_timeout" })); // /compact never idle (the real transport reports sent: false)
// This control exercises transport ordering, not elapsed preparation time.
// Native fixture file I/O and busy CI scheduling must not consume its 50ms budget.
const enforcer = new ClaudeCompactionEnforcer(settings, transport, { now: () => 1_700_000_000_000 });
const outcome = await enforcer.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20 });
expect(outcome).toEqual({ triggered: false, stage: "skipped-or-failed", reason: "wait_for_idle_timeout",
preparation: { attemptId: expect.any(String), mapPath: expect.stringContaining("/RESTORE-MAP.md"), delivery: "delivered", compact: "not_sent" } });
expect(enforcer.getManualCompactionState(SEAT)?.stage).toBe("skipped-or-failed");
// No turn_boundary was seeded, so a below-threshold poll finds nothing to drain.
expect(await enforcer.maybeAutoCompact({
sessionName: SEAT, runtime: "claude-code", usedPercentage: 20,
})).toEqual({ triggered: false, reason: "below_threshold" });
expect(send).toHaveBeenCalledTimes(2);
});
it("prep send failure surfaces the transport reason and does not send /compact", async () => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport, send } = makeSessionTransport();
send.mockImplementationOnce(async () => ({ ok: false, reason: "mid_work" }));
const enforcer = new ClaudeCompactionEnforcer(settings, transport);
const outcome = await enforcer.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20 });
expect(outcome).toEqual({ triggered: false, stage: "skipped-or-failed", reason: "mid_work",
preparation: { attemptId: expect.any(String), mapPath: expect.stringContaining("/RESTORE-MAP.md"), delivery: "not_sent", compact: "not_sent" } });
expect(send).toHaveBeenCalledTimes(1);
});
it("non-Claude runtime is rejected with a clear reason (never a silent no-op)", async () => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport);
const outcome = await enforcer.triggerManualCompact({ sessionName: "codex@rig", runtime: "codex", usedPercentage: 20 });
expect(outcome).toEqual({ triggered: false, stage: "skipped-or-failed", reason: "runtime_filter" });
expect(send).not.toHaveBeenCalled();
expect(enforcer.getManualCompactionState("codex@rig")?.stage).toBe("skipped-or-failed");
});
it("no known usage sample → rejected with no_usage_data (never triggers blind)", async () => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport);
const outcome = await enforcer.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: null });
expect(outcome).toEqual({ triggered: false, stage: "skipped-or-failed", reason: "no_usage_data" });
expect(send).not.toHaveBeenCalled();
});
it("bounded to the triggered seat: only that seat is sent to; no other seat gets manual state", async () => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport);
await enforcer.triggerManualCompact({ sessionName: "a@rig", runtime: "claude-code", usedPercentage: 20 });
expect(send.mock.calls.every((call) => call[0] === "a@rig")).toBe(true);
expect(enforcer.getManualCompactionState("b@rig")).toBeNull();
});
it("after a manual trigger, a fresh high tick advances the owed stage without a second preparation", async () => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport, send } = makeSessionTransport();
const enforcer = new ClaudeCompactionEnforcer(settings, transport, {
dedupWindowMs: 60_000,
openrigHome: HOME,
});
let now = 1_700_000_000_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
// Manual trigger below threshold → prep + /compact (2 sends), seeds the
// back-half AND the durable above-threshold suppression.
expect(await enforcer.triggerManualCompact({
sessionName: SEAT, runtime: "claude-code", usedPercentage: 20, transcriptPath: "/tmp/claude.jsonl",
})).toEqual({ triggered: true, stage: "compact-sent" });
expect(send).toHaveBeenCalledTimes(2);
// Advancing a pending stage is not a new trigger, even past the short dedup window.
now += 61_000;
expect(await enforcer.maybeAutoCompact({
sessionName: SEAT, runtime: "claude-code", usedPercentage: 95,
})).toEqual({ triggered: true }); // owed turn boundary, not another prep
expect(send).toHaveBeenCalledTimes(3);
expect(send).toHaveBeenLastCalledWith(SEAT, expect.stringContaining("OpenRig post-compaction turn boundary"), { openrigNotice: true, preserveInput: true, waitForIdleMs: expect.any(Number), readinessFromPaneOnly: true, postCompactTurnBoundary: true, beforeWrite: expect.any(Function) });
expect(await enforcer.maybeAutoCompact({
sessionName: SEAT, runtime: "claude-code", usedPercentage: 20, transcriptPath: "/tmp/claude.jsonl",
})).toEqual({ triggered: true }); // restore
expect(await enforcer.maybeAutoCompact({
sessionName: SEAT, runtime: "claude-code", usedPercentage: 20,
})).toEqual({ triggered: true }); // audit
expect(send).toHaveBeenLastCalledWith(SEAT, expect.stringContaining("Now audit your compaction restore"), { openrigNotice: true, preserveInput: true, waitForIdleMs: expect.any(Number), beforeWrite: expect.any(Function) });
expect(enforcer.getManualCompactionState(SEAT)?.stage).toBe("audit-sent");
expect(send).toHaveBeenCalledTimes(5);
});
it("state surfacing: preparing → compact-sent → restore-sent → audit-sent across the sequence", async () => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
const { transport, send } = makeSessionTransport();
// Holding phase 1 open (and a busy CI) must not consume the 50ms preparation budget: this checks the stages, not time.
const enforcer = new ClaudeCompactionEnforcer(settings, transport, { openrigHome: HOME, now: () => 1_700_000_000_000 });
// Hold phase 1 open so we can observe "preparing".
let resolvePrep: (v: { ok: boolean }) => void = () => {};
send.mockImplementationOnce(() => new Promise<{ ok: boolean }>((r) => { resolvePrep = r; }));
const pending = enforcer.triggerManualCompact({
sessionName: SEAT, runtime: "claude-code", usedPercentage: 20, transcriptPath: "/tmp/claude.jsonl",
});
await Promise.resolve();
expect(enforcer.getManualCompactionState(SEAT)?.stage).toBe("preparing");
resolvePrep({ ok: true }); // prep completes; phase 2 /compact resolves ok via default mock
expect(await pending).toEqual({ triggered: true, stage: "compact-sent" });
expect(enforcer.getManualCompactionState(SEAT)?.stage).toBe("compact-sent");
await enforcer.maybeAutoCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20, transcriptPath: "/tmp/claude.jsonl" }); // turn boundary
expect(enforcer.getManualCompactionState(SEAT)?.stage).toBe("compact-sent");
await enforcer.maybeAutoCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20, transcriptPath: "/tmp/claude.jsonl" }); // restore
expect(enforcer.getManualCompactionState(SEAT)?.stage).toBe("restore-sent");
await enforcer.maybeAutoCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20 }); // audit
expect(enforcer.getManualCompactionState(SEAT)?.stage).toBe("audit-sent");
});
// Regression for the slices 13–14 delivery-ordering defect: the daemon surfaced
// restore-sent/audit-sent although the restore prompt was NEVER delivered — it was
// injected into a busy pane right after /compact and dropped, yet the send reported
// ok (busy is a non-blocking advisory on the default path). Fix: each post-compact
// back-half send is idle-gated (waitForIdleMs); a busy tick does NOT advance the
// stage, a later idle tick advances it once, and audit can never overtake restore.
it("post-compact restore/audit are idle-gated: a busy tick retries the SAME stage without advancing; restore delivers before audit", async () => {
const settings = makeSettingsStore(POLICY_ENABLED_AT_80);
let busy = false;
const send = vi.fn(async (_s: string, _t: string, opts?: { waitForIdleMs?: number }) => {
// An idle-gated send does NOT deliver while the seat is busy (waitForIdle times out).
if (opts?.waitForIdleMs !== undefined && busy) return { ok: false, reason: "wait_for_idle_timeout" };
return { ok: true };
});
const transport = { send } as unknown as SessionTransport;
const enforcer = new ClaudeCompactionEnforcer(settings, transport, { openrigHome: HOME });
const isRestore = (c: unknown[]) =>
typeof c[1] === "string" && (c[1] as string).includes("Please respond to this normal user message now");
const isAudit = (c: unknown[]) =>
typeof c[1] === "string" && (c[1] as string).includes("Now audit your compaction restore");
// Seed the guided sequence (prep + /compact) → stage compact-sent, pending turn_boundary.
expect(await enforcer.triggerManualCompact({
sessionName: SEAT, runtime: "claude-code", usedPercentage: 20, transcriptPath: "/tmp/claude.jsonl",
})).toEqual({ triggered: true, stage: "compact-sent" });
// turn_boundary tick while idle → delivers; pending advances to restore_prompt (stage stays compact-sent).
await enforcer.maybeAutoCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20, transcriptPath: "/tmp/claude.jsonl" });
expect(enforcer.getManualCompactionState(SEAT)?.stage).toBe("compact-sent");
// Claude is BUSY (still processing the boundary turn). The restore tick MUST NOT advance.
busy = true;
await enforcer.maybeAutoCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20, transcriptPath: "/tmp/claude.jsonl" });
expect(enforcer.getManualCompactionState(SEAT)?.stage).toBe("compact-sent"); // busy timeout does NOT advance to restore-sent
const restoreCall = send.mock.calls.find(isRestore);
expect(restoreCall, "restore send must be attempted with an idle gate").toBeDefined();
expect(restoreCall![2]).toEqual({ openrigNotice: true, preserveInput: true, waitForIdleMs: expect.any(Number), beforeWrite: expect.any(Function) }); // idle-gated, not a busy-pane fire-and-forget
expect(send.mock.calls.some(isAudit)).toBe(false); // audit cannot overtake restore
// Claude goes idle → the SAME restore stage delivers and advances ONCE.
busy = false;
await enforcer.maybeAutoCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20, transcriptPath: "/tmp/claude.jsonl" });
expect(enforcer.getManualCompactionState(SEAT)?.stage).toBe("restore-sent");
// audit only AFTER restore delivered.
await enforcer.maybeAutoCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20 });
expect(enforcer.getManualCompactionState(SEAT)?.stage).toBe("audit-sent");
const restoreIdx = send.mock.calls.findIndex(isRestore);
const auditIdx = send.mock.calls.findIndex(isAudit);
expect(restoreIdx).toBeGreaterThanOrEqual(0);
expect(auditIdx).toBeGreaterThan(restoreIdx); // one durable restore enqueue/delivery precedes audit advancement
});
});
});