mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 10:59:32 +01:00
Claude Code lets a subscription's one-hour prompt cache expire after an hour idle, and the first turn back re-writes the whole conversation at the cache-write rate; nearly every gap over an hour goes cold, while the saving is concentrated in gaps of one to four hours. Codeman now sends one short keep-alive turn five minutes before the expiry Claude Code reports on its statusline, so the next real prompt still reads the conversation from cache, and stops two hours (configurable) after the last real prompt. Codeman owns the idle policy for these sessions, as agreed in Discussion #554: a launch with keep-warm on also carries idleCompaction: false in its one --settings JSON, so Claude Code's own idle compaction cannot replace the conversation with a summary a minute before the nudge. It is decided once per launch (keep-warm on, the statusLine exporter injected, Claude Code 2.1.290 or newer, an unknown version fails closed like --name), recorded on the mux record, and picked up by any relaunch; a launch with keep-warm off is byte-identical. A session switched on after launch is kept warm best-effort and the readout says so. Every guard lives in one pure rulebook: the opt-in (an agent-spawned session does not inherit the app default), a launch that reports cache state, a warm 1h cache with enough tokens at stake and no compaction this gap, a live pane, no usage-limit pause, no respawn, Ralph, task or waiting agent, idle, no open dialog, an empty composer and no recent keystroke. The nudge is a submit and moves lastSubmitAt, which the response viewer's conversation correlation needs; the cap counts from its own persisted lastRealPromptAt instead. Read My Mind drops the exact nudge text, and the turn's stop hook and idle prompt open pre-acknowledged so nothing alerts. The last cache report and the clock survive a restart, so the timer re-arms.
253 lines
11 KiB
TypeScript
253 lines
11 KiB
TypeScript
/**
|
||
* The Session Options "Prompt cache" readout (`CodemanPromptCache.format`, constants.js):
|
||
* the one sentence that tells the user whether the next turn reads the conversation
|
||
* 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;
|
||
};
|
||
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 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: 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);
|
||
|
||
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/);
|
||
// 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.');
|
||
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(
|
||
`Warm until ${clock(expiresAt)} (1h cache). About 110k tokens would be re-cached if it goes cold.`
|
||
);
|
||
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(
|
||
'Cold. The next turn re-caches about 90k tokens.'
|
||
);
|
||
expect(format({ warm: true, expiresAt: NOW - 1, recacheTokensIfCold: 500 }, NOW)).toBe(
|
||
'Cold. The next turn re-caches about 500 tokens.'
|
||
);
|
||
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(
|
||
`Warm until ${clock(expiresAt)} (1h cache). About 50k tokens would be re-cached if it goes cold. 91% cache hit rate.`
|
||
);
|
||
expect(
|
||
format({ warm: false, recacheTokensIfCold: 90000, hitRatio: 0.6, lastMissCauses: ['ttl_expired_1h'] }, NOW)
|
||
).toBe('Cold. The next turn re-caches about 90k tokens. 60% cache hit rate. Last miss: ttl_expired_1h.');
|
||
});
|
||
|
||
it('names a compaction, with no re-cache stake while the prefix is still warm', () => {
|
||
const format = loadFormat();
|
||
const expiresAt = NOW + 42 * 60_000;
|
||
const compactedAt = NOW - 6 * 60_000;
|
||
// recacheTokensIfCold is null right after a compaction, so no "would be re-cached" clause.
|
||
expect(format({ warm: true, ttl: '1h', expiresAt, compactedAt, hitRatio: 0.88 }, NOW)).toBe(
|
||
`Warm until ${clock(expiresAt)} (1h cache). Context compacted at ${clock(compactedAt)}. 88% cache hit rate.`
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('Keep-warm line on the Status row', () => {
|
||
const T = NOW + 55 * 60_000;
|
||
const END = NOW + 2 * 3_600_000;
|
||
|
||
it('says nothing until the service has published, or while switched off', () => {
|
||
const { formatKeepWarm } = loadApi();
|
||
expect(formatKeepWarm(null, NOW, 'injected')).toBe('');
|
||
expect(formatKeepWarm({ enabled: false, state: 'off', nudges: 0 }, NOW, 'injected')).toBe('');
|
||
expect(formatKeepWarm({ enabled: false, state: 'off', reason: 'off', nudges: 0 }, NOW, 'injected')).toBe('');
|
||
});
|
||
|
||
it('armed: the due time and when the window closes', () => {
|
||
const { formatKeepWarm } = loadApi();
|
||
expect(formatKeepWarm({ enabled: true, state: 'armed', nextCheckAt: T, nudges: 0, budgetEndsAt: END }, NOW)).toBe(
|
||
`Keep-alive: next ${clock(T)} · stops ${clock(END)}`
|
||
);
|
||
expect(
|
||
formatKeepWarm({ enabled: true, state: 'armed', nextCheckAt: T, nudges: 3, budgetEndsAt: NOW - 1 }, NOW)
|
||
).toBe(`Keep-alive: next ${clock(T)} · window closed`);
|
||
});
|
||
|
||
it('waiting and sent carry the reason and the count', () => {
|
||
const { formatKeepWarm } = loadApi();
|
||
expect(
|
||
formatKeepWarm(
|
||
{
|
||
enabled: true,
|
||
state: 'waiting',
|
||
reason: 'the composer holds text',
|
||
nextCheckAt: T,
|
||
nudges: 1,
|
||
budgetEndsAt: END,
|
||
},
|
||
NOW
|
||
)
|
||
).toBe(`Keep-alive: waiting · the composer holds text · retry ${clock(T)} · stops ${clock(END)}`);
|
||
expect(formatKeepWarm({ enabled: true, state: 'nudged', lastNudgeAt: T, nudges: 2, budgetEndsAt: END }, NOW)).toBe(
|
||
`Keep-alive: sent ${clock(T)} (2×) · stops ${clock(END)}`
|
||
);
|
||
});
|
||
|
||
it('stopped, or off for a reason, names it', () => {
|
||
const { formatKeepWarm } = loadApi();
|
||
expect(formatKeepWarm({ enabled: true, state: 'stopped', reason: 'cache is cold', nudges: 0 }, NOW)).toBe(
|
||
'Keep-alive: stopped · cache is cold'
|
||
);
|
||
expect(
|
||
formatKeepWarm({ enabled: false, state: 'off', reason: 'an agent-spawned session ...', nudges: 0 }, NOW)
|
||
).toBe('Keep-alive: off · an agent-spawned session ...');
|
||
});
|
||
|
||
it("adds nothing on a launch without the exporter: the readout's Not collected sentence already says why", () => {
|
||
const { formatKeepWarm } = loadApi();
|
||
const stopped = { enabled: true, state: 'stopped', reason: 'no cache reports on this launch', nudges: 0 };
|
||
for (const statusLine of ['collection-off', 'workspace-statusline', 'remote']) {
|
||
expect(formatKeepWarm(stopped, NOW, statusLine)).toBe('');
|
||
}
|
||
});
|
||
|
||
it('says idle compaction stays on when the opt-out was not on this launch', () => {
|
||
const { formatKeepWarm } = loadApi();
|
||
const armed = { enabled: true, state: 'armed', nextCheckAt: T, nudges: 0 };
|
||
expect(formatKeepWarm({ ...armed, idleCompactionOff: true }, NOW, 'injected')).toBe(`Keep-alive: next ${clock(T)}`);
|
||
expect(formatKeepWarm({ ...armed, idleCompactionOff: false }, NOW, 'injected')).toBe(
|
||
`Keep-alive: next ${clock(T)} · idle compaction on until relaunch`
|
||
);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* session-ui.js `updateKeepWarmStatus`, run as shipped against a minimal DOM: the keep-warm line
|
||
* on the Status row is hidden when it has nothing to say, and a launch without cache reports
|
||
* greys the switch's row and refuses to switch it on, never to switch it off.
|
||
*/
|
||
describe('Keep-warm switch and its Status line', () => {
|
||
/** One Session Options modal: the same elements across every session it is opened on. */
|
||
function makeModal() {
|
||
const ui = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
||
const start = ui.indexOf(' updateKeepWarmStatus(sessionId) {');
|
||
expect(start).toBeGreaterThan(-1);
|
||
const update = new Function(
|
||
'document',
|
||
'window',
|
||
'sessionId',
|
||
ui.slice(ui.indexOf('{', start) + 1, ui.indexOf('\n },\n', start))
|
||
);
|
||
const rowClasses = new Set<string>();
|
||
const line = { textContent: '', hidden: true };
|
||
const row = {
|
||
classList: { toggle: (c: string, on: boolean) => void (on ? rowClasses.add(c) : rowClasses.delete(c)) },
|
||
};
|
||
const box = { checked: false, disabled: false, closest: () => row };
|
||
const elements: Record<string, unknown> = { keepWarmStatus: line, modalKeepWarmEnabled: box };
|
||
const document = { getElementById: (id: string) => elements[id] ?? null };
|
||
const window = { CodemanPromptCache: loadApi() };
|
||
return (statusLine: string, keepWarm: KeepWarm | undefined, checked: boolean) => {
|
||
box.checked = checked;
|
||
const self = {
|
||
editingSessionId: 's1',
|
||
sessions: new Map([['s1', { statusLineExporter: statusLine, keepWarm }]]),
|
||
};
|
||
update.call(self, document, window, 's1');
|
||
return { line: { ...line }, greyed: rowClasses.has('set-row-disabled'), box: { ...box } };
|
||
};
|
||
}
|
||
const render = (statusLine: string, keepWarm: KeepWarm | undefined, checked: boolean) =>
|
||
makeModal()(statusLine, keepWarm, checked);
|
||
|
||
it('shows the line whenever it has words, any state, and hides it otherwise', () => {
|
||
const waiting = render(
|
||
'injected',
|
||
{ enabled: true, state: 'waiting', reason: 'the composer holds text', nudges: 0 },
|
||
true
|
||
);
|
||
expect(waiting.line.textContent).toContain('Keep-alive: waiting');
|
||
expect(waiting.line.hidden).toBe(false);
|
||
expect(waiting.greyed).toBe(false);
|
||
expect(waiting.box.disabled).toBe(false);
|
||
expect(render('injected', { enabled: false, state: 'off', nudges: 0 }, false).line.hidden).toBe(true);
|
||
});
|
||
|
||
it('greys a launch without cache reports, but lets a switch that is on be turned off', () => {
|
||
const off = render('collection-off', { enabled: false, state: 'off', nudges: 0 }, false);
|
||
expect(off.line.hidden).toBe(true);
|
||
expect(off.greyed).toBe(true);
|
||
expect(off.box.disabled).toBe(true);
|
||
const on = render('collection-off', { enabled: true, state: 'stopped', nudges: 0 }, true);
|
||
expect(on.greyed).toBe(true);
|
||
expect(on.box.disabled).toBe(false);
|
||
});
|
||
|
||
it('undoes what the previous session left on the same modal', () => {
|
||
const show = makeModal();
|
||
expect(show('collection-off', { enabled: false, state: 'off', nudges: 0 }, false).box.disabled).toBe(true);
|
||
const next = show('injected', { enabled: false, state: 'off', nudges: 0 }, false);
|
||
expect(next.box.disabled).toBe(false);
|
||
expect(next.greyed).toBe(false);
|
||
expect(show('injected', { enabled: true, state: 'armed', nextCheckAt: NOW, nudges: 0 }, true).line.hidden).toBe(
|
||
false
|
||
);
|
||
const off = show('injected', { enabled: false, state: 'off', nudges: 0 }, false);
|
||
expect(off.line.hidden).toBe(true);
|
||
expect(off.line.textContent).toBe('');
|
||
});
|
||
});
|