From ecb2e3d8f32bb10f2dfde13afe2a70a265b8662c Mon Sep 17 00:00:00 2001 From: JD Date: Sun, 11 Oct 2026 12:36:59 -0400 Subject: [PATCH] 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. --- CLAUDE.md | 2 +- docs/api-reference.md | 8 +- docs/architecture-invariants.md | 2 +- docs/usage-limits-display-plan.md | 2 +- docs/wiki/Keeping-Agents-Running.md | 2 +- src/mux-interface.ts | 4 +- src/session.ts | 29 +++--- src/tmux-manager.ts | 6 +- src/types/session.ts | 16 +-- src/web/public/constants.js | 16 +-- src/web/public/session-ui.js | 2 +- src/web/routes/status-telemetry-routes.ts | 2 + test/prompt-cache-status.test.ts | 20 ++-- test/routes/status-telemetry-routes.test.ts | 18 +++- test/session-prompt-cache.test.ts | 104 ++++++++------------ test/statusline-cli-flag.test.ts | 2 +- 16 files changed, 110 insertions(+), 125 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index aa6aa901..9308f610 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -224,7 +224,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit) -**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF): resolve DISPLAY only through `planUsageChipEnabled()` (settings-ui.js). The same setting is the server-side COLLECTION switch, read fresh by `readPlanUsageTelemetryEnabled()` (hooks-config.ts) at every claude create/respawn. ⚠️ An ABSENT key reads as ON in the reader; `GET /api/settings` must never write. ⚠️ A save sends `showPlanUsageLimits` ONLY when it flips the chip on that device (`planUsageCollectionFlip()`), or a phone switches collection off for every desktop. Claude data comes from the statusLine exporter, injected as an EPHEMERAL `claude --settings` flag (`resolveStatusLineCliCommand`), never written to disk, WRAPPING a user's own statusLine, posting to `POST /api/status-telemetry`. Codex comes from a read-only `account/rateLimits/read` poll (main bucket only). The statusline's `prompt_cache` object (Claude Code v2.1.251+) is kept by `StatusTelemetrySchema` (every documented scalar field, null-tolerant; `parsePromptCache()` in usage-telemetry.ts normalizes only the stored subset) and shown in the footer as `cache:until HH:MM` / `cache:cold` (only when the user has no statusline of their own) and in Session Options → Respawn (`#promptCacheStatus`, fed by `Session.promptCache` in `toState()` plus the change-only `session:promptCache` SSE event, worded by the pure `CodemanPromptCache.format`): warm-until/cold, the re-read stake, the hit rate, the last miss's causes, and a `compacted at HH:MM` note for Claude Code's own idle compaction (`recache_tokens_if_cold` null while `expected_rebuilds` rose — NEVER an alert). ⚠️ `Session.setPromptCache()` stamps `compactedAt` onto a NEW object (never mutating the parsed report) and the route broadcasts the STORED object; the last report is restored on restart (constructor `promptCache`, like `displayModel`) and the readout is time-aware (a passed `expiresAt` reads cold, not stale-warm). It is the source of truth for the cache TTL, never the plan tier. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md` +**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF): resolve DISPLAY only through `planUsageChipEnabled()` (settings-ui.js). The same setting is the server-side COLLECTION switch, read fresh by `readPlanUsageTelemetryEnabled()` (hooks-config.ts) at every claude create/respawn. ⚠️ An ABSENT key reads as ON in the reader; `GET /api/settings` must never write. ⚠️ A save sends `showPlanUsageLimits` ONLY when it flips the chip on that device (`planUsageCollectionFlip()`), or a phone switches collection off for every desktop. Claude data comes from the statusLine exporter, injected as an EPHEMERAL `claude --settings` flag (`resolveStatusLineCliCommand`), never written to disk, WRAPPING a user's own statusLine, posting to `POST /api/status-telemetry`. Codex comes from a read-only `account/rateLimits/read` poll (main bucket only). The statusline's `prompt_cache` object (Claude Code v2.1.251+) is kept by `StatusTelemetrySchema` (every documented scalar field, null-tolerant; `parsePromptCache()` in usage-telemetry.ts normalizes only the stored subset) and shown in the footer as `cache:until HH:MM` / `cache:cold` (only when the user has no statusline of their own) and in Session Options → Respawn (`#promptCacheStatus`, fed by `Session.promptCache` in `toState()` plus the change-only `session:promptCache` SSE event, worded by the pure `CodemanPromptCache.format`): warm-until/cold, the re-cache stake, the hit rate, the last miss's causes, and a `Context compacted at HH:MM` note after a compaction or a tool-result clearing (stamped on the first warm report whose `recache_tokens_if_cold` is null after one that carried it, never from `expected_rebuilds`, which Claude Code bumps only at the next request; NEVER an alert). ⚠️ `Session.setPromptCache()` stamps `compactedAt` onto a NEW object (never mutating the parsed report) and the route broadcasts the STORED object; the last report is restored on restart (constructor `promptCache`, like `displayModel`) and the readout is time-aware (a passed `expiresAt` reads cold, not stale-warm). It is the source of truth for the cache TTL, never the plan tier. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md` **Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`. diff --git a/docs/api-reference.md b/docs/api-reference.md index 770ac917..ce307e82 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -645,7 +645,7 @@ change-only `session:promptCache` event (`{ sessionId, ...promptCache }`): | `expectedRebuilds` | Cache rebuilds that followed a compaction or a clearing of old tool results. | | `hitRatio` | Cache read tokens as a fraction of all input tokens this session, 0 to 1. | | `lastMissCauses` | Claude Code's diagnosis of the last miss (e.g. `ttl_expired_1h`, `tools_changed`); untrusted text. | -| `compactedAt` | Epoch MILLISECONDS when Codeman first saw Claude Code's own idle compaction of the current prefix (derived, not reported). | +| `compactedAt` | Epoch MILLISECONDS when the context was compacted or its old tool results cleared: the first warm report without `recacheTokensIfCold` after one that had it. Derived, not reported; dropped at the next request. | The whole object is absent until the first report, and restored after a server restart (so the readout is not blank until the pane's next render) rather than recomputed; a value @@ -654,6 +654,12 @@ whose `expiresAt` has passed reads as cold. The statusline re-runs when a warm c it. Shown in Session Options → Respawn and, for sessions without a statusline of their own, as a `cache:until HH:MM` / `cache:cold` group in the terminal footer. +Beside it, `statusLineExporter` says whether this launch carries the statusline exporter +that sends the report, decided when the pane starts: `injected` (it does), `collection-off` +(Plan Usage was off at launch), `workspace-statusline` (the workspace's +`.claude/settings.local.json` sets its own `statusLine`, which Codeman never replaces) or +`remote` (remote and Docker sessions). Absent when unknown. + ## Approvals Inbox Cross-session queue of prompts waiting on a human (permission dialogs, diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 6a77a935..5a19a570 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -157,7 +157,7 @@ Session create additionally pre-seeds the skill's §0 preamble cache (`seedAgent **Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON** since 1.9.3, handhelds OFF) renders compact Claude and Codex provider rows. Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command` on each render; on Pro/Max it carries a `rate_limits` object (`five_hour`/`seven_day` windows only — no Opus weekly field — each `{used_percentage 0-100, resets_at epoch-SECONDS}`). ⚠️ **Injected as an EPHEMERAL `claude --settings` CLI flag at spawn (2026-09-07), never written to disk** — `resolveStatusLineCliCommand()`/`ensureStatusLineExporterScript()` in `hooks-config.ts` (`generateStatusLineCommand()`/`applyStatusLineConfig()` remain, but only as the legacy disk-write self-heal path: a workspace an older Codeman build touched gets its stale `.claude/settings.local.json` entry stripped the first time a session starts there again). The exporter WRAPS a user's own real statusline (`findEffectiveUserStatusLineCommand()`, walking Claude Code's own settings precedence) rather than replacing it, and POSTs the `rate_limits` blob to `POST /api/status-telemetry`. That route (auth-exempt like `/api/hook-event` — localhost-only, hook-secret-gated whenever auth is active, COD-91) parses via `usage-telemetry.ts` (pure, unit-tested), broadcasts SSE `session:statusTelemetry` (de-duped per session by `telemetrySignature` since the statusline fires on every assistant message), and returns a compact plain-text footer for the exporter to **print-through** (foreground POST in the no-wrap branch so its own stdout becomes the footer, printing NOTHING on failure, `curl -sfk` plus `|| true`, since a bare brand word on the statusline is what discussion #405 opened with; backgrounded — `>/dev/null 2>&1 omitted -> 4`), so both counters must be present. - if ( - inPostCompactWindow && - prev?.expectedRebuilds != null && - status.expectedRebuilds != null && - status.expectedRebuilds > prev.expectedRebuilds - ) { + if (inWindow && prev?.recacheTokensIfCold != null) { compactedAt = Date.now(); - } else if (prev?.compactedAt != null && inPostCompactWindow && status.expectedRebuilds === prev.expectedRebuilds) { + } else if (inWindow && prev?.compactedAt != null) { compactedAt = prev.compactedAt; } // A NEW object when a stamp applies, never a mutation of the caller's parsed report: the @@ -1956,7 +1949,7 @@ export class Session extends EventEmitter { autoResumeEnabled: this._autoOps.autoResumeEnabled, autoResumeAt: this._autoOps.autoResumeAt ?? undefined, promptCache: this._promptCache ?? undefined, - statusLineTelemetry: this._muxSession?.statusLine, + statusLineExporter: this._muxSession?.statusLine, imageWatcherEnabled: this._imageWatcherEnabled, pinned: this._pinned || undefined, pinnedAt: this._pinned ? (this._pinnedAt ?? undefined) : undefined, diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 2adad484..2f149cce 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -59,7 +59,7 @@ import { type SessionDocker, type DockerCommandMode, type PaneExit, - type StatusLineTelemetry, + type StatusLineExporter, } from './types.js'; import { getCli } from './config/cli-registry/registry.js'; import { missingCliMessage, resolveCliBinDir } from './utils/cli-resolver.js'; @@ -2046,14 +2046,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * only (remote/docker have their own separate command builders). Resolving still runs * with collection off, because it also self-heals: it strips any legacy disk-written * exporter from an older Codeman build the first time a session starts in that - * workspace again. `state` lands on the mux record so the Status row and keep-warm can + * workspace again. `state` lands on the mux record so the Status row can * say why no cache report will come, instead of "not reported yet" forever. */ private async _resolveStatusLine( mode: SessionMode, workingDir: string, remoteOrDocker: boolean - ): Promise<{ command?: string; state?: StatusLineTelemetry }> { + ): Promise<{ command?: string; state?: StatusLineExporter }> { if (!getCli(mode)?.capabilities.statusLineTelemetry) return {}; if (remoteOrDocker) return { state: 'remote' }; const enabled = await readPlanUsageTelemetryEnabled(); diff --git a/src/types/session.ts b/src/types/session.ts index 5da97a48..61b3d450 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -747,10 +747,12 @@ export interface PromptCacheStatus { /** Claude Code's diagnosis of the last miss, e.g. `ttl_expired_1h` or `tools_changed`. */ lastMissCauses?: string[]; /** - * Epoch MS when Codeman first saw Claude Code's own idle compaction for the current prefix - * (`recacheTokensIfCold` null while `expectedRebuilds` rose). Derived in `Session.setPromptCache`, - * not reported by the statusline, so it is never set by `parsePromptCache`. Lets the readout say - * "compacted at HH:MM"; it is never an alert (CC's idle compaction is benign). + * Epoch MS when Codeman saw the context compacted (`/compact`, Claude Code's own idle + * compaction, or a clearing of old tool results): the first warm report whose + * `recacheTokensIfCold` is null after one that carried it. Carried while the null window holds, + * dropped at the next request. Derived in `Session.setPromptCache`, not reported by the + * statusline, so it is never set by `parsePromptCache`. Lets the readout say "Context compacted + * at HH:MM"; it is never an alert. */ compactedAt?: number; } @@ -765,7 +767,7 @@ export interface PromptCacheStatus { * statusLine (or could not be read), and Codeman never replaces one there. * - `remote`: remote and Docker launches never carry the exporter. */ -export type StatusLineTelemetry = 'injected' | 'collection-off' | 'workspace-statusline' | 'remote'; +export type StatusLineExporter = 'injected' | 'collection-off' | 'workspace-statusline' | 'remote'; export interface SessionState { /** Unique session identifier */ @@ -826,8 +828,8 @@ export interface SessionState { * `expiresAt` has passed reads as cold rather than as a stale warm. */ promptCache?: PromptCacheStatus; - /** Why a cache report will or will not arrive (see `StatusLineTelemetry`); absent when unknown. */ - statusLineTelemetry?: StatusLineTelemetry; + /** Why a cache report will or will not arrive (see `StatusLineExporter`); absent when unknown. */ + statusLineExporter?: StatusLineExporter; /** Pinned to the top of the session manager list (COD-139) */ pinned?: boolean; /** When the session was pinned (epoch ms) — orders the pinned group, most-recent-first */ diff --git a/src/web/public/constants.js b/src/web/public/constants.js index a612fdbf..15c4c7ce 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -709,14 +709,12 @@ function computeConnectionLossUi(input) { }; } -// SSE staleness policy: is this stream a zombie? -// // Session Options → Respawn → "Prompt cache": one sentence of the main // conversation's cache state as Claude Code reports it on the statusline // (`session.promptCache`, v2.1.251+). A clock time rather than a countdown // because the row refreshes on SSE, not on a timer, and a countdown would sit // stale between turns. Pure: no DOM, `now` is passed in. `statusLine` is the -// session's `statusLineTelemetry`: a launch without the exporter never reports, so +// session's `statusLineExporter`: a launch without the exporter never reports, so // it says why instead of "not reported yet" forever. const PROMPT_CACHE_NOT_COLLECTED = { 'collection-off': @@ -738,18 +736,20 @@ function formatPromptCacheStatus(cache, now, statusLine) { if (cache.warm && Number.isFinite(cache.expiresAt) && cache.expiresAt > now) { const ttl = cache.ttl ? ` (${cache.ttl} cache)` : ''; parts.push(`Warm until ${clock(cache.expiresAt)}${ttl}.`); - if (tokens) parts.push(`About ${tokens} tokens would be re-read if it goes cold.`); + if (tokens) parts.push(`About ${tokens} tokens would be re-cached if it goes cold.`); } else { - parts.push(tokens ? `Cold. The next turn re-reads about ${tokens} tokens at the cache-write rate.` : 'Cold.'); + parts.push(tokens ? `Cold. The next turn re-caches about ${tokens} tokens.` : 'Cold.'); } - // Claude Code's own idle compaction kept the prefix warm but smaller, so there is no - // re-read stake to show; naming when it happened explains the shrink. Never an alert. - if (Number.isFinite(cache.compactedAt)) parts.push(`Compacted at ${clock(cache.compactedAt)}.`); + // A compaction or a tool-result clearing leaves no re-cache stake to show until the next + // request; naming when it happened explains the gap. Never an alert. + if (Number.isFinite(cache.compactedAt)) parts.push(`Context compacted at ${clock(cache.compactedAt)}.`); if (Number.isFinite(cache.hitRatio)) parts.push(`${Math.round(cache.hitRatio * 100)}% cache hit rate.`); if (cache.lastMissCauses && cache.lastMissCauses.length) parts.push(`Last miss: ${cache.lastMissCauses.join(', ')}.`); return parts.join(' '); } +// SSE staleness policy: is this stream a zombie? +// // An EventSource that stops delivering does not always error. A proxy that // idle-closed the connection, a laptop resumed from sleep, a tailnet // reconnect: `onerror` never fires, the header dot stays green, and every diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 39b18678..82e001cf 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -2795,7 +2795,7 @@ Object.assign(CodemanApp.prototype, { const el = document.getElementById('promptCacheStatus'); if (!el || this.editingSessionId !== sessionId) return; const session = this.sessions.get(sessionId); - el.textContent = window.CodemanPromptCache.format(session?.promptCache, Date.now(), session?.statusLineTelemetry); + el.textContent = window.CodemanPromptCache.format(session?.promptCache, Date.now(), session?.statusLineExporter); }, _onSessionPromptCache(data) { diff --git a/src/web/routes/status-telemetry-routes.ts b/src/web/routes/status-telemetry-routes.ts index 56f33343..a568ac74 100644 --- a/src/web/routes/status-telemetry-routes.ts +++ b/src/web/routes/status-telemetry-routes.ts @@ -71,6 +71,8 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session const promptCache = parsePromptCache(payload); if (promptCache && session?.setPromptCache(promptCache)) { ctx.broadcast(SessionPromptCache, { sessionId, ...session.promptCache }); + // Persisted on change, like a model change, so a crash restores the latest report. + ctx.persistSessionState(session); } // Plan-usage limits (account-wide) → broadcast to the header chip, when diff --git a/test/prompt-cache-status.test.ts b/test/prompt-cache-status.test.ts index ec0e67bf..6f3e9026 100644 --- a/test/prompt-cache-status.test.ts +++ b/test/prompt-cache-status.test.ts @@ -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.` ); }); }); diff --git a/test/routes/status-telemetry-routes.test.ts b/test/routes/status-telemetry-routes.test.ts index 55199ec2..0741b17f 100644 --- a/test/routes/status-telemetry-routes.test.ts +++ b/test/routes/status-telemetry-routes.test.ts @@ -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(); + }); }); diff --git a/test/session-prompt-cache.test.ts b/test/session-prompt-cache.test.ts index d2463743..9f9fa942 100644 --- a/test/session-prompt-cache.test.ts +++ b/test/session-prompt-cache.test.ts @@ -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 = {}): 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'); diff --git a/test/statusline-cli-flag.test.ts b/test/statusline-cli-flag.test.ts index ce4927a0..929fa0f5 100644 --- a/test/statusline-cli-flag.test.ts +++ b/test/statusline-cli-flag.test.ts @@ -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 = (