mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
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:
commit
f80f107445
45 files changed
+3017
-59
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,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);
|
||||
});
|
||||
});
|
||||
@@ -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,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,');
|
||||
});
|
||||
});
|
||||
@@ -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,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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
|
||||
Reference in new issue
Block a user