mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-13 18:29:32 +01:00
Only a human spends an idle alert, but the keep-alive turn resolved an unseen one three ways: its working transition, its stop hook, and its own idle prompt replacing the item with a pre-acknowledged one. While the turn runs, a pending idle item is now left exactly as found, the page's stop handler returns on the keep-alive reason before clearing hooks or raising Response Complete, and session:working/session:idle carry keepAlive so the stuck-detection timer neither restarts nor re-arms. The window that marks the turn closes on its own idle prompt or any other submit instead of a fixed three minutes, which a slow turn's idle prompt (sent a minute after the turn ends) could outlast. The service keeps the default and cap in memory, read through a strict reader so a mid-write read is retried rather than kept, and the cap is clamped before the strict settings save.
262 lines
12 KiB
TypeScript
262 lines
12 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`
|
||
);
|
||
});
|
||
|
||
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('');
|
||
});
|
||
});
|