mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 17:32:41 -10:00
feat(daemon,ui): Claude auto-compaction policy (slice 27)
Operator-configurable pre-compaction trigger. When a Claude seat's
context usage crosses a configured threshold, the daemon dispatches
`/compact` via SessionTransport and the existing PreCompact hook
appends an operator-provided message to the post-compaction system
message alongside the standard restore-instructions.
Architecture:
- 4 new ConfigStore + SettingsStore keys
(policies.claude_compaction.{enabled,threshold_percent,
message_inline,message_file_path}) in lockstep across the CLI +
daemon allowlists.
- ClaudeCompactionEnforcer encapsulates policy + dedup + send so the
trigger path is unit-testable independently from ContextMonitor's
scheduler concern.
- ContextMonitor's polling loop calls the enforcer after each
observation persists; enforcer respects runtime filter (claude-code
only), threshold gating, opt-in default-off, and a per-seat 60s
dedup window. Send-failure does NOT advance the dedup timestamp,
so a transient failure can retry on the next tick.
- precompact-hook.mjs reads $OPENRIG_HOME/config.json directly (no
daemon-HTTP dependency). Inline message wins when both inline + file
path are set; missing file degrades gracefully (no append, no fail).
- /settings/policies route + ClaudeCompactionPolicyForm shipped as a
temporary mount until slice 26's Settings explorer integrates the
form into the canonical Policies destination.
Hard-gate coverage:
- HG-1 settings round-trip via SettingsStore set/resolveOne
- HG-2 threshold-check fires at + above threshold
- HG-3 SessionTransport.send dispatched with canonical session name
- HG-4 dedup window blocks repeat triggers; resumes after interval
- HG-5 opt-in default-off regression (compaction lifecycle blast-radius
guard per banked permission-layer foot-gun rule)
- HG-6 inline message appended to systemMessage
- HG-7 file-path message read + appended when inline empty
- HG-8 neither set: standard restore-instructions preserved
- HG-9 form renders + validates + persists each key
- HG-10 resolveClaudeCompactionPolicy rereads config.json without
daemon restart (single resolve call picks up live edits)
- HG-11 / HG-12 land at velocity-qa VM dogfood + final SC-29 verify
Test counts: enforcer 8/8, settings-store +5/+5, hook 6/6, UI form 4/4
(23 new tests; full daemon + cli + ui suites green minus 2 pre-existing
UI flakes + 1 bootstrap-orchestrator ordering flake — verified on main).
SC-29 EXCEPTION #10 declared: 4 new ConfigStore + SettingsStore keys in
the policies.claude_compaction.* namespace. Lockstep CLI + daemon
allowlists; no schema migrations / new endpoints / new event types
beyond reusing the existing /api/config POST surface.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
f27da1a48a
commit
fe50ce7759
17 files changed
+1218
-8
No files matched your search
@@ -43,6 +43,8 @@ Keys:
|
||||
agents.* advisor_session, operator_session
|
||||
feed.subscriptions.* action_required, approvals, shipped, progress, audit_log
|
||||
runtime.codex.* hooks_enabled
|
||||
policies.claude_compaction.*
|
||||
enabled, threshold_percent, message_inline, message_file_path
|
||||
|
||||
Precedence: CLI flag > environment variable > config file > default`)
|
||||
.action((opts: { json?: boolean; withSource?: boolean }) => {
|
||||
|
||||
@@ -91,6 +91,25 @@ export interface RiggedConfig {
|
||||
hooksEnabled: boolean;
|
||||
};
|
||||
};
|
||||
// Slice 27 — Claude auto-compaction policy. Operator-configurable
|
||||
// pre-compaction trigger: when a Claude seat's context usage crosses
|
||||
// `thresholdPercent`, daemon sends /compact via SessionTransport and
|
||||
// the existing PreCompact hook injects `messageInline` (or contents of
|
||||
// `messageFilePath` if inline is empty) alongside the standard
|
||||
// restore-instructions in the post-compact systemMessage.
|
||||
//
|
||||
// Defaults: opt-in default-off (enabled=false). Empty string on
|
||||
// message_inline / message_file_path means "not set" (consistent with
|
||||
// agents.operator_session pattern; ConfigStore schema avoids nullable
|
||||
// strings at the file layer).
|
||||
policies: {
|
||||
claudeCompaction: {
|
||||
enabled: boolean;
|
||||
thresholdPercent: number;
|
||||
messageInline: string;
|
||||
messageFilePath: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_WORKSPACE_ROOT = getDefaultOpenRigPath("workspace");
|
||||
@@ -151,6 +170,15 @@ const DEFAULTS = {
|
||||
hooksEnabled: true,
|
||||
},
|
||||
},
|
||||
// Slice 27 — opt-in default-off. Threshold default 80% per spec.
|
||||
policies: {
|
||||
claudeCompaction: {
|
||||
enabled: false,
|
||||
thresholdPercent: 80,
|
||||
messageInline: "",
|
||||
messageFilePath: "",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const VALID_KEYS = [
|
||||
@@ -192,6 +220,12 @@ export const VALID_KEYS = [
|
||||
"feed.subscriptions.audit_log",
|
||||
// plugin-primitive Phase 3a slice 3.5 — Codex feature flag.
|
||||
"runtime.codex.hooks_enabled",
|
||||
// Slice 27 — Claude auto-compaction policy. SC-29 EXCEPTION #10:
|
||||
// 4 new ConfigStore keys (lockstep with daemon SETTINGS_VALID_KEYS).
|
||||
"policies.claude_compaction.enabled",
|
||||
"policies.claude_compaction.threshold_percent",
|
||||
"policies.claude_compaction.message_inline",
|
||||
"policies.claude_compaction.message_file_path",
|
||||
] as const;
|
||||
|
||||
export type ValidKey = typeof VALID_KEYS[number];
|
||||
@@ -233,6 +267,12 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
|
||||
// Net-new key post-rename: OPENRIG_X primary only per the 5-key
|
||||
// boundary doctrine (no RIGGED_X legacy on net-new keys).
|
||||
"runtime.codex.hooks_enabled": { primary: "OPENRIG_RUNTIME_CODEX_HOOKS_ENABLED" },
|
||||
// Slice 27 — Claude auto-compaction policy. OPENRIG_X primary only
|
||||
// (net-new keys, no legacy).
|
||||
"policies.claude_compaction.enabled": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_ENABLED" },
|
||||
"policies.claude_compaction.threshold_percent": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT" },
|
||||
"policies.claude_compaction.message_inline": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_INLINE" },
|
||||
"policies.claude_compaction.message_file_path": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_FILE_PATH" },
|
||||
};
|
||||
|
||||
// Maps dotted-string config keys to the camelCase RiggedConfig path.
|
||||
@@ -269,6 +309,10 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
|
||||
"feed.subscriptions.progress": ["feed", "subscriptions", "progress"],
|
||||
"feed.subscriptions.audit_log": ["feed", "subscriptions", "auditLog"],
|
||||
"runtime.codex.hooks_enabled": ["runtime", "codex", "hooksEnabled"],
|
||||
"policies.claude_compaction.enabled": ["policies", "claudeCompaction", "enabled"],
|
||||
"policies.claude_compaction.threshold_percent": ["policies", "claudeCompaction", "thresholdPercent"],
|
||||
"policies.claude_compaction.message_inline": ["policies", "claudeCompaction", "messageInline"],
|
||||
"policies.claude_compaction.message_file_path": ["policies", "claudeCompaction", "messageFilePath"],
|
||||
};
|
||||
|
||||
function isValidKey(key: string): key is ValidKey {
|
||||
@@ -444,6 +488,14 @@ export class ConfigStore {
|
||||
hooksEnabled: v("runtime.codex.hooks_enabled") as boolean,
|
||||
},
|
||||
},
|
||||
policies: {
|
||||
claudeCompaction: {
|
||||
enabled: v("policies.claude_compaction.enabled") as boolean,
|
||||
thresholdPercent: v("policies.claude_compaction.threshold_percent") as number,
|
||||
messageInline: v("policies.claude_compaction.message_inline") as string,
|
||||
messageFilePath: v("policies.claude_compaction.message_file_path") as string,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,11 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
|
||||
"feed.subscriptions.audit_log",
|
||||
// plugin-primitive Phase 3a slice 3.5 — Codex feature flag.
|
||||
"runtime.codex.hooks_enabled",
|
||||
// Slice 27 — Claude auto-compaction policy. SC-29 EXCEPTION #10.
|
||||
"policies.claude_compaction.enabled",
|
||||
"policies.claude_compaction.threshold_percent",
|
||||
"policies.claude_compaction.message_inline",
|
||||
"policies.claude_compaction.message_file_path",
|
||||
];
|
||||
expect([...VALID_KEYS]).toEqual(expected);
|
||||
});
|
||||
|
||||
+52
-2
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
@@ -17,6 +18,51 @@ function readHookInput() {
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
// Slice 27 — read the OpenRig config directly (no daemon HTTP dependency
|
||||
// so the hook still works when the daemon isn't running or isn't
|
||||
// reachable from this process). Returns "" for either field when the
|
||||
// config is missing, malformed, or the policy isn't set.
|
||||
function readClaudeCompactionMessage() {
|
||||
const configPath = path.join(
|
||||
process.env.OPENRIG_HOME || process.env.RIGGED_HOME || path.join(os.homedir(), ".openrig"),
|
||||
"config.json",
|
||||
);
|
||||
let inline = "";
|
||||
let filePath = "";
|
||||
try {
|
||||
if (!fs.existsSync(configPath)) return "";
|
||||
const raw = fs.readFileSync(configPath, "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
const policy = parsed?.policies?.claudeCompaction;
|
||||
if (policy && typeof policy === "object") {
|
||||
if (typeof policy.messageInline === "string") inline = policy.messageInline;
|
||||
if (typeof policy.messageFilePath === "string") filePath = policy.messageFilePath;
|
||||
}
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (inline && inline.length > 0) return inline;
|
||||
if (filePath && filePath.length > 0) {
|
||||
try {
|
||||
const expanded = filePath.startsWith("~/")
|
||||
? path.join(os.homedir(), filePath.slice(2))
|
||||
: filePath;
|
||||
if (fs.existsSync(expanded)) {
|
||||
return fs.readFileSync(expanded, "utf8");
|
||||
}
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function buildSystemMessage(restoreInstruction, customMessage) {
|
||||
if (!customMessage) return restoreInstruction;
|
||||
return `${restoreInstruction}\n\n--- Operator-configured pre-compaction message ---\n${customMessage}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const input = readHookInput();
|
||||
const args = [restoreScript, "--out", outRoot, "--json"];
|
||||
@@ -25,19 +71,23 @@ try {
|
||||
args.push(input.transcript_path);
|
||||
}
|
||||
|
||||
const customMessage = readClaudeCompactionMessage();
|
||||
|
||||
const result = spawnSync("node", args, { encoding: "utf8" });
|
||||
if (result.status !== 0) {
|
||||
const baseFailure = `Claude compaction restore packet generation failed: ${(result.stderr || result.stdout || "unknown error").trim()}. After compaction, load the claude-compaction-restore skill and run restore-from-jsonl manually.`;
|
||||
emit({
|
||||
continue: true,
|
||||
systemMessage: `Claude compaction restore packet generation failed: ${(result.stderr || result.stdout || "unknown error").trim()}. After compaction, load the claude-compaction-restore skill and run restore-from-jsonl manually.`,
|
||||
systemMessage: buildSystemMessage(baseFailure, customMessage),
|
||||
});
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
const baseRestore = `Pre-compaction restore seed packet prepared at ${parsed.outputDir}. After compaction, immediately restore before doing any other work: load/read the claude-compaction-restore skill, run "node ~/.claude/skills/claude-compaction-restore/scripts/restore-from-jsonl.mjs --out /tmp/claude-compaction-restore --json" yourself, read the generated restore-instructions.md, read the generated touched-files.md, identify remembered important files, read those files in full, read root/as-built/codemap docs before real work, then report exactly "restored from packet at <path>; resumed at step <X>" with the files you read in full. If any step fails, report the failure explicitly.`;
|
||||
emit({
|
||||
continue: true,
|
||||
systemMessage: `Pre-compaction restore seed packet prepared at ${parsed.outputDir}. After compaction, immediately restore before doing any other work: load/read the claude-compaction-restore skill, run "node ~/.claude/skills/claude-compaction-restore/scripts/restore-from-jsonl.mjs --out /tmp/claude-compaction-restore --json" yourself, read the generated restore-instructions.md, read the generated touched-files.md, identify remembered important files, read those files in full, read root/as-built/codemap docs before real work, then report exactly "restored from packet at <path>; resumed at step <X>" with the files you read in full. If any step fails, report the failure explicitly.`,
|
||||
systemMessage: buildSystemMessage(baseRestore, customMessage),
|
||||
});
|
||||
} catch (error) {
|
||||
emit({
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { SessionTransport } from "./session-transport.js";
|
||||
import type { SettingsStore } from "./user-settings/settings-store.js";
|
||||
|
||||
/**
|
||||
* Slice 27 — Claude auto-compaction policy enforcer.
|
||||
*
|
||||
* Decides per-seat whether ContextMonitor should send `/compact` based on
|
||||
* operator-configured policy (`policies.claude_compaction.*` settings).
|
||||
* Decoupled from ContextMonitor's scheduling concern so it can be tested
|
||||
* + composed independently.
|
||||
*
|
||||
* Risk class: compaction lifecycle is load-bearing (banked permission-layer
|
||||
* foot-gun rule extends to any agent-runtime trigger). Defensive contract:
|
||||
*
|
||||
* - Opt-in default-off: `enabled=false` → never triggers. Verified by
|
||||
* regression test HG-5.
|
||||
* - Runtime filter: triggers only when runtime === "claude-code". Codex
|
||||
* compacts cleanly via its own runtime per agent-startup-guide; other
|
||||
* runtimes are out of scope.
|
||||
* - Dedup: in-memory per-session window (default 60s) prevents a
|
||||
* flapping percentage from firing repeated /compact sends. State is
|
||||
* intentionally NOT persisted; daemon restart resets the window which
|
||||
* is the safer-failure direction (might re-compact once on restart in
|
||||
* rare cases, won't lock out forever).
|
||||
* - Send-failure graceful-degrade: returns { triggered: false } with a
|
||||
* reason; does not throw. The dedup timestamp is only set on
|
||||
* successful send, so a transient send failure can retry on the next
|
||||
* polling tick.
|
||||
*/
|
||||
export const DEDUP_WINDOW_MS_DEFAULT = 60_000;
|
||||
|
||||
export interface EnforcerInput {
|
||||
sessionName: string;
|
||||
runtime: string | null;
|
||||
usedPercentage: number | null;
|
||||
}
|
||||
|
||||
export type EnforcerOutcome =
|
||||
| { triggered: true }
|
||||
| { triggered: false; reason: EnforcerSkipReason };
|
||||
|
||||
export type EnforcerSkipReason =
|
||||
| "runtime_filter"
|
||||
| "no_usage_data"
|
||||
| "disabled"
|
||||
| "below_threshold"
|
||||
| "dedup_window"
|
||||
| "send_failed";
|
||||
|
||||
export class ClaudeCompactionEnforcer {
|
||||
private readonly settingsStore: SettingsStore;
|
||||
private readonly sessionTransport: SessionTransport;
|
||||
private readonly dedupWindowMs: number;
|
||||
private readonly lastAutoCompactAt = new Map<string, number>();
|
||||
|
||||
constructor(
|
||||
settingsStore: SettingsStore,
|
||||
sessionTransport: SessionTransport,
|
||||
opts?: { dedupWindowMs?: number },
|
||||
) {
|
||||
this.settingsStore = settingsStore;
|
||||
this.sessionTransport = sessionTransport;
|
||||
this.dedupWindowMs = opts?.dedupWindowMs ?? DEDUP_WINDOW_MS_DEFAULT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect a single observation and trigger /compact when policy says so.
|
||||
* Safe to call on every poll tick; non-eligible inputs return early
|
||||
* with a skip reason and never touch SessionTransport.
|
||||
*/
|
||||
async maybeAutoCompact(input: EnforcerInput): Promise<EnforcerOutcome> {
|
||||
if (input.runtime !== "claude-code") {
|
||||
return { triggered: false, reason: "runtime_filter" };
|
||||
}
|
||||
if (input.usedPercentage == null) {
|
||||
return { triggered: false, reason: "no_usage_data" };
|
||||
}
|
||||
|
||||
const policy = this.settingsStore.resolveClaudeCompactionPolicy();
|
||||
if (!policy.enabled) {
|
||||
return { triggered: false, reason: "disabled" };
|
||||
}
|
||||
if (input.usedPercentage < policy.thresholdPercent) {
|
||||
return { triggered: false, reason: "below_threshold" };
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const last = this.lastAutoCompactAt.get(input.sessionName);
|
||||
if (last !== undefined && now - last < this.dedupWindowMs) {
|
||||
return { triggered: false, reason: "dedup_window" };
|
||||
}
|
||||
|
||||
const result = await this.sessionTransport.send(input.sessionName, "/compact");
|
||||
if (!result.ok) {
|
||||
return { triggered: false, reason: "send_failed" };
|
||||
}
|
||||
this.lastAutoCompactAt.set(input.sessionName, now);
|
||||
return { triggered: true };
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import type Database from "better-sqlite3";
|
||||
import type { ClaudeCompactionEnforcer } from "./claude-compaction-enforcer.js";
|
||||
import type { ContextUsageStore } from "./context-usage-store.js";
|
||||
import {
|
||||
isAttentionRequiredReadinessCode,
|
||||
type NodeBinding,
|
||||
type ReadinessResult,
|
||||
} from "./runtime-adapter.js";
|
||||
import type { ContextUsage } from "./types.js";
|
||||
|
||||
/** Default polling interval: 30 seconds. */
|
||||
export const DEFAULT_POLL_INTERVAL_MS = 30_000;
|
||||
@@ -28,31 +30,41 @@ interface ClaudeContextProvisioner {
|
||||
* Polls known managed runtime context sources and persists the latest
|
||||
* normalized telemetry. Scheduler-only: no queries, no response shaping,
|
||||
* no in-memory truth.
|
||||
*
|
||||
* Slice 27: optional `compactionEnforcer` participates in each polling
|
||||
* tick after persistence so policy-driven /compact triggers fire on the
|
||||
* same observation the operator sees in the UI. Without an enforcer
|
||||
* provided, polling behavior is unchanged.
|
||||
*/
|
||||
export class ContextMonitor {
|
||||
private db: Database.Database;
|
||||
private store: ContextUsageStore;
|
||||
private claudeContextProvisioner: ClaudeContextProvisioner | null;
|
||||
private compactionEnforcer: ClaudeCompactionEnforcer | null;
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(
|
||||
db: Database.Database,
|
||||
store: ContextUsageStore,
|
||||
claudeContextProvisioner?: ClaudeContextProvisioner,
|
||||
compactionEnforcer?: ClaudeCompactionEnforcer,
|
||||
) {
|
||||
this.db = db;
|
||||
this.store = store;
|
||||
this.claudeContextProvisioner = claudeContextProvisioner ?? null;
|
||||
this.compactionEnforcer = compactionEnforcer ?? null;
|
||||
}
|
||||
|
||||
/** Discover active managed Claude sessions and poll their sidecar files. */
|
||||
async pollOnce(): Promise<void> {
|
||||
const sessions = this.getEligibleSessions();
|
||||
for (const session of sessions) {
|
||||
let observed: ContextUsage | null = null;
|
||||
try {
|
||||
const usage = this.readContextUsage(session);
|
||||
this.store.persist(session.node_id, usage);
|
||||
observed = this.readContextUsage(session);
|
||||
this.store.persist(session.node_id, observed);
|
||||
} catch {
|
||||
observed = null;
|
||||
// One bad session must not crash polling for others
|
||||
try {
|
||||
this.store.persist(session.node_id, this.store.unknownUsage("parse_error"));
|
||||
@@ -60,6 +72,31 @@ export class ContextMonitor {
|
||||
}
|
||||
|
||||
await this.normalizeStartupStatus(session);
|
||||
await this.maybeAutoCompact(session, observed);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice 27 — invoke the enforcer with the latest observation. The
|
||||
* enforcer owns policy + dedup + send; ContextMonitor only relays
|
||||
* data and absorbs enforcer errors so a trigger-path fault never
|
||||
* crashes telemetry polling.
|
||||
*/
|
||||
private async maybeAutoCompact(
|
||||
session: EligibleSession,
|
||||
usage: ContextUsage | null,
|
||||
): Promise<void> {
|
||||
if (!this.compactionEnforcer) return;
|
||||
if (!usage || usage.availability !== "known") return;
|
||||
try {
|
||||
await this.compactionEnforcer.maybeAutoCompact({
|
||||
sessionName: session.session_name,
|
||||
runtime: session.runtime,
|
||||
usedPercentage: usage.usedPercentage,
|
||||
});
|
||||
} catch {
|
||||
// Defensive: enforcer should not throw, but absorb here so the
|
||||
// polling loop continues to make progress for remaining sessions.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,16 @@ export const SETTINGS_VALID_KEYS = [
|
||||
// Codex runtime. When false, operator is managing Codex config
|
||||
// independently — daemon does NOT mutate.
|
||||
"runtime.codex.hooks_enabled",
|
||||
// Slice 27 — Claude auto-compaction policy. SC-29 EXCEPTION #10:
|
||||
// 4 new keys (lockstep with cli/src/config-store.ts VALID_KEYS).
|
||||
// Opt-in default-off; daemon ContextMonitor reads `enabled` +
|
||||
// `threshold_percent` to decide when to send /compact; PreCompact
|
||||
// hook reads `message_inline` + `message_file_path` to inject a
|
||||
// custom message alongside the existing restore-instructions.
|
||||
"policies.claude_compaction.enabled",
|
||||
"policies.claude_compaction.threshold_percent",
|
||||
"policies.claude_compaction.message_inline",
|
||||
"policies.claude_compaction.message_file_path",
|
||||
] as const;
|
||||
|
||||
export type SettingsValidKey = typeof SETTINGS_VALID_KEYS[number];
|
||||
@@ -124,6 +134,12 @@ const ENV_MAP: Record<SettingsValidKey, { primary: string; legacy?: string }> =
|
||||
// OPENRIG_X primary only per the post-rename 5-key boundary doctrine
|
||||
// (no RIGGED_X legacy on net-new keys).
|
||||
"runtime.codex.hooks_enabled": { primary: "OPENRIG_RUNTIME_CODEX_HOOKS_ENABLED" },
|
||||
// Slice 27 — Claude auto-compaction policy. Net-new keys; OPENRIG_X
|
||||
// primary only.
|
||||
"policies.claude_compaction.enabled": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_ENABLED" },
|
||||
"policies.claude_compaction.threshold_percent": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT" },
|
||||
"policies.claude_compaction.message_inline": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_INLINE" },
|
||||
"policies.claude_compaction.message_file_path": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_FILE_PATH" },
|
||||
};
|
||||
|
||||
const KEY_TO_PATH: Record<SettingsValidKey, string[]> = {
|
||||
@@ -156,6 +172,10 @@ const KEY_TO_PATH: Record<SettingsValidKey, string[]> = {
|
||||
"feed.subscriptions.progress": ["feed", "subscriptions", "progress"],
|
||||
"feed.subscriptions.audit_log": ["feed", "subscriptions", "auditLog"],
|
||||
"runtime.codex.hooks_enabled": ["runtime", "codex", "hooksEnabled"],
|
||||
"policies.claude_compaction.enabled": ["policies", "claudeCompaction", "enabled"],
|
||||
"policies.claude_compaction.threshold_percent": ["policies", "claudeCompaction", "thresholdPercent"],
|
||||
"policies.claude_compaction.message_inline": ["policies", "claudeCompaction", "messageInline"],
|
||||
"policies.claude_compaction.message_file_path": ["policies", "claudeCompaction", "messageFilePath"],
|
||||
};
|
||||
|
||||
export type SettingSource = "env" | "file" | "default";
|
||||
@@ -274,6 +294,15 @@ function getDefaultValue(key: SettingsValidKey, workspaceRoot: string): string |
|
||||
// Daemon ensures `codex_hooks = true` in ~/.codex/config.toml on
|
||||
// launch unless operator explicitly sets to false.
|
||||
case "runtime.codex.hooks_enabled": return true;
|
||||
// Slice 27 — Claude auto-compaction policy defaults. Opt-in
|
||||
// default-off; threshold 80% per spec. Empty-string defaults on
|
||||
// message_inline / message_file_path mean "not set" (consistent
|
||||
// with agents.operator_session pattern; hook treats empty as
|
||||
// "skip custom message").
|
||||
case "policies.claude_compaction.enabled": return false;
|
||||
case "policies.claude_compaction.threshold_percent": return 80;
|
||||
case "policies.claude_compaction.message_inline": return "";
|
||||
case "policies.claude_compaction.message_file_path": return "";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
@@ -293,6 +322,18 @@ function coerceValue(key: SettingsValidKey, raw: string, workspaceRoot: string):
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice 27 — projected Claude auto-compaction policy. ContextMonitor
|
||||
* consumes this snapshot per-poll; the PreCompact hook reads the same
|
||||
* shape via direct config.json read (without depending on the daemon).
|
||||
*/
|
||||
export interface ClaudeCompactionPolicy {
|
||||
enabled: boolean;
|
||||
thresholdPercent: number;
|
||||
messageInline: string;
|
||||
messageFilePath: string;
|
||||
}
|
||||
|
||||
export interface ResolvedConfig {
|
||||
workspaceRoot: string;
|
||||
workspaceSlicesRoot: string;
|
||||
@@ -373,6 +414,22 @@ export class SettingsStore {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice 27 — read the Claude auto-compaction policy as a typed snapshot.
|
||||
* Called per-poll by ContextMonitor so live edits to ~/.openrig/config.json
|
||||
* take effect within one polling interval without daemon restart.
|
||||
*/
|
||||
resolveClaudeCompactionPolicy(): ClaudeCompactionPolicy {
|
||||
const fc = this.readConfigFile();
|
||||
const wr = this.resolveWorkspaceRootRaw(fc);
|
||||
return {
|
||||
enabled: this.resolveOne("policies.claude_compaction.enabled", fc, wr).value as boolean,
|
||||
thresholdPercent: this.resolveOne("policies.claude_compaction.threshold_percent", fc, wr).value as number,
|
||||
messageInline: this.resolveOne("policies.claude_compaction.message_inline", fc, wr).value as string,
|
||||
messageFilePath: this.resolveOne("policies.claude_compaction.message_file_path", fc, wr).value as string,
|
||||
};
|
||||
}
|
||||
|
||||
set(key: string, value: string): void {
|
||||
if (!isSettingsValidKey(key)) {
|
||||
throw new Error(`Unknown config key "${key}". Valid keys: ${SETTINGS_VALID_KEYS.join(", ")}`);
|
||||
|
||||
@@ -1155,8 +1155,18 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
|
||||
// Context monitor — constructed before createApp so routes can access pollOnce for refresh.
|
||||
// Caller (index.ts) starts polling after listen.
|
||||
// Slice 27: wire ClaudeCompactionEnforcer when sessionTransport is
|
||||
// available (it always is in the assembled deps; the conditional keeps
|
||||
// the type narrow for downstream consumers).
|
||||
const { ContextMonitor } = await import("./domain/context-monitor.js");
|
||||
const contextMonitor = new ContextMonitor(db, contextUsageStore, claudeAdapter);
|
||||
const { ClaudeCompactionEnforcer } = await import("./domain/claude-compaction-enforcer.js");
|
||||
const compactionEnforcer = deps.sessionTransport
|
||||
? new ClaudeCompactionEnforcer(
|
||||
new ContextPackSettingsStore(),
|
||||
deps.sessionTransport,
|
||||
)
|
||||
: undefined;
|
||||
const contextMonitor = new ContextMonitor(db, contextUsageStore, claudeAdapter, compactionEnforcer);
|
||||
deps.contextMonitor = contextMonitor;
|
||||
|
||||
const app = createApp(deps);
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
// Slice 27 — ClaudeCompactionEnforcer unit tests.
|
||||
//
|
||||
// Hard-gate coverage:
|
||||
// HG-2 threshold-check fires when usedPercentage >= threshold + policy enabled
|
||||
// HG-3 send('/compact') routed via SessionTransport with canonical session name
|
||||
// HG-4 dedup window blocks repeated triggers within the configured interval
|
||||
// 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, vi } from "vitest";
|
||||
import { ClaudeCompactionEnforcer } from "../src/domain/claude-compaction-enforcer.js";
|
||||
import type { SessionTransport } from "../src/domain/session-transport.js";
|
||||
import type { ClaudeCompactionPolicy, SettingsStore } from "../src/domain/user-settings/settings-store.js";
|
||||
|
||||
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,
|
||||
messageInline: "",
|
||||
messageFilePath: "",
|
||||
};
|
||||
const POLICY_ENABLED_AT_80: ClaudeCompactionPolicy = {
|
||||
enabled: true,
|
||||
thresholdPercent: 80,
|
||||
messageInline: "",
|
||||
messageFilePath: "",
|
||||
};
|
||||
|
||||
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: fires when usedPercentage >= threshold AND policy enabled; sends '/compact' to the session", 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: 80,
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({ triggered: true });
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
expect(send).toHaveBeenCalledWith("claude-seat@rig", "/compact");
|
||||
});
|
||||
|
||||
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: dedup window blocks repeated trigger within interval (default 60s)", 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);
|
||||
|
||||
const first = await enforcer.maybeAutoCompact({
|
||||
sessionName: "claude-seat@rig",
|
||||
runtime: "claude-code",
|
||||
usedPercentage: 90,
|
||||
});
|
||||
expect(first).toEqual({ triggered: true });
|
||||
|
||||
now += 30_000;
|
||||
const second = await enforcer.maybeAutoCompact({
|
||||
sessionName: "claude-seat@rig",
|
||||
runtime: "claude-code",
|
||||
usedPercentage: 95,
|
||||
});
|
||||
expect(second).toEqual({ triggered: false, reason: "dedup_window" });
|
||||
|
||||
now += 31_000;
|
||||
const third = await enforcer.maybeAutoCompact({
|
||||
sessionName: "claude-seat@rig",
|
||||
runtime: "claude-code",
|
||||
usedPercentage: 95,
|
||||
});
|
||||
expect(third).toEqual({ triggered: true });
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
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("send-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 });
|
||||
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);
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
// Slice 27 — precompact-hook.mjs end-to-end tests.
|
||||
//
|
||||
// The hook ships as a static asset under
|
||||
// `packages/daemon/assets/plugins/openrig-core/skills/claude-compaction-restore/scripts/precompact-hook.mjs`
|
||||
// and is invoked by Claude Code as a child process. These tests spawn the
|
||||
// actual hook file with controlled stdin + an isolated OPENRIG_HOME so
|
||||
// the on-disk behavior matches what Claude will observe at PreCompact
|
||||
// time.
|
||||
//
|
||||
// Hard-gate coverage:
|
||||
// HG-6 inline message appended to systemMessage
|
||||
// HG-7 file-path message read + appended when inline is empty
|
||||
// HG-8 neither set → existing restore-instructions only (no custom append)
|
||||
// Inline wins when both set
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { join, resolve, dirname } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const HOOK_SCRIPT = resolve(
|
||||
HERE,
|
||||
"..",
|
||||
"assets",
|
||||
"plugins",
|
||||
"openrig-core",
|
||||
"skills",
|
||||
"claude-compaction-restore",
|
||||
"scripts",
|
||||
"precompact-hook.mjs",
|
||||
);
|
||||
const APPEND_MARKER = "Operator-configured pre-compaction message";
|
||||
|
||||
function runHook(openrigHome: string): { stdout: string; stderr: string; status: number | null } {
|
||||
const result = spawnSync(process.execPath, [HOOK_SCRIPT], {
|
||||
input: JSON.stringify({}),
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
OPENRIG_HOME: openrigHome,
|
||||
// Ensure RIGGED_HOME doesn't pre-empt OPENRIG_HOME selection.
|
||||
RIGGED_HOME: undefined,
|
||||
} as NodeJS.ProcessEnv,
|
||||
});
|
||||
return {
|
||||
stdout: result.stdout || "",
|
||||
stderr: result.stderr || "",
|
||||
status: result.status,
|
||||
};
|
||||
}
|
||||
|
||||
function writePolicyConfig(home: string, policy: {
|
||||
enabled?: boolean;
|
||||
thresholdPercent?: number;
|
||||
messageInline?: string;
|
||||
messageFilePath?: string;
|
||||
}): void {
|
||||
mkdirSync(home, { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, "config.json"),
|
||||
JSON.stringify({
|
||||
policies: {
|
||||
claudeCompaction: {
|
||||
enabled: policy.enabled ?? false,
|
||||
thresholdPercent: policy.thresholdPercent ?? 80,
|
||||
messageInline: policy.messageInline ?? "",
|
||||
messageFilePath: policy.messageFilePath ?? "",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe("precompact-hook.mjs (slice 27 custom message append)", () => {
|
||||
let tmpDir: string;
|
||||
let openrigHome: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "precompact-hook-"));
|
||||
openrigHome = join(tmpDir, ".openrig");
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("HG-6: inline message is appended to systemMessage", () => {
|
||||
writePolicyConfig(openrigHome, {
|
||||
messageInline: "Operator says hi — remember the migration step.",
|
||||
});
|
||||
|
||||
const { stdout, status } = runHook(openrigHome);
|
||||
expect(status).toBe(0);
|
||||
const payload = JSON.parse(stdout.trim());
|
||||
expect(payload.continue).toBe(true);
|
||||
expect(payload.systemMessage).toContain(APPEND_MARKER);
|
||||
expect(payload.systemMessage).toContain("Operator says hi — remember the migration step.");
|
||||
});
|
||||
|
||||
it("HG-7: file-path message is read + appended when inline is empty", () => {
|
||||
const messageFile = join(tmpDir, "msg.txt");
|
||||
writeFileSync(messageFile, "Read from disk on every compaction.");
|
||||
writePolicyConfig(openrigHome, {
|
||||
messageInline: "",
|
||||
messageFilePath: messageFile,
|
||||
});
|
||||
|
||||
const { stdout, status } = runHook(openrigHome);
|
||||
expect(status).toBe(0);
|
||||
const payload = JSON.parse(stdout.trim());
|
||||
expect(payload.continue).toBe(true);
|
||||
expect(payload.systemMessage).toContain(APPEND_MARKER);
|
||||
expect(payload.systemMessage).toContain("Read from disk on every compaction.");
|
||||
});
|
||||
|
||||
it("HG-8: neither inline nor file-path set → no custom append (existing restore-instructions preserved)", () => {
|
||||
writePolicyConfig(openrigHome, { messageInline: "", messageFilePath: "" });
|
||||
|
||||
const { stdout, status } = runHook(openrigHome);
|
||||
expect(status).toBe(0);
|
||||
const payload = JSON.parse(stdout.trim());
|
||||
expect(payload.continue).toBe(true);
|
||||
expect(payload.systemMessage).not.toContain(APPEND_MARKER);
|
||||
expect(payload.systemMessage).toContain("Pre-compaction restore seed packet prepared");
|
||||
});
|
||||
|
||||
it("inline wins when both are set", () => {
|
||||
const messageFile = join(tmpDir, "msg.txt");
|
||||
writeFileSync(messageFile, "FILE WINS NOT");
|
||||
writePolicyConfig(openrigHome, {
|
||||
messageInline: "INLINE WINS",
|
||||
messageFilePath: messageFile,
|
||||
});
|
||||
|
||||
const { stdout, status } = runHook(openrigHome);
|
||||
expect(status).toBe(0);
|
||||
const payload = JSON.parse(stdout.trim());
|
||||
expect(payload.systemMessage).toContain("INLINE WINS");
|
||||
expect(payload.systemMessage).not.toContain("FILE WINS NOT");
|
||||
});
|
||||
|
||||
it("file-path with missing file degrades gracefully (no append, no error)", () => {
|
||||
writePolicyConfig(openrigHome, {
|
||||
messageInline: "",
|
||||
messageFilePath: join(tmpDir, "no-such-file.txt"),
|
||||
});
|
||||
|
||||
const { stdout, status } = runHook(openrigHome);
|
||||
expect(status).toBe(0);
|
||||
const payload = JSON.parse(stdout.trim());
|
||||
expect(payload.continue).toBe(true);
|
||||
expect(payload.systemMessage).not.toContain(APPEND_MARKER);
|
||||
expect(payload.systemMessage).toContain("Pre-compaction restore seed packet prepared");
|
||||
});
|
||||
|
||||
it("missing config.json: hook still emits restore-instructions (graceful degrade)", () => {
|
||||
// No config written — OPENRIG_HOME directory may not even exist.
|
||||
const { stdout, status } = runHook(openrigHome);
|
||||
expect(status).toBe(0);
|
||||
const payload = JSON.parse(stdout.trim());
|
||||
expect(payload.continue).toBe(true);
|
||||
expect(payload.systemMessage).not.toContain(APPEND_MARKER);
|
||||
expect(payload.systemMessage).toContain("Pre-compaction restore seed packet prepared");
|
||||
});
|
||||
});
|
||||
@@ -70,8 +70,9 @@ describe("config routes (User Settings v0)", () => {
|
||||
// 18 v0 keys + 2 Phase 4 (advisor/operator) + 5 Phase 5 (feed.subscriptions.*)
|
||||
// + 2 V1 pre-release Item 1 (transcripts.lines / transcripts.poll_interval_seconds)
|
||||
// + 1 plugin-primitive Phase 3a slice 3.5 (runtime.codex.hooks_enabled)
|
||||
// + 1 V0.3.1 slice 05 (workspace.operator_seat_name).
|
||||
expect(Object.keys(body.settings).length).toBe(29);
|
||||
// + 1 V0.3.1 slice 05 (workspace.operator_seat_name)
|
||||
// + 4 slice 27 (policies.claude_compaction.*) → 33 total.
|
||||
expect(Object.keys(body.settings).length).toBe(33);
|
||||
expect(body.settings["daemon.port"]?.source).toBe("default");
|
||||
expect(body.settings["ui.preview.refresh_interval_seconds"]?.value).toBe(3);
|
||||
expect(body.settings["ui.preview.max_pins"]?.value).toBe(4);
|
||||
|
||||
@@ -32,6 +32,12 @@ function clearEnv(): () => void {
|
||||
"OPENRIG_FEED_SUBSCRIPTIONS_ACTION_REQUIRED", "OPENRIG_FEED_SUBSCRIPTIONS_APPROVALS",
|
||||
"OPENRIG_FEED_SUBSCRIPTIONS_SHIPPED", "OPENRIG_FEED_SUBSCRIPTIONS_PROGRESS",
|
||||
"OPENRIG_FEED_SUBSCRIPTIONS_AUDIT_LOG",
|
||||
"OPENRIG_RUNTIME_CODEX_HOOKS_ENABLED",
|
||||
// Slice 27 — Claude auto-compaction policy env-map.
|
||||
"OPENRIG_POLICIES_CLAUDE_COMPACTION_ENABLED",
|
||||
"OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT",
|
||||
"OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_INLINE",
|
||||
"OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_FILE_PATH",
|
||||
"RIGGED_PORT", "RIGGED_HOST", "RIGGED_DB",
|
||||
"RIGGED_TRANSCRIPTS_ENABLED", "RIGGED_TRANSCRIPTS_PATH",
|
||||
];
|
||||
@@ -90,6 +96,11 @@ describe("SettingsStore (User Settings v0)", () => {
|
||||
"feed.subscriptions.audit_log",
|
||||
// plugin-primitive Phase 3a slice 3.5 — Codex feature flag.
|
||||
"runtime.codex.hooks_enabled",
|
||||
// Slice 27 — Claude auto-compaction policy. SC-29 EXCEPTION #10.
|
||||
"policies.claude_compaction.enabled",
|
||||
"policies.claude_compaction.threshold_percent",
|
||||
"policies.claude_compaction.message_inline",
|
||||
"policies.claude_compaction.message_file_path",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -234,6 +245,68 @@ describe("SettingsStore (User Settings v0)", () => {
|
||||
expect(raw.workspace.slicesRoot).toBe("/x");
|
||||
expect(raw.daemon.port).toBe(1234);
|
||||
});
|
||||
|
||||
// Slice 27 — Claude auto-compaction policy resolution.
|
||||
it("HG-5 + HG-10: resolveClaudeCompactionPolicy returns defaults when no file/env present (opt-in default-off)", () => {
|
||||
const store = new SettingsStore(configPath);
|
||||
const policy = store.resolveClaudeCompactionPolicy();
|
||||
expect(policy.enabled).toBe(false);
|
||||
expect(policy.thresholdPercent).toBe(80);
|
||||
expect(policy.messageInline).toBe("");
|
||||
expect(policy.messageFilePath).toBe("");
|
||||
});
|
||||
|
||||
it("HG-10: resolveClaudeCompactionPolicy picks up direct config.json edits without daemon restart (single resolve call rereads file)", () => {
|
||||
const store = new SettingsStore(configPath);
|
||||
expect(store.resolveClaudeCompactionPolicy().enabled).toBe(false);
|
||||
|
||||
writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
policies: {
|
||||
claudeCompaction: {
|
||||
enabled: true,
|
||||
thresholdPercent: 65,
|
||||
messageInline: "carry-forward note",
|
||||
messageFilePath: "",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const updated = store.resolveClaudeCompactionPolicy();
|
||||
expect(updated.enabled).toBe(true);
|
||||
expect(updated.thresholdPercent).toBe(65);
|
||||
expect(updated.messageInline).toBe("carry-forward note");
|
||||
expect(updated.messageFilePath).toBe("");
|
||||
});
|
||||
|
||||
it("HG-1: set/get round-trip for each policy key persists to disk and reads back", () => {
|
||||
const store = new SettingsStore(configPath);
|
||||
|
||||
store.set("policies.claude_compaction.enabled", "true");
|
||||
store.set("policies.claude_compaction.threshold_percent", "60");
|
||||
store.set("policies.claude_compaction.message_inline", "rehydrate the agent");
|
||||
store.set("policies.claude_compaction.message_file_path", "/tmp/msg.txt");
|
||||
|
||||
const raw = JSON.parse(require("node:fs").readFileSync(configPath, "utf-8"));
|
||||
expect(raw.policies.claudeCompaction.enabled).toBe(true);
|
||||
expect(raw.policies.claudeCompaction.thresholdPercent).toBe(60);
|
||||
expect(raw.policies.claudeCompaction.messageInline).toBe("rehydrate the agent");
|
||||
expect(raw.policies.claudeCompaction.messageFilePath).toBe("/tmp/msg.txt");
|
||||
|
||||
expect(store.resolveOne("policies.claude_compaction.enabled").value).toBe(true);
|
||||
expect(store.resolveOne("policies.claude_compaction.threshold_percent").value).toBe(60);
|
||||
expect(store.resolveOne("policies.claude_compaction.message_inline").value).toBe("rehydrate the agent");
|
||||
expect(store.resolveOne("policies.claude_compaction.message_file_path").value).toBe("/tmp/msg.txt");
|
||||
});
|
||||
|
||||
it("HG-1: invalid threshold rejected by coerceValue (non-numeric raises)", () => {
|
||||
const store = new SettingsStore(configPath);
|
||||
expect(() => store.set("policies.claude_compaction.threshold_percent", "not-a-number")).toThrow(/expected a number/);
|
||||
});
|
||||
|
||||
void existsSync;
|
||||
});
|
||||
|
||||
describe("parseNamedPairs (daemon copy)", () => {
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
// Slice 27 — Claude Auto-Compaction Policy form.
|
||||
//
|
||||
// Operator-configurable pre-compaction trigger: when a Claude seat's
|
||||
// context usage crosses `threshold_percent`, the daemon's ContextMonitor
|
||||
// dispatches `/compact` via SessionTransport; the existing PreCompact
|
||||
// hook injects the operator's `message_inline` (or contents of
|
||||
// `message_file_path`) alongside the standard restore-instructions.
|
||||
//
|
||||
// Opt-in default-off. Empty inline + file path = standard restore behavior
|
||||
// preserved. Designed to land on the slice 26 `/settings/policies` route
|
||||
// once the route shell ships; while slice 26 is in flight, the form is
|
||||
// also reachable from a temporary mount at `/settings/policies` here.
|
||||
|
||||
import { useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { SectionHeader } from "../ui/section-header.js";
|
||||
import { useSettings, useSetSetting } from "../../hooks/useSettings.js";
|
||||
|
||||
type FormState = {
|
||||
enabled: boolean;
|
||||
thresholdPercent: number;
|
||||
messageInline: string;
|
||||
messageFilePath: string;
|
||||
};
|
||||
|
||||
const KEY_ENABLED = "policies.claude_compaction.enabled" as const;
|
||||
const KEY_THRESHOLD = "policies.claude_compaction.threshold_percent" as const;
|
||||
const KEY_INLINE = "policies.claude_compaction.message_inline" as const;
|
||||
const KEY_FILE_PATH = "policies.claude_compaction.message_file_path" as const;
|
||||
|
||||
function coerceBoolean(value: unknown, fallback: boolean): boolean {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function coerceNumber(value: unknown, fallback: number): number {
|
||||
if (typeof value === "number") return value;
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
const n = Number(value);
|
||||
if (!Number.isNaN(n)) return n;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function coerceString(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (value === null || value === undefined) return "";
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function ClaudeCompactionPolicyForm() {
|
||||
const { data, isLoading, error } = useSettings();
|
||||
const setSetting = useSetSetting();
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<section
|
||||
data-testid="claude-compaction-policy-form"
|
||||
className="border border-outline-variant p-5 bg-white/50"
|
||||
>
|
||||
<SectionHeader tone="muted">Policy</SectionHeader>
|
||||
<h2 className="font-headline text-headline-sm font-bold tracking-tight uppercase text-stone-900 mt-1">
|
||||
Claude Auto-Compaction
|
||||
</h2>
|
||||
{isLoading && (
|
||||
<p className="mt-4 text-sm text-on-surface-variant" data-testid="claude-compaction-policy-loading">
|
||||
Loading current settings…
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="mt-4 text-sm text-error" data-testid="claude-compaction-policy-error">
|
||||
{error instanceof Error ? error.message : String(error)}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return <PolicyFormBody data={data.settings} setSetting={setSetting} />;
|
||||
}
|
||||
|
||||
interface PolicyFormBodyProps {
|
||||
data: Record<string, { value: unknown }>;
|
||||
setSetting: ReturnType<typeof useSetSetting>;
|
||||
}
|
||||
|
||||
function PolicyFormBody({ data, setSetting }: PolicyFormBodyProps) {
|
||||
const [form, setForm] = useState<FormState>(() => ({
|
||||
enabled: coerceBoolean(data[KEY_ENABLED]?.value, false),
|
||||
thresholdPercent: coerceNumber(data[KEY_THRESHOLD]?.value, 80),
|
||||
messageInline: coerceString(data[KEY_INLINE]?.value),
|
||||
messageFilePath: coerceString(data[KEY_FILE_PATH]?.value),
|
||||
}));
|
||||
const [thresholdError, setThresholdError] = useState<string | null>(null);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [submitOk, setSubmitOk] = useState(false);
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setSubmitOk(false);
|
||||
setSubmitError(null);
|
||||
if (form.thresholdPercent < 1 || form.thresholdPercent > 100 || !Number.isInteger(form.thresholdPercent)) {
|
||||
setThresholdError("Threshold must be an integer between 1 and 100.");
|
||||
return;
|
||||
}
|
||||
setThresholdError(null);
|
||||
|
||||
const updates: Array<{ key: Parameters<typeof setSetting.mutateAsync>[0]["key"]; value: string }> = [
|
||||
{ key: KEY_ENABLED, value: form.enabled ? "true" : "false" },
|
||||
{ key: KEY_THRESHOLD, value: String(form.thresholdPercent) },
|
||||
{ key: KEY_INLINE, value: form.messageInline },
|
||||
{ key: KEY_FILE_PATH, value: form.messageFilePath },
|
||||
];
|
||||
try {
|
||||
for (const update of updates) {
|
||||
await setSetting.mutateAsync(update);
|
||||
}
|
||||
setSubmitOk(true);
|
||||
} catch (err) {
|
||||
setSubmitError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
data-testid="claude-compaction-policy-form"
|
||||
className="border border-outline-variant p-5 bg-white/50"
|
||||
>
|
||||
<SectionHeader tone="muted">Policy</SectionHeader>
|
||||
<h2 className="font-headline text-headline-sm font-bold tracking-tight uppercase text-stone-900 mt-1">
|
||||
Claude Auto-Compaction
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-on-surface-variant max-w-prose">
|
||||
When a Claude seat's context usage crosses the configured threshold,
|
||||
OpenRig sends <code className="font-mono text-[12px]">/compact</code> to the seat.
|
||||
The existing pre-compaction hook continues to emit the standard
|
||||
restore packet; the optional custom message below is appended to the
|
||||
post-compaction system message.
|
||||
</p>
|
||||
|
||||
<form
|
||||
className="mt-4 flex flex-col gap-5"
|
||||
onSubmit={handleSubmit}
|
||||
data-testid="claude-compaction-policy-form-element"
|
||||
>
|
||||
<label className="inline-flex items-center gap-2 text-sm text-stone-900">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="claude-compaction-enabled"
|
||||
checked={form.enabled}
|
||||
onChange={(e) => setForm((s) => ({ ...s, enabled: e.target.checked }))}
|
||||
/>
|
||||
<span>Enable automatic pre-compaction trigger</span>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="claude-compaction-threshold" className="text-sm font-medium text-stone-900">
|
||||
Threshold percentage
|
||||
</label>
|
||||
<input
|
||||
id="claude-compaction-threshold"
|
||||
data-testid="claude-compaction-threshold"
|
||||
type="number"
|
||||
step={1}
|
||||
value={form.thresholdPercent}
|
||||
onChange={(e) => setForm((s) => ({ ...s, thresholdPercent: Number(e.target.value) }))}
|
||||
className="border border-outline-variant px-2 py-1 w-32 font-mono text-sm"
|
||||
aria-describedby="claude-compaction-threshold-hint"
|
||||
/>
|
||||
<span className="text-xs text-on-surface-variant">
|
||||
Fires when context usage at-or-above this percentage (1–100).
|
||||
</span>
|
||||
{thresholdError && (
|
||||
<span className="text-xs text-error" data-testid="claude-compaction-threshold-error">
|
||||
{thresholdError}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="claude-compaction-inline" className="text-sm font-medium text-stone-900">
|
||||
Custom message (inline)
|
||||
</label>
|
||||
<textarea
|
||||
id="claude-compaction-inline"
|
||||
data-testid="claude-compaction-message-inline"
|
||||
rows={4}
|
||||
value={form.messageInline}
|
||||
onChange={(e) => setForm((s) => ({ ...s, messageInline: e.target.value }))}
|
||||
placeholder="Optional. Appended to the restore-instructions in the post-compaction system message."
|
||||
className="border border-outline-variant px-2 py-1 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="claude-compaction-file" className="text-sm font-medium text-stone-900">
|
||||
Custom message (file path)
|
||||
</label>
|
||||
<input
|
||||
id="claude-compaction-file"
|
||||
data-testid="claude-compaction-message-file-path"
|
||||
type="text"
|
||||
value={form.messageFilePath}
|
||||
onChange={(e) => setForm((s) => ({ ...s, messageFilePath: e.target.value }))}
|
||||
placeholder="Optional. Path read at hook-fire time; ignored when inline is set."
|
||||
className="border border-outline-variant px-2 py-1 font-mono text-sm"
|
||||
/>
|
||||
<span className="text-xs text-on-surface-variant">
|
||||
When both fields are set, inline wins. When neither is set, only
|
||||
the standard restore packet is injected.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="claude-compaction-policy-submit"
|
||||
disabled={setSetting.isPending}
|
||||
className="border border-outline px-4 py-2 bg-stone-900 text-white font-medium text-sm disabled:opacity-60"
|
||||
>
|
||||
{setSetting.isPending ? "Saving…" : "Save policy"}
|
||||
</button>
|
||||
{submitOk && (
|
||||
<span className="text-sm text-success" data-testid="claude-compaction-policy-saved">
|
||||
Saved.
|
||||
</span>
|
||||
)}
|
||||
{submitError && (
|
||||
<span className="text-sm text-error" data-testid="claude-compaction-policy-submit-error">
|
||||
{submitError}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Slice 27 — Policies page wrapper.
|
||||
//
|
||||
// The slice 26 spec lands `/settings/policies` as the destination route for
|
||||
// operator-tunable policies; slice 27 provides the first form (Claude
|
||||
// auto-compaction). Until slice 26 merges, this page is reachable directly
|
||||
// at /settings/policies via the route added in this slice; on integration,
|
||||
// slice 26's Policies tab/explorer is expected to host the same component
|
||||
// without further changes.
|
||||
|
||||
import { SectionHeader } from "../ui/section-header.js";
|
||||
import { ClaudeCompactionPolicyForm } from "./ClaudeCompactionPolicyForm.js";
|
||||
|
||||
export function PoliciesPage() {
|
||||
return (
|
||||
<div data-testid="policies-page" className="mx-auto w-full max-w-[960px] px-6 py-8">
|
||||
<div className="border-b border-outline-variant pb-4 mb-4">
|
||||
<SectionHeader tone="muted">Settings</SectionHeader>
|
||||
<h1 className="font-headline text-headline-md font-bold tracking-tight uppercase text-stone-900 mt-1">
|
||||
Policies
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-on-surface-variant max-w-prose">
|
||||
Opt-in policies that affect agent runtime behavior. Each policy
|
||||
ships disabled by default and can be turned on independently.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-6">
|
||||
<ClaudeCompactionPolicyForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,7 +34,12 @@ export type SettingsKey =
|
||||
| "feed.subscriptions.approvals"
|
||||
| "feed.subscriptions.shipped"
|
||||
| "feed.subscriptions.progress"
|
||||
| "feed.subscriptions.audit_log";
|
||||
| "feed.subscriptions.audit_log"
|
||||
// Slice 27 — Claude auto-compaction policy keys (SC-29 EXCEPTION #10).
|
||||
| "policies.claude_compaction.enabled"
|
||||
| "policies.claude_compaction.threshold_percent"
|
||||
| "policies.claude_compaction.message_inline"
|
||||
| "policies.claude_compaction.message_file_path";
|
||||
|
||||
export interface SettingsResponse {
|
||||
settings: Record<SettingsKey, ResolvedSetting>;
|
||||
|
||||
@@ -47,6 +47,9 @@ import { PluginsIndexPage } from "./components/specs/PluginsIndexPage.js";
|
||||
import { PluginDetailPage } from "./components/specs/PluginDetailPage.js";
|
||||
import { FilesWorkspace } from "./components/files/FilesWorkspace.js";
|
||||
import { SettingsCenter } from "./components/system/SettingsCenter.js";
|
||||
// Slice 27 — Policies page (temporary mount at /settings/policies until
|
||||
// slice 26's Settings explorer ships the canonical route shell).
|
||||
import { PoliciesPage } from "./components/system/PoliciesPage.js";
|
||||
import {
|
||||
HostScopePage,
|
||||
RigScopePage,
|
||||
@@ -216,6 +219,18 @@ const settingsRoute = createRoute({
|
||||
component: SettingsCenter,
|
||||
});
|
||||
|
||||
// Slice 27 — Policies page; mounts the Claude auto-compaction form. The
|
||||
// route is a temporary integration point until slice 26's Settings
|
||||
// explorer ships the canonical Policies destination — once it does, this
|
||||
// route either delegates to the slice 26 shell or is removed in favor of
|
||||
// slice 26's mount; either way, the form component (ClaudeCompactionPolicyForm)
|
||||
// stays at its current path.
|
||||
const settingsPoliciesRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/settings/policies",
|
||||
component: PoliciesPage,
|
||||
});
|
||||
|
||||
const searchRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/search",
|
||||
@@ -438,6 +453,7 @@ const routeTree = rootRoute.addChildren([
|
||||
filesRoute,
|
||||
specsKindRoute,
|
||||
settingsRoute,
|
||||
settingsPoliciesRoute,
|
||||
searchRoute,
|
||||
projectGraphicsPreviewRoute,
|
||||
cardPreviewsLabRoute,
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// Slice 27 — Claude Compaction Policy form tests.
|
||||
//
|
||||
// HG-9: form renders + persists settings.
|
||||
// Also covers:
|
||||
// - opt-in default-off rendering (enabled toggle reflects current value)
|
||||
// - threshold validation rejects out-of-range values without persisting
|
||||
// - submit issues one POST per key against /api/config/:key
|
||||
// - shows "Saved." indicator after successful submit
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||
import { createTestRouter } from "./helpers/test-router.js";
|
||||
import { ClaudeCompactionPolicyForm } from "../src/components/system/ClaudeCompactionPolicyForm.js";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.fetch = mockFetch as unknown as typeof fetch;
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
afterEach(() => cleanup());
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
}) as unknown as Response;
|
||||
}
|
||||
|
||||
function makeSettingsResponse(overrides: Partial<{
|
||||
enabled: boolean;
|
||||
thresholdPercent: number;
|
||||
messageInline: string;
|
||||
messageFilePath: string;
|
||||
}> = {}) {
|
||||
return {
|
||||
settings: {
|
||||
"policies.claude_compaction.enabled": {
|
||||
value: overrides.enabled ?? false,
|
||||
source: "default",
|
||||
defaultValue: false,
|
||||
},
|
||||
"policies.claude_compaction.threshold_percent": {
|
||||
value: overrides.thresholdPercent ?? 80,
|
||||
source: "default",
|
||||
defaultValue: 80,
|
||||
},
|
||||
"policies.claude_compaction.message_inline": {
|
||||
value: overrides.messageInline ?? "",
|
||||
source: "default",
|
||||
defaultValue: "",
|
||||
},
|
||||
"policies.claude_compaction.message_file_path": {
|
||||
value: overrides.messageFilePath ?? "",
|
||||
source: "default",
|
||||
defaultValue: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("ClaudeCompactionPolicyForm — slice 27", () => {
|
||||
it("HG-9: renders form with current settings populated (opt-in default-off shows disabled toggle, 80% threshold)", async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse(makeSettingsResponse()));
|
||||
render(createTestRouter({ component: () => <ClaudeCompactionPolicyForm />, path: "/" }));
|
||||
|
||||
// Form body waits for data; assert on input presence to confirm data
|
||||
// has loaded before reading values.
|
||||
await waitFor(() => expect(screen.getByTestId("claude-compaction-enabled")).toBeDefined());
|
||||
|
||||
const enabled = screen.getByTestId("claude-compaction-enabled") as HTMLInputElement;
|
||||
expect(enabled.checked).toBe(false);
|
||||
const threshold = screen.getByTestId("claude-compaction-threshold") as HTMLInputElement;
|
||||
expect(threshold.value).toBe("80");
|
||||
const inline = screen.getByTestId("claude-compaction-message-inline") as HTMLTextAreaElement;
|
||||
expect(inline.value).toBe("");
|
||||
const filePath = screen.getByTestId("claude-compaction-message-file-path") as HTMLInputElement;
|
||||
expect(filePath.value).toBe("");
|
||||
});
|
||||
|
||||
it("HG-9: loads existing non-default values from /api/config when present", async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse(makeSettingsResponse({
|
||||
enabled: true,
|
||||
thresholdPercent: 65,
|
||||
messageInline: "stay calm",
|
||||
messageFilePath: "/tmp/m.txt",
|
||||
})));
|
||||
render(createTestRouter({ component: () => <ClaudeCompactionPolicyForm />, path: "/" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect((screen.getByTestId("claude-compaction-enabled") as HTMLInputElement).checked).toBe(true);
|
||||
});
|
||||
expect((screen.getByTestId("claude-compaction-threshold") as HTMLInputElement).value).toBe("65");
|
||||
expect((screen.getByTestId("claude-compaction-message-inline") as HTMLTextAreaElement).value).toBe("stay calm");
|
||||
expect((screen.getByTestId("claude-compaction-message-file-path") as HTMLInputElement).value).toBe("/tmp/m.txt");
|
||||
});
|
||||
|
||||
it("HG-9: submit POSTs each key to /api/config/:key with the user's value", async () => {
|
||||
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
||||
mockFetch.mockImplementation(async (url: RequestInfo | URL, init?: RequestInit) => {
|
||||
calls.push({ url: String(url), init });
|
||||
if (init?.method === "POST") {
|
||||
return jsonResponse({ ok: true });
|
||||
}
|
||||
return jsonResponse(makeSettingsResponse());
|
||||
});
|
||||
|
||||
render(createTestRouter({ component: () => <ClaudeCompactionPolicyForm />, path: "/" }));
|
||||
await waitFor(() => expect(screen.getByTestId("claude-compaction-enabled")).toBeDefined());
|
||||
|
||||
fireEvent.click(screen.getByTestId("claude-compaction-enabled"));
|
||||
fireEvent.change(screen.getByTestId("claude-compaction-threshold"), { target: { value: "70" } });
|
||||
fireEvent.change(screen.getByTestId("claude-compaction-message-inline"), {
|
||||
target: { value: "Reload the slice doc before resuming." },
|
||||
});
|
||||
|
||||
expect((screen.getByTestId("claude-compaction-enabled") as HTMLInputElement).checked).toBe(true);
|
||||
expect((screen.getByTestId("claude-compaction-threshold") as HTMLInputElement).value).toBe("70");
|
||||
|
||||
fireEvent.click(screen.getByTestId("claude-compaction-policy-submit"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(calls.filter((c) => c.init?.method === "POST").length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
const postCalls = calls.filter((c) => c.init?.method === "POST");
|
||||
const findKey = (key: string) => postCalls.find((c) => c.url.includes(encodeURIComponent(key)));
|
||||
expect(findKey("policies.claude_compaction.enabled")?.init?.body as string).toContain("true");
|
||||
expect(findKey("policies.claude_compaction.threshold_percent")?.init?.body as string).toContain("70");
|
||||
expect(findKey("policies.claude_compaction.message_inline")?.init?.body as string).toContain("Reload the slice doc before resuming.");
|
||||
// message_file_path posted even when empty (clears any prior value).
|
||||
expect(findKey("policies.claude_compaction.message_file_path")).toBeDefined();
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("claude-compaction-policy-saved")).toBeDefined());
|
||||
});
|
||||
|
||||
it("HG-9: threshold validation rejects out-of-range values without issuing POSTs", async () => {
|
||||
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
||||
mockFetch.mockImplementation(async (url: RequestInfo | URL, init?: RequestInit) => {
|
||||
calls.push({ url: String(url), init });
|
||||
return jsonResponse(makeSettingsResponse());
|
||||
});
|
||||
|
||||
render(createTestRouter({ component: () => <ClaudeCompactionPolicyForm />, path: "/" }));
|
||||
await waitFor(() => expect(screen.getByTestId("claude-compaction-enabled")).toBeDefined());
|
||||
|
||||
fireEvent.change(screen.getByTestId("claude-compaction-threshold"), { target: { value: "101" } });
|
||||
fireEvent.click(screen.getByTestId("claude-compaction-policy-submit"));
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("claude-compaction-threshold-error")).toBeDefined());
|
||||
expect(calls.filter((c) => c.init?.method === "POST")).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user