mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 17:19:33 +02:00
Claude Code's tracker nulls recache_tokens_if_cold at a compaction or a tool-result clearing and only counts the expected rebuild at the next request, which also closes the window, so no report carried both and the old rule never fired. Stamp on the first warm null report after one that carried a value, carry it through idle re-reports and drop it at the next request; the readout now says "Context compacted at HH:MM" since a clearing opens the same window. Also persist a changed report so a crash restores the latest one, rename the session field to statusLineExporter (statusLineTelemetry is the registry capability) and document its values, word the stake as re-cached, move the readout block above the SSE staleness heading, and drop keep-warm mentions until it lands.
131 lines
6.5 KiB
TypeScript
131 lines
6.5 KiB
TypeScript
/**
|
|
* 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();
|
|
});
|
|
});
|