mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
feat(sessions): keep an idle session's prompt cache warm with a guarded keep-alive turn
Claude Code lets a subscription's one-hour prompt cache expire after an hour idle, and the first turn back re-writes the whole conversation at the cache-write rate; nearly every gap over an hour goes cold, while the saving is concentrated in gaps of one to four hours. Codeman now sends one short keep-alive turn five minutes before the expiry Claude Code reports on its statusline, so the next real prompt still reads the conversation from cache, and stops two hours (configurable) after the last real prompt. Codeman owns the idle policy for these sessions, as agreed in Discussion #554: a launch with keep-warm on also carries idleCompaction: false in its one --settings JSON, so Claude Code's own idle compaction cannot replace the conversation with a summary a minute before the nudge. It is decided once per launch (keep-warm on, the statusLine exporter injected, Claude Code 2.1.290 or newer, an unknown version fails closed like --name), recorded on the mux record, and picked up by any relaunch; a launch with keep-warm off is byte-identical. A session switched on after launch is kept warm best-effort and the readout says so. Every guard lives in one pure rulebook: the opt-in (an agent-spawned session does not inherit the app default), a launch that reports cache state, a warm 1h cache with enough tokens at stake and no compaction this gap, a live pane, no usage-limit pause, no respawn, Ralph, task or waiting agent, idle, no open dialog, an empty composer and no recent keystroke. The nudge is a submit and moves lastSubmitAt, which the response viewer's conversation correlation needs; the cap counts from its own persisted lastRealPromptAt instead. Read My Mind drops the exact nudge text, and the turn's stop hook and idle prompt open pre-acknowledged so nothing alerts. The last cache report and the clock survive a restart, so the timer re-arms.
This commit is contained in:
1 parent
e63bb65b36
commit
71a51df2e2
42 files changed
+2131
-38
No files matched your search
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* @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 } 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('const limit = detectUsageLimitPause(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 });
|
||||
});
|
||||
});
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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,18 @@ 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;
|
||||
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 +94,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();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
/**
|
||||
* 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 } from '../src/session-submit-verifier.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'],
|
||||
['respawn, Ralph or an agent driving', { automationActive: true }, 'driving the session'],
|
||||
['the cap after the last prompt', { budgetAnchorAt: NOW - 2 * HOUR }, 'window after the last prompt'],
|
||||
['an unreadable composer', { composerEmpty: null }, 'cannot read the composer'],
|
||||
])('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'],
|
||||
['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();
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 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);
|
||||
}
|
||||
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();
|
||||
});
|
||||
|
||||
it('keeps a failed write out of the tally and retries after the lead time', 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(last()).toMatchObject({
|
||||
state: 'waiting',
|
||||
nudges: 0,
|
||||
reason: expect.stringContaining('could not be written'),
|
||||
});
|
||||
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 = () => {};
|
||||
const second = service.schedule(session.id);
|
||||
await settle();
|
||||
releaseWrite(true);
|
||||
await first;
|
||||
releaseRead();
|
||||
await second;
|
||||
expect(published).toEqual([expect.objectContaining({ state: 'armed' })]);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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,149 @@ 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`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 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('');
|
||||
});
|
||||
});
|
||||
@@ -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,34 @@ 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);
|
||||
});
|
||||
|
||||
it('sends push notifications for hook events', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
@@ -144,6 +173,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) =>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user