2026-10-10 12:22:00 -04:00
|
|
|
|
/**
|
|
|
|
|
|
* The Session Options "Prompt cache" readout (`CodemanPromptCache.format`, constants.js):
|
2026-10-11 12:36:59 -04:00
|
|
|
|
* the one sentence that tells the user whether the next turn reads the conversation
|
2026-10-10 12:22:00 -04:00
|
|
|
|
* from cache or re-processes it cold. Loaded the way the other constants.js policies are.
|
|
|
|
|
|
*/
|
|
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
|
|
|
|
import { resolve } from 'node:path';
|
|
|
|
|
|
import vm from 'node:vm';
|
|
|
|
|
|
|
|
|
|
|
|
type Cache = {
|
|
|
|
|
|
warm: boolean;
|
|
|
|
|
|
ttl?: string;
|
|
|
|
|
|
expiresAt?: number;
|
|
|
|
|
|
recacheTokensIfCold?: number;
|
|
|
|
|
|
hitRatio?: number;
|
|
|
|
|
|
lastMissCauses?: string[];
|
|
|
|
|
|
compactedAt?: number;
|
|
|
|
|
|
};
|
2026-10-18 13:09:06 -04:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
2026-10-10 12:22:00 -04:00
|
|
|
|
|
2026-10-18 13:09:06 -04:00
|
|
|
|
function loadApi(): Api {
|
2026-10-10 12:22:00 -04:00
|
|
|
|
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' });
|
2026-10-18 13:09:06 -04:00
|
|
|
|
return (context.window as { CodemanPromptCache: Api }).CodemanPromptCache;
|
2026-10-10 12:22:00 -04:00
|
|
|
|
}
|
2026-10-18 13:09:06 -04:00
|
|
|
|
const loadFormat = () => loadApi().format;
|
2026-10-10 12:22:00 -04:00
|
|
|
|
|
|
|
|
|
|
const clock = (ms: number) => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
|
|
|
|
const NOW = Date.UTC(2026, 9, 6, 22, 18, 0);
|
|
|
|
|
|
|
|
|
|
|
|
describe('Prompt cache readout', () => {
|
|
|
|
|
|
it('says so when Claude has not reported the cache yet (older CLI or no response yet)', () => {
|
|
|
|
|
|
const format = loadFormat();
|
|
|
|
|
|
expect(format(null, NOW)).toMatch(/^Not reported yet/);
|
|
|
|
|
|
expect(format(undefined, NOW)).toMatch(/^Not reported yet/);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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/);
|
2026-10-18 13:09:06 -04:00
|
|
|
|
// 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');
|
2026-10-10 12:22:00 -04:00
|
|
|
|
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.');
|
|
|
|
|
|
expect(format(null, NOW, 'injected')).toMatch(/^Not reported yet/);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('warm: a clock time, the lifetime and the tokens at stake', () => {
|
|
|
|
|
|
const format = loadFormat();
|
|
|
|
|
|
const expiresAt = NOW + 42 * 60_000;
|
|
|
|
|
|
expect(format({ warm: true, ttl: '1h', expiresAt, recacheTokensIfCold: 109833 }, NOW)).toBe(
|
2026-10-11 12:36:59 -04:00
|
|
|
|
`Warm until ${clock(expiresAt)} (1h cache). About 110k tokens would be re-cached if it goes cold.`
|
2026-10-10 12:22:00 -04:00
|
|
|
|
);
|
|
|
|
|
|
expect(format({ warm: true, expiresAt }, NOW)).toBe(`Warm until ${clock(expiresAt)}.`);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('cold once reported cold, past the expiry, or warm with no expiry to promise', () => {
|
|
|
|
|
|
const format = loadFormat();
|
|
|
|
|
|
expect(format({ warm: false, recacheTokensIfCold: 89882 }, NOW)).toBe(
|
2026-10-11 12:36:59 -04:00
|
|
|
|
'Cold. The next turn re-caches about 90k tokens.'
|
2026-10-10 12:22:00 -04:00
|
|
|
|
);
|
|
|
|
|
|
expect(format({ warm: true, expiresAt: NOW - 1, recacheTokensIfCold: 500 }, NOW)).toBe(
|
2026-10-11 12:36:59 -04:00
|
|
|
|
'Cold. The next turn re-caches about 500 tokens.'
|
2026-10-10 12:22:00 -04:00
|
|
|
|
);
|
|
|
|
|
|
expect(format({ warm: true }, NOW)).toBe('Cold.');
|
|
|
|
|
|
expect(format({ warm: false }, NOW)).toBe('Cold.');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('appends the hit rate and the last miss cause when Claude reports them', () => {
|
|
|
|
|
|
const format = loadFormat();
|
|
|
|
|
|
const expiresAt = NOW + 42 * 60_000;
|
|
|
|
|
|
expect(format({ warm: true, ttl: '1h', expiresAt, recacheTokensIfCold: 50000, hitRatio: 0.91 }, NOW)).toBe(
|
2026-10-11 12:36:59 -04:00
|
|
|
|
`Warm until ${clock(expiresAt)} (1h cache). About 50k tokens would be re-cached if it goes cold. 91% cache hit rate.`
|
2026-10-10 12:22:00 -04:00
|
|
|
|
);
|
|
|
|
|
|
expect(
|
|
|
|
|
|
format({ warm: false, recacheTokensIfCold: 90000, hitRatio: 0.6, lastMissCauses: ['ttl_expired_1h'] }, NOW)
|
2026-10-11 12:36:59 -04:00
|
|
|
|
).toBe('Cold. The next turn re-caches about 90k tokens. 60% cache hit rate. Last miss: ttl_expired_1h.');
|
2026-10-10 12:22:00 -04:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-11 12:36:59 -04:00
|
|
|
|
it('names a compaction, with no re-cache stake while the prefix is still warm', () => {
|
2026-10-10 12:22:00 -04:00
|
|
|
|
const format = loadFormat();
|
|
|
|
|
|
const expiresAt = NOW + 42 * 60_000;
|
|
|
|
|
|
const compactedAt = NOW - 6 * 60_000;
|
2026-10-11 12:36:59 -04:00
|
|
|
|
// recacheTokensIfCold is null right after a compaction, so no "would be re-cached" clause.
|
2026-10-10 12:22:00 -04:00
|
|
|
|
expect(format({ warm: true, ttl: '1h', expiresAt, compactedAt, hitRatio: 0.88 }, NOW)).toBe(
|
2026-10-11 12:36:59 -04:00
|
|
|
|
`Warm until ${clock(expiresAt)} (1h cache). Context compacted at ${clock(compactedAt)}. 88% cache hit rate.`
|
2026-10-10 12:22:00 -04:00
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-10-18 13:09:06 -04:00
|
|
|
|
|
|
|
|
|
|
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`
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
2026-10-19 01:42:00 -04:00
|
|
|
|
|
|
|
|
|
|
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'
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
2026-10-18 13:09:06 -04:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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('');
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|