mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
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.
1520 lines
80 KiB
TypeScript
1520 lines
80 KiB
TypeScript
// 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
|
||
});
|
||
});
|
||
});
|