fix(telemetry): stamp a compaction on entering the null window

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.
This commit is contained in:
JD committed 2026-10-11 12:36:59 -04:00
1 parent 551a7f2792
commit ecb2e3d8f3
16 files changed
+110 -125

No files matched your search

+9 -11
View File
@@ -1,6 +1,6 @@
/**
* The Session Options "Prompt cache" readout (`CodemanPromptCache.format`, constants.js):
* the one sentence that tells the user whether the next turn re-reads the conversation
* 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';
@@ -49,7 +49,7 @@ describe('Prompt cache readout', () => {
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-read if it goes cold.`
`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)}.`);
});
@@ -57,10 +57,10 @@ describe('Prompt cache readout', () => {
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-reads about 90k tokens at the cache-write rate.'
'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-reads about 500 tokens at the cache-write rate.'
'Cold. The next turn re-caches about 500 tokens.'
);
expect(format({ warm: true }, NOW)).toBe('Cold.');
expect(format({ warm: false }, NOW)).toBe('Cold.');
@@ -70,22 +70,20 @@ describe('Prompt cache readout', () => {
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-read if it goes cold. 91% cache hit rate.`
`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-reads about 90k tokens at the cache-write rate. 60% cache hit rate. Last miss: ttl_expired_1h.'
);
).toBe('Cold. The next turn re-caches about 90k tokens. 60% cache hit rate. Last miss: ttl_expired_1h.');
});
it("names Claude Code's own idle compaction, with no re-read stake while the prefix is still warm", () => {
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-read" clause.
// 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). Compacted at ${clock(compactedAt)}. 88% cache hit rate.`
`Warm until ${clock(expiresAt)} (1h cache). Context compacted at ${clock(compactedAt)}. 88% cache hit rate.`
);
});
});
+14 -4
View File
@@ -215,9 +215,9 @@ describe('POST /api/status-telemetry: the session model (displayModel)', () => {
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;
// A normal warm report (rebuild baseline 0), then Claude Code's idle compaction: recache
// goes null (absent), expected_rebuilds rises above the known baseline, still warm. The
// session stamps compactedAt in setPromptCache.
// 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: {
@@ -232,7 +232,7 @@ describe('POST /api/status-telemetry: the session model (displayModel)', () => {
});
await post({
sessionId: 'claude-compact',
data: { prompt_cache: { warm: true, ttl: '1h', expires_at: expiresAtS, expected_rebuilds: 1 } },
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
@@ -243,4 +243,14 @@ describe('POST /api/status-telemetry: the session model (displayModel)', () => {
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();
});
});
+39 -65
View File
@@ -1,13 +1,15 @@
/**
* Session.setPromptCache's compaction detection and the restore of the last report.
*
* The statusline never reports WHEN Claude Code's own idle compaction happened, so the
* session stamps it: `recacheTokensIfCold` null (the transient post-compaction window the
* CC statusline docs describe) while `expectedRebuilds` rose above the last report. The
* stamp drives the "compacted at HH:MM" readout; it is never an alert. A rise is only a rise
* against a KNOWN prior counter, so a first high report never invents one. The last report is
* restored through the constructor (keeping an old compaction stamp, never re-stamping at
* restart time), and a conversation switch drops it.
* 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';
@@ -20,78 +22,50 @@ function claudeSession(extra: Record<string, unknown> = {}): Session {
describe('Session.setPromptCache compaction detection', () => {
afterEach(() => vi.useRealTimers());
it('stamps compactedAt only when recache went null and expected_rebuilds rose', () => {
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;
// A normal warm report establishes the rebuild baseline (known 0): recache known, no
// compaction yet. Claude reports expected_rebuilds from the first response, so a real
// baseline always carries it.
s.setPromptCache({
warm: true,
ttl: '1h',
expiresAt: Date.now() + 3600_000,
expectedRebuilds: 0,
recacheTokensIfCold: 50000,
});
// 1. Warm, 500k at stake.
s.setPromptCache({ warm: true, ttl: '1h', expiresAt, expectedRebuilds: 0, recacheTokensIfCold: 500000 });
expect(s.promptCache?.compactedAt).toBeUndefined();
// Claude Code compacts: recache goes null (absent after parsing), expected_rebuilds rises,
// the prefix stays warm. This is the one transition that stamps.
// 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: Date.now() + 3600_000, expectedRebuilds: 1 });
s.setPromptCache({ warm: true, ttl: '1h', expiresAt, expectedRebuilds: 0 });
expect(s.promptCache?.compactedAt).toBe(Date.parse('2026-10-06T22:31:00Z'));
});
it('carries the stamp forward through the transient window, drops it once a real request re-caches', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-10-06T22:30:00Z'));
const s = claudeSession();
// Baseline (known rebuild count) then the compaction, so the rise is against a known prior.
s.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 0, recacheTokensIfCold: 70000 });
vi.setSystemTime(new Date('2026-10-06T22:31:00Z'));
s.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 1 });
const stampedAt = s.promptCache?.compactedAt;
expect(stampedAt).toBe(Date.parse('2026-10-06T22:31:00Z'));
// A later idle report, still no request since the compaction: same rebuild count, recache
// still null. The stamp is kept (not re-stamped), so the clock in the readout does not move.
// 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, expiresAt: Date.now() + 3600_000, expectedRebuilds: 1 });
expect(s.promptCache?.compactedAt).toBe(stampedAt);
s.setPromptCache({ warm: true, ttl: '1h', expiresAt, expectedRebuilds: 0 });
expect(s.promptCache?.compactedAt).toBe(Date.parse('2026-10-06T22:31:00Z'));
// The next real request records the rewritten size (recache non-null): the window is over.
s.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 1, recacheTokensIfCold: 42000 });
// 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 plain miss (recache present) or an unchanged rebuild count', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-10-06T22:31:00Z'));
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();
// expected_rebuilds rose but recache is present: the transient window was already missed.
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();
// recache null but the rebuild count did not rise: not a fresh compaction.
s.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 2 });
expect(s.promptCache?.compactedAt).toBeUndefined();
});
it('never invents a stamp without a KNOWN prior rebuild count (a rise needs two known reports)', () => {
// The very first report Codeman sees already carries a high count with recache null (a
// restart mid-session). Treating an absent prior as 0 would fabricate "compacted now".
const s1 = claudeSession();
s1.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 4 });
expect(s1.promptCache?.compactedAt).toBeUndefined();
// `4 -> omitted -> 4`: a report that drops the counter clears the known prior, so the next
// report carrying 4 again is not a provable rise.
const s2 = claudeSession();
s2.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 4, recacheTokensIfCold: 10 });
s2.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000 }); // counter omitted
s2.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 4 });
expect(s2.promptCache?.compactedAt).toBeUndefined();
});
it('reports changed only when the stored object differs', () => {
@@ -120,9 +94,9 @@ describe('prompt-cache report survives a restart', () => {
});
it('restores a post-compaction value WITHOUT re-stamping it at restart time', () => {
// A direct restore, not a setPromptCache call: an already-compacted report (recache absent,
// expectedRebuilds > 0, an OLD compactedAt) must keep its original timestamp through
// construction, never be re-stamped with "now" as a fresh compaction.
// 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');
+1 -1
View File
@@ -100,7 +100,7 @@ describe('buildSpawnCommand statusLineCommand (claude mode)', () => {
});
// Why a launch does or does not carry the exporter. Recorded on the mux record and
// published as `statusLineTelemetry`, so the Status row and keep-warm can say why no
// 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 = (