mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
Merge pull request #607: feat(telemetry): read out Claude Code's prompt cache state per session
This commit is contained in:
commit
62227145dc
24 files changed
+931
-30
No files matched your search
@@ -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[] = [];
|
||||
|
||||
@@ -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.`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user