Merge pull request #607: feat(telemetry): read out Claude Code's prompt cache state per session

This commit is contained in:
Codeman maintainer committed 2026-10-13 18:07:15 +02:00
commit 62227145dc
24 files changed
+931 -30

No files matched your search

+8 -1
View File
@@ -4,7 +4,7 @@
*/
import { EventEmitter } from 'node:events';
import { vi } from 'vitest';
import type { SessionAttachmentHistoryItem, SessionStatus, SessionRemote } from '../../src/types.js';
import type { PromptCacheStatus, SessionAttachmentHistoryItem, SessionStatus, SessionRemote } from '../../src/types.js';
/**
* Enhanced mock session for testing RespawnController.
@@ -41,6 +41,13 @@ export class MockSession extends EventEmitter {
* need to be able to set it.
*/
watching: string | null = null;
/** Mirrors Session.promptCache / setPromptCache (fed by POST /api/status-telemetry). */
promptCache: PromptCacheStatus | null = null;
setPromptCache(status: PromptCacheStatus): boolean {
const changed = JSON.stringify(status) !== JSON.stringify(this.promptCache);
this.promptCache = status;
return changed;
}
private _activeChildProcesses: { pid: number; command: string }[] = [];
ralphTracker: null = null;
writeBuffer: string[] = [];
+89
View File
@@ -0,0 +1,89 @@
/**
* 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;
};
function loadFormat() {
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;
}
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/);
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.`
);
});
});
+100 -1
View File
@@ -6,13 +6,14 @@
* skip, per-session change-detection (dedup), rebroadcast on a displayed change,
* NO rebroadcast on context-only drift, null-tolerance of Claude's undocumented
* fields (the .nullish() schema — the project's recurring .optional()/null trap),
* the prompt-cache group of the footer (warm and cold shapes of `prompt_cache`),
* and 400 on a malformed body. Also the session's model (`displayModel`) the route
* records off the same payload.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerStatusTelemetryRoutes } from '../../src/web/routes/status-telemetry-routes.js';
import { SessionStatusTelemetry } from '../../src/web/sse-events.js';
import { SessionPromptCache, SessionStatusTelemetry } from '../../src/web/sse-events.js';
import { Session } from '../../src/session.js';
const SID = 'test-session-1'; // default id created by createMockRouteContext
@@ -97,6 +98,62 @@ describe('POST /api/status-telemetry', () => {
expect(payload.sevenDay).toBeUndefined();
});
it('prints the prompt-cache state in the footer: a clock time while warm, cold once Claude says so', async () => {
const expiresAtS = Math.floor(Date.now() / 1000) + 3600;
const warm = await post({
sessionId: SID,
data: {
...REAL,
prompt_cache: { warm: true, ttl: '1h', expires_at: expiresAtS, recache_tokens_if_cold: 109833 },
},
});
expect(warm.statusCode).toBe(200);
expect(warm.body).toMatch(/^Opus 4\.8 \(1M context\) in:562,411 out:1,188 ctx:56% cache:until \d\d:\d\d$/);
// The cold flip Claude sends when expires_at passes: null expiry, and a last_miss_cause
// that carries numeric detail keys beside `causes` (must not 400, which would blank the footer).
const cold = await post({
sessionId: SID,
data: {
...REAL,
prompt_cache: {
warm: false,
ttl: '1h',
expires_at: null,
misses: 1,
last_miss_cause: { causes: ['ttl_expired_1h'], system_char_delta: 0 },
},
},
});
expect(cold.statusCode).toBe(200);
expect(cold.body.endsWith(' cache:cold')).toBe(true);
// The report is kept on the session (toState / Session Options readout) and each CHANGE
// is broadcast on its own event; the plan chip did not change, so it broadcast once.
const session = h.ctx.sessions.get(SID) as unknown as { promptCache: { warm: boolean } | null };
expect(session.promptCache).toMatchObject({
warm: false,
ttl: '1h',
misses: 1,
lastMissCauses: ['ttl_expired_1h'],
});
const events = h.ctx.broadcast.mock.calls.map(([name]) => name);
expect(events.filter((e) => e === SessionStatusTelemetry)).toHaveLength(1);
expect(events.filter((e) => e === SessionPromptCache)).toHaveLength(2);
expect(h.ctx.broadcast).toHaveBeenCalledWith(
SessionPromptCache,
expect.objectContaining({ sessionId: SID, warm: true, ttl: '1h', recacheTokensIfCold: 109833 })
);
});
it('does not rebroadcast an unchanged prompt-cache report', async () => {
const pc = { warm: true, ttl: '1h', expires_at: Math.floor(Date.now() / 1000) + 3600 };
await post({ sessionId: SID, data: { ...REAL, prompt_cache: pc } });
await post({ sessionId: SID, data: { ...REAL, prompt_cache: pc } });
const events = h.ctx.broadcast.mock.calls.map(([name]) => name);
expect(events.filter((e) => e === SessionPromptCache)).toHaveLength(1);
});
it('rejects a malformed body (missing sessionId) with 400', async () => {
const res = await post({ data: REAL });
expect(res.statusCode).toBe(400);
@@ -154,4 +211,46 @@ describe('POST /api/status-telemetry: the session model (displayModel)', () => {
await post({ sessionId: 'codex-1', data: { model: { display_name: 'Opus 4.8' } } });
expect(session.toState().displayModel).toBeUndefined();
});
it('broadcasts the STORED prompt-cache object, so a compaction stamp reaches the live readout', async () => {
const session = realSession('claude', 'claude-compact');
const expiresAtS = Math.floor(Date.now() / 1000) + 3600;
// The order Claude Code really reports in: a warm report with 500k at stake, then
// `/compact` finishes (recache null, expected_rebuilds unchanged), and the session stamps
// compactedAt in setPromptCache on entering that window.
await post({
sessionId: 'claude-compact',
data: {
prompt_cache: {
warm: true,
ttl: '1h',
expires_at: expiresAtS,
expected_rebuilds: 0,
recache_tokens_if_cold: 500000,
},
},
});
await post({
sessionId: 'claude-compact',
data: { prompt_cache: { warm: true, ttl: '1h', expires_at: expiresAtS, expected_rebuilds: 0 } },
});
expect(typeof session.toState().promptCache?.compactedAt).toBe('number');
// The broadcast must carry the stored object, not just the freshly parsed one, or the
// frontend's live update would never see the stamp (only a modal reopen would).
const last = (h.ctx.broadcast.mock.calls as [string, Record<string, unknown>][])
.filter(([name]) => name === SessionPromptCache)
.at(-1)?.[1];
expect(last).toMatchObject({ sessionId: 'claude-compact', warm: true });
expect(typeof last?.compactedAt).toBe('number');
});
it('persists a changed prompt-cache report, and only a changed one', async () => {
const session = realSession('claude', 'claude-persist');
const data = { prompt_cache: { warm: true, ttl: '1h', expires_at: Math.floor(Date.now() / 1000) + 3600 } };
await post({ sessionId: 'claude-persist', data });
expect(h.ctx.persistSessionState).toHaveBeenCalledWith(session);
h.ctx.persistSessionState.mockClear();
await post({ sessionId: 'claude-persist', data });
expect(h.ctx.persistSessionState).not.toHaveBeenCalled();
});
});
+130
View File
@@ -0,0 +1,130 @@
/**
* Session.setPromptCache's compaction detection and the restore of the last report.
*
* The statusline never reports WHEN the context was compacted, so the session stamps it.
* Claude Code's tracker (2.1.294-2.1.296) opens a window at a compaction or a tool-result
* clearing in which `recacheTokensIfCold` reads null, and closes it on the next request,
* which is also the one that counts as an expected rebuild. So `expectedRebuilds` never rises
* inside the window, and the stamp fires on ENTERING it: a warm null report after one that
* carried a value. It is carried through idle re-reports, dropped at the next request, and a
* first report already inside the window never invents one. The fixtures follow that real
* order. The last report is restored through the constructor (keeping an old stamp, never
* re-stamping at restart time), and a conversation switch drops it.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Session } from '../src/session.js';
import type { PromptCacheStatus } from '../src/types.js';
function claudeSession(extra: Record<string, unknown> = {}): Session {
return new Session({ workingDir: '/tmp', mode: 'claude', ...extra } as ConstructorParameters<typeof Session>[0]);
}
describe('Session.setPromptCache compaction detection', () => {
afterEach(() => vi.useRealTimers());
it('stamps when /compact finishes, keeps the stamp through idle re-reports, drops it at the next request', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-10-06T22:05:00Z'));
const s = claudeSession();
const expiresAt = Date.now() + 3600_000;
// 1. Warm, 500k at stake.
s.setPromptCache({ warm: true, ttl: '1h', expiresAt, expectedRebuilds: 0, recacheTokensIfCold: 500000 });
expect(s.promptCache?.compactedAt).toBeUndefined();
// 2. `/compact` finishes: recache null (absent after parsing), expected_rebuilds unchanged.
vi.setSystemTime(new Date('2026-10-06T22:31:00Z'));
s.setPromptCache({ warm: true, ttl: '1h', expiresAt, expectedRebuilds: 0 });
expect(s.promptCache?.compactedAt).toBe(Date.parse('2026-10-06T22:31:00Z'));
// An idle re-report inside the same window: the stamp is carried, not re-stamped.
vi.setSystemTime(new Date('2026-10-06T22:45:00Z'));
s.setPromptCache({ warm: true, ttl: '1h', expiresAt, expectedRebuilds: 0 });
expect(s.promptCache?.compactedAt).toBe(Date.parse('2026-10-06T22:31:00Z'));
// 3. The next request closes the window: expected_rebuilds 1, recache 40k.
s.setPromptCache({ warm: true, ttl: '1h', expiresAt, expectedRebuilds: 1, recacheTokensIfCold: 40000 });
expect(s.promptCache?.compactedAt).toBeUndefined();
});
it('never stamps a first report that is already inside the window', () => {
// The first report Codeman sees (a restart mid-window) has no prior value to leave.
const s = claudeSession();
s.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 4 });
expect(s.promptCache?.compactedAt).toBeUndefined();
// Nor does a later report in the same window: the stamp needs a known value before it.
s.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 4 });
expect(s.promptCache?.compactedAt).toBeUndefined();
});
it('never stamps a cold report or a report that still carries the stake', () => {
const s = claudeSession();
s.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, recacheTokensIfCold: 60000 });
// Cold with no stake: an expired cache, not a compaction.
s.setPromptCache({ warm: false, expectedRebuilds: 0 });
expect(s.promptCache?.compactedAt).toBeUndefined();
// A rebuild count that rose with the stake present is the request after a window, not one.
s.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 2, recacheTokensIfCold: 60000 });
expect(s.promptCache?.compactedAt).toBeUndefined();
});
it('reports changed only when the stored object differs', () => {
const s = claudeSession();
const pc: PromptCacheStatus = { warm: true, ttl: '1h', expiresAt: 1, recacheTokensIfCold: 10 };
expect(s.setPromptCache({ ...pc })).toBe(true);
expect(s.setPromptCache({ ...pc })).toBe(false);
expect(s.setPromptCache({ ...pc, warm: false })).toBe(true);
});
});
describe('prompt-cache report survives a restart', () => {
it('restores the constructor value onto the session and toState, and a new report replaces it', () => {
const restored: PromptCacheStatus = { warm: true, ttl: '1h', expiresAt: 1, recacheTokensIfCold: 25000, misses: 1 };
const s = claudeSession({ promptCache: restored });
expect(s.promptCache).toEqual(restored);
expect(s.toState().promptCache).toEqual(restored);
s.setPromptCache({ warm: false });
expect(s.toState().promptCache).toEqual({ warm: false });
});
it('is absent, not null, when nothing was restored', () => {
const s = claudeSession();
expect(s.promptCache).toBeNull();
expect(s.toState().promptCache).toBeUndefined();
});
it('restores a post-compaction value WITHOUT re-stamping it at restart time', () => {
// A direct restore, not a setPromptCache call: a report from inside the window (recache
// absent, an OLD compactedAt) must keep its original timestamp through construction,
// never be re-stamped with "now" as a fresh compaction.
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-10-06T23:59:00Z'));
const oldStamp = Date.parse('2026-10-06T22:31:00Z');
const s = claudeSession({
promptCache: {
warm: true,
ttl: '1h',
expiresAt: Date.now() + 600_000,
expectedRebuilds: 3,
compactedAt: oldStamp,
},
});
expect(s.promptCache?.compactedAt).toBe(oldStamp);
vi.useRealTimers();
});
});
describe('a conversation switch drops the prompt-cache report', () => {
it('clears it when the Claude conversation id changes, keeps it on a same-id reattach', () => {
const s = claudeSession();
s.adoptClaudeSessionId('conv-a', { firstHand: true }); // establish the current conversation
s.setPromptCache({ warm: true, ttl: '1h', expiresAt: Date.now() + 600_000, expectedRebuilds: 0 });
// A hook reports the SAME conversation (a reattach): the report must survive.
s.adoptClaudeSessionId('conv-a', { firstHand: true });
expect(s.promptCache).not.toBeNull();
// A post-/clear switch to a NEW conversation: the old prefix's state is gone.
s.adoptClaudeSessionId('conv-b', { firstHand: true });
expect(s.promptCache).toBeNull();
expect(s.toState().promptCache).toBeUndefined();
});
});
+55 -2
View File
@@ -15,9 +15,13 @@
* sees.
*/
import { describe, it, expect } from 'vitest';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { execFileSync } from 'node:child_process';
import { buildSpawnCommand } from '../src/tmux-manager.js';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { buildSpawnCommand, TmuxManager } from '../src/tmux-manager.js';
import { SETTINGS_PATH } from '../src/web/route-helpers.js';
const EXPORTER_CMD = 'curl -sfk -X POST "$CODEMAN_API_URL/api/status-telemetry" --data @- 2>/dev/null || true';
@@ -94,3 +98,52 @@ describe('buildSpawnCommand statusLineCommand (claude mode)', () => {
expect(cmd).not.toContain('--settings');
});
});
// Why a launch does or does not carry the exporter. Recorded on the mux record and
// published as `statusLineExporter`, so the Status row can say why no
// cache report will come instead of "not reported yet" forever.
describe('TmuxManager statusLine classification', () => {
type Resolve = (
mode: string,
workingDir: string,
remoteOrDocker: boolean
) => Promise<{ command?: string; state?: string }>;
let workingDir: string;
let resolve: Resolve;
beforeEach(() => {
workingDir = mkdtempSync(join(tmpdir(), 'statusline-state-'));
const mux = new TmuxManager() as unknown as { _resolveStatusLine: Resolve };
resolve = mux._resolveStatusLine.bind(mux);
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
});
afterEach(() => {
rmSync(workingDir, { recursive: true, force: true });
rmSync(SETTINGS_PATH, { force: true });
});
it('injected when plan-usage collection is on (an absent key reads as on)', async () => {
const r = await resolve('claude', workingDir, false);
expect(r.state).toBe('injected');
expect(r.command).toBeTruthy();
});
it('collection-off when the setting is explicitly false', async () => {
writeFileSync(SETTINGS_PATH, JSON.stringify({ showPlanUsageLimits: false }));
expect(await resolve('claude', workingDir, false)).toEqual({ command: undefined, state: 'collection-off' });
});
it("workspace-statusline when the workspace's settings.local.json sets its own", async () => {
mkdirSync(join(workingDir, '.claude'));
writeFileSync(
join(workingDir, '.claude', 'settings.local.json'),
JSON.stringify({ statusLine: { type: 'command', command: 'echo mine' } })
);
expect(await resolve('claude', workingDir, false)).toEqual({ command: undefined, state: 'workspace-statusline' });
});
it('remote for remote and docker launches, nothing at all for a CLI without the capability', async () => {
expect(await resolve('claude', workingDir, true)).toEqual({ state: 'remote' });
expect(await resolve('codex', workingDir, false)).toEqual({});
});
});
+121
View File
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
import {
parseStatusTelemetry,
parseSessionStatus,
parsePromptCache,
formatSessionStatusText,
telemetrySignature,
type RawStatuslinePayload,
@@ -88,7 +89,111 @@ describe('parseStatusTelemetry', () => {
});
});
// Mirrors a captured `prompt_cache` object (CC 2.1.280, Claude Max, 1h TTL) from a
// session whose first turn finished at 23:00:06Z; expires_at is epoch SECONDS.
const EXPIRES_AT_S = Date.UTC(2026, 9, 6, 23, 0, 6) / 1000;
const WARM_CACHE: RawStatuslinePayload = {
prompt_cache: {
warm: true,
ttl: '1h',
expires_at: EXPIRES_AT_S,
recache_tokens_if_cold: 109833,
misses: 0,
last_miss_cause: null,
},
};
/** The footer prints the server-local clock, so derive the expected text the same way. */
function localHHMM(ms: number): string {
const d = new Date(ms);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
describe('parsePromptCache', () => {
it('normalizes the captured object, converting expires_at seconds to ms', () => {
expect(parsePromptCache(WARM_CACHE)).toEqual({
warm: true,
ttl: '1h',
expiresAt: EXPIRES_AT_S * 1000,
recacheTokensIfCold: 109833,
misses: 0,
});
});
it('keeps a cold report (warm:false, null expires_at) and the diagnosed miss causes', () => {
expect(
parsePromptCache({
prompt_cache: {
warm: false,
ttl: '1h',
expires_at: null,
misses: 1,
// Real shape: numeric detail keys ride next to `causes`
last_miss_cause: { causes: ['ttl_expired_1h', 7, ''] } as never,
},
})
).toEqual({ warm: false, ttl: '1h', misses: 1, lastMissCauses: ['ttl_expired_1h'] });
});
it('returns null when the payload has no prompt_cache or warm is not a boolean (state unknown, not cold)', () => {
expect(parsePromptCache(undefined)).toBeNull();
expect(parsePromptCache({})).toBeNull();
expect(parsePromptCache({ prompt_cache: null })).toBeNull();
expect(parsePromptCache({ prompt_cache: { warm: null, ttl: '1h' } })).toBeNull();
});
it('drops a TTL it does not know and a non-positive or non-finite expiry', () => {
expect(parsePromptCache({ prompt_cache: { warm: true, ttl: '2h', expires_at: 0 } })).toEqual({ warm: true });
expect(parsePromptCache({ prompt_cache: { warm: true, expires_at: Number.NaN } })).toEqual({ warm: true });
expect(parsePromptCache({ prompt_cache: { warm: true, expires_at: -5 } })).toEqual({ warm: true });
});
it('drops an expiry that overflows or leaves the Date range after the seconds->ms multiply', () => {
// These pass `expires_at > 0 && isFinite` on the SECONDS but render "cache:until NaN:NaN"
// once converted, so the converted ms must be validated too (finite, within Date range).
expect(parsePromptCache({ prompt_cache: { warm: true, expires_at: Number.POSITIVE_INFINITY } })).toEqual({
warm: true,
});
expect(parsePromptCache({ prompt_cache: { warm: true, expires_at: 1e308 } })).toEqual({ warm: true }); // *1000 = Infinity
expect(parsePromptCache({ prompt_cache: { warm: true, expires_at: 1e14 } })).toEqual({ warm: true }); // 1e17 ms, out of range
// The documented boundary still parses (8.64e15 ms = 8.64e12 s).
expect(parsePromptCache({ prompt_cache: { warm: true, expires_at: 8.64e12 } })).toEqual({
warm: true,
expiresAt: 8.64e15,
});
});
it('keeps hit_ratio (clamped 0-1) and expected_rebuilds (rounded, non-negative)', () => {
expect(parsePromptCache({ prompt_cache: { warm: true, hit_ratio: 0.91, expected_rebuilds: 2 } })).toEqual({
warm: true,
hitRatio: 0.91,
expectedRebuilds: 2,
});
// Out-of-range or non-finite values are clamped or dropped, never stored as-is.
expect(parsePromptCache({ prompt_cache: { warm: true, hit_ratio: 1.5 } })).toEqual({ warm: true, hitRatio: 1 });
expect(parsePromptCache({ prompt_cache: { warm: true, hit_ratio: -0.2 } })).toEqual({ warm: true, hitRatio: 0 });
// expected_rebuilds is rounded and floored at 0 (it indexes the compaction rise).
expect(parsePromptCache({ prompt_cache: { warm: true, expected_rebuilds: 2.9 } })).toEqual({
warm: true,
expectedRebuilds: 3,
});
expect(parsePromptCache({ prompt_cache: { warm: true, expected_rebuilds: -4 } })).toEqual({
warm: true,
expectedRebuilds: 0,
});
expect(parsePromptCache({ prompt_cache: { warm: true, hit_ratio: null, expected_rebuilds: null } })).toEqual({
warm: true,
});
});
});
describe('parseSessionStatus', () => {
it('carries the prompt-cache state into the footer status', () => {
expect(parseSessionStatus({ ...REAL, ...WARM_CACHE })?.cache).toEqual(parsePromptCache(WARM_CACHE));
// prompt_cache alone IS session status (a cold flip arrives with nothing else changed)
expect(parseSessionStatus(WARM_CACHE)).toEqual({ cache: parsePromptCache(WARM_CACHE) });
});
it('extracts model, token totals, and context % for the footer', () => {
const s = parseSessionStatus(REAL);
expect(s).toEqual({
@@ -123,6 +228,22 @@ describe('formatSessionStatusText', () => {
expect(formatSessionStatusText(null)).toBe('');
expect(formatSessionStatusText({} as never)).toBe('');
});
it('shows the warm cache as a server-local clock time, not a countdown that would sit stale', () => {
const expiresAt = EXPIRES_AT_S * 1000;
const now = expiresAt - 42 * 60_000;
expect(formatSessionStatusText(parseSessionStatus({ ...REAL, ...WARM_CACHE }), now)).toBe(
`Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56% cache:until ${localHHMM(expiresAt)}`
);
});
it('shows cache:cold once Claude reports it cold or the render happened past expires_at', () => {
const expiresAt = EXPIRES_AT_S * 1000;
expect(formatSessionStatusText({ cache: { warm: false } })).toBe('cache:cold');
expect(formatSessionStatusText({ cache: { warm: true, expiresAt } }, expiresAt + 1)).toBe('cache:cold');
// warm without an expiry is unknowable, so it is not promised as warm
expect(formatSessionStatusText({ cache: { warm: true } })).toBe('cache:cold');
});
});
describe('telemetrySignature', () => {