diff --git a/apps/web-server/scripts/persistent-room-maintenance-settings-smoke.ts b/apps/web-server/scripts/persistent-room-maintenance-settings-smoke.ts index 574c7c5..b5ddb24 100644 --- a/apps/web-server/scripts/persistent-room-maintenance-settings-smoke.ts +++ b/apps/web-server/scripts/persistent-room-maintenance-settings-smoke.ts @@ -18,9 +18,10 @@ function assert(condition: unknown, message: string): asserts condition { } try { - // Default: off, 20k budget, no file created by reading. + // Default: off, quick-checkpoint auto-apply on, 20k budget, no file created by reading. const initial = readPersistentRoomMaintenanceSettings(agentId); assert(initial.fastPathSecondApproval === false, "default fast-path must be off"); + assert(initial.quickCheckpointAutoApply === true, "default quick-checkpoint auto-apply must be on"); assert(initial.memoryBudgetTokens === 20_000, "default memory budget must be 20k tokens"); assert(!fs.existsSync(persistentRoomMaintenanceSettingsPath(agentId)), "read must not create the settings file"); @@ -61,6 +62,20 @@ try { writePersistentRoomMaintenanceSettings(agentId, { fastPathSecondApproval: false }); assert(readPersistentRoomMaintenanceSettings(agentId).fastPathSecondApproval === false, "toggle off should persist"); + // Quick-checkpoint auto-apply: off round-trips, other writes preserve it, + // and settings files written before the field existed keep auto-apply on. + const quickOff = writePersistentRoomMaintenanceSettings(agentId, { quickCheckpointAutoApply: false }); + assert(quickOff.quickCheckpointAutoApply === false, "quick auto-apply off should persist"); + assert(quickOff.fastPathSecondApproval === false, "quick auto-apply write should preserve the fast-path toggle"); + assert(readPersistentRoomMaintenanceSettings(agentId).quickCheckpointAutoApply === false, "reread should see quick auto-apply off"); + assert(writePersistentRoomMaintenanceSettings(agentId, { memoryBudgetTokens: 25_000 }).quickCheckpointAutoApply === false, "budget-only write should preserve quick auto-apply"); + writePersistentRoomMaintenanceSettings(agentId, { quickCheckpointAutoApply: true }); + assert(readPersistentRoomMaintenanceSettings(agentId).quickCheckpointAutoApply === true, "quick auto-apply back on should persist"); + const legacy = JSON.parse(fs.readFileSync(persistentRoomMaintenanceSettingsPath(agentId), "utf-8")); + delete legacy.quickCheckpointAutoApply; + fs.writeFileSync(persistentRoomMaintenanceSettingsPath(agentId), JSON.stringify(legacy, null, 2) + "\n", "utf-8"); + assert(readPersistentRoomMaintenanceSettings(agentId).quickCheckpointAutoApply === true, "legacy settings without the field should keep auto-apply on"); + // Validation. let threw = false; try { @@ -69,6 +84,13 @@ try { threw = /must be a boolean/.test((error as Error).message); } assert(threw, "non-boolean input should be rejected"); + let quickThrew = false; + try { + writePersistentRoomMaintenanceSettings(agentId, { quickCheckpointAutoApply: "yes" as unknown as boolean }); + } catch (error) { + quickThrew = /quickCheckpointAutoApply must be a boolean/.test((error as Error).message); + } + assert(quickThrew, "non-boolean quick auto-apply should be rejected"); let threwId = false; try { readPersistentRoomMaintenanceSettings("../escape"); @@ -80,6 +102,7 @@ try { // Corrupt file falls back to defaults. fs.writeFileSync(persistentRoomMaintenanceSettingsPath(agentId), "not json", "utf-8"); assert(readPersistentRoomMaintenanceSettings(agentId).fastPathSecondApproval === false, "corrupt settings should fall back to default off"); + assert(readPersistentRoomMaintenanceSettings(agentId).quickCheckpointAutoApply === true, "corrupt settings should fall back to auto-apply on"); fs.rmSync(root, { recursive: true, force: true }); console.log("persistent-room maintenance settings smoke passed"); diff --git a/apps/web-server/src/index.ts b/apps/web-server/src/index.ts index c51595b..a9499f1 100644 --- a/apps/web-server/src/index.ts +++ b/apps/web-server/src/index.ts @@ -1162,7 +1162,7 @@ app.put("/api/persistent-agents/:id/maintenance-settings", async (req, reply) => try { const status = getUsablePersistentAgentStatusForNormalUse(idRaw); const body = (req.body ?? {}) as any; - const settings = writePersistentRoomMaintenanceSettings(status.id, { fastPathSecondApproval: body.fastPathSecondApproval, memoryBudgetTokens: body.memoryBudgetTokens }); + const settings = writePersistentRoomMaintenanceSettings(status.id, { fastPathSecondApproval: body.fastPathSecondApproval, quickCheckpointAutoApply: body.quickCheckpointAutoApply, memoryBudgetTokens: body.memoryBudgetTokens }); return { agentId: status.id, settings }; } catch (e) { return persistentAgentNormalUseErrorReply(reply, e); diff --git a/apps/web-server/src/persistent-room-maintenance-settings.ts b/apps/web-server/src/persistent-room-maintenance-settings.ts index e8f5b79..a109292 100644 --- a/apps/web-server/src/persistent-room-maintenance-settings.ts +++ b/apps/web-server/src/persistent-room-maintenance-settings.ts @@ -12,6 +12,13 @@ import { DEFAULT_PERSISTENT_ROOM_AGENTS_ROOT, persistentAgentRootPath } from "./ * and the server-side propose/approve split is unchanged — this file only * stores the preference. * + * `quickCheckpointAutoApply` governs the default Checkpoint button: when true + * (the default, matching historical behaviour) a blocker-free proposal is + * approved immediately without showing the preview; when false the quick + * button still generates the proposal but always falls back to the manual + * preview for approval. Proposals with blockers fall back to the preview + * either way, and the server-side propose/approve split is unchanged. + * * `memoryBudgetTokens` is the room's advisory memory budget: the size the * whole L1b should stay near. It never gates anything — it drives the * settings meter, the room-card nudge, and a target line in the absorb / @@ -21,6 +28,7 @@ import { DEFAULT_PERSISTENT_ROOM_AGENTS_ROOT, persistentAgentRootPath } from "./ export interface PersistentRoomMaintenanceSettings { schemaVersion: 1; fastPathSecondApproval: boolean; + quickCheckpointAutoApply: boolean; memoryBudgetTokens: number; updatedAt: string; } @@ -41,6 +49,7 @@ function clampMemoryBudgetTokens(value: unknown): number { const DEFAULT_SETTINGS: PersistentRoomMaintenanceSettings = { schemaVersion: 1, fastPathSecondApproval: false, + quickCheckpointAutoApply: true, memoryBudgetTokens: MEMORY_BUDGET_DEFAULT_TOKENS, updatedAt: "", }; @@ -65,6 +74,9 @@ export function readPersistentRoomMaintenanceSettings(agentIdRaw: string, option return { schemaVersion: 1, fastPathSecondApproval: raw.fastPathSecondApproval === true, + // Settings files written before this field existed must keep the + // historical auto-apply behaviour, so only an explicit false disables. + quickCheckpointAutoApply: raw.quickCheckpointAutoApply !== false, memoryBudgetTokens: clampMemoryBudgetTokens(raw.memoryBudgetTokens), updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : "", }; @@ -73,14 +85,16 @@ export function readPersistentRoomMaintenanceSettings(agentIdRaw: string, option } } -export function writePersistentRoomMaintenanceSettings(agentIdRaw: string, input: { fastPathSecondApproval?: unknown; memoryBudgetTokens?: unknown }, options: PersistentRoomMaintenanceSettingsStorageOptions = {}, now = new Date()): PersistentRoomMaintenanceSettings { +export function writePersistentRoomMaintenanceSettings(agentIdRaw: string, input: { fastPathSecondApproval?: unknown; quickCheckpointAutoApply?: unknown; memoryBudgetTokens?: unknown }, options: PersistentRoomMaintenanceSettingsStorageOptions = {}, now = new Date()): PersistentRoomMaintenanceSettings { if (input?.fastPathSecondApproval !== undefined && typeof input.fastPathSecondApproval !== "boolean") throw new Error("fastPathSecondApproval must be a boolean"); + if (input?.quickCheckpointAutoApply !== undefined && typeof input.quickCheckpointAutoApply !== "boolean") throw new Error("quickCheckpointAutoApply must be a boolean"); if (input?.memoryBudgetTokens !== undefined && (typeof input.memoryBudgetTokens !== "number" || !Number.isFinite(input.memoryBudgetTokens))) throw new Error("memoryBudgetTokens must be a number"); const current = readPersistentRoomMaintenanceSettings(agentIdRaw, options); const settingsPath = persistentRoomMaintenanceSettingsPath(agentIdRaw, options); const settings: PersistentRoomMaintenanceSettings = { schemaVersion: 1, fastPathSecondApproval: input?.fastPathSecondApproval !== undefined ? input.fastPathSecondApproval as boolean : current.fastPathSecondApproval, + quickCheckpointAutoApply: input?.quickCheckpointAutoApply !== undefined ? input.quickCheckpointAutoApply as boolean : current.quickCheckpointAutoApply, memoryBudgetTokens: input?.memoryBudgetTokens !== undefined ? clampMemoryBudgetTokens(input.memoryBudgetTokens) : current.memoryBudgetTokens, updatedAt: now.toISOString(), }; diff --git a/apps/web-ui/src/App.tsx b/apps/web-ui/src/App.tsx index 87f1426..d85ab68 100644 --- a/apps/web-ui/src/App.tsx +++ b/apps/web-ui/src/App.tsx @@ -3246,6 +3246,10 @@ export function App() { setCheckpointQuickBlockedReasons(blockers); return; } + if (!(await roomQuickCheckpointAutoApplyEnabled(targetChat.agentId))) { + setCheckpointQuickBlockedReasons(["automatic apply is turned off for this room"]); + return; + } setCheckpointApprovalLoading(true); try { const approvedRecentContext = buildApprovedRecentContextMarkdown(proposal, { @@ -3257,7 +3261,7 @@ export function App() { await bindToApprovedCheckpointRuntime(approval, targetChat); void refreshPersistentAgentStatus(); resetCheckpointInput(); - const savedNote = approval.warnings.length > 0 ? `Checkpoint saved to memory. ${approval.warnings.join(" ")}` : "Checkpoint saved to memory."; + const savedNote = approval.warnings.length > 0 ? `Checkpoint saved to memory automatically. ${approval.warnings.join(" ")}` : "Checkpoint saved to memory automatically."; setItems((s) => [...s, { kind: "system", id: nid(), text: savedNote }]); } catch (e) { setCheckpointApprovalError(`The checkpoint could not save automatically. Review and approve it manually. ${(e as Error).message}`); @@ -3806,6 +3810,16 @@ export function App() { } } + async function roomQuickCheckpointAutoApplyEnabled(agentId: PersistentAgentId): Promise { + try { + return (await fetchPersistentRoomMaintenanceSettings(agentId)).settings.quickCheckpointAutoApply !== false; + } catch { + // When the preference cannot be read, fall back to manual review — + // the safe direction is showing the proposal, not silently applying it. + return false; + } + } + /** * Fast path: when the room opts in and the proposal is warning-free, apply * it immediately instead of showing the second approval screen. Returns diff --git a/apps/web-ui/src/components/RoomMaintenanceSection.tsx b/apps/web-ui/src/components/RoomMaintenanceSection.tsx index 6e536aa..6e26be8 100644 --- a/apps/web-ui/src/components/RoomMaintenanceSection.tsx +++ b/apps/web-ui/src/components/RoomMaintenanceSection.tsx @@ -14,6 +14,7 @@ function fmtTokensK(value: number): string { export function RoomMaintenanceSection({ status }: { status: PersistentAgentStatus }) { const [fastPath, setFastPath] = useState(null); + const [quickAutoApply, setQuickAutoApply] = useState(null); const [budget, setBudget] = useState(null); const [savedBudget, setSavedBudget] = useState(null); const [saving, setSaving] = useState(false); @@ -23,6 +24,7 @@ export function RoomMaintenanceSection({ status }: { status: PersistentAgentStat useEffect(() => { let cancelled = false; setFastPath(null); + setQuickAutoApply(null); setBudget(null); setSavedBudget(null); setError(null); @@ -30,6 +32,7 @@ export function RoomMaintenanceSection({ status }: { status: PersistentAgentStat .then((response) => { if (cancelled) return; setFastPath(response.settings.fastPathSecondApproval); + setQuickAutoApply(response.settings.quickCheckpointAutoApply); setBudget(response.settings.memoryBudgetTokens); setSavedBudget(response.settings.memoryBudgetTokens); }) @@ -78,6 +81,23 @@ export function RoomMaintenanceSection({ status }: { status: PersistentAgentStat } } + async function toggleQuickAutoApply(next: boolean) { + if (saving || quickAutoApply === null) return; + setSaving(true); + setError(null); + const previous = quickAutoApply; + setQuickAutoApply(next); + try { + const response = await updatePersistentRoomMaintenanceSettings(status.id, { quickCheckpointAutoApply: next }); + setQuickAutoApply(response.settings.quickCheckpointAutoApply); + } catch (e) { + setQuickAutoApply(previous); + setError((e as Error).message); + } finally { + setSaving(false); + } + } + const currentMemoryTokens = status.promptBudget?.l1bEstimatedTokens ?? null; const usagePercent = budget !== null && currentMemoryTokens !== null ? Math.round((currentMemoryTokens / budget) * 100) : null; const overBudget = usagePercent !== null && usagePercent > 100; @@ -106,6 +126,29 @@ export function RoomMaintenanceSection({ status }: { status: PersistentAgentStat an audit record.

+ +

Save blocker-free Checkpoint proposals without showing the preview.

+
+ How it works +

+ The Checkpoint button drafts a memory proposal and, when nothing deterministic + blocks it, saves it without showing you the content first. Turn this off to + always review the proposed entry before it becomes memory. Proposals with + blockers — trimmed transcripts or incomplete drafts — always come back for + review either way, and every save still archives the previous memory and writes + an audit record. +

+
Memory budget diff --git a/apps/web-ui/src/persistent-room-management-api.ts b/apps/web-ui/src/persistent-room-management-api.ts index 7068d68..baeb46a 100644 --- a/apps/web-ui/src/persistent-room-management-api.ts +++ b/apps/web-ui/src/persistent-room-management-api.ts @@ -55,6 +55,7 @@ export function renamePersistentRoom(agentId: PersistentAgentId, displayName: st export interface PersistentRoomMaintenanceSettings { schemaVersion: 1; fastPathSecondApproval: boolean; + quickCheckpointAutoApply: boolean; memoryBudgetTokens: number; updatedAt: string; } @@ -72,7 +73,7 @@ export function fetchPersistentRoomMaintenanceSettings(agentId: PersistentAgentI ); } -export function updatePersistentRoomMaintenanceSettings(agentId: PersistentAgentId, update: { fastPathSecondApproval?: boolean; memoryBudgetTokens?: number }): Promise { +export function updatePersistentRoomMaintenanceSettings(agentId: PersistentAgentId, update: { fastPathSecondApproval?: boolean; quickCheckpointAutoApply?: boolean; memoryBudgetTokens?: number }): Promise { return fetchJson( `/api/persistent-agents/${encodeURIComponent(agentId)}/maintenance-settings`, {