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 = (