Merge pull request #666: feat(sessions): keep an idle session's prompt cache warm with a guarded keep-alive turn

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-22 19:31:08 +02:00
commit f80f107445
45 files changed
+3017 -59

No files matched your search

+24
View File
@@ -102,6 +102,30 @@ describe('claude', () => {
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666"'
);
});
it('folds the keep-warm idleCompaction opt-out LAST into the one --settings JSON, and only when asked', () => {
const launch = {
claudeCliVersion: '2.1.300',
statusLineCommand: '/x/exporter.sh',
advisorModel: 'opus',
sessionName: 'w1 alpha',
};
// Keep-warm off: byte-identical to the launch from before keep-warm existed.
expect(claude(launch)).toBe(
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" ' +
'--settings \'{"advisorModel":"opus","statusLine":{"type":"command","command":"/x/exporter.sh"}}\' --name "w1 alpha"'
);
expect(claude({ ...launch, effort: 'ultracode', idleCompactionOff: true })).toBe(
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" ' +
'--settings \'{"ultracode":true,"advisorModel":"opus","statusLine":{"type":"command","command":"/x/exporter.sh"},"idleCompaction":false}\' --name "w1 alpha"'
);
// The builder folds the key whenever asked, even with nothing else in the JSON (the
// launch only asks alongside the exporter, but the builder does not rely on that).
expect(claude({ idleCompactionOff: true })).toBe(
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" ' +
'--settings \'{"idleCompaction":false}\''
);
});
});
describe('opencode', () => {
+126
View File
@@ -0,0 +1,126 @@
// Port: none (handlers sliced out of the shipped frontend files and run against stubs).
//
// The prompt-cache keep-alive turn (prompt-cache-keepwarm.ts) is Codeman's, not the user's, so
// on the page it must spend nothing: an idle alert still pending stays (only a human spends
// one), no "Response Complete" is raised, and the stuck-detection timer neither restarts nor
// re-arms ("Session Idle 10+ minutes" would otherwise follow every nudge). The server marks the
// turn's `hook:stop` with `acknowledgedReason` and its `session:working`/`session:idle` with
// `keepAlive`; these tests run the real handlers on those payloads.
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const SESSION = 'keep-alive-session';
const THRESHOLD = 600_000;
/** The body of a method as shipped, compiled with `data` as its one parameter. */
function method(file: string, signature: string): (this: unknown, data: Record<string, unknown>) => void {
const source = readFileSync(resolve(PUBLIC, file), 'utf8');
const start = source.indexOf(` ${signature} {`);
expect(start).toBeGreaterThan(-1);
const bodyStart = source.indexOf('{', start) + 1;
const end = source.indexOf('\n }', bodyStart);
return new Function('data', source.slice(bodyStart, end)) as (data: Record<string, unknown>) => void;
}
function makeApp() {
const notifications: string[] = [];
const app = {
sessions: new Map([[SESSION, { status: 'idle' }]]),
activeSessionId: 'another-session',
respawnStatus: {},
notificationManager: { preferences: { stuckThresholdMs: THRESHOLD } },
idleTimers: new Map<string, unknown>(),
pendingHooks: new Map<string, Set<string>>(),
tabAlerts: new Map<string, string>(),
notifications,
renderSessionTabs() {},
sendPendingCtrlL() {},
_updateLocalEchoState() {},
_notifySession: (_id: string, _level: string, kind: string) => notifications.push(kind),
clearPendingHooks(sessionId: string) {
app.pendingHooks.delete(sessionId);
app.tabAlerts.delete(sessionId);
},
onIdle: method('app.js', '_onSessionIdle(data)'),
onWorking: method('app.js', '_onSessionWorking(data)'),
onStop: method('settings-ui.js', '_onHookStop(data)'),
};
return app;
}
describe('the keep-alive turn spends nothing on the page', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('keeps the stuck timer of the last real turn: not cleared, not re-armed', () => {
const app = makeApp();
app.onIdle({ id: SESSION });
const realTimer = app.idleTimers.get(SESSION);
expect(realTimer).toBeDefined();
vi.advanceTimersByTime(THRESHOLD / 2);
app.onWorking({ id: SESSION, keepAlive: true });
expect(app.idleTimers.get(SESSION)).toBe(realTimer);
app.onIdle({ id: SESSION, keepAlive: true });
expect(app.idleTimers.get(SESSION)).toBe(realTimer);
// Fires once, on the real turn's schedule, and nothing follows the keep-alive.
vi.advanceTimersByTime(THRESHOLD / 2);
expect(app.notifications).toEqual(['session-stuck']);
vi.advanceTimersByTime(THRESHOLD * 2);
expect(app.notifications).toEqual(['session-stuck']);
});
it('a real turn still restarts the stuck timer', () => {
const app = makeApp();
app.onIdle({ id: SESSION });
app.onWorking({ id: SESSION });
expect(app.idleTimers.has(SESSION)).toBe(false);
app.onIdle({ id: SESSION });
expect(app.idleTimers.has(SESSION)).toBe(true);
});
it("its stop leaves a pending idle alert and raises no 'Response Complete'", () => {
const app = makeApp();
app.pendingHooks.set(SESSION, new Set(['idle_prompt']));
app.tabAlerts.set(SESSION, 'idle');
app.onStop({ sessionId: SESSION, acknowledgedReason: 'cache keep-alive' });
expect([...(app.pendingHooks.get(SESSION) ?? [])]).toEqual(['idle_prompt']);
expect(app.tabAlerts.get(SESSION)).toBe('idle');
// _onSessionWorking deletes the tab alert only with no pending hook, so it stays too.
app.onWorking({ id: SESSION, keepAlive: true });
expect(app.tabAlerts.get(SESSION)).toBe('idle');
expect(app.notifications).toEqual([]);
});
it('a real stop still clears and notifies', () => {
const app = makeApp();
app.pendingHooks.set(SESSION, new Set(['idle_prompt']));
app.onStop({ sessionId: SESSION });
expect(app.pendingHooks.has(SESSION)).toBe(false);
expect(app.notifications).toEqual(['hook-stop']);
});
});
/**
* `promptCacheKeepWarmMaxHours` is clamped to the settings schema's range (0.5 to 24) before the
* save: the schema is strict, so one value outside it would reject every other change in the body.
*/
describe('the keep-warm cap is clamped before the save', () => {
it.each([
['0.25', 0.5],
['30', 24],
['', 2],
['abc', 2],
['3.5', 3.5],
])('%j saves as %d', (input, expected) => {
const source = readFileSync(resolve(PUBLIC, 'settings-ui.js'), 'utf8');
const match = source.match(/promptCacheKeepWarmMaxHours:\s*\n?((?:\s*\/\/.*\n)*)\s*([^\n]+),\n/);
expect(match).not.toBeNull();
const document = { getElementById: () => ({ value: input }) };
const value = new Function('document', `return ${match![2]};`)(document);
expect(value).toBe(expected);
});
});
+68
View File
@@ -0,0 +1,68 @@
/**
* @fileoverview The prompt-cache keep-warm rows (Session Options → Respawn switch, App Settings
* → Automation default and cap) in Chinese (zh-CN): each label and description runs through
* the real translator over the real index.html in JSDOM and reads in Chinese with no English
* left beyond the product names, and every new key, the toggle toasts included, is in the
* dictionary exactly once (a repeated key in the object literal silently replaces the first).
*
* Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterAll, describe, expect, it } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8');
const INDEX = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
type Api = { configure(o: Record<string, string>): void; start(): void };
const IDS = ['modalKeepWarmEnabled', 'appSettingsPromptCacheKeepWarm', 'appSettingsPromptCacheKeepWarmMaxHours'];
/** What may stay Latin: the product names. */
const leftover = (text: string) =>
text.replace(/\b(Claude Code|Claude|Codeman|Ralph)\b/g, '').match(/[A-Za-z]+/g) ?? [];
function rowStrings(doc: Document, id: string): { label: string; description: string } {
const row = doc.getElementById(id)!.closest('.set-row')!;
return {
label: row.querySelector('.set-row-label')!.textContent!.trim(),
description: row.querySelector('.set-row-desc')!.textContent!.trim(),
};
}
const source = new JSDOM(INDEX);
const dom = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' });
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n;
api.start();
api.configure({ language: 'zh-CN' });
afterAll(() => {
source.window.close();
dom.window.close();
});
describe('the keep-warm rows in zh-CN', () => {
it.each(IDS)('translates the %s row, with no English left', (id) => {
const english = rowStrings(source.window.document, id);
const chinese = rowStrings(dom.window.document, id);
expect(english.label.length).toBeGreaterThan(0);
for (const key of ['label', 'description'] as const) {
expect(chinese[key]).not.toBe(english[key]);
expect(leftover(chinese[key])).toEqual([]);
}
});
it('adds each key to the dictionary once, the toasts included', () => {
const texts = IDS.flatMap((id) => Object.values(rowStrings(source.window.document, id)));
texts.push('hours', 'Prompt-cache keep-warm enabled', 'Prompt-cache keep-warm disabled');
for (const text of texts) {
const quoted = I18N.split(`'${text}'`).length + I18N.split(`"${text}"`).length - 2;
const bare = /^\w+$/.test(text) ? I18N.split(`\n ${text}: `).length - 1 : 0;
expect(quoted + bare, text).toBe(1);
}
});
});
+175
View File
@@ -0,0 +1,175 @@
/**
* @fileoverview The launch half of the prompt-cache keep-warm (Discussion #554): which launch
* carries `idleCompaction: false` (decideIdleCompactionOptOut, tmux-manager.ts), that every
* create and relaunch path decides it and records it on the mux record, that the session hands
* its choice to both, that a restart keeps the choice and the real-prompt clock, that the app
* settings read defaults to OFF and a 2 h cap, and that Read My Mind's capture runs through the
* filter that drops the nudge. The argv shape itself is pinned in cli-registry-spawn-golden.
*
* Port: N/A (pure functions and source slices; no server).
*/
import { mkdir, writeFile, rm } from 'node:fs/promises';
import { readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { describe, it, expect, afterEach } from 'vitest';
import { decideIdleCompactionOptOut } from '../src/tmux-manager.js';
import { readKeepWarmSettings, readKeepWarmSettingsOrThrow } from '../src/hooks-config.js';
import { SETTINGS_PATH } from '../src/web/route-helpers.js';
import { DEFAULT_KEEP_WARM_MAX_HOURS } from '../src/prompt-cache-keepwarm.js';
const src = (file: string) => readFileSync(resolve(import.meta.dirname, '../src', file), 'utf8');
/** The body of `signature` up to the next class member, so a pin cannot pass on a sibling. */
function methodSlice(source: string, signature: string): string {
const start = source.indexOf(signature);
expect(start, `${signature} not found`).toBeGreaterThan(-1);
const next = source
.slice(start + signature.length)
.search(/\n {2}(?:(?:private|public|protected|static|async|get|set) )*\w+\(/);
return source.slice(start, next === -1 ? undefined : start + signature.length + next);
}
describe('decideIdleCompactionOptOut', () => {
const on = {
mode: 'claude' as const,
statusLine: 'injected' as const,
keepWarm: true,
keepWarmDefault: false,
spawnedByAgent: false,
claudeCliVersion: '2.1.290',
};
it('adds the key when keep-warm is on, the exporter is injected and Claude Code knows the setting', () => {
expect(decideIdleCompactionOptOut(on)).toBe(true);
expect(decideIdleCompactionOptOut({ ...on, claudeCliVersion: '2.1.300' })).toBe(true);
expect(decideIdleCompactionOptOut({ ...on, keepWarm: undefined, keepWarmDefault: true })).toBe(true);
});
it.each<[string, Partial<typeof on> & Record<string, unknown>]>([
['the version is unknown (fail-closed, like --name)', { claudeCliVersion: null }],
['2.1.289, which idle-compacts but has no idleCompaction setting', { claudeCliVersion: '2.1.289' }],
['keep-warm is off for the session', { keepWarm: false, keepWarmDefault: true }],
['keep-warm is left to an app default that is off', { keepWarm: undefined }],
[
'an agent-spawned session on the app default',
{ keepWarm: undefined, keepWarmDefault: true, spawnedByAgent: true },
],
['Plan Usage was off at launch (keep-warm cannot run)', { statusLine: 'collection-off' }],
["the workspace's own statusLine (keep-warm cannot run)", { statusLine: 'workspace-statusline' }],
['a remote or Docker launch', { statusLine: 'remote' }],
['a CLI with no exporter at all', { statusLine: undefined }],
['a CLI that declares no such gate', { mode: 'codex' }],
])('leaves it off when %s', (_label, over) => {
expect(decideIdleCompactionOptOut({ ...on, ...over } as Parameters<typeof decideIdleCompactionOptOut>[0])).toBe(
false
);
});
});
describe('every launch decides the opt-out and records it', () => {
const tmux = src('tmux-manager.ts');
it.each(['async createSession(', 'async respawnPane('])('%s resolves it, renders it and records it', (sig) => {
const body = methodSlice(tmux, sig);
expect(body).toContain(
'const idleCompactionOff = await this._resolveIdleCompactionOptOut(mode, statusLine, keepWarm, spawnedByAgent);'
);
expect(body).toMatch(/statusLineCommand,\n\s+idleCompactionOff,\n\s+sessionName: cliName,/);
});
it('createSession records it on the new mux record, next to statusLine', () => {
expect(methodSlice(tmux, 'async createSession(')).toMatch(/name,\n\s+statusLine,\n\s+idleCompactionOff,\n\s+};/);
});
it('respawnPane re-records it on the surviving mux record', () => {
expect(methodSlice(tmux, 'async respawnPane(')).toContain('session.idleCompactionOff = idleCompactionOff;');
});
it('the slice stops at the next member, private async ones included', () => {
expect(methodSlice(tmux, 'async createSession(')).not.toContain('async respawnPane(');
expect(methodSlice(tmux, 'private async _resolveStatusLine(')).not.toContain('_resolveIdleCompactionOptOut(\n');
});
it('the session hands its choice to the create AND the relaunch option bags', () => {
const session = src('session.ts');
const handoff = /keepWarm: this\._keepWarmEnabled,\n\s+spawnedByAgent: !!this\._parentSessionId,/;
expect(session.match(new RegExp(handoff, 'g'))).toHaveLength(2);
expect(methodSlice(session, 'private _buildRespawnPaneOptions(')).toMatch(handoff);
expect(methodSlice(session, 'async startInteractive(')).toMatch(handoff);
});
});
describe('restart keeps the choice and the real-prompt clock', () => {
it('both constructors that rebuild a session from state.json pass them', () => {
for (const file of ['web/server.ts', 'web/routes/reboot-restore-routes.ts']) {
const source = src(file);
expect(source, file).toMatch(/lastRealPromptAt: saved(State\?)?\.lastRealPromptAt,/);
expect(source, file).toMatch(/keepWarmEnabled: saved(State\?)?\.keepWarmEnabled,/);
}
});
});
describe('the pane read behind the last two guards', () => {
it('reads the usage-limit message off the same frame and counts only a reset still ahead', () => {
const port = src('web/server.ts');
const at = port.indexOf('readPane: (view) => {');
expect(at).toBeGreaterThan(-1);
const body = port.slice(at, port.indexOf('publish: (id, status) => {', at));
expect(body).toContain('this.mux.capturePaneText?.(muxName, undefined, { styles: true })');
expect(body).toContain('const limit = detectUsageLimitPause(plainFrame(text));');
expect(body).toContain('limitShown: limit !== null && limit.resetAt > Date.now(),');
expect(body).toContain('composerEmpty: composerIsEmpty(text,');
});
});
describe('Read My Mind capture', () => {
it('records a transcript prompt only through the filter that drops the nudge', () => {
expect(methodSlice(src('web/server.ts'), 'private async captureIntentPrompt(')).toContain(
'intentStore.recordPrompt('
);
const record = methodSlice(src('intent-store.ts'), 'recordPrompt(');
expect(record.indexOf('if (!isCapturablePrompt(rawText)) return false;')).toBeGreaterThan(-1);
expect(src('intent-store.ts')).toContain('if (text.trim() === KEEP_WARM_NUDGE) return false;');
});
});
describe('readKeepWarmSettings', () => {
afterEach(async () => {
await rm(SETTINGS_PATH, { force: true });
});
it('reads the temp HOME the suite set up, never a real data dir', () => {
expect(SETTINGS_PATH.startsWith(join(tmpdir(), 'codeman-vitest-'))).toBe(true);
});
it('defaults to off with a 2 h cap', async () => {
expect(DEFAULT_KEEP_WARM_MAX_HOURS).toBe(2);
expect(await readKeepWarmSettings()).toEqual({ keepWarmDefault: false, maxHours: 2 });
});
it('reads the default and the cap, and ignores a cap that is not a positive number', async () => {
await mkdir(dirname(SETTINGS_PATH), { recursive: true });
await writeFile(SETTINGS_PATH, JSON.stringify({ promptCacheKeepWarm: true, promptCacheKeepWarmMaxHours: 3.5 }));
expect(await readKeepWarmSettings()).toEqual({ keepWarmDefault: true, maxHours: 3.5 });
await writeFile(SETTINGS_PATH, JSON.stringify({ promptCacheKeepWarm: 'yes', promptCacheKeepWarmMaxHours: -1 }));
expect(await readKeepWarmSettings()).toEqual({ keepWarmDefault: false, maxHours: 2 });
});
/**
* The service keeps what it reads until the next keep-warm settings change, so its reader must
* never hand back a fallback for a file it could not read: settings.json is rewritten in place
* by several routes, and defaults kept from a mid-write read would hold keep-warm off.
*/
it('the service reader rejects an unreadable file instead of falling back, and the server wires it', async () => {
expect(await readKeepWarmSettingsOrThrow()).toEqual({ keepWarmDefault: false, maxHours: 2 });
await mkdir(dirname(SETTINGS_PATH), { recursive: true });
await writeFile(SETTINGS_PATH, JSON.stringify({ promptCacheKeepWarm: true, promptCacheKeepWarmMaxHours: 3.5 }));
expect(await readKeepWarmSettingsOrThrow()).toEqual({ keepWarmDefault: true, maxHours: 3.5 });
await writeFile(SETTINGS_PATH, '{"promptCacheKeepWarm": tr');
await expect(readKeepWarmSettingsOrThrow()).rejects.toThrow();
expect(await readKeepWarmSettings()).toEqual({ keepWarmDefault: false, maxHours: 2 });
expect(src('web/server.ts')).toContain('readSettings: readKeepWarmSettingsOrThrow,');
});
});
+3
View File
@@ -83,6 +83,9 @@ export function createMockRouteContext(options?: {
restoreRespawnController: vi.fn(),
saveRespawnConfig: vi.fn(),
// -- KeepWarmPort --
keepWarm: { schedule: vi.fn(), rescheduleAll: vi.fn(), remove: vi.fn() },
// -- ConfigPort --
store: {
getConfig: vi.fn(() => ({ ralphEnabled: false, maxConcurrentSessions: 5 })),
+26 -3
View File
@@ -4,7 +4,13 @@
*/
import { EventEmitter } from 'node:events';
import { vi } from 'vitest';
import type { PromptCacheStatus, SessionAttachmentHistoryItem, SessionStatus, SessionRemote } from '../../src/types.js';
import type {
KeepWarmStatus,
PromptCacheStatus,
SessionAttachmentHistoryItem,
SessionStatus,
SessionRemote,
} from '../../src/types.js';
/**
* Enhanced mock session for testing RespawnController.
@@ -48,6 +54,21 @@ export class MockSession extends EventEmitter {
this.promptCache = status;
return changed;
}
/** Mirrors the prompt-cache keep-warm surface on Session (prompt-cache-keepwarm.ts). */
keepAliveTurnActive = false;
endKeepAliveTurn(): void {
this.keepAliveTurnActive = false;
}
keepWarmEnabled: boolean | undefined = undefined;
setKeepWarmEnabled(enabled: boolean | undefined): void {
this.keepWarmEnabled = enabled;
}
keepWarm: KeepWarmStatus | null = null;
setKeepWarmStatus(status: KeepWarmStatus): boolean {
const changed = JSON.stringify(status) !== JSON.stringify(this.keepWarm);
this.keepWarm = status;
return changed;
}
private _activeChildProcesses: { pid: number; command: string }[] = [];
ralphTracker: null = null;
writeBuffer: string[] = [];
@@ -76,8 +97,10 @@ export class MockSession extends EventEmitter {
this.claudeSessionId = newId;
}
/** Mirrors Session.markPromptSubmitted. */
markPromptSubmitted(): void {
/** Mirrors Session.markPromptSubmitted; records the hook's prompt text it was handed. */
submittedPrompts: Array<string | undefined> = [];
markPromptSubmitted(prompt?: string): void {
this.submittedPrompts.push(prompt);
this.lastSubmitAt = Date.now();
}
+890
View File
@@ -0,0 +1,890 @@
/**
* Prompt-cache keep-warm (src/prompt-cache-keepwarm.ts): the rulebook as a table, the
* effective opt-in shared with the launch, the composer-empty read it depends on, the two
* submit clocks on a real Session, and the service's timer and nudge path with a fake
* session and fake ports. Fake timers in the service block; nothing here touches tmux.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
decideKeepWarm,
keepWarmOn,
PromptCacheKeepWarmService,
KEEP_WARM_NUDGE,
KEEP_WARM_LEAD_MS,
KEEP_WARM_RETRY_MS,
KEEP_WARM_LAST_CHANCE_MS,
KEEP_WARM_MIN_TOKENS,
KEEP_WARM_RECENT_INPUT_MS,
KEEP_WARM_TURN_WINDOW_MS,
type KeepWarmFacts,
type KeepWarmPorts,
type KeepWarmSessionView,
} from '../src/prompt-cache-keepwarm.js';
import { composerIsEmpty, plainFrame } from '../src/session-submit-verifier.js';
import { detectUsageLimitPause } from '../src/usage-limit-patterns.js';
import { isCapturablePrompt } from '../src/intent-store.js';
import { Session } from '../src/session.js';
import type { KeepWarmStatus, PromptCacheStatus } from '../src/types.js';
const NOW = Date.UTC(2026, 9, 6, 22, 0, 0);
const HOUR = 3_600_000;
function warmCache(expiresAt: number, extra: Partial<PromptCacheStatus> = {}): PromptCacheStatus {
return { warm: true, ttl: '1h', expiresAt, recacheTokensIfCold: 110_000, misses: 0, ...extra };
}
/** Everything green and due: the one shape that nudges. */
function dueFacts(over: Partial<KeepWarmFacts> = {}): KeepWarmFacts {
return {
now: NOW,
choice: true,
appDefault: false,
spawnedByAgent: false,
statusLine: 'injected',
cache: warmCache(NOW + KEEP_WARM_LEAD_MS - 1000),
idle: true,
paneAlive: true,
limitPaused: false,
automationActive: false,
pendingApproval: false,
composerEmpty: true,
lastHumanInputAt: NOW - HOUR,
budgetAnchorAt: NOW - HOUR,
maxKeepWarmMs: 2 * HOUR,
...over,
};
}
describe('decideKeepWarm', () => {
it('nudges only when every guard holds', () => {
expect(decideKeepWarm(dueFacts())).toEqual({ action: 'nudge' });
});
it('nudges on the app default, and for an agent-spawned session switched on for itself', () => {
expect(decideKeepWarm(dueFacts({ choice: undefined, appDefault: true }))).toEqual({ action: 'nudge' });
expect(decideKeepWarm(dueFacts({ choice: true, spawnedByAgent: true }))).toEqual({ action: 'nudge' });
});
it.each<[string, Partial<KeepWarmFacts>, string]>([
['switched off for the session', { choice: false, appDefault: true }, 'off'],
['no choice and the app default off', { choice: undefined }, 'off'],
[
'an agent-spawned session on the app default',
{ choice: undefined, appDefault: true, spawnedByAgent: true },
'agent-spawned',
],
['Plan Usage off at launch', { statusLine: 'collection-off' }, '(collection-off)'],
["the workspace's own statusLine", { statusLine: 'workspace-statusline' }, '(workspace-statusline)'],
['a remote or Docker launch', { statusLine: 'remote' }, '(remote)'],
['a launch nobody recorded (a CLI without the exporter, a direct PTY)', { statusLine: undefined }, '(unknown)'],
['no report yet', { cache: null }, 'no cache report yet'],
['a cold cache', { cache: { warm: false } }, 'cold'],
[
'a context compacted this gap (the shape Session.setPromptCache stamps: no re-cache size)',
{ cache: warmCache(NOW + 60_000, { recacheTokensIfCold: undefined, compactedAt: NOW - 60_000 }) },
'compacted',
],
['warm but no expiry to schedule on', { cache: { warm: true, recacheTokensIfCold: 110_000 } }, 'cold'],
['the five-minute lifetime', { cache: warmCache(NOW + 4 * 60_000, { ttl: '5m' }) }, '5-minute'],
[
'a small context',
{ cache: warmCache(NOW + 60_000, { recacheTokensIfCold: KEEP_WARM_MIN_TOKENS - 1 }) },
'too small',
],
['a dead pane', { paneAlive: false }, 'no live pane'],
['a usage-limit pause', { limitPaused: true }, 'usage-limit'],
['the cap after the last prompt', { budgetAnchorAt: NOW - 2 * HOUR }, 'window after the last prompt'],
])('stops for %s', (_label, over, reason) => {
const d = decideKeepWarm(dueFacts(over));
expect(d.action).toBe('stop');
expect(d.action === 'stop' && d.reason).toContain(reason);
});
it('waits for the due time, five minutes before the expiry, when it is not due yet', () => {
const expiresAt = NOW + HOUR;
expect(decideKeepWarm(dueFacts({ cache: warmCache(expiresAt) }))).toEqual({
action: 'wait',
until: expiresAt - KEEP_WARM_LEAD_MS,
reason: 'not due yet',
});
});
it('gives up inside the last-chance minute rather than race the expiry', () => {
const d = decideKeepWarm(dueFacts({ cache: warmCache(NOW + KEEP_WARM_LAST_CHANCE_MS - 1) }));
expect(d).toMatchObject({ action: 'stop', reason: expect.stringContaining('too close') });
});
it.each<[string, Partial<KeepWarmFacts>, string]>([
['the session is working', { idle: false }, 'working'],
['a dialog is open', { pendingApproval: true }, 'dialog'],
['the composer holds a draft', { composerEmpty: false }, 'composer holds text'],
// Neither ends with a turn, so a stop here would leave an idle session with no trigger.
['respawn, Ralph or an agent is driving (a long-poll waiter included)', { automationActive: true }, 'driving'],
['one capture of the composer failed', { composerEmpty: null }, 'cannot read the composer'],
['someone typed a moment ago', { lastHumanInputAt: NOW - KEEP_WARM_RECENT_INPUT_MS + 1 }, 'typed'],
])('defers by a minute while %s', (_label, over, reason) => {
const d = decideKeepWarm(dueFacts(over));
expect(d).toMatchObject({
action: 'wait',
until: NOW + KEEP_WARM_RETRY_MS,
reason: expect.stringContaining(reason),
});
});
it('clamps a deferral to the last-chance line so a retry never lands on an expired cache', () => {
const expiresAt = NOW + KEEP_WARM_LAST_CHANCE_MS + 30_000; // due, 90 s left
const d = decideKeepWarm(dueFacts({ cache: warmCache(expiresAt), idle: false }));
expect(d).toMatchObject({ action: 'wait', until: expiresAt - KEEP_WARM_LAST_CHANCE_MS });
});
it('checks the cap before the schedule, so a session past its window never arms a timer', () => {
const d = decideKeepWarm(dueFacts({ cache: warmCache(NOW + HOUR), budgetAnchorAt: NOW - 4 * HOUR }));
expect(d.action).toBe('stop');
});
});
describe('keepWarmOn', () => {
it('is the session choice, else the app default, which an agent-spawned session does not inherit', () => {
expect(keepWarmOn(true, false, false)).toBe(true);
expect(keepWarmOn(false, true, false)).toBe(false);
expect(keepWarmOn(undefined, true, false)).toBe(true);
expect(keepWarmOn(undefined, false, false)).toBe(false);
expect(keepWarmOn(undefined, true, true)).toBe(false);
expect(keepWarmOn(true, false, true)).toBe(true);
});
});
describe('composerIsEmpty', () => {
const glyph = '\u276f';
const rule = '─'.repeat(20);
it('reads the last composer down to its bottom rule: empty, a draft, or nothing to read', () => {
expect(composerIsEmpty(`transcript\n${glyph}\u00a0\n${rule}\n bypass permissions on`, glyph)).toBe(true);
expect(composerIsEmpty(`${glyph} old prompt\n● ok\n${glyph} half a draft\n${rule}`, glyph)).toBe(false);
expect(composerIsEmpty('Accessing workspace:\n Yes, I trust this folder', glyph)).toBeUndefined();
expect(composerIsEmpty(`${glyph} anything\n${rule}`, '')).toBeUndefined();
});
it('sees a draft that opens with a newline, on the continuation lines under a blank glyph line', () => {
expect(composerIsEmpty(`${glyph} \n unfinished draft\n${rule}\n bypass permissions on`, glyph)).toBe(false);
});
it('never takes an indented draft line holding the glyph or a rule for the composer edges', () => {
expect(composerIsEmpty(`${glyph} explain this terminal prompt:\n ${glyph}\n${rule}`, glyph)).toBe(false);
expect(composerIsEmpty(`${glyph} \n ${rule} under the line\n${rule}\n bypass permissions on`, glyph)).toBe(false);
});
it('gives no verdict without the bottom rule that bounds the composer', () => {
expect(composerIsEmpty(`${glyph} \n unfinished draft`, glyph)).toBeUndefined();
});
/**
* `capture-pane -p -e` of one Claude Code 2.1.300 pane, byte for byte apart from the
* statusline row (dropped): the composer with its prompt suggestion, empty, holding a draft,
* holding the same draft with the cursor moved home, and holding a pasted-text chip. The suggestion's first letter sits
* in the reverse-video cursor cell and the rest is faint; a typed draft is neither.
*/
const RULE = `\x1b[38;5;244m${'─'.repeat(120)}`;
const FOOTER = ' \x1b[38;5;220m⏵⏵ auto mode on\x1b[38;5;246m (shift+tab to cycle)\x1b[39m';
const frame = (composer: string) =>
['\x1b[38;5;246m✻ Cooked for 2s · done 6:56 PM\x1b[39m', '', RULE, composer, RULE, FOOTER].join('\n');
const SUGGESTION = '\x1b[39m❯\u00a0\x1b[7mw\x1b[0;2m\x1b[39m\x1b[49mrite tests for add\x1b[0m\x1b[39m\x1b[49m';
const EMPTY = '\x1b[39m❯\u00a0\x1b[7m \x1b[0m\x1b[39m\x1b[49m';
const DRAFT = '\x1b[39m❯\u00a0half a draft\x1b[7m \x1b[0m\x1b[39m\x1b[49m';
const DRAFT_CURSOR_HOME = '\x1b[39m❯\u00a0\x1b[7mh\x1b[0m\x1b[39m\x1b[49malf a draft';
// A 40-line paste collapses to a chip, drawn plain like typed text.
const PASTED_CHIP = '\x1b[39m❯\u00a0[Pasted text #1 +40 lines]\x1b[7m \x1b[0m\x1b[39m\x1b[49m';
it("reads Claude Code's prompt suggestion as empty and a typed draft as text, from a real styled capture", () => {
expect(composerIsEmpty(frame(SUGGESTION), glyph)).toBe(true);
expect(composerIsEmpty(frame(EMPTY), glyph)).toBe(true);
expect(composerIsEmpty(frame(DRAFT), glyph)).toBe(false);
// The cursor cell holds the draft's first letter, but no faint text follows it.
expect(composerIsEmpty(frame(DRAFT_CURSOR_HOME), glyph)).toBe(false);
expect(composerIsEmpty(frame(PASTED_CHIP), glyph)).toBe(false);
});
it('reads a suggestion drawn without a cursor cell as empty, and a plain capture of it as text', () => {
expect(composerIsEmpty(frame('❯\u00a0\x1b[2mwrite tests for add\x1b[0m'), glyph)).toBe(true);
expect(composerIsEmpty(frame('❯\u00a0\x1b[2mwrite \x1b[22mtyped\x1b[0m'), glyph)).toBe(false);
// Without the styles nothing tells the two apart, which is why the pane is read with -e.
expect(composerIsEmpty(frame('❯\u00a0write tests for add'), glyph)).toBe(false);
});
it("reads text styled with tmux's colon sub-parameters, which stripAnsi leaves behind", () => {
const limit = "You've hit your \x1b[4:3mlimit\x1b[0m · resets 1:40pm (UTC)";
expect(detectUsageLimitPause(plainFrame(limit), NOW)).not.toBeNull();
expect(composerIsEmpty(frame('\x1b[4:3m❯\x1b[0m\u00a0'), glyph)).toBe(true);
});
it("never takes an extended colour's arguments for the faint or reverse attributes", () => {
expect(composerIsEmpty(frame('❯\u00a0\x1b[38;2;2;7;2mtyped in truecolour\x1b[0m'), glyph)).toBe(false);
expect(composerIsEmpty(frame('❯\u00a0\x1b[38;5;2mtyped in a 256 colour\x1b[0m'), glyph)).toBe(false);
});
});
describe('Read My Mind ignores the nudge', () => {
it('drops the exact keep-alive line and nothing else', () => {
expect(isCapturablePrompt(KEEP_WARM_NUDGE)).toBe(false);
expect(isCapturablePrompt(`${KEEP_WARM_NUDGE}\n`)).toBe(false);
expect(isCapturablePrompt(`why did "${KEEP_WARM_NUDGE}" show up in my session?`)).toBe(true);
expect(isCapturablePrompt('fix the login bug')).toBe(true);
});
});
describe('the two submit clocks on a Session', () => {
it('a nudge moves lastSubmitAt but not lastRealPromptAt, from the write and from the hook', () => {
vi.useFakeTimers();
try {
vi.setSystemTime(NOW);
const session = new Session({ workingDir: '/tmp' });
session.write('real prompt\r', { fromUser: true });
expect(session.lastRealPromptAt).toBe(NOW);
vi.setSystemTime(NOW + HOUR);
session.beginKeepAliveTurn(KEEP_WARM_TURN_WINDOW_MS);
session.write(`${KEEP_WARM_NUDGE}\r`);
session.markPromptSubmitted(); // its UserPromptSubmit hook
expect(session.lastSubmitAt).toBe(NOW + HOUR);
expect(session.lastRealPromptAt).toBe(NOW);
// A person's prompt ends the keep-alive window at once and counts again.
vi.setSystemTime(NOW + HOUR + 1000);
session.write('next real prompt\r', { fromUser: true });
expect(session.keepAliveTurnActive).toBe(false);
expect(session.lastRealPromptAt).toBe(NOW + HOUR + 1000);
// A prompt typed in an attached tmux client reaches Codeman only through the hook.
vi.setSystemTime(NOW + 2 * HOUR);
session.markPromptSubmitted();
expect(session.lastRealPromptAt).toBe(NOW + 2 * HOUR);
// ... even inside a keep-alive window: its hook carries the prompt text, and anything but
// the nudge's own ends the window, so it counts and its stop hook alerts normally.
vi.setSystemTime(NOW + 3 * HOUR);
session.beginKeepAliveTurn(KEEP_WARM_TURN_WINDOW_MS);
session.markPromptSubmitted(KEEP_WARM_NUDGE);
expect(session.keepAliveTurnActive).toBe(true);
expect(session.lastRealPromptAt).toBe(NOW + 2 * HOUR);
vi.setSystemTime(NOW + 3 * HOUR + 5000);
session.markPromptSubmitted('fix the login bug');
expect(session.keepAliveTurnActive).toBe(false);
expect(session.lastRealPromptAt).toBe(NOW + 3 * HOUR + 5000);
} finally {
vi.useRealTimers();
}
});
/**
* The window is a ceiling, not the turn's length: a slow keep-alive turn's idle prompt lands
* about a minute after it ends, so the window closes on that prompt (the route calls
* endKeepAliveTurn) or on any other submit, never by a short clock alone.
*/
it('the keep-alive window survives the nudge and a bare Enter, and ends on any other submit', () => {
const session = new Session({ workingDir: '/tmp' });
session.beginKeepAliveTurn(KEEP_WARM_TURN_WINDOW_MS);
session.write(`${KEEP_WARM_NUDGE}\r`);
session.write('\r');
expect(session.keepAliveTurnActive).toBe(true);
session.write('/compact\r');
expect(session.keepAliveTurnActive).toBe(false);
session.beginKeepAliveTurn(KEEP_WARM_TURN_WINDOW_MS);
session.write('typed but not sent');
expect(session.keepAliveTurnActive).toBe(true);
session.endKeepAliveTurn();
expect(session.keepAliveTurnActive).toBe(false);
expect(KEEP_WARM_TURN_WINDOW_MS).toBeGreaterThanOrEqual(10 * 60_000);
});
it('persists the real-prompt clock and the choice, and restores both', () => {
const session = new Session({ workingDir: '/tmp', lastRealPromptAt: NOW, keepWarmEnabled: true });
expect(session.toState()).toMatchObject({ lastRealPromptAt: NOW, keepWarmEnabled: true });
const fresh = new Session({ workingDir: '/tmp' });
expect(fresh.lastRealPromptAt).toBe(0);
expect(fresh.toState().lastRealPromptAt).toBeUndefined();
});
it('seeds the real-prompt clock from lastSubmitAt for a state.json written before it existed', () => {
expect(new Session({ workingDir: '/tmp', lastSubmitAt: NOW - HOUR }).lastRealPromptAt).toBe(NOW - HOUR);
const both = new Session({ workingDir: '/tmp', lastSubmitAt: NOW, lastRealPromptAt: NOW - HOUR });
expect(both.lastRealPromptAt).toBe(NOW - HOUR);
});
});
// ─── Service ─────────────────────────────────────────────────────────────────
class FakeSession implements KeepWarmSessionView {
id = 'sess-1';
createdAt = NOW - HOUR;
pid: number | null = 4242;
paneExit: unknown = undefined;
parentSessionId: string | undefined = undefined;
promptCache: PromptCacheStatus | null = null;
statusLineExporter = 'injected' as const;
idleCompactionOptOut = true;
keepWarmEnabled: boolean | undefined = undefined;
isWorking = false;
isLimitPaused = false;
lastHumanInputAt = NOW - HOUR;
lastRealPromptAt = NOW - HOUR;
idle = true;
writes: string[] = [];
keepAliveWindows: number[] = [];
writeOk = true;
isIdle(): boolean {
return this.idle;
}
beginKeepAliveTurn(windowMs: number): void {
this.keepAliveWindows.push(windowMs);
}
keepAliveWindowsEnded = 0;
endKeepAliveTurn(): void {
this.keepAliveWindowsEnded += 1;
}
async writeViaMux(data: string): Promise<boolean> {
this.writes.push(data);
return this.writeOk;
}
}
function makePorts(session: FakeSession, over: Partial<KeepWarmPorts> = {}) {
const published: KeepWarmStatus[] = [];
const ports: KeepWarmPorts = {
getSession: (id) => (id === session.id ? session : undefined),
sessionIds: () => [session.id],
readSettings: async () => ({ keepWarmDefault: false, maxHours: 2 }),
isAutomationActive: () => false,
hasPendingApproval: () => false,
readPane: () => ({ composerEmpty: true, limitShown: false }),
publish: (_id, status) => {
published.push(status);
},
...over,
};
return { ports, published, last: () => published[published.length - 1] };
}
/** Let the service's awaited settings read and write settle. */
const settle = async () => {
for (let i = 0; i < 5; i++) await Promise.resolve();
};
describe('PromptCacheKeepWarmService', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
it('arms on the cache report, nudges at the due time, and reports what it did', async () => {
const session = new FakeSession();
session.keepWarmEnabled = true;
session.promptCache = warmCache(NOW + HOUR);
const { ports, last } = makePorts(session);
const service = new PromptCacheKeepWarmService(ports);
service.schedule(session.id);
await settle();
expect(last()).toMatchObject({
enabled: true,
idleCompactionOff: true,
state: 'armed',
nextCheckAt: NOW + HOUR - KEEP_WARM_LEAD_MS,
nudges: 0,
});
expect(session.writes).toHaveLength(0);
await vi.advanceTimersByTimeAsync(HOUR - KEEP_WARM_LEAD_MS);
await settle();
expect(session.writes).toEqual([`${KEEP_WARM_NUDGE}\r`]);
expect(session.keepAliveWindows).toEqual([KEEP_WARM_TURN_WINDOW_MS]);
expect(last()).toMatchObject({ state: 'nudged', nudges: 1, lastNudgeAt: NOW + HOUR - KEEP_WARM_LEAD_MS });
// The nudge's own report moves the expiry out; the next evaluation arms on it.
session.promptCache = warmCache(NOW + 2 * HOUR - KEEP_WARM_LEAD_MS);
service.schedule(session.id);
await settle();
expect(last()).toMatchObject({ state: 'armed', nudges: 1 });
service.stop();
});
it('reads the composer only when otherwise due, and retries while it holds a draft', async () => {
const session = new FakeSession();
session.keepWarmEnabled = true;
session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000);
let draft = true;
const reads: number[] = [];
const { ports, last } = makePorts(session, {
readPane: () => {
reads.push(Date.now());
return { composerEmpty: !draft, limitShown: false };
},
});
const service = new PromptCacheKeepWarmService(ports);
service.schedule(session.id);
await settle();
expect(reads).toHaveLength(1);
expect(last()).toMatchObject({
state: 'waiting',
reason: 'the composer holds text',
nextCheckAt: NOW + KEEP_WARM_RETRY_MS,
});
expect(session.writes).toHaveLength(0);
draft = false;
await vi.advanceTimersByTimeAsync(KEEP_WARM_RETRY_MS);
await settle();
expect(reads).toHaveLength(2);
expect(session.writes).toHaveLength(1);
expect(last()).toMatchObject({ state: 'nudged', nudges: 1 });
service.stop();
});
it('follows the app-wide default when the session has not chosen, and publishes the effective flag', async () => {
const session = new FakeSession();
session.promptCache = warmCache(NOW + HOUR);
const { ports, last } = makePorts(session, { readSettings: async () => ({ keepWarmDefault: true, maxHours: 2 }) });
const service = new PromptCacheKeepWarmService(ports);
service.schedule(session.id);
await settle();
expect(last()).toMatchObject({ enabled: true, state: 'armed' });
session.keepWarmEnabled = false;
service.schedule(session.id);
await settle();
expect(last()).toMatchObject({ enabled: false, state: 'off' });
expect(vi.getTimerCount()).toBe(0);
service.stop();
});
it('applies the cap from settings and never arms past it', async () => {
const session = new FakeSession();
session.keepWarmEnabled = true;
session.lastRealPromptAt = NOW - 2.5 * HOUR;
session.promptCache = warmCache(NOW + HOUR);
const { ports, last } = makePorts(session, { readSettings: async () => ({ keepWarmDefault: false, maxHours: 2 }) });
const service = new PromptCacheKeepWarmService(ports);
service.schedule(session.id);
await settle();
expect(last()).toMatchObject({
state: 'stopped',
reason: expect.stringContaining('window'),
budgetEndsAt: NOW - 0.5 * HOUR,
});
expect(vi.getTimerCount()).toBe(0);
service.stop();
});
/**
* A nudge only goes out inside the last five minutes before the expiry, so a retry armed a
* whole lead time later would land past it and stop: the failed write ends the keep-alive
* window (no turn is running) and retries a minute later, never past the last-chance line.
*/
it('keeps a failed write out of the tally, ends the keep-alive window and retries a minute later', async () => {
const session = new FakeSession();
session.keepWarmEnabled = true;
session.writeOk = false;
session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000);
const { ports, last } = makePorts(session);
const service = new PromptCacheKeepWarmService(ports);
service.schedule(session.id);
await settle();
expect(session.writes).toHaveLength(1);
expect(session.keepAliveWindowsEnded).toBe(1);
expect(last()).toMatchObject({
state: 'waiting',
nudges: 0,
nextCheckAt: NOW + KEEP_WARM_RETRY_MS,
reason: expect.stringContaining('could not be written'),
});
session.writeOk = true;
await vi.advanceTimersByTimeAsync(KEEP_WARM_RETRY_MS);
await settle();
expect(session.writes).toEqual([`${KEEP_WARM_NUDGE}\r`, `${KEEP_WARM_NUDGE}\r`]);
expect(session.keepAliveWindowsEnded).toBe(1);
expect(last()).toMatchObject({ state: 'nudged', nudges: 1 });
service.stop();
});
it('never arms the retry after a failed write past the last-chance line', async () => {
const session = new FakeSession();
session.keepWarmEnabled = true;
session.writeOk = false;
// Due, with only 30 s left before the last-chance line.
session.promptCache = warmCache(NOW + KEEP_WARM_LAST_CHANCE_MS + 30_000);
const { ports, last } = makePorts(session);
const service = new PromptCacheKeepWarmService(ports);
await service.schedule(session.id);
expect(last()).toMatchObject({ state: 'waiting', nextCheckAt: NOW + 30_000 });
await vi.advanceTimersByTimeAsync(30_000);
await settle();
expect(session.writes).toHaveLength(1);
expect(last()).toMatchObject({ state: 'stopped', reason: 'too close to expiry to land a turn' });
expect(vi.getTimerCount()).toBe(0);
service.stop();
});
it('does not nudge a session whose screen shows a usage limit, with auto-resume off', async () => {
const session = new FakeSession();
session.keepWarmEnabled = true;
session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000);
const { ports, last } = makePorts(session, { readPane: () => ({ composerEmpty: true, limitShown: true }) });
const service = new PromptCacheKeepWarmService(ports);
await service.schedule(session.id);
expect(session.writes).toHaveLength(0);
expect(last()).toMatchObject({ state: 'stopped', reason: 'usage-limit pause' });
service.stop();
});
it('does not nudge an agent that exited in a pane that kept its pid', async () => {
const session = new FakeSession();
session.keepWarmEnabled = true;
session.paneExit = { status: 137 };
session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000);
const { ports, last } = makePorts(session);
const service = new PromptCacheKeepWarmService(ports);
await service.schedule(session.id);
expect(session.writes).toHaveLength(0);
expect(last()).toMatchObject({ state: 'stopped', reason: 'no live pane' });
service.stop();
});
it('never arms a timer past what setTimeout honours, however far off the expiry', async () => {
const session = new FakeSession();
session.keepWarmEnabled = true;
session.promptCache = warmCache(NOW + 30 * 24 * HOUR);
const { ports } = makePorts(session, {
readSettings: async () => ({ keepWarmDefault: false, maxHours: 24 * 60 }),
});
const spy = vi.spyOn(globalThis, 'setTimeout');
const service = new PromptCacheKeepWarmService(ports);
await service.schedule(session.id);
expect(spy).toHaveBeenCalledTimes(1);
expect(spy.mock.calls[0][1]).toBe(2 ** 31 - 1);
spy.mockRestore();
service.stop();
});
it('stop() during the settings read sends nothing, arms nothing and publishes nothing', async () => {
const session = new FakeSession();
session.keepWarmEnabled = true;
session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000);
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
const { ports, published } = makePorts(session, {
readSettings: async () => {
await gate;
return { keepWarmDefault: false, maxHours: 2 };
},
});
const service = new PromptCacheKeepWarmService(ports);
const run = service.schedule(session.id);
service.stop();
release();
await run;
expect(session.writes).toHaveLength(0);
expect(published).toHaveLength(0);
expect(vi.getTimerCount()).toBe(0);
});
it('remove() while the nudge is being written leaves no timer, tally or status behind', async () => {
const session = new FakeSession();
session.keepWarmEnabled = true;
session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000);
let release!: (ok: boolean) => void;
session.writeViaMux = async (data: string) => {
session.writes.push(data);
return new Promise<boolean>((r) => (release = r));
};
const { ports, published } = makePorts(session);
const service = new PromptCacheKeepWarmService(ports);
const run = service.schedule(session.id);
await settle();
expect(session.writes).toHaveLength(1);
service.remove(session.id);
release(true);
await run;
expect(published).toHaveLength(0);
expect(vi.getTimerCount()).toBe(0);
service.stop();
});
it('a cancelled evaluation finishing late does not cancel the one that replaced it', async () => {
const session = new FakeSession();
session.keepWarmEnabled = true;
session.promptCache = warmCache(NOW + HOUR);
let releaseWrite!: (ok: boolean) => void;
let releaseRead: (() => void) | undefined;
const { ports, published } = makePorts(session, {
readSettings: async () => {
if (releaseRead === undefined) return { keepWarmDefault: false, maxHours: 2 };
await new Promise<void>((r) => (releaseRead = r));
return { keepWarmDefault: false, maxHours: 2 };
},
});
const service = new PromptCacheKeepWarmService(ports);
// First evaluation: due, held in its write.
session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000);
session.writeViaMux = async (data: string) => {
session.writes.push(data);
return new Promise<boolean>((r) => (releaseWrite = r));
};
const first = service.schedule(session.id);
await settle();
service.remove(session.id);
// Second evaluation: not due, held in its settings read.
session.promptCache = warmCache(NOW + HOUR);
releaseRead = () => {};
// rescheduleAll() drops the kept settings, so this evaluation really waits in its read.
service.rescheduleAll();
await settle();
releaseWrite(true);
await first;
releaseRead();
await settle();
expect(published).toEqual([expect.objectContaining({ state: 'armed' })]);
service.stop();
});
/**
* Every statusline report schedules an evaluation, so the settings are read once and kept
* rather than read from disk on each report, the feature off included; the settings PUT calls
* rescheduleAll() when either key changes, which is what makes the next read fresh.
*/
it('reads the settings once, again after rescheduleAll(), and again after a failed read', async () => {
const session = new FakeSession();
session.promptCache = warmCache(NOW + HOUR);
let reads = 0;
let fail = false;
const { ports } = makePorts(session, {
readSettings: async () => {
reads += 1;
if (fail) throw new Error('unreadable');
return { keepWarmDefault: false, maxHours: 2 };
},
});
const service = new PromptCacheKeepWarmService(ports);
await service.schedule(session.id);
await service.schedule(session.id);
expect(reads).toBe(1);
fail = true;
service.rescheduleAll();
await settle();
expect(reads).toBe(2);
fail = false;
await service.schedule(session.id);
expect(reads).toBe(3);
service.stop();
});
it('publishes no window while off, so a real prompt on a session with keep-warm off broadcasts nothing', async () => {
const session = new FakeSession();
session.keepWarmEnabled = false;
session.promptCache = warmCache(NOW + HOUR);
const { ports, published } = makePorts(session);
const service = new PromptCacheKeepWarmService(ports);
await service.schedule(session.id);
session.lastRealPromptAt = NOW;
await service.schedule(session.id);
expect(published[0]).toMatchObject({ enabled: false, state: 'off' });
expect(published[0]).not.toHaveProperty('budgetEndsAt');
expect(published[0]).not.toHaveProperty('nextCheckAt');
expect(JSON.stringify(published[1])).toBe(JSON.stringify(published[0]));
service.stop();
});
it('retries a minute later when an evaluation throws, since an idle session has no other trigger', async () => {
const session = new FakeSession();
session.keepWarmEnabled = true;
session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000);
let fail = true;
const { ports, last } = makePorts(session, {
readSettings: async () => {
if (fail) throw new Error('settings.json mid-rewrite');
return { keepWarmDefault: false, maxHours: 2 };
},
});
const service = new PromptCacheKeepWarmService(ports);
await service.schedule(session.id);
expect(session.writes).toHaveLength(0);
expect(vi.getTimerCount()).toBe(1);
fail = false;
await vi.advanceTimersByTimeAsync(KEEP_WARM_RETRY_MS);
await settle();
expect(session.writes).toEqual([`${KEEP_WARM_NUDGE}\r`]);
expect(last()).toMatchObject({ state: 'nudged' });
service.stop();
});
/**
* A settings.json left broken (a hand edit with a trailing comma) fails every read until it is
* fixed. Only a session that could nudge retries on its own, and the failure is logged once
* rather than once a minute per session.
*/
it.each([
{ label: 'keep-warm off', set: (s: FakeSession) => (s.keepWarmEnabled = false) },
{ label: 'no exporter on this launch', set: (s: FakeSession) => (s.statusLineExporter = 'remote' as never) },
{ label: 'no cache report', set: (s: FakeSession) => (s.promptCache = null) },
{ label: 'a cold cache', set: (s: FakeSession) => (s.promptCache = { ...warmCache(NOW + HOUR), warm: false }) },
])('does not retry an unreadable settings file for a session that cannot nudge ($label)', async ({ set }) => {
const session = new FakeSession();
session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000);
set(session);
let reads = 0;
const logged: string[] = [];
const { ports, published } = makePorts(session, {
readSettings: async () => {
reads += 1;
throw new Error('Unexpected token } in JSON at position 812');
},
log: (m) => logged.push(m),
});
const service = new PromptCacheKeepWarmService(ports);
await service.schedule(session.id);
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(10 * KEEP_WARM_RETRY_MS);
await settle();
expect(reads).toBe(1);
expect(logged).toEqual([expect.stringContaining('Unexpected token')]);
expect(published).toHaveLength(0);
service.stop();
});
it('retries an unreadable settings file every minute for a session that can nudge, logging it once', async () => {
const session = new FakeSession();
session.promptCache = warmCache(NOW + 30 * 60_000);
let reads = 0;
let fail = true;
const logged: string[] = [];
const { ports, last } = makePorts(session, {
readSettings: async () => {
reads += 1;
if (fail) throw new Error('Unexpected token } in JSON at position 812');
return { keepWarmDefault: true, maxHours: 2 };
},
log: (m) => logged.push(m),
});
const service = new PromptCacheKeepWarmService(ports);
await service.schedule(session.id);
expect(vi.getTimerCount()).toBe(1);
await vi.advanceTimersByTimeAsync(10 * KEEP_WARM_RETRY_MS);
await settle();
expect(reads).toBe(11);
expect(logged).toEqual([expect.stringContaining('Unexpected token')]);
// Fixed: the next retry reads it and the session is kept warm from there.
fail = false;
await vi.advanceTimersByTimeAsync(KEEP_WARM_RETRY_MS);
await settle();
expect(reads).toBe(12);
expect(last()).toMatchObject({ enabled: true, state: 'armed' });
// Broken again later: logged again, once.
fail = true;
service.rescheduleAll();
await settle();
await vi.advanceTimersByTimeAsync(3 * KEEP_WARM_RETRY_MS);
await settle();
expect(logged).toHaveLength(2);
service.stop();
});
it('runs one follow-up for triggers that land during an evaluation, and resolves after it', async () => {
const session = new FakeSession();
session.keepWarmEnabled = true;
session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000);
let releaseWrite!: (ok: boolean) => void;
session.writeViaMux = async (data: string) => {
session.writes.push(data);
return new Promise<boolean>((r) => (releaseWrite = r));
};
const { ports, published } = makePorts(session);
const service = new PromptCacheKeepWarmService(ports);
const first = service.schedule(session.id);
await settle();
expect(session.writes).toHaveLength(1);
// Switched off while the nudge is being written: the toggle's own evaluation must not be lost.
session.keepWarmEnabled = false;
const toggle = service.schedule(session.id);
const settingsChange = service.schedule(session.id);
expect(settingsChange).toBe(toggle);
releaseWrite(true);
await first;
await toggle;
expect(published.map((s) => s.state)).toEqual(['nudged', 'off']);
service.stop();
});
it('remove() drops a follow-up queued behind an evaluation, and a failed read after it arms nothing', async () => {
const session = new FakeSession();
session.keepWarmEnabled = true;
session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000);
let release!: () => void;
let fail = false;
const { ports, published } = makePorts(session, {
readSettings: async () => {
await new Promise<void>((r) => (release = r));
if (fail) throw new Error('settings.json mid-rewrite');
return { keepWarmDefault: false, maxHours: 2 };
},
});
const service = new PromptCacheKeepWarmService(ports);
// The session stays discoverable while it is being deleted, so only remove() can stop these.
const first = service.schedule(session.id);
const followUp = service.schedule(session.id);
service.remove(session.id);
release();
await first;
await followUp;
await settle();
expect(session.writes).toHaveLength(0);
expect(published).toHaveLength(0);
expect(vi.getTimerCount()).toBe(0);
// rescheduleAll() drops the kept settings, so this read really runs, and fails.
fail = true;
const logged: string[] = [];
ports.log = (m) => logged.push(m);
service.rescheduleAll();
service.remove(session.id);
release();
await settle();
expect(logged).toEqual([expect.stringContaining('mid-rewrite')]);
expect(vi.getTimerCount()).toBe(0);
service.stop();
});
it('remove() forgets the nudge count, so an id seen again starts from zero', async () => {
const session = new FakeSession();
session.keepWarmEnabled = true;
session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000);
const { ports, last } = makePorts(session);
const service = new PromptCacheKeepWarmService(ports);
await service.schedule(session.id);
expect(last()).toMatchObject({ state: 'nudged', nudges: 1 });
service.remove(session.id);
session.promptCache = warmCache(NOW + HOUR);
await service.schedule(session.id);
expect(last()).toMatchObject({ state: 'armed', nudges: 0 });
service.stop();
});
it('remove() drops the timer so a deleted session never gets a nudge', async () => {
const session = new FakeSession();
session.keepWarmEnabled = true;
session.promptCache = warmCache(NOW + HOUR);
const { ports } = makePorts(session);
const service = new PromptCacheKeepWarmService(ports);
service.schedule(session.id);
await settle();
expect(vi.getTimerCount()).toBe(1);
service.remove(session.id);
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(2 * HOUR);
expect(session.writes).toHaveLength(0);
});
});
+175 -3
View File
@@ -17,14 +17,29 @@ type Cache = {
lastMissCauses?: string[];
compactedAt?: number;
};
type KeepWarm = {
enabled: boolean;
idleCompactionOff?: boolean;
state: string;
nextCheckAt?: number;
reason?: string;
nudges: number;
lastNudgeAt?: number;
budgetEndsAt?: number;
};
type Api = {
format: (c: Cache | null | undefined, now: number, statusLine?: string) => string;
formatKeepWarm: (s: KeepWarm | null | undefined, now: number, statusLine?: string) => string;
keepWarmUnavailable: (statusLine?: string) => boolean;
};
function loadFormat() {
function loadApi(): Api {
const context = vm.createContext({ window: {}, globalThis: {} });
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
vm.runInContext(source, context, { filename: 'constants.js' });
return (context.window as { CodemanPromptCache: { format: (c: Cache | null | undefined, now: number) => string } })
.CodemanPromptCache.format as (c: Cache | null | undefined, now: number, statusLine?: string) => string;
return (context.window as { CodemanPromptCache: Api }).CodemanPromptCache;
}
const loadFormat = () => loadApi().format;
const clock = (ms: number) => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const NOW = Date.UTC(2026, 9, 6, 22, 18, 0);
@@ -39,6 +54,8 @@ describe('Prompt cache readout', () => {
it('says why when the launch carries no statusLine exporter, whatever report is stored', () => {
const format = loadFormat();
expect(format(null, NOW, 'collection-off')).toMatch(/^Not collected: Plan Usage was off when this session started/);
// Collection is decided at launch, so the fix is a new session, and the readout says where the switch is.
expect(format(null, NOW, 'collection-off')).toContain('App Settings → Header & Panels, then start a new session');
expect(format({ warm: true, expiresAt: NOW + 60_000 }, NOW, 'collection-off')).toMatch(/^Not collected/);
expect(format(null, NOW, 'workspace-statusline')).toMatch(/settings\.local\.json sets its own statusLine/);
expect(format(null, NOW, 'remote')).toBe('Not collected for remote and Docker sessions.');
@@ -87,3 +104,158 @@ describe('Prompt cache readout', () => {
);
});
});
describe('Keep-warm line on the Status row', () => {
const T = NOW + 55 * 60_000;
const END = NOW + 2 * 3_600_000;
it('says nothing until the service has published, or while switched off', () => {
const { formatKeepWarm } = loadApi();
expect(formatKeepWarm(null, NOW, 'injected')).toBe('');
expect(formatKeepWarm({ enabled: false, state: 'off', nudges: 0 }, NOW, 'injected')).toBe('');
expect(formatKeepWarm({ enabled: false, state: 'off', reason: 'off', nudges: 0 }, NOW, 'injected')).toBe('');
});
it('armed: the due time and when the window closes', () => {
const { formatKeepWarm } = loadApi();
expect(formatKeepWarm({ enabled: true, state: 'armed', nextCheckAt: T, nudges: 0, budgetEndsAt: END }, NOW)).toBe(
`Keep-alive: next ${clock(T)} · stops ${clock(END)}`
);
expect(
formatKeepWarm({ enabled: true, state: 'armed', nextCheckAt: T, nudges: 3, budgetEndsAt: NOW - 1 }, NOW)
).toBe(`Keep-alive: next ${clock(T)} · window closed`);
});
it('waiting and sent carry the reason and the count', () => {
const { formatKeepWarm } = loadApi();
expect(
formatKeepWarm(
{
enabled: true,
state: 'waiting',
reason: 'the composer holds text',
nextCheckAt: T,
nudges: 1,
budgetEndsAt: END,
},
NOW
)
).toBe(`Keep-alive: waiting · the composer holds text · retry ${clock(T)} · stops ${clock(END)}`);
expect(formatKeepWarm({ enabled: true, state: 'nudged', lastNudgeAt: T, nudges: 2, budgetEndsAt: END }, NOW)).toBe(
`Keep-alive: sent ${clock(T)} (2×) · stops ${clock(END)}`
);
});
it('stopped, or off for a reason, names it', () => {
const { formatKeepWarm } = loadApi();
expect(formatKeepWarm({ enabled: true, state: 'stopped', reason: 'cache is cold', nudges: 0 }, NOW)).toBe(
'Keep-alive: stopped · cache is cold'
);
expect(
formatKeepWarm({ enabled: false, state: 'off', reason: 'an agent-spawned session ...', nudges: 0 }, NOW)
).toBe('Keep-alive: off · an agent-spawned session ...');
});
it("adds nothing on a launch without the exporter: the readout's Not collected sentence already says why", () => {
const { formatKeepWarm } = loadApi();
const stopped = { enabled: true, state: 'stopped', reason: 'no cache reports on this launch', nudges: 0 };
for (const statusLine of ['collection-off', 'workspace-statusline', 'remote']) {
expect(formatKeepWarm(stopped, NOW, statusLine)).toBe('');
}
});
it('says idle compaction stays on when the opt-out was not on this launch', () => {
const { formatKeepWarm } = loadApi();
const armed = { enabled: true, state: 'armed', nextCheckAt: T, nudges: 0 };
expect(formatKeepWarm({ ...armed, idleCompactionOff: true }, NOW, 'injected')).toBe(`Keep-alive: next ${clock(T)}`);
expect(formatKeepWarm({ ...armed, idleCompactionOff: false }, NOW, 'injected')).toBe(
`Keep-alive: next ${clock(T)} · idle compaction on until relaunch`
);
});
it('says idle compaction stays off when keep-warm was switched off on a launch that carries the opt-out', () => {
const { formatKeepWarm } = loadApi();
const off = { enabled: false, state: 'off', reason: 'off', nudges: 0 };
expect(formatKeepWarm({ ...off, idleCompactionOff: false }, NOW, 'injected')).toBe('');
expect(formatKeepWarm({ ...off, idleCompactionOff: true }, NOW, 'injected')).toBe(
'Keep-alive: off · idle compaction off until relaunch'
);
});
});
/**
* session-ui.js `updateKeepWarmStatus`, run as shipped against a minimal DOM: the keep-warm line
* on the Status row is hidden when it has nothing to say, and a launch without cache reports
* greys the switch's row and refuses to switch it on, never to switch it off.
*/
describe('Keep-warm switch and its Status line', () => {
/** One Session Options modal: the same elements across every session it is opened on. */
function makeModal() {
const ui = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
const start = ui.indexOf(' updateKeepWarmStatus(sessionId) {');
expect(start).toBeGreaterThan(-1);
const update = new Function(
'document',
'window',
'sessionId',
ui.slice(ui.indexOf('{', start) + 1, ui.indexOf('\n },\n', start))
);
const rowClasses = new Set<string>();
const line = { textContent: '', hidden: true };
const row = {
classList: { toggle: (c: string, on: boolean) => void (on ? rowClasses.add(c) : rowClasses.delete(c)) },
};
const box = { checked: false, disabled: false, closest: () => row };
const elements: Record<string, unknown> = { keepWarmStatus: line, modalKeepWarmEnabled: box };
const document = { getElementById: (id: string) => elements[id] ?? null };
const window = { CodemanPromptCache: loadApi() };
return (statusLine: string, keepWarm: KeepWarm | undefined, checked: boolean) => {
box.checked = checked;
const self = {
editingSessionId: 's1',
sessions: new Map([['s1', { statusLineExporter: statusLine, keepWarm }]]),
};
update.call(self, document, window, 's1');
return { line: { ...line }, greyed: rowClasses.has('set-row-disabled'), box: { ...box } };
};
}
const render = (statusLine: string, keepWarm: KeepWarm | undefined, checked: boolean) =>
makeModal()(statusLine, keepWarm, checked);
it('shows the line whenever it has words, any state, and hides it otherwise', () => {
const waiting = render(
'injected',
{ enabled: true, state: 'waiting', reason: 'the composer holds text', nudges: 0 },
true
);
expect(waiting.line.textContent).toContain('Keep-alive: waiting');
expect(waiting.line.hidden).toBe(false);
expect(waiting.greyed).toBe(false);
expect(waiting.box.disabled).toBe(false);
expect(render('injected', { enabled: false, state: 'off', nudges: 0 }, false).line.hidden).toBe(true);
});
it('greys a launch without cache reports, but lets a switch that is on be turned off', () => {
const off = render('collection-off', { enabled: false, state: 'off', nudges: 0 }, false);
expect(off.line.hidden).toBe(true);
expect(off.greyed).toBe(true);
expect(off.box.disabled).toBe(true);
const on = render('collection-off', { enabled: true, state: 'stopped', nudges: 0 }, true);
expect(on.greyed).toBe(true);
expect(on.box.disabled).toBe(false);
});
it('undoes what the previous session left on the same modal', () => {
const show = makeModal();
expect(show('collection-off', { enabled: false, state: 'off', nudges: 0 }, false).box.disabled).toBe(true);
const next = show('injected', { enabled: false, state: 'off', nudges: 0 }, false);
expect(next.box.disabled).toBe(false);
expect(next.greyed).toBe(false);
expect(show('injected', { enabled: true, state: 'armed', nextCheckAt: NOW, nudges: 0 }, true).line.hidden).toBe(
false
);
const off = show('injected', { enabled: false, state: 'off', nudges: 0 }, false);
expect(off.line.hidden).toBe(true);
expect(off.line.textContent).toBe('');
});
});
+149
View File
@@ -17,6 +17,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { approvalInbox } from '../../src/web/approval-inbox.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { registerHookEventRoutes } from '../../src/web/routes/hook-event-routes.js';
@@ -98,6 +99,103 @@ describe('hook-event-routes', () => {
);
});
/**
* The prompt-cache keep-warm's own turn (prompt-cache-keepwarm.ts) ends with the same stop
* hook and idle prompt as a real one. While the session marks the turn as its keep-alive,
* both open pre-acknowledged: the broadcast says why, and nothing is pushed.
*/
it('opens the stop and idle prompt of a keep-alive turn pre-acknowledged and pushes nothing', async () => {
harness.ctx._session.keepAliveTurnActive = true;
for (const event of ['stop', 'idle_prompt']) {
const res = await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: { event, sessionId: harness.ctx._sessionId, data: null },
});
expect(res.statusCode).toBe(200);
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
`hook:${event}`,
expect.objectContaining({ sessionId: harness.ctx._sessionId, acknowledgedReason: 'cache keep-alive' })
);
}
expect(harness.ctx.sendPushNotifications).not.toHaveBeenCalled();
// The inbox item is what a reload seeds tab alerts from, so it must open acknowledged too.
const item = approvalInbox.getForSession(harness.ctx._sessionId);
approvalInbox.resolveForSession(harness.ctx._sessionId, 'dismissed');
expect(item?.acknowledgedReason).toBe('cache keep-alive');
// acknowledgedAt is what approvals-ui.js reads to skip the alert on a reload.
expect(item?.acknowledgedAt).toBe(item?.createdAt);
});
/**
* Only a human spends an idle alert (CLAUDE.md, Approvals Inbox). A keep-alive turn that
* starts while an idle item is pending must leave that item exactly as it found it: its stop
* does not resolve it, and its idle prompt does not replace it, so an unseen alert stays
* unseen (and pushes nothing a second time) and one a person looked at keeps saying so.
*/
it.each([
{ seen: false, label: 'an unseen' },
{ seen: true, label: 'a seen' },
])('leaves $label idle item pending through a keep-alive turn', async ({ seen }) => {
const sessionId = harness.ctx._sessionId;
const hook = (event: string) =>
harness.app.inject({ method: 'POST', url: '/api/hook-event', payload: { event, sessionId, data: null } });
await hook('idle_prompt');
const before = approvalInbox.getForSession(sessionId);
expect(before?.acknowledgedAt).toBeUndefined();
if (seen) approvalInbox.acknowledge(sessionId);
const acknowledgedAt = before?.acknowledgedAt;
harness.ctx.sendPushNotifications.mockClear();
harness.ctx.broadcast.mockClear();
harness.ctx._session.keepAliveTurnActive = true;
await hook('stop');
await hook('idle_prompt');
const after = approvalInbox.getForSession(sessionId);
approvalInbox.resolveForSession(sessionId, 'dismissed');
expect(after?.id).toBe(before?.id);
expect(after?.acknowledgedAt).toBe(acknowledgedAt);
expect(after?.acknowledgedReason).toBeUndefined();
expect(harness.ctx.sendPushNotifications).not.toHaveBeenCalled();
// The page's _onHookStop and _onHookIdlePrompt return on the reason, leaving the tab alert.
for (const event of ['stop', 'idle_prompt']) {
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
`hook:${event}`,
expect.objectContaining({ acknowledgedReason: 'cache keep-alive' })
);
}
});
it("a keep-alive turn's idle prompt ends the keep-alive window, its stop does not", async () => {
const sessionId = harness.ctx._sessionId;
const hook = (event: string) =>
harness.app.inject({ method: 'POST', url: '/api/hook-event', payload: { event, sessionId, data: null } });
harness.ctx._session.keepAliveTurnActive = true;
await hook('stop');
expect(harness.ctx._session.keepAliveTurnActive).toBe(true);
await hook('idle_prompt');
approvalInbox.resolveForSession(sessionId, 'dismissed');
expect(harness.ctx._session.keepAliveTurnActive).toBe(false);
});
it("a keep-alive turn's stop still resolves a pending permission dialog", async () => {
const sessionId = harness.ctx._sessionId;
await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: { event: 'permission_prompt', sessionId, data: null },
});
expect(approvalInbox.getForSession(sessionId)?.kind).toBe('permission');
harness.ctx._session.keepAliveTurnActive = true;
await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: { event: 'stop', sessionId, data: null },
});
expect(approvalInbox.getForSession(sessionId)).toBeUndefined();
});
it('sends push notifications for hook events', async () => {
const res = await harness.app.inject({
method: 'POST',
@@ -144,6 +242,27 @@ describe('hook-event-routes', () => {
expect(harness.ctx.persistSessionState).toHaveBeenCalledWith(session);
});
/**
* The hook's prompt text is what tells a person typing in an attached tmux client apart
* from the prompt-cache keep-alive (Session.markPromptSubmitted ends the keep-alive window
* for anything but the nudge), so the route must hand it over, and nothing else.
*/
it("hands the prompt_submitted hook's prompt text to the session", async () => {
const session = harness.ctx._session;
for (const data of [
{ session_id: 'conv-1', prompt: 'fix the login bug' },
{ session_id: 'conv-1', prompt: 42 },
{ session_id: 'conv-1' },
]) {
await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: { event: 'prompt_submitted', sessionId: harness.ctx._sessionId, data },
});
}
expect(session.submittedPrompts).toEqual(['fix the login bug', undefined, undefined]);
});
it('records a /clear successor in the chain and persists it, without duplicating a repeat', async () => {
const session = harness.ctx._session;
const submit = async (conversationId: string) =>
@@ -308,6 +427,36 @@ describe('hook-event-routes', () => {
expect(mockTracker.recordHookEvent).toHaveBeenCalledWith('stop', expect.any(Object));
});
/**
* The run summary files every `stop` as a warning, and the Away digest lists warnings under
* Needs Attention, so a keep-alive turn's hooks would add a row there (and to the session's
* Summary timeline) for every nudge. They are Codeman's own turn, not the agent's.
*/
it("keeps a keep-alive turn's stop and idle prompt out of the run summary", async () => {
const sessionId = harness.ctx._sessionId;
const mockTracker = { recordHookEvent: vi.fn() };
harness.ctx.runSummaryTrackers.set(sessionId, mockTracker as never);
harness.ctx._session.keepAliveTurnActive = true;
for (const event of ['stop', 'idle_prompt']) {
const res = await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: { event, sessionId, data: null },
});
expect(res.statusCode).toBe(200);
}
approvalInbox.resolveForSession(sessionId, 'dismissed');
expect(mockTracker.recordHookEvent).not.toHaveBeenCalled();
// The next real turn is recorded as before.
await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: { event: 'stop', sessionId, data: null },
});
expect(mockTracker.recordHookEvent).toHaveBeenCalledWith('stop', expect.any(Object));
});
it('starts transcript watcher when transcript_path is provided', async () => {
const res = await harness.app.inject({
method: 'POST',
@@ -0,0 +1,76 @@
/**
* `POST /api/sessions/:id/keep-warm` (session-routes.ts): the per-session opt-in for the
* prompt-cache keep-warm. Stores the choice, persists and broadcasts the session, asks the
* scheduler to re-evaluate, clears the choice on `null`, and refuses a CLI with no cache
* telemetry. app.inject(), no port.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
interface Harness {
app: FastifyInstance;
ctx: MockRouteContext;
}
async function createHarness(): Promise<Harness> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext();
registerSessionRoutes(app, ctx);
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
}
describe('POST /api/sessions/:id/keep-warm', () => {
let harness: Harness;
beforeEach(async () => {
harness = await createHarness();
});
afterEach(async () => {
await harness.app.close();
});
const post = (payload: unknown) =>
harness.app.inject({ method: 'POST', url: `/api/sessions/${harness.ctx._sessionId}/keep-warm`, payload });
it('stores the choice, persists, broadcasts and re-evaluates', async () => {
const res = await post({ enabled: true });
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).data.keepWarm).toMatchObject({ enabled: true });
expect(harness.ctx._session.keepWarmEnabled).toBe(true);
expect(harness.ctx.persistSessionState).toHaveBeenCalled();
expect(harness.ctx.keepWarm.schedule).toHaveBeenCalledWith(harness.ctx._sessionId);
// The re-evaluation runs first, so the broadcast state and the response carry the new status.
expect(harness.ctx.keepWarm.schedule.mock.invocationCallOrder[0]).toBeLessThan(
harness.ctx.persistSessionState.mock.invocationCallOrder[0]
);
});
it('clears the choice on null so the app-wide default applies again', async () => {
await post({ enabled: false });
const res = await post({ enabled: null });
expect(res.statusCode).toBe(200);
expect(harness.ctx._session.keepWarmEnabled).toBeUndefined();
expect(JSON.parse(res.body).data.keepWarm.enabled).toBeNull();
});
it('rejects a body without enabled', async () => {
expect((await post({})).statusCode).toBe(400);
});
it('refuses a CLI that reports no cache telemetry', async () => {
harness.ctx._session.mode = 'shell';
const res = await post({ enabled: true });
// This harness has no envelope hook, so the refusal shows in the body, not the status.
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: 'INVALID_INPUT' });
expect(harness.ctx._session.keepWarmEnabled).toBeUndefined();
expect(harness.ctx.keepWarm.schedule).not.toHaveBeenCalled();
});
});
@@ -48,7 +48,13 @@ const { EXISTING_SETTINGS, subagentWatcher, imageWatcher, workflowRunWatcher } =
};
return {
// Persisted settings.json for these tests: two watchers ON, subagent tracking OFF.
EXISTING_SETTINGS: { subagentTrackingEnabled: false, imageWatcherEnabled: true, showUltracodeAgents: true },
EXISTING_SETTINGS: {
subagentTrackingEnabled: false,
imageWatcherEnabled: true,
showUltracodeAgents: true,
promptCacheKeepWarm: false,
promptCacheKeepWarmMaxHours: 2,
},
subagentWatcher: makeWatcher(false),
imageWatcher: makeWatcher(true),
workflowRunWatcher: makeWatcher(true),
@@ -143,4 +149,22 @@ describe('PUT /api/settings — partial body must not reset service toggles', ()
// showUltracodeAgents is still true in settings.json, so it stays up.
expect(workflowRunWatcher.stop).not.toHaveBeenCalled();
});
/**
* The browser sends both keep-warm keys on every App Settings save, so testing for the keys in
* the body re-read the settings and re-evaluated every session on each save of anything.
*/
it('re-evaluates the prompt-cache keep-warm only when its default or cap changes', async () => {
const put = (payload: Record<string, unknown>) =>
harness.app.inject({ method: 'PUT', url: '/api/settings', payload });
expect((await put({ promptCacheKeepWarm: false, promptCacheKeepWarmMaxHours: 2 })).statusCode).toBe(200);
expect(harness.ctx.keepWarm.rescheduleAll).not.toHaveBeenCalled();
expect((await put({ promptCacheKeepWarm: false, promptCacheKeepWarmMaxHours: 3 })).statusCode).toBe(200);
expect(harness.ctx.keepWarm.rescheduleAll).toHaveBeenCalledTimes(1);
expect((await put({ promptCacheKeepWarm: true })).statusCode).toBe(200);
expect(harness.ctx.keepWarm.rescheduleAll).toHaveBeenCalledTimes(2);
});
});
+32
View File
@@ -6,6 +6,7 @@ import {
detachSessionListeners,
} from '../src/web/session-listener-wiring.js';
import { SseEvent } from '../src/web/sse-events.js';
import { approvalInbox } from '../src/web/approval-inbox.js';
describe('session listener wiring', () => {
it('forwards the attachment request source through registerAttachment', async () => {
@@ -62,6 +63,37 @@ describe('session listener wiring', () => {
expect(session.listenerCount('displayModelChanged')).toBe(0);
});
/**
* A working transition resolves a pending idle item (input arrived), except during Codeman's
* own keep-alive turn: only a human spends an idle alert. Both transitions of that turn are
* flagged `keepAlive`, which is what keeps the page's stuck-detection timer out of it.
*/
it.each([
{ keepAlive: false, resolved: true },
{ keepAlive: true, resolved: false },
])('working and idle with keepAlive=$keepAlive', ({ keepAlive, resolved }) => {
const session = new Session({ id: `wiring-keepalive-${keepAlive}`, workingDir: '/tmp', mode: 'claude' });
const broadcast = vi.fn();
const deps = {
broadcast,
broadcastSessionStateDebounced: vi.fn(),
getRunSummaryTracker: vi.fn(() => undefined),
} as unknown as Parameters<typeof createSessionListeners>[1];
const refs = createSessionListeners(session, deps);
approvalInbox.notePrompt({ sessionId: session.id, sessionName: 'w1', kind: 'idle' });
if (keepAlive) session.beginKeepAliveTurn(60_000);
refs.working();
refs.idle();
const pending = approvalInbox.getForSession(session.id);
approvalInbox.resolveForSession(session.id, 'dismissed');
expect(pending === undefined).toBe(resolved);
const flag = keepAlive ? { keepAlive: true } : {};
expect(broadcast).toHaveBeenCalledWith(SseEvent.SessionWorking, { id: session.id, ...flag });
expect(broadcast).toHaveBeenCalledWith(SseEvent.SessionIdle, { id: session.id, ...flag });
});
/** The listener reads the setting asynchronously; let its promise chain settle. */
const flush = () => new Promise((resolve) => setTimeout(resolve, 5));