diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ed856fc6..2ae8fd70 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -33,14 +33,13 @@ jobs: # script since #948, so CI and a contributor's `npm test` can never drift. - name: Test suite (parallel) run: npm test - # Single forked worker, so lock contention comes only from the child processes the - # tests spawn deliberately. Quarantined (reports, never gates): the process - # suite still races its own takeover window even serially on slow runners - - # tracked in #904; drop continue-on-error once that race is settled. - # Step-level timeout so a stalled lock suite fails soft here instead of - # tripping the job's 15-minute budget, which kills the whole job as - # "cancelled" and hides the parallel suite's green result. - - name: Cache-lock suite (serial, quarantined) - continue-on-error: true + # Single forked worker, so lock contention comes only from the child + # processes the tests spawn deliberately. Serial because the suite is + # parallelism-sensitive (fails under full worker pressure, passes + # serially). After #904 the loser of a stale-lock contest may be + # timed-out or completed-by-other; this step gates CI. + # Step-level timeout so a stalled lock suite fails this job instead of + # eating the 15-minute budget and cancelling a green parallel suite. + - name: Cache-lock suite (serial) timeout-minutes: 5 run: npm run test:locks diff --git a/CHANGELOG.md b/CHANGELOG.md index ee7f1562..b741c511 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,12 +29,17 @@ - **A date-ranged report classifies only the turns it keeps.** Every cached turn went through the turn classifier — category, retries, edit detection, and a full reconstruction of its API calls — before the date slice discarded most of them, so a week view paid to classify all of history to keep a few percent of it. The keep/drop decision is now taken on the raw cached turn and only the survivors are classified, still from their complete call list, with the branch and pull-request carries still walking the full ordered turn list. Output is byte-identical. - **One rule for every cache file.** `CODEBURN_CACHE_DIR` when set, otherwise `~/.cache/codeburn`. `XDG_CACHE_HOME` is no longer consulted; the sync ledger, the only file that ever honored it, is merged into the canonical location on first read and the legacy copy is retired, so nothing is re-uploaded after the move. (#972) +### Changed (Linux packaging) +- **The snap asks for the log directories it reads, not each tool's whole home.** The first Snap Store submission declared a `personal-files` read of every AI tool's root — `$HOME/.claude`, `$HOME/.codex`, `$HOME/.cursor` and the rest — and that interface is recursive, so it granted read of every credential file those roots hold. Each entry now names the subdirectory the provider actually opens (`.claude/projects`, `.codex/sessions`, `.cline/data`, `.vibe/logs/session`, `.dsh/sessions`, `.kiro/sessions`, `.quickwork/{profiles.json,sessions,metrics}`, `.config/Claude/local-agent-mode-sessions`, `.config/Open Design/{runs,data/runs,namespaces}`), two are single files (`.forge/.forge.db`, `.zcode/cli/db/db.sqlite`), and the editor entries name only the extension folders holding transcripts instead of the editor's whole configuration. Five providers that were missing entirely and would have shown no data are declared — opencode, crush, goose, kilo, kimi-code — and four roots stay roots only because the file the provider opens sits directly in them (`.config/github-copilot`, `.local/share/{opencode,crush,kilo}`). One credential file is now requested openly rather than implicitly: `.claude/.credentials.json`, read-only, for the live plan gauge. Codex's equivalent would need write access to the Codex CLI's own `auth.json` to rotate the token, so neither it nor a Codex root is declared and the Codex live gauge is disabled under `$SNAP`; Codex usage and cost are unaffected, they come from the session rollouts. Two consequences inside the snap: `.lingtai` is dropped, because its per-agent log directory needs a wildcard the interface has no form for, and `optimize`, `context-budget` and `act` no longer see the user-scope `~/.claude/settings.json`, `agents/`, `skills/` and `commands/` — project-scope copies still work through the `home` plug. Nothing outside the snap changes. + ### Fixed (Desktop & Menubar) - **The menubar's copies of your Claude and Codex credentials move out of Application Support and into the login Keychain.** Connecting a provider used to leave the copied OAuth material in `~/Library/Application Support/CodeBurn/*-credentials.v1.json`, written world-readable (0644) because macOS ignores `.completeFileProtection` outside iOS. The copy now lives in a CodeBurn-owned login-Keychain item, and the first read after upgrading migrates the old file: it is reopened with `O_NOFOLLOW`, refused if it is a symlink or not owned by you, repaired to 0600 before a single secret byte is read, written to the Keychain, read back and compared, and only then unlinked — a failed or unverified write leaves the (now 0600) file in place so a retry can still find it, and the next read retries the cleanup. Where both a Keychain item and an old file exist, the one that expires later wins before anything is removed, so an item left behind by a much older build cannot displace a fresher token. Claude's entry no longer stores a refresh token at all — the CLI owns that grant and the menubar never spends it — and any refresh token in a historical blob is dropped on read. Disconnect only reports success once the material is actually gone; if the delete fails it says so and leaves the provider connected so you can retry. Keychain reads are non-interactive and are skipped outright while the login Keychain is locked, so a background quota refresh can never raise an unlock panel. (#1037) - **First launch no longer asks to control System Events.** The macOS menubar registered its login item by driving System Events over AppleScript, which made macOS put up an Automation consent dialog the first time the app ran. It now registers itself through `SMAppService.mainApp`, an in-process call that needs no Automation grant; there is no AppleScript fallback, so a failure logs and leaves the login item unset rather than bringing the prompt back. The same `codeburn.loginItemRegistered` guard still limits this to the first launch, so a login item you removed by hand stays removed. (#1026) - **The resident `codeburn serve` child.** The first real panel request is also the cache warm-up, so startup never runs an artificial warm-up query beside a duplicate one-shot child; each served command carries its own read-only option allowlist, and anything outside it falls back to a normal spawn; the child exits when its stdin closes, so it can never outlive the app. Requests whose response exceeds the 16 MiB frame limit still replace the child, but that deliberate kill no longer spends the resident's unexpected-death budget. (#972) ### Fixed +- **Mixed-version installs no longer thrash the Codex / Cursor / Antigravity result caches.** Daily and session caches already own a version-suffixed file so an old desktop binary and a newer CLI cannot clobber each other. The three per-provider result caches still used one unsuffixed filename with an internal version field, so a v10 and a v11 binary rewrote the same `codex-results.json` (and the Cursor / Antigravity siblings) on every run and each re-parsed its whole corpus. They now write `*-results.v.json` the same way the daily cache does. The unsuffixed file is left for older binaries; a matching-version copy is adopted once and never overwritten. (#1082) +- **Codex spend no longer counts reasoning tokens twice, and cache writes are priced only where OpenAI actually charges for them.** OpenAI bills reasoning tokens as *part of* `output_tokens`, not on top of it — on a 1,396-rollout corpus all 134,316 events carrying a total satisfy `input + output == total` — but CodeBurn added `reasoning_output_tokens` to output when pricing a Codex call and again in the models, audit and per-model displays. Every Codex number was therefore too high: on that corpus **cost by $166.03 (3.5%)** and **displayed Output tokens by 34.6%** ($4,713.12 -> $4,547.09; 22.6M -> 16.8M output tokens). The raw `reasoningTokens` figure is unchanged and still reported on its own; only the double-count is gone. Both places that price a Codex call — the parser and the cache-rehydration re-price — now go through one shared `billableOutputTokens` helper, so a cold run and a warm run can never disagree. Separately, Codex's `cache_write_input_tokens` (new in codex PR #33454) was never read and cache-creation tokens were hardcoded to 0; they are now carved out of the uncached-input bucket and clamped so they can never exceed it. That carve-out happens **only on models whose pricing source publishes a real cache-write rate** — gpt-5.6 and its terra/sol/luna variants charge 1.25x input for a cache write, everything before it charges nothing extra — because CodeBurn fabricates a 1.25x rate when a source omits one, and charging that would have invented a surcharge on gpt-5.5, gpt-5.4, gpt-5.3-codex and gpt-5. On models without an explicit rate the tokens stay in the plain input bucket and the price is unchanged to the cent. The field is new enough that today's impact is $0 on that corpus. Codex sessions re-parse once and the daily cache re-derives once off the warm session cache (a global re-derivation of every day and every provider, since it has no per-provider invalidation); no other provider's numbers move. Days whose Codex transcripts have since aged out are held by the same never-lose guard #1040 relies on: a re-derivation that finds fewer calls than the settled baseline keeps the older, pre-fix (double-counted) total rather than truncating it, so those days do not pick up the repricing until their sources are re-derived with equal or greater evidence. Long-context pricing tiers from the same report are tracked separately in #1076 and the missing `gpt-5.6-codex` snapshot rows in #1077. Thanks @chr-evensen. (#1075) - **Codex calls attributed from session metadata no longer carry a stale model.** The Buffer fast path scanned `session_meta` for the first `"model"` string anywhere in the payload, so a nested `base_instructions.provenance.model` was read as if it were `payload.model` — and since the model is last-writer-wins state, that wrong value was credited to every call before the rollout's first `turn_context` and to every call after any mid-file `session_meta` (29 of 1380 rollouts on one real corpus carry a late `session_meta`, and 57 record usage before any `turn_context`). Direct payload fields are now read depth-aware, which is what the non-fast `JSON.parse` path always did. Codex sessions re-parse once (~9s on a 4 GB rollout corpus) and the daily cache re-derives once off the warm session cache, a global re-derivation of every day and every provider since it has no per-provider invalidation; it moves per-model attribution, and clears any rollup an earlier parse change had left stale. Days whose transcripts have partly aged out are held by the never-lose guard: on a real 110-day cache no day lost value and none disappeared — 100 days came back identical and 9 grok days rose by $19.80 in total. Thanks @timdp. (#1040) - **Codex `session_meta` cwd / session id / originator follow the same depth-1 window as `model`.** #1040 fixed nested `provenance.model`; the compact Buffer path still took the first `cwd`, `session_id`, `originator`, `name`, `forked_from_id` or `model_provider` anywhere in the payload, so a `dynamic_tools[].name` (or any same-named nested key) could steal the top-level field. Those strings now use the existing payload-depth-1 scan. Function-call `name` on other event types is unchanged. Codex sessions re-parse once. (#1045) - **Plan rows for sticker-price presets read as a budget instead of live provider quota.** There is no Grok quota endpoint, so a SuperGrok row was parsed API-equivalent spend divided by the plan's sticker price on a monthly reset — but the TUI labelled that math "plan" and "reset", which next to a client showing xAI's real weekly window read as CodeBurn being wrong. The bars and the arithmetic are unchanged; the words are not. Both the dashboard and the desktop app now say the number is an API-equivalent monthly budget and not a live provider window, in the same wording on both surfaces, and for every preset rather than as a SuperGrok special case. The window is anniversary-based (`plan.resetDay`, settable with `codeburn plan set --reset-day`), so it is called a budget reset rather than a calendar one. The row was also shortened to fit 80 columns: at that width the percentage and the projected month were being truncated away, including on custom plans, whose label carries the provider. diff --git a/README.md b/README.md index b04e01db..b53a88b0 100644 --- a/README.md +++ b/README.md @@ -699,7 +699,7 @@ These are starting points, not verdicts. A 60% cache hit on a single experimenta | **Claude Code** | `~/.claude/projects//.jsonl` | Each assistant entry carries model name, token usage (input, output, cache read, cache write), `tool_use` blocks, and timestamps. | | **Claude (multiple config dirs)** | Set via `CLAUDE_CONFIG_DIRS` (e.g. `~/.claude-work:~/.claude-personal`) | Scans every listed directory and merges sessions into one row per project so totals reflect all your Claude usage. Use `:` on POSIX, `;` on Windows; overrides `CLAUDE_CONFIG_DIR`. Missing or unreadable directories are skipped. | | **Codex (OpenAI)** | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`, `~/.codex/archived_sessions/rollout-*.jsonl` | Reads `token_count` events (per-call and cumulative usage) and `function_call` entries for tool tracking; attributes cost by project working directory. `codeburn report --provider codex` views Codex alone. | -| **Cursor** | SQLite `state.vscdb` under `globalStorage`: macOS `~/Library/Application Support/Cursor/User/globalStorage/`, Linux `~/.config/Cursor/User/globalStorage/`, Windows `%APPDATA%/Cursor/User/globalStorage/`; results cached at `~/.cache/codeburn/cursor-results.json` | Input tokens come from Cursor's own per-conversation context meter (`composerData.promptTokenBreakdown`), credited once per conversation on a stable anchor; tool calls and shell commands are read from the agent stream (`agentKv`), and Composer house models are priced from Cursor's published rates. Output is a reply-text estimate and cache tokens are server-side only, so figures are marked estimated and undercount the Cursor admin console for long conversations. The cache auto-invalidates when the database changes; the first run on a large database can take a minute. | +| **Cursor** | SQLite `state.vscdb` under `globalStorage`: macOS `~/Library/Application Support/Cursor/User/globalStorage/`, Linux `~/.config/Cursor/User/globalStorage/`, Windows `%APPDATA%/Cursor/User/globalStorage/`; results cached at `~/.cache/codeburn/cursor-results.v.json` | Input tokens come from Cursor's own per-conversation context meter (`composerData.promptTokenBreakdown`), credited once per conversation on a stable anchor; tool calls and shell commands are read from the agent stream (`agentKv`), and Composer house models are priced from Cursor's published rates. Output is a reply-text estimate and cache tokens are server-side only, so figures are marked estimated and undercount the Cursor admin console for long conversations. The cache auto-invalidates when the database changes; the first run on a large database can take a minute. | | **OpenCode** | SQLite `~/.local/share/opencode/opencode*.db` or file-based `~/.local/share/opencode/storage/` (respects `XDG_DATA_HOME`; `OPENCODE_DATA_DIR`/`OPENCODE_DB_PREFIX` for renamed/forked builds) | Queries `session`, `message`, and `part` read-only and recalculates cost via LiteLLM (falling back to OpenCode's own cost field for unpriced models). Subtask sessions (`parent_id IS NOT NULL`) are excluded to avoid double counting; multiple channel databases are supported. | | **Gemini CLI** | `~/.gemini/tmp//chats/session-*.json` | One JSON file per session with real token counts (input, output, cached, thoughts) per message, so no estimation is needed. Input is reported inclusive of cached, so CodeBurn subtracts cached before pricing to avoid double charging. | | **Antigravity (CLI & IDE)** | Session files under `.gemini/` folders, plus the running language server | Pulls granular trajectory and pricing from the language server process. For the short-lived CLI, optionally install a status-line hook with `codeburn antigravity-hook install` so usage is captured between menubar refreshes. The IDE is detected via the `--app-data-dir antigravity-ide` flag on Windows. | diff --git a/app/electron/quota/index.test.ts b/app/electron/quota/index.test.ts index 8250c452..7ff48f99 100644 --- a/app/electron/quota/index.test.ts +++ b/app/electron/quota/index.test.ts @@ -8,6 +8,32 @@ const quota = (provider: 'claude' | 'codex'): QuotaProvider => ({ }) describe('QuotaService', () => { + // The snap declares no Codex credential path, because the live gauge would + // need write access to the Codex CLI's own auth.json to rotate the token. + // Under $SNAP the Codex fetch must not run at all; Claude is unaffected. + it('skips the Codex live gauge under snap confinement', async () => { + const previous = process.env['SNAP'] + process.env['SNAP'] = '/snap/codeburn/current' + try { + const claude = vi.fn(async () => ({ quota: quota('claude') })) + const codex = vi.fn(async () => ({ quota: quota('codex') })) + const service = new QuotaService({ + claude, codex, now: () => Date.parse('2026-08-14T00:00:00Z'), + readFile: vi.fn(async () => null), + writeFile: vi.fn(async () => {}), + statePath: '/mock/backoff.json', + }) + const [claudeQuota, codexQuota] = await service.getQuota({ force: true }) + expect(codex).not.toHaveBeenCalled() + expect(claude).toHaveBeenCalledTimes(1) + expect(codexQuota?.connection).toBe('disconnected') + expect(claudeQuota?.connection).toBe('connected') + } finally { + if (previous === undefined) delete process.env['SNAP'] + else process.env['SNAP'] = previous + } + }) + it('persists provider 429 blocked-until and gates the next forced fetch', async () => { const writes: string[] = [] const claude = vi.fn(async () => ({ quota: quota('claude'), retryAfterSeconds: 60 })) diff --git a/app/electron/quota/index.ts b/app/electron/quota/index.ts index a17dac19..1fa0b7e9 100644 --- a/app/electron/quota/index.ts +++ b/app/electron/quota/index.ts @@ -37,6 +37,19 @@ function unavailable(provider: ProviderName, connection: QuotaProvider['connecti return { provider, connection, primary: null, details: [], planLabel: null, footerLines: [] } } +/** + * The Codex live gauge needs read-write access to the Codex CLI's own + * `~/.codex/auth.json`, because refreshing the OAuth grant rotates the token + * and writes it back. A store-distributed snap should not hold write access to + * another vendor's credential file, so the snap's personal-files declaration + * requests neither that file nor a Codex root, and the gauge is disabled here + * to match. Codex usage and cost analytics are unaffected: those come from the + * session rollouts under `~/.codex/sessions`, which the snap does read. + */ +function codexQuotaSupported(): boolean { + return !process.env['SNAP'] +} + export class QuotaService { private readonly deps: QuotaDeps private cache: { at: number; value: QuotaProvider[] } | null = null @@ -115,7 +128,10 @@ export class QuotaService { if (this.controllers[provider] === controller) this.controllers[provider] = undefined return retainOnFailure(result.quota) } - const value = await Promise.all([run('claude'), run('codex')]) + const value = await Promise.all([ + run('claude'), + codexQuotaSupported() ? run('codex') : Promise.resolve(unavailable('codex', 'disconnected')), + ]) if (startingGenerations.claude === this.generations.claude && startingGenerations.codex === this.generations.codex) { this.cache = { at: this.deps.now(), value } } diff --git a/app/package.json b/app/package.json index ab5a29b4..1c459862 100644 --- a/app/package.json +++ b/app/package.json @@ -162,42 +162,70 @@ "ai-agent-session-logs": { "interface": "personal-files", "read": [ - "$HOME/.claude", - "$HOME/.cline", - "$HOME/.codewhale", - "$HOME/.codex", - "$HOME/.copilot", - "$HOME/.cursor", - "$HOME/.deepseek", + "$HOME/.claude/projects", + "$HOME/.claude/.credentials.json", + "$HOME/.codex/sessions", + "$HOME/.codex/archived_sessions", + "$HOME/.cline/data", + "$HOME/.codewhale/sessions", + "$HOME/.copilot/session-state", + "$HOME/.cursor/projects", + "$HOME/.cursor/ai-tracking", + "$HOME/.deepseek/sessions", "$HOME/.dsh/sessions", - "$HOME/.factory", - "$HOME/.forge", - "$HOME/.gemini", - "$HOME/.grok", - "$HOME/.hermes", - "$HOME/.kimi", - "$HOME/.kiro", - "$HOME/.kiro-server", - "$HOME/.lingtai", - "$HOME/.lingtai-tui", - "$HOME/.mux", - "$HOME/.omp", - "$HOME/.openclaude", - "$HOME/.pi", - "$HOME/.quickwork", - "$HOME/.qwen", - "$HOME/.vibe", - "$HOME/.zcode", - "$HOME/.config/Claude", - "$HOME/.config/Code", - "$HOME/.config/Code - Insiders", - "$HOME/.config/Cursor", - "$HOME/.config/Kiro", - "$HOME/.config/Open Design", - "$HOME/.config/VSCodium", + "$HOME/.factory/sessions", + "$HOME/.gemini/tmp", + "$HOME/.gemini/antigravity", + "$HOME/.gemini/antigravity-cli", + "$HOME/.gemini/antigravity-ide", + "$HOME/.grok/sessions", + "$HOME/.hermes/profiles", + "$HOME/.hermes/state.db", + "$HOME/.kimi/sessions", + "$HOME/.kimi-code/sessions", + "$HOME/.kiro/sessions", + "$HOME/.mux/sessions", + "$HOME/.mux/config.json", + "$HOME/.omp/agent", + "$HOME/.openclaude/projects", + "$HOME/.openclaw/agents", + "$HOME/.pi/agent", + "$HOME/.qwen/projects", + "$HOME/.quickwork/profiles.json", + "$HOME/.quickwork/sessions", + "$HOME/.quickwork/metrics", + "$HOME/.vibe/logs/session", + "$HOME/.forge/.forge.db", + "$HOME/.zcode/cli/db/db.sqlite", + "$HOME/.config/Claude/local-agent-mode-sessions", + "$HOME/.config/Code/User/globalStorage/saoudrizwan.claude-dev", + "$HOME/.config/Code/User/globalStorage/rooveterinaryinc.roo-cline", + "$HOME/.config/Code/User/globalStorage/kilocode.kilo-code", + "$HOME/.config/Code/User/globalStorage/GitHub.copilot-chat", + "$HOME/.config/Code/User/globalStorage/emptyWindowChatSessions", + "$HOME/.config/Code/User/workspaceStorage", + "$HOME/.config/Code - Insiders/User/globalStorage/saoudrizwan.claude-dev", + "$HOME/.config/Code - Insiders/User/globalStorage/rooveterinaryinc.roo-cline", + "$HOME/.config/Code - Insiders/User/globalStorage/kilocode.kilo-code", + "$HOME/.config/Code - Insiders/User/workspaceStorage", + "$HOME/.config/VSCodium/User/globalStorage/saoudrizwan.claude-dev", + "$HOME/.config/VSCodium/User/globalStorage/rooveterinaryinc.roo-cline", + "$HOME/.config/VSCodium/User/globalStorage/kilocode.kilo-code", + "$HOME/.config/VSCodium/User/workspaceStorage", + "$HOME/.config/Cursor/User/globalStorage/state.vscdb", + "$HOME/.config/Kiro/User/globalStorage/kiro.kiroagent", + "$HOME/.config/Kiro/User/workspaceStorage", + "$HOME/.config/Open Design/runs", + "$HOME/.config/Open Design/data/runs", + "$HOME/.config/Open Design/namespaces", + "$HOME/.kiro-server/data/User/globalStorage/kiro.kiroagent", "$HOME/.config/github-copilot", - "$HOME/.config/manicode", - "$HOME/.local/share/zed" + "$HOME/.config/manicode/projects", + "$HOME/.local/share/zed/threads", + "$HOME/.local/share/opencode", + "$HOME/.local/share/crush", + "$HOME/.local/share/goose/sessions", + "$HOME/.local/share/kilo" ] } } diff --git a/app/scripts/snap-grants.test.ts b/app/scripts/snap-grants.test.ts new file mode 100644 index 00000000..c6f92add --- /dev/null +++ b/app/scripts/snap-grants.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'fs' +import { join } from 'path' + +// Snap Store review rejected the first submission because every entry was a +// tool's whole root, and personal-files read is recursive: granting $HOME/.claude +// granted .claude/.credentials.json with it. Each entry must name the log +// directory (or file) the provider actually opens, never the root above it. +// The exceptions below are roots only because the provider reads a file sitting +// directly in them, so no narrower path exists without wildcards. +const ROOT_GRANTS_WITH_NO_NARROWER_FORM = new Set([ + '$HOME/.config/github-copilot', // JetBrains stores nest under a variable // + '$HOME/.local/share/opencode', // opencode*.db sits in the data dir itself + '$HOME/.local/share/crush', // projects.json sits in the data dir itself + '$HOME/.local/share/kilo', // kilo*.db sits in the data dir itself +]) + +const XDG_PARENTS = ['.config', '.local'] + +function readGrants(): string[] { + const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')) + const plug = pkg.build.snap.plugs.find((p: unknown) => typeof p === 'object') + return plug['ai-agent-session-logs'].read +} + +describe('snap personal-files declaration', () => { + it('names a log path under each tool root, never the root itself', () => { + const bare: string[] = [] + for (const entry of readGrants()) { + if (ROOT_GRANTS_WITH_NO_NARROWER_FORM.has(entry)) continue + const segments = entry.replace('$HOME/', '').split('/') + const depth = XDG_PARENTS.includes(segments[0] ?? '') ? 3 : 2 + if (segments.length < depth) bare.push(entry) + } + expect(bare).toEqual([]) + }) + + it('requests read only, and one credential file explicitly', () => { + const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')) + const plug = pkg.build.snap.plugs.find((p: unknown) => typeof p === 'object')['ai-agent-session-logs'] + expect(Object.keys(plug).sort()).toEqual(['interface', 'read']) + expect(readGrants().filter(e => e.includes('credential') || e.includes('auth.json'))) + .toEqual(['$HOME/.claude/.credentials.json']) + }) +}) diff --git a/docs/architecture.md b/docs/architecture.md index 5ec39e28..5bb015a4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -136,8 +136,8 @@ Three caches under `~/.cache/codeburn/` (override with `CODEBURN_CACHE_DIR`): | File | Owner | Invalidation | |---|---|---| -| `codex-results.json` | `src/codex-cache.ts` | `mtimeMs + sizeBytes` per Codex `.jsonl`. | -| `cursor-results.json` | `src/cursor-cache.ts` | `mtimeMs + sizeBytes` of the Cursor SQLite db. | +| `codex-results.v.json` | `src/codex-cache.ts` | `mtimeMs + sizeBytes` per Codex `.jsonl`. Unsuffixed `codex-results.json` is adopted when versions match and never overwritten. | +| `cursor-results.v.json` | `src/cursor-cache.ts` | `mtimeMs + sizeBytes` of the Cursor SQLite db. Unsuffixed `cursor-results.json` is adopted when versions match and never overwritten. | | `daily-cache.json` | `src/daily-cache.ts` | Tracks `lastComputedDate`; new days are backfilled, old days are reused. | All three use atomic write (temp file + `rename`) and write with mode `0o600`. All three carry a numeric `version` field; bumping it forces a recompute next run. diff --git a/docs/providers/antigravity.md b/docs/providers/antigravity.md index c308c5fc..70a6c46c 100644 --- a/docs/providers/antigravity.md +++ b/docs/providers/antigravity.md @@ -40,7 +40,7 @@ Protobuf. Cascade and response objects map to `ParsedProviderCall` directly. ## Caching -Custom file cache at `$CODEBURN_CACHE_DIR/antigravity-results.json` (defaults to `~/.cache/codeburn/`). The cache is also used as the data source when the RPC endpoint is unavailable, not just as an optimization. Bumping the cache version forces a recompute. +Custom file cache at `$CODEBURN_CACHE_DIR/antigravity-results.v.json` (defaults to `~/.cache/codeburn/`). The unsuffixed `antigravity-results.json` is left for older binaries; a matching-version copy is adopted once and never overwritten. The cache is also used as the data source when the RPC endpoint is unavailable, not just as an optimization. Bumping the cache version forces a recompute. ## Deduplication diff --git a/docs/providers/codex.md b/docs/providers/codex.md index 1ba46fdd..5bc7e953 100644 --- a/docs/providers/codex.md +++ b/docs/providers/codex.md @@ -34,7 +34,7 @@ The first line read is capped at 1 MB (`FIRST_LINE_READ_CAP`). Codex CLI 0.128+ ## Caching -`src/codex-cache.ts` writes `~/.cache/codeburn/codex-results.json` (or `$CODEBURN_CACHE_DIR/codex-results.json`). Each entry is keyed by absolute file path and validated against `mtimeMs + sizeBytes`. Cached entries are returned wholesale. +`src/codex-cache.ts` writes `~/.cache/codeburn/codex-results.v.json` (or `$CODEBURN_CACHE_DIR/codex-results.v.json`). The unsuffixed `codex-results.json` is left for older binaries; a matching-version copy is adopted once and never overwritten. Each entry is keyed by absolute file path and validated against `mtimeMs + sizeBytes`. Cached entries are returned wholesale. A session that yielded zero parseable lines does **not** write to the cache (`codex.ts:419`); this prevents a transient read failure from pinning an empty result against a fingerprint. diff --git a/docs/providers/cursor.md b/docs/providers/cursor.md index 8ccf6c48..381e450c 100644 --- a/docs/providers/cursor.md +++ b/docs/providers/cursor.md @@ -27,7 +27,7 @@ The parser tries both and dedupes via `seenKeys`. ## Caching -`src/cursor-cache.ts` writes `~/.cache/codeburn/cursor-results.json` (override with `$CODEBURN_CACHE_DIR`). The fingerprint is `dbMtimeMs + dbSizeBytes` of `state.vscdb`. Atomic write via temp + rename. +`src/cursor-cache.ts` writes `~/.cache/codeburn/cursor-results.v.json` (override with `$CODEBURN_CACHE_DIR`). The unsuffixed `cursor-results.json` is left for older binaries; a matching-version copy is adopted once and never overwritten. The fingerprint is `dbMtimeMs + dbSizeBytes` of `state.vscdb`. Atomic write via temp + rename. ## Deduplication diff --git a/docs/sync/README.md b/docs/sync/README.md index 64540b83..85258fc5 100644 --- a/docs/sync/README.md +++ b/docs/sync/README.md @@ -106,7 +106,6 @@ A pseudonymous `device_id` distinguishes your machines without revealing hostnam | Field | Example | Description | |---|---|---| -| `ai.session_id` | `abc123…` | Session (shares the usage spans' traceId) | | `ai.project` | `my-app` | Project name | | `git.repo` | `github.com/acme/widget` | Normalized `origin` remote (credentials and ports stripped) | | `git.pr_links` | `["…/pull/12"]` | PR URLs captured for the session | @@ -120,7 +119,7 @@ A pseudonymous `device_id` distinguishes your machines without revealing hostnam | `git.in_main` | `true` | Whether the commit landed in the main branch | | `git.was_reverted` | `false` | Whether a later commit reverted it | -Attribution is **inferred** (timestamp-window correlation, the same heuristic as `codeburn yield`); the resource attribute `codeburn.attribution_methodology: timestamp-window` marks it as such. State transitions (a commit merging to main, or being reverted) are re-sent automatically on later pushes — receivers should upsert commits by `(git.repo, git.sha)` and session spans by `ai.session_id` (latest state wins). When a commit migrates to a later-parsed session with a tighter window, the losing session re-emits with `git.commit_count: 0` (a retraction), so summing `git.commit_count` across upserted session rows never double-counts. Retractions fire only when the commit was won by another session — commits that merely age out of the `--since` window are not retracted, so a previously-synced count stays correct. Session spans also re-emit when an ongoing session's window grows, keeping the span end time current. +Attribution is **inferred** (timestamp-window correlation, the same heuristic as `codeburn yield`); the resource attribute `codeburn.attribution_methodology: timestamp-window` marks it as such. State transitions (a commit merging to main, or being reverted) are re-sent automatically on later pushes — receivers should upsert commits by `(git.repo, git.sha)` and session spans by `traceId` (the same id usage spans already carry; latest state wins). When a commit migrates to a later-parsed session with a tighter window, the losing session re-emits with `git.commit_count: 0` (a retraction), so summing `git.commit_count` across upserted session rows never double-counts. Retractions fire only when the commit was won by another session — commits that merely age out of the `--since` window are not retracted, so a previously-synced count stays correct. Session spans also re-emit when an ongoing session's window grows, keeping the span end time current. With `--attribution`, normalized repo remote URLs, commit SHAs, commit timestamps (span start times), PR URLs, and the merged/reverted booleans leave your machine — plus the same pseudonymous `codeburn.device_id` resource attribute the usage spans carry. PR links are rebuilt client-side from scheme + host + path only (userinfo, query strings, and fragments are dropped; https, `/org/repo/pull/N` path, bounded length, max 20 per session), and the repo identity itself passes a strict hostname/path allow-list before sending — malformed or transport-helper remotes (`ext::…`, `codecommit::…`) are rejected outright rather than parsed. Precisely what is and is not sent: diff --git a/scripts/upgrade-path/compare.mjs b/scripts/upgrade-path/compare.mjs index 0d0ef49a..67ad3426 100644 --- a/scripts/upgrade-path/compare.mjs +++ b/scripts/upgrade-path/compare.mjs @@ -22,11 +22,26 @@ // which is the part the corpus can honestly establish. // dsh did not exist in the published CLI. Reported; required to be absent // in the baseline and present after the upgrade. +// codex PRICING changed by design in #1075: reasoning tokens are billed +// inside output rather than on top of it, and cache writes are carved out +// of the input bucket. Nothing about what was PARSED moved, so codex keeps +// the full exact treatment for the call count and every token field; the +// cost tolerance is instead replaced with REPRICE_TOLERANCE — the +// upgraded cost must be strictly lower than the baseline and within 25% +// of it, since #1075 only ever removes a double-count and never raises +// cost — and the delta is reported instead. Drop it from this list once a +// published CLI carries the fix. const EXACT = ['claude', 'codex', 'gemini', 'kiro', 'cursor'] const CHANGED_BY_DESIGN = ['grok'] +const COST_CHANGED_BY_DESIGN = ['codex'] const NEW_IN_THIS_RELEASE = ['dsh'] const COST_TOLERANCE = 0.005 // 0.5% relative +// #1075 only ever LOWERS codex cost (double-counted reasoning removed, cache +// writes carved out of the input bucket) and by a bounded amount on any real +// corpus; a rise, or a drop past this bound, means something beyond the known +// repricing changed. +const REPRICE_TOLERANCE = 0.25 // 25% relative import { readFileSync } from 'node:fs' import { join } from 'node:path' @@ -95,13 +110,25 @@ for (const name of providers) { if (b.calls !== u.calls) diffs.push(`calls ${b.calls} != ${u.calls}`) for (const f of TOKEN_FIELDS) if (b[f] !== u[f]) diffs.push(`${f} ${b[f]} != ${u[f]}`) const costDrift = relDiff(b.cost, u.cost) - if (costDrift > COST_TOLERANCE) diffs.push(`cost ${fmt(b.cost)} != ${fmt(u.cost)} (${(costDrift * 100).toFixed(3)}% > ${(COST_TOLERANCE * 100).toFixed(1)}%)`) + let repriced = false + if (COST_CHANGED_BY_DESIGN.includes(name)) { + if (u.cost > b.cost) diffs.push(`cost ${fmt(b.cost)} -> ${fmt(u.cost)} rose; #1075 should only lower codex cost`) + else if (costDrift > REPRICE_TOLERANCE) diffs.push(`cost ${fmt(b.cost)} -> ${fmt(u.cost)} (${(costDrift * 100).toFixed(3)}% > ${(REPRICE_TOLERANCE * 100).toFixed(0)}% expected bound for #1075)`) + else { + repriced = true + notes.push(`${name}: cost ${fmt(b.cost)} -> ${fmt(u.cost)} (${(costDrift * 100).toFixed(3)}%) — repricing expected (#1075); tokens and calls still asserted exactly`) + } + } else if (costDrift > COST_TOLERANCE) { + diffs.push(`cost ${fmt(b.cost)} != ${fmt(u.cost)} (${(costDrift * 100).toFixed(3)}% > ${(COST_TOLERANCE * 100).toFixed(1)}%)`) + } if (!EXACT.includes(name)) { notes.push(`${name}: no expectation declared in compare.mjs; ${diffs.length ? diffs.join(', ') : 'identical'}`) verdict = diffs.length ? 'differs (unclassified)' : 'identical' } else if (diffs.length) { failures.push(`${name}: ${diffs.join(', ')}`) verdict = 'DIFFERS' + } else if (repriced) { + verdict = `repriced (cost ${(costDrift * 100).toFixed(3)}% drift)` } else { verdict = costDrift === 0 ? 'identical' : `identical (cost ${(costDrift * 100).toFixed(3)}% drift)` } diff --git a/scripts/upgrade-path/run.mjs b/scripts/upgrade-path/run.mjs index 8e15392c..16b669a1 100644 --- a/scripts/upgrade-path/run.mjs +++ b/scripts/upgrade-path/run.mjs @@ -33,7 +33,7 @@ const WORK = process.env['UPGRADE_PATH_WORK'] || join(tmpdir(), 'codeburn upgrad const OLD_SESSION_CACHE = 'session-cache.v7.json' const OLD_DAILY_CACHE = 'daily-cache.v17.json' const NEW_SESSION_CACHE_DIR = 'session-cache.v9' -const NEW_DAILY_CACHE = 'daily-cache.v22.json' +const NEW_DAILY_CACHE = 'daily-cache.v24.json' const HOME = join(WORK, 'user home') const PAYLOADS = join(WORK, 'payloads') diff --git a/src/audit-report.ts b/src/audit-report.ts index 7a40c5c7..c3c4f251 100644 --- a/src/audit-report.ts +++ b/src/audit-report.ts @@ -1,4 +1,4 @@ -import { getModelCosts, sanitizeModelForDisplay, type ModelCosts } from './models.js' +import { billableOutputTokens, getModelCosts, sanitizeModelForDisplay, type ModelCosts } from './models.js' import { getProvider } from './providers/index.js' import { formatCost, formatTokens } from './format.js' import { renderTable, type TableColumn } from './text-table.js' @@ -124,7 +124,7 @@ export async function aggregateAudit(projects: ProjectSummary[]): Promise { + try { + return { status: 'ok', text: await readFile(path, 'utf-8') } + } catch (err) { + const code = err && typeof err === 'object' && 'code' in err ? (err as { code?: string }).code : undefined + return { status: code === 'ENOENT' ? 'absent' : 'unreadable' } + } +} diff --git a/src/codex-cache.ts b/src/codex-cache.ts index aa0e21ca..f2418897 100644 --- a/src/codex-cache.ts +++ b/src/codex-cache.ts @@ -4,7 +4,7 @@ import { randomBytes } from 'crypto' import { join, resolve } from 'path' import { AsyncLocalStorage } from 'node:async_hooks' -import { getCodeburnCacheDir } from './cache-dir.js' +import { getCodeburnCacheDir, readExistingTextFile } from './cache-dir.js' import type { ParsedProviderCall } from './providers/types.js' // v4: attribute MCP calls emitted as event_msg/mcp_tool_call_end (issue #478). @@ -22,12 +22,19 @@ import type { ParsedProviderCall } from './providers/types.js' // v9: parse large session_meta records structurally so nested provenance.model // cannot overwrite the model selected by turn_context. // v10: same depth-1 window for the rest of session_meta's raw string fields -// (cwd/name/originator/session_id/forked_from_id/model_provider). (#1055) -// v11: builtin alias prices `codex-auto-review` (#1047). Exact-hit cache +// (cwd/name/originator/session_id/forked_from_id/model_provider). +// v11: codex pricing fix (#1075) - reasoning is no longer added on top of +// output, and cache_write_input_tokens is carved out of the input bucket. This +// file stores each call's costUSD and token buckets verbatim, so entries +// written by v10 carry the old (overstated) cost and must be re-derived. +// v12: builtin alias prices `codex-auto-review` (#1047). Exact-hit cache // entries still hold the pre-alias $0; bump so unchanged rollouts reprice. -// Must be max(#1055, this)+1 — both claimed v10 independently. -const CODEX_CACHE_VERSION = 11 -const CACHE_FILE = 'codex-results.json' +// Must be max(main v11 #1075, this)+1 — both claimed v11 independently. +export const CODEX_CACHE_VERSION = 12 +export const CODEX_LEGACY_CACHE_FILE = 'codex-results.json' +export function codexCacheFileName(version = CODEX_CACHE_VERSION): string { + return `codex-results.v${version}.json` +} export type CodexFileFingerprint = { dev: number; ino: number; mtimeMs: number; sizeBytes: number } type FileFingerprint = CodexFileFingerprint @@ -73,7 +80,15 @@ export function withCodexCacheDirectory(cacheDir: string, operation: () => T) } function getCachePath(cacheDir: string): string { - return join(cacheDir, CACHE_FILE) + return join(cacheDir, codexCacheFileName()) +} + +function getLegacyCachePath(cacheDir: string): string { + return join(cacheDir, CODEX_LEGACY_CACHE_FILE) +} + +function isCurrentCache(cache: ResultCache): boolean { + return cache.version === CODEX_CACHE_VERSION && !!cache.files && typeof cache.files === 'object' } // Embedded consumers can change CODEBURN_CACHE_DIR without reloading this @@ -90,15 +105,33 @@ export function clearCodexMemCaches(): void { async function loadCache(cacheDir: string): Promise { const inMemory = memCaches.get(cacheDir) if (inMemory) return inMemory + const empty = { version: CODEX_CACHE_VERSION, files: {} } + const versioned = await readExistingTextFile(getCachePath(cacheDir)) + if (versioned.status === 'ok') { + try { + const cache = JSON.parse(versioned.text) as ResultCache + if (isCurrentCache(cache)) { + memCaches.set(cacheDir, cache) + return cache + } + } catch {} + memCaches.set(cacheDir, empty) + return empty + } + if (versioned.status === 'unreadable') { + memCaches.set(cacheDir, empty) + return empty + } + // Versioned file is absent (ENOENT). Adopt the unsuffixed file only when its + // version matches — old binaries still own that path; we never write or delete it. try { - const raw = await readFile(getCachePath(cacheDir), 'utf-8') + const raw = await readFile(getLegacyCachePath(cacheDir), 'utf-8') const cache = JSON.parse(raw) as ResultCache - if (cache.version === CODEX_CACHE_VERSION && cache.files && typeof cache.files === 'object') { + if (isCurrentCache(cache)) { memCaches.set(cacheDir, cache) return cache } } catch {} - const empty = { version: CODEX_CACHE_VERSION, files: {} } memCaches.set(cacheDir, empty) return empty } diff --git a/src/codex-credits.ts b/src/codex-credits.ts index 0e848d9e..20678873 100644 --- a/src/codex-credits.ts +++ b/src/codex-credits.ts @@ -45,9 +45,9 @@ export type CodexCreditTokens = { inputTokens: number /// Cache-read (cached input) tokens, billed at the cheaper cached rate. cachedReadTokens: number + /// Billable output tokens: reasoning is already included (billableOutputTokens + /// in models.ts), so callers must not add it on top here. outputTokens: number - /// Reasoning tokens are billed as output, matching CodeBurn's cost model. - reasoningTokens?: number } /// Credits consumed for one Codex usage record. Returns null when the model has @@ -57,10 +57,9 @@ export function codexCredits(model: string, tokens: CodexCreditTokens): number | if (!rate) return null const safe = (n: number) => (Number.isFinite(n) && n > 0 ? n : 0) const PER_MILLION = 1_000_000 - const output = safe(tokens.outputTokens) + safe(tokens.reasoningTokens ?? 0) return ( (safe(tokens.inputTokens) / PER_MILLION) * rate.input + (safe(tokens.cachedReadTokens) / PER_MILLION) * rate.cachedInput + - (output / PER_MILLION) * rate.output + (safe(tokens.outputTokens) / PER_MILLION) * rate.output ) } diff --git a/src/cursor-cache.ts b/src/cursor-cache.ts index d48ca33c..cd262a7c 100644 --- a/src/cursor-cache.ts +++ b/src/cursor-cache.ts @@ -2,7 +2,7 @@ import { readFile, writeFile, mkdir, rename, stat, unlink } from 'fs/promises' import { join } from 'path' import { randomBytes } from 'crypto' -import { getCodeburnCacheDir } from './cache-dir.js' +import { getCodeburnCacheDir, readExistingTextFile } from './cache-dir.js' import type { ParsedProviderCall } from './providers/types.js' // Bumped to 3 for the workspace-aware breakdown change: the cursor parser @@ -19,7 +19,11 @@ import type { ParsedProviderCall } from './providers/types.js' // (cursor:composer-input:) with per-conversation source selection, the // agent stream regained tool/system context and stream-only sessions, and // tool names are canonicalized. v5 results mix crediting regimes. -const CURSOR_CACHE_VERSION = 6 +export const CURSOR_CACHE_VERSION = 6 +export const CURSOR_LEGACY_CACHE_FILE = 'cursor-results.json' +export function cursorCacheFileName(version = CURSOR_CACHE_VERSION): string { + return `cursor-results.v${version}.json` +} type ResultCache = { version?: number @@ -29,10 +33,30 @@ type ResultCache = { calls: ParsedProviderCall[] } -const CACHE_FILE = 'cursor-results.json' - function getCachePath(): string { - return join(getCodeburnCacheDir(), CACHE_FILE) + return join(getCodeburnCacheDir(), cursorCacheFileName()) +} + +function getLegacyCachePath(): string { + return join(getCodeburnCacheDir(), CURSOR_LEGACY_CACHE_FILE) +} + +function isCurrentHit(cache: ResultCache, fp: { mtimeMs: number; size: number }, requestedFloor: string): boolean { + return ( + cache.version === CURSOR_CACHE_VERSION + && cache.dbMtimeMs === fp.mtimeMs + && cache.dbSizeBytes === fp.size + && typeof cache.lookbackFloor === 'string' + && cache.lookbackFloor <= requestedFloor + ) +} + +async function readCacheFile(path: string): Promise { + try { + const cache = JSON.parse(await readFile(path, 'utf-8')) as ResultCache + if (cache && typeof cache === 'object') return cache + } catch {} + return null } async function getDbFingerprint(dbPath: string): Promise<{ mtimeMs: number; size: number } | null> { @@ -52,18 +76,20 @@ export async function readCachedResults( const fp = await getDbFingerprint(dbPath) if (!fp) return null - const raw = await readFile(getCachePath(), 'utf-8') - const cache = JSON.parse(raw) as ResultCache - - if ( - cache.version === CURSOR_CACHE_VERSION && - cache.dbMtimeMs === fp.mtimeMs && - cache.dbSizeBytes === fp.size && - typeof cache.lookbackFloor === 'string' && - cache.lookbackFloor <= requestedFloor - ) { - return cache.calls + const versioned = await readExistingTextFile(getCachePath()) + if (versioned.status === 'ok') { + try { + const cache = JSON.parse(versioned.text) as ResultCache + if (cache && typeof cache === 'object' && isCurrentHit(cache, fp, requestedFloor)) return cache.calls + } catch {} + return null } + if (versioned.status === 'unreadable') return null + + // Versioned file is absent (ENOENT). Adopt the unsuffixed file only when its + // version and fingerprint match — old binaries still own that path. + const legacy = await readCacheFile(getLegacyCachePath()) + if (legacy && isCurrentHit(legacy, fp, requestedFloor)) return legacy.calls return null } catch { return null diff --git a/src/daily-cache.ts b/src/daily-cache.ts index f56e3bdd..4e740d81 100644 --- a/src/daily-cache.ts +++ b/src/daily-cache.ts @@ -6,17 +6,14 @@ import { join } from 'path' import { getCodeburnCacheDir } from './cache-dir.js' import type { DateRange, ProjectSummary } from './types.js' -// Bumped to 22: `codex-auto-review` now prices as the recommended GPT-5.5 -// row (#1047). Days already finalized under v20/v21 keep that id at $0 +// Bumped to 24: `codex-auto-review` now prices as the recommended GPT-5.5 +// row (#1047). Days already finalized under v23 keep that id at $0 // forever unless MIN_SUPPORTED_VERSION moves: the daily cache has no // per-provider invalidation. The Codex parse version and CODEX_CACHE_VERSION // move with this so the lower caches reprice first; this pass then re-derives // ALL days from the warm session cache (seconds, not a full re-parse). -// adoptOlderDailyCaches keeps the superseded file as the baseline. v22 not -// v21: #946 (in landing) already claims 21. -// -// Bumped to 21: unreleased on this branch; skipped so we do not collide -// with #946's daily-cache.v21.json. +// adoptOlderDailyCaches keeps the superseded file as the baseline. v21 is +// #946, v22 was this PR's previous claim, v23 is #1075 on main. // // Bumped to 20: the Codex fast-path read a nested // `base_instructions.provenance.model` out of `session_meta` as if it were @@ -122,8 +119,18 @@ import type { DateRange, ProjectSummary } from './types.js' // that older binaries skipped. v8 added local-model savings to the daily // rollup; the `savingsConfigHash` field is invalidated separately when the // user changes their `localModelSavings` mapping. -export const DAILY_CACHE_VERSION = 22 -const MIN_SUPPORTED_VERSION = 22 +// v23: codex pricing fix (#1075) - reasoning tokens were billed on top of +// output (they are a subset of it) and cache_write_input_tokens was ignored, so +// days finalized at v20 carry codex costs overstated by ~3.5% and codex output +// tokens overstated by ~34.6%. Raising MIN_SUPPORTED_VERSION forces the +// one-time re-derivation. +// It takes 23, not 21: v21 is claimed by the #946 landing branch and v22 by +// PR #1056, so those numbers are spoken for and reusing one would let two +// incompatible schemas share a filename. (feat/core-extraction sits at 26 and +// reconciles at its final merge by keeping the max.) +// v24: #1047 activity-id pricing. v23 on main already shipped #1075. +export const DAILY_CACHE_VERSION = 24 +const MIN_SUPPORTED_VERSION = 24 // Version-suffixed so different binaries each own a distinct file and never // clobber an incompatible schema. Bumping the version mints a fresh filename; // adoptOlderDailyCaches then unions days out of every previous file (including diff --git a/src/granular-history.ts b/src/granular-history.ts index 75b6e328..875b96b6 100644 --- a/src/granular-history.ts +++ b/src/granular-history.ts @@ -64,6 +64,7 @@ type SessionLabelEntry = { key: string info: SessionLabelInfo baseLabel: string + idFirst: string } function nonNegative(value: number): number { @@ -174,19 +175,41 @@ function preferredSessionTitle(titleCandidates: Map): Map { // Stable raw-key order makes the residual used-label guard independent of // project/session discovery order when a title happens to match another // label shape. - const entries: SessionLabelEntry[] = [...inputs.entries()].map(([key, info]) => { - const sessionLabel = preferredSessionTitle(info.titleCandidates) - ?? shortProjectLabel(info.projectPath, preferredProjectName(info.projectNames)) - return { - key, - info, - baseLabel: `${shortSessionId(info.sessionId)} (${info.provider}) · ${sessionLabel}`, - } + const draft: SessionLabelEntry[] = [...inputs.entries()].map(([key, info]) => { + const title = preferredSessionTitle(info.titleCandidates) + const project = shortProjectLabel(info.projectPath, preferredProjectName(info.projectNames)) + const shortId = shortSessionId(info.sessionId) + const idFirst = title + ? `${shortId} (${info.provider}) · ${title}` + : `${shortId} (${info.provider}) · ${project}` + const titleFirst = title ? `${title} (${info.provider})` : idFirst + return { key, info, baseLabel: titleFirst, idFirst } }).sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0) + + const titlePrefixCounts = new Map() + for (const entry of draft) { + const prefix = visibleLegendPrefix(entry.baseLabel) + titlePrefixCounts.set(prefix, (titlePrefixCounts.get(prefix) ?? 0) + 1) + } + const entries: SessionLabelEntry[] = draft.map(entry => { + const prefix = visibleLegendPrefix(entry.baseLabel) + const titleLeads = (titlePrefixCounts.get(prefix) ?? 0) === 1 + return { ...entry, baseLabel: titleLeads ? entry.baseLabel : entry.idFirst } + }) const byBaseLabel = new Map() for (const entry of entries) { const group = byBaseLabel.get(entry.baseLabel) ?? [] diff --git a/src/main.ts b/src/main.ts index fdaea87d..684f6016 100644 --- a/src/main.ts +++ b/src/main.ts @@ -472,7 +472,7 @@ program.hook('preAction', async (thisCommand) => { await loadCurrency() }) -function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: string, durable?: DurablePeriod) { +function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: string, durable: DurablePeriod) { const sessions = projects.flatMap(p => p.sessions) const { code } = getCurrency() @@ -480,67 +480,28 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: // session files have expired still count), matching the menubar exactly. The // proxied/net split is a surviving-session concept (subscription attribution // isn't stored per day), so it stays live; net is taken off the durable total. - const totalCostUSD = durable ? durable.data.cost : projects.reduce((s, p) => s + p.totalCostUSD, 0) - const totalSavingsUSD = durable ? durable.data.savingsUSD : projects.reduce((s, p) => s + p.totalSavingsUSD, 0) - const totalEstimatedUSD = durable ? (durable.data.estimatedCostUSD ?? 0) : projects.reduce((s, p) => s + (p.totalEstimatedCostUSD ?? 0), 0) + const totalCostUSD = durable.data.cost + const totalSavingsUSD = durable.data.savingsUSD + const totalEstimatedUSD = durable.data.estimatedCostUSD ?? 0 // Subscription-covered (proxied) portion of totalCostUSD, and the resulting // out-of-pocket figure. `cost` stays the full billable/would-be amount. const totalProxiedUSD = projects.reduce((s, p) => s + p.totalProxiedCostUSD, 0) const netCostUSD = totalCostUSD - totalProxiedUSD - const totalCalls = durable ? durable.data.calls : projects.reduce((s, p) => s + p.totalApiCalls, 0) - const totalSessions = durable ? durable.data.sessions : projects.reduce((s, p) => s + p.sessions.length, 0) - const totalInput = durable ? durable.data.inputTokens : sessions.reduce((s, sess) => s + sess.totalInputTokens, 0) - const totalOutput = durable ? durable.data.outputTokens : sessions.reduce((s, sess) => s + sess.totalOutputTokens, 0) - const totalCacheRead = durable ? durable.data.cacheReadTokens : sessions.reduce((s, sess) => s + sess.totalCacheReadTokens, 0) - const totalCacheWrite = durable ? durable.data.cacheWriteTokens : sessions.reduce((s, sess) => s + sess.totalCacheWriteTokens, 0) + const totalCalls = durable.data.calls + const totalSessions = durable.data.sessions + const totalInput = durable.data.inputTokens + const totalOutput = durable.data.outputTokens + const totalCacheRead = durable.data.cacheReadTokens + const totalCacheWrite = durable.data.cacheWriteTokens // Match src/menubar-json.ts:cacheHitPercent: reads over reads+fresh-input. cache_write // counts tokens being stored, not served, so it doesn't belong in the denominator. const cacheHitDenom = totalInput + totalCacheRead const cacheHitPercent = cacheHitDenom > 0 ? Math.round((totalCacheRead / cacheHitDenom) * 1000) / 10 : 0 - // Per-day rollup. Mirrors parser.ts categoryBreakdown semantics so a - // consumer summing daily[].editTurns over a period gets the same total as - // sum(activities[].editTurns) for that period: every turn counts once for - // `turns`, edit turns count for `editTurns`, edit turns with zero retries - // count for `oneShotTurns`. Issue #279 — daily-resolution efficiency - // dashboards need this without re-deriving from activity-level rollups. - const dailyMap: Record = {} - for (const sess of sessions) { - for (const turn of sess.turns) { - // Prefer the user-message timestamp on the turn; fall back to the first - // assistant-call timestamp when the user line is missing (continuation - // sessions where the JSONL begins mid-conversation). Previously these - // turns dropped from daily but stayed in activities, breaking the - // sum(daily[].editTurns) === sum(activities[].editTurns) invariant. - const ts = turn.timestamp || turn.assistantCalls[0]?.timestamp - if (!ts) { continue } - const day = dateKey(ts) - if (!dailyMap[day]) { dailyMap[day] = { cost: 0, savings: 0, calls: 0, turns: 0, editTurns: 0, oneShotTurns: 0 } } - dailyMap[day].turns += 1 - if (turn.hasEdits) { - dailyMap[day].editTurns += 1 - if (turn.retries === 0) dailyMap[day].oneShotTurns += 1 - } - for (const call of turn.assistantCalls) { - // Cost/savings/calls bucket under each call's OWN day — the same - // per-call rule as the durable day set (day-aggregator.ts), so this - // fallback and durable.days never diverge on a midnight-straddling - // turn (issue #852). Turn counts/edit stats stay anchored on the - // turn's day above. An unparseable call timestamp falls back to the - // turn's day rather than producing a garbage date key. - const callDay = Number.isNaN(new Date(call.timestamp).getTime()) ? day : dateKey(call.timestamp) - if (!dailyMap[callDay]) { dailyMap[callDay] = { cost: 0, savings: 0, calls: 0, turns: 0, editTurns: 0, oneShotTurns: 0 } } - dailyMap[callDay].cost += call.costUSD - dailyMap[callDay].savings += call.savingsUSD ?? 0 - dailyMap[callDay].calls += 1 - } - } - } // Daily rows come from the same durable day set as the headline so they sum - // to it, carried days included. The live per-turn rollup (dailyMap) is only - // the fallback for callers that pass no durable period. - const daily = durable - ? durable.days.map(d => { + // to it, carried days included. Both JSON call sites always pass durable + // (#1067); the live dailyMap fallback was unreachable and is gone. + const daily = durable.days.map(d => { const turns = Object.values(d.categories).reduce((s, c) => s + c.turns, 0) return { date: d.date, @@ -555,21 +516,6 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: : null, } }) - : Object.entries(dailyMap).sort().map(([date, d]) => ({ - date, - cost: convertCost(d.cost), - savings: convertCost(d.savings), - calls: d.calls, - turns: d.turns, - editTurns: d.editTurns, - oneShotTurns: d.oneShotTurns, - // Pre-computed convenience for dashboards that don't want to do the math. - // null when there are no edit turns (the rate is undefined, not zero — - // a day where the user only had Q&A turns shouldn't read as 0% one-shot). - oneShotRate: d.editTurns > 0 - ? Math.round((d.oneShotTurns / d.editTurns) * 1000) / 10 - : null, - })) const projectList = projects.map(p => ({ name: p.project, diff --git a/src/models-report.ts b/src/models-report.ts index 66938b6f..110b9a0f 100644 --- a/src/models-report.ts +++ b/src/models-report.ts @@ -3,7 +3,7 @@ import stripAnsi from 'strip-ansi' import { codexCredits } from './codex-credits.js' import { formatCost, formatTokens } from './format.js' -import { sanitizeModelForDisplay } from './models.js' +import { billableOutputTokens, sanitizeModelForDisplay } from './models.js' import { getProvider } from './providers/index.js' import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js' @@ -120,7 +120,7 @@ export async function aggregateModels(projects: ProjectSummary[], opts: Aggregat buckets.set(key, bucket) } bucket.inputTokens += call.usage.inputTokens - bucket.outputTokens += call.usage.outputTokens + call.usage.reasoningTokens + bucket.outputTokens += billableOutputTokens(provider, call.usage.outputTokens, call.usage.reasoningTokens) bucket.cacheWriteTokens += call.usage.cacheCreationInputTokens // cacheReadInputTokens (Anthropic vocab) and cachedInputTokens (OpenAI vocab) // are two names for the same thing. Providers populate one or set both to the @@ -182,9 +182,10 @@ export async function aggregateModels(projects: ProjectSummary[], opts: Aggregat savingsUSD: bucket.savingsUSD, savingsBaselineModel: bucket.savingsBaselineModel, calls: bucket.calls, - // outputTokens already includes reasoning (folded in above), and for Codex - // inputTokens is non-cached with cacheReadTokens holding cached input, which - // is exactly what the credit rates expect. + // outputTokens is the billable output (for Codex that already contains + // reasoning, so nothing is added on top), and inputTokens is non-cached + // with cacheReadTokens holding cached input - exactly what the credit + // rates expect. credits: bucket.provider === 'codex' ? codexCredits(bucket.model, { inputTokens: bucket.inputTokens, diff --git a/src/models.ts b/src/models.ts index 2c92354a..e73a5c86 100644 --- a/src/models.ts +++ b/src/models.ts @@ -13,6 +13,28 @@ export type ModelCosts = { cacheReadCostPerToken: number webSearchCostPerRequest: number fastMultiplier: number + /// True only when the pricing source carried a real cache-write rate. When + /// absent/false, `cacheWriteCostPerToken` is the fabricated `1.25 x input` + /// default, which is right for Anthropic-style pricing but would invent a + /// surcharge on providers that charge nothing extra to write cache. Callers + /// that decide WHICH bucket to put tokens in (rather than what to multiply + /// them by) must consult this before routing tokens to the cache-write + /// bucket. Optional so an incomplete literal defaults to the safe answer. + cacheWriteCostIsExplicit?: boolean +} + +/// Providers whose reported `reasoningTokens` are a SUBSET of `outputTokens` +/// rather than a separate bucket to add on top. OpenAI bills reasoning as part +/// of output (every codex `token_count` event satisfies input + output == +/// total), and Anthropic folds thinking into output the same way, so summing +/// the two double-counts both the cost and the displayed output tokens. +const REASONING_INCLUDED_IN_OUTPUT = new Set(['claude', 'codex']) + +/// Output tokens to bill and display for one call. Single source of truth so +/// the pricing sites and the display sums can never disagree about whether a +/// provider's reasoning tokens are already inside its output count (#1075). +export function billableOutputTokens(provider: string, outputTokens: number, reasoningTokens: number): number { + return REASONING_INCLUDED_IN_OUTPUT.has(provider) ? outputTokens : outputTokens + reasoningTokens } type PriceOverrideRates = { @@ -37,6 +59,11 @@ type SnapshotEntry = [number, number, number | null, number | null, (number | nu const LITELLM_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json' const CACHE_TTL_MS = 24 * 60 * 60 * 1000 +// Bump whenever a ModelCosts field changes pricing behavior (cacheWriteCostIsExplicit, +// added in #1075/#1078). A cache written under an older/missing version is treated as a +// miss instead of read verbatim, so a stale on-disk file can't reintroduce a killed bug +// for up to CACHE_TTL_MS after an upgrade. +const CACHE_SCHEMA_VERSION = 2 const WEB_SEARCH_COST = 0.01 const ONE_HOUR_CACHE_WRITE_MULTIPLIER_FROM_FIVE_MINUTE_RATE = 1.6 @@ -71,6 +98,7 @@ function buildCosts( cacheReadCostPerToken: cacheRead ?? input * 0.1, webSearchCostPerRequest: WEB_SEARCH_COST, fastMultiplier: fast ?? 1, + cacheWriteCostIsExplicit: cacheWrite !== null && cacheWrite !== undefined, } } @@ -200,6 +228,7 @@ async function fetchAndCachePricing(): Promise> { await mkdir(getCodeburnCacheDir(), { recursive: true }) await writeFile(getCachePath(), JSON.stringify({ + version: CACHE_SCHEMA_VERSION, timestamp: Date.now(), data: Object.fromEntries(pricing), })) @@ -210,7 +239,8 @@ async function fetchAndCachePricing(): Promise> { async function loadCachedPricing(): Promise | null> { try { const raw = await readFile(getCachePath(), 'utf-8') - const cached = JSON.parse(raw) as { timestamp: number; data: Record } + const cached = JSON.parse(raw) as { version?: number; timestamp: number; data: Record } + if (cached.version !== CACHE_SCHEMA_VERSION) return null if (Date.now() - cached.timestamp > CACHE_TTL_MS) return null return new Map(Object.entries(cached.data)) } catch { diff --git a/src/optimize.ts b/src/optimize.ts index 2ecd348b..134424c4 100644 --- a/src/optimize.ts +++ b/src/optimize.ts @@ -8,7 +8,7 @@ import { homedir } from 'os' import { isReadShapedBashCommand } from './bash-utils.js' import { readSessionLines, readSessionFileSync } from './fs-utils.js' -import { discoverAllSessions } from './providers/index.js' +import { discoverAllSessions, providerDisplayName } from './providers/index.js' import { parseJsonlLine, shouldSkipLine } from './parser.js' import type { DateRange, ProjectSummary, SessionSummary } from './types.js' import { formatCost } from './currency.js' @@ -249,6 +249,88 @@ export type PasteDestination = | 'shell-config' // append to ~/.zshrc / ~/.bashrc | 'manual' // instructions the user carries out directly +/// Provider-scoped remediation nouns. Cross-provider detectors and both +/// render surfaces (CLI + TUI) share this so `--provider codex` cannot still +/// tell the user to ask Claude or edit CLAUDE.md. Claude / unset / `all` +/// keep the shipped CLAUDE.md copy. Only `codex` has a file CodeBurn already +/// names (`AGENTS.md` in the Codex parser); every other provider stays on +/// the generic "project instructions" rather than inventing a filename. +export type OptimizeRemediationCopy = { + agent: string + instructionFile: string +} + +const REMEDIATION_INSTRUCTION_FILES: Record = { + claude: 'CLAUDE.md', + codex: 'AGENTS.md', +} + +export function isDefaultClaudeProvider(provider?: string): boolean { + return !provider || provider === 'all' || provider === 'claude' +} + +export function optimizeRemediationCopy(provider?: string): OptimizeRemediationCopy { + const key = !provider || provider === 'all' ? 'claude' : provider.toLowerCase() + return { + agent: providerDisplayName(key), + instructionFile: REMEDIATION_INSTRUCTION_FILES[key] ?? 'project instructions', + } +} + +export function sessionOpenerLabel(copy: OptimizeRemediationCopy): string { + return `Paste at the start of your NEXT expensive thread (one-time, do not add to ${copy.instructionFile}):` +} + +export function askAgentLabel(copy: OptimizeRemediationCopy, rest: string): string { + return `Ask ${copy.agent} to ${rest}:` +} + +export function optimizePasteHeader(destination: PasteDestination | undefined, copy: OptimizeRemediationCopy): string { + switch (destination) { + case 'claude-md': return `Suggested ${copy.instructionFile} addition (permanent rule)` + case 'session-opener': return `One-time session opener (do NOT add to ${copy.instructionFile})` + case 'prompt': return `Ask ${copy.agent} in the current session` + case 'shell-config': return 'Add to your shell config' + case 'manual': return 'Manual action' + default: return 'Suggested action' + } +} + +/// Exact pre-#1049 TUI strings for unset/`all`/`claude`. Non-Claude providers +/// reuse the CLI header table so the two surfaces cannot drift on new copy. +export function optimizeTuiPasteHeader(destination: PasteDestination | undefined, provider?: string): string { + if (isDefaultClaudeProvider(provider)) { + switch (destination) { + case 'claude-md': return '── Suggested CLAUDE.md addition (permanent rule) '.padEnd(64, '─') + case 'session-opener': return '── One-time session opener (do not add to CLAUDE.md) '.padEnd(64, '─') + case 'prompt': return '── Ask Claude in the current session '.padEnd(64, '─') + case 'shell-config': return '── Add to your shell config '.padEnd(64, '─') + case 'manual': return '── Manual action '.padEnd(64, '─') + default: return '── Suggested action '.padEnd(64, '─') + } + } + return `── ${optimizePasteHeader(destination, optimizeRemediationCopy(provider))} `.padEnd(64, '─') +} + +export function optimizeEmptyScanLines(provider?: string): [string, string, string] { + if (isDefaultClaudeProvider(provider)) { + return [ + 'CodeBurn optimize scans your Claude Code sessions and config for', + 'token waste: junk directory reads, duplicate file reads, unused', + 'agents/skills/MCP servers, bloated CLAUDE.md, and more.', + ] + } + const copy = optimizeRemediationCopy(provider) + // scanSessions is Claude-only. Naming the provider's instruction file here + // would claim a scan that did not run (Health A / 100 under --provider + // codex with every listed detector claudeOnly-disabled). + return [ + `Session-scan detectors do not cover ${copy.agent} yet.`, + 'junk directory reads, duplicate file reads, unused agents/skills/MCP,', + 'and bloated instruction files currently scan Claude Code only.', + ] +} + export type WasteAction = | { type: 'paste'; label: string; text: string; destination?: PasteDestination } | { type: 'command'; label: string; text: string } @@ -1695,6 +1777,7 @@ function collectMcpProjectProfiles( export function detectMcpProfileAdvisor( projects: ProjectSummary[], coverage = aggregateMcpCoverage(projects), + provider?: string, ): WasteFinding | null { const candidates = collectMcpProjectProfiles(projects, coverage) if (candidates.length === 0) return null @@ -1733,7 +1816,7 @@ export function detectMcpProfileAdvisor( fix: { type: 'paste', destination: 'prompt', - label: 'Ask Claude to turn this into a project-scoped MCP profile:', + label: askAgentLabel(optimizeRemediationCopy(provider), 'turn this into a project-scoped MCP profile'), text: [ `Review these MCP profile recommendations before changing config (${preview.length} of ${candidates.length} shown):`, ...preview.map(candidate => { @@ -1926,7 +2009,7 @@ function findCapabilityReliabilityCandidates(projects: ProjectSummary[]): Capabi return candidates } -export function detectCapabilityReliability(projects: ProjectSummary[]): WasteFinding | null { +export function detectCapabilityReliability(projects: ProjectSummary[], provider?: string): WasteFinding | null { projects = userStartedProjects(projects) const candidates = findCapabilityReliabilityCandidates(projects) if (candidates.length === 0) return null @@ -1989,7 +2072,7 @@ export function detectCapabilityReliability(projects: ProjectSummary[]): WasteFi fix: { type: 'paste', destination: 'prompt', - label: 'Ask Claude to audit the retry-heavy capability before changing config:', + label: askAgentLabel(optimizeRemediationCopy(provider), 'audit the retry-heavy capability before changing config'), text: `Investigate these retry-correlated capabilities: ${names}. Compare edit turns with retries against one-shot edit turns, identify whether the MCP server or skill actually caused rework, then propose a scoped MCP config or skill-instruction change with session evidence. Do not remove a capability solely because it appears in this report.`, }, } @@ -3121,7 +3204,7 @@ export function findLowWorthCandidates(projects: ProjectSummary[]): LowWorthCand return candidates } -export function detectLowWorthSessions(projects: ProjectSummary[]): WasteFinding | null { +export function detectLowWorthSessions(projects: ProjectSummary[], provider?: string): WasteFinding | null { const candidates = findLowWorthCandidates(projects) if (candidates.length === 0) return null @@ -3156,7 +3239,7 @@ export function detectLowWorthSessions(projects: ProjectSummary[]): WasteFinding fix: { type: 'paste', destination: 'session-opener', - label: 'Paste at the start of your NEXT expensive thread (one-time, do not add to CLAUDE.md):', + label: sessionOpenerLabel(optimizeRemediationCopy(provider)), text: LOW_WORTH_OPENER, }, } @@ -3231,7 +3314,7 @@ export function findContextBloatCandidates(projects: ProjectSummary[]): ContextB return candidates } -export function detectContextBloat(projects: ProjectSummary[], excludedSessionIds?: ReadonlySet): WasteFinding | null { +export function detectContextBloat(projects: ProjectSummary[], excludedSessionIds?: ReadonlySet, provider?: string): WasteFinding | null { const candidates = findContextBloatCandidates(projects) .filter(c => !excludedSessionIds?.has(c.sessionId)) if (candidates.length === 0) return null @@ -3272,13 +3355,13 @@ export function detectContextBloat(projects: ProjectSummary[], excludedSessionId fix: { type: 'paste', destination: 'session-opener', - label: 'Paste at the start of your NEXT expensive thread (one-time, do not add to CLAUDE.md):', + label: sessionOpenerLabel(optimizeRemediationCopy(provider)), text: CONTEXT_HEAVY_OPENER, }, } } -export function detectSessionOutliers(projects: ProjectSummary[], excludedSessionIds?: ReadonlySet): WasteFinding | null { +export function detectSessionOutliers(projects: ProjectSummary[], excludedSessionIds?: ReadonlySet, provider?: string): WasteFinding | null { type Outlier = { project: string sessionId: string @@ -3353,7 +3436,7 @@ export function detectSessionOutliers(projects: ProjectSummary[], excludedSessio fix: { type: 'paste', destination: 'session-opener', - label: 'Paste at the start of your NEXT expensive thread (one-time, do not add to CLAUDE.md):', + label: sessionOpenerLabel(optimizeRemediationCopy(provider)), text: 'Before making changes, summarize the smallest viable plan. Keep context narrow, avoid broad searches, and stop after the first working patch so I can review before continuing.', }, } @@ -3551,15 +3634,15 @@ export async function scanAndDetect( claudeOnly(() => detectDuplicateReads(toolCalls, dateRange)), claudeOnly(() => detectUnusedMcp(toolCalls, projects, projectCwds, mcpCoverage)), () => detectMcpToolCoverage(projects, mcpCoverage, localMcpServerNames(projectCwds)), - () => detectMcpProfileAdvisor(projects, mcpCoverage), + () => detectMcpProfileAdvisor(projects, mcpCoverage, provider), // mcp-deferral-gaps family (#614): detection only, no apply plans yet. claudeOnly(() => detectMcpDeferralOff(toolCalls, projects, projectCwds, apiCalls)), claudeOnly(() => detectMcpAlwaysLoadHygiene(projects, projectCwds, apiCalls, mcpCoverage)), claudeOnly(() => detectMcpDeferThreshold(projects, projectCwds)), - () => detectCapabilityReliability(behavioralProjects), - () => detectLowWorthSessions(behavioralProjects), - () => detectContextBloat(behavioralProjects, lowWorthSessionIds), - () => detectSessionOutliers(behavioralProjects, outlierExclusions), + () => detectCapabilityReliability(behavioralProjects, provider), + () => detectLowWorthSessions(behavioralProjects, provider), + () => detectContextBloat(behavioralProjects, lowWorthSessionIds, provider), + () => detectSessionOutliers(behavioralProjects, outlierExclusions, provider), claudeOnly(() => detectBloatedClaudeMd(projectCwds)), claudeOnly(() => detectBashBloat()), claudeOnly(() => detectRecurringContext(openers)), @@ -3624,7 +3707,7 @@ function wrap(text: string, width: number, indent: string): string { /// destination. Issue #277: users were dropping one-time session openers /// into CLAUDE.md as permanent rules because the prompts had no labeled /// home in the output. -function renderActionHeader(action: WasteAction): string { +function renderActionHeader(action: WasteAction, copy: OptimizeRemediationCopy): string { const headerWidth = PANEL_WIDTH - 4 const fillTo = (label: string): string => { const inner = ` ${label} ` @@ -3637,18 +3720,11 @@ function renderActionHeader(action: WasteAction): string { case 'command': return fillTo('Run this command') case 'paste': - switch (action.destination) { - case 'claude-md': return fillTo('Suggested CLAUDE.md addition (permanent rule)') - case 'session-opener': return fillTo('One-time session opener (do NOT add to CLAUDE.md)') - case 'prompt': return fillTo('Ask Claude in the current session') - case 'shell-config': return fillTo('Add to your shell config') - case 'manual': return fillTo('Manual action') - default: return fillTo('Suggested action') - } + return fillTo(optimizePasteHeader(action.destination, copy)) } } -function renderFinding(n: number, f: WasteFinding, costRate: number): string[] { +function renderFinding(n: number, f: WasteFinding, costRate: number, copy: OptimizeRemediationCopy): string[] { const lines: string[] = [] const costSaved = f.tokensSaved * costRate const impactLabel = f.impact.charAt(0).toUpperCase() + f.impact.slice(1) @@ -3674,7 +3750,7 @@ function renderFinding(n: number, f: WasteFinding, costRate: number): string[] { // permanent rules and one-time prompts are no longer interchangeable in // the output. const a = f.fix - lines.push(chalk.hex(ORANGE)(` ${renderActionHeader(a)}`)) + lines.push(chalk.hex(ORANGE)(` ${renderActionHeader(a, copy)}`)) lines.push(chalk.hex(DIM)(` ${a.label}`)) if (a.type === 'file-content') { for (const line of a.content.split('\n')) lines.push(chalk.hex(CYAN)(` ${line}`)) @@ -3743,7 +3819,9 @@ export function renderOptimize( previouslyApplied?: Record, modelRecommendations?: ModelDefaultRecommendation[], appliedFixes: AppliedFix[] = [], + provider?: string, ): string { + const copy = optimizeRemediationCopy(provider) const lines: string[] = [] lines.push('') lines.push(` ${chalk.bold.hex(ORANGE)('CodeBurn config health')}${chalk.dim(' ' + periodLabel)}`) @@ -3766,9 +3844,9 @@ export function renderOptimize( if (findings.length === 0) { lines.push(chalk.hex(GREEN)(' Nothing to fix. Your setup is lean.')) lines.push('') - lines.push(chalk.dim(' CodeBurn optimize scans your Claude Code sessions and config for')) - lines.push(chalk.dim(' token waste: junk directory reads, duplicate file reads, unused')) - lines.push(chalk.dim(' agents/skills/MCP servers, bloated CLAUDE.md, and more.')) + for (const line of optimizeEmptyScanLines(provider)) { + lines.push(chalk.dim(` ${line}`)) + } lines.push('') lines.push(...renderAppliedFixes(appliedFixes)) lines.push(...renderWorkflowSection(reworkedFiles, coachingNotes)) @@ -3799,7 +3877,7 @@ export function renderOptimize( for (const f of group) { const appliedOn = previouslyApplied?.[f.id] const shown = appliedOn ? { ...f, title: `${f.title} (previously applied ${appliedOn}, re-flagged)` } : f - lines.push(...renderFinding(++n, shown, costRate)) + lines.push(...renderFinding(++n, shown, costRate, copy)) } } @@ -3868,7 +3946,7 @@ export async function runOptimize( } const { topReworkedFiles, coachingNotes } = buildWorkflowReport(projects) - const output = renderOptimize(findings, costRate, periodLabel, periodCost, sessionCount, callCount, healthScore, healthGrade, topReworkedFiles, coachingNotes, opts.appliedHeader, opts.previouslyApplied, result.modelRecommendations, opts.appliedFixes) + const output = renderOptimize(findings, costRate, periodLabel, periodCost, sessionCount, callCount, healthScore, healthGrade, topReworkedFiles, coachingNotes, opts.appliedHeader, opts.previouslyApplied, result.modelRecommendations, opts.appliedFixes, opts.provider) console.log(output) } diff --git a/src/parser.ts b/src/parser.ts index 481f6d93..43137d74 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -2,7 +2,7 @@ import { existsSync } from 'fs' import { lstat, readFile, readdir, stat } from 'fs/promises' import { basename, dirname, join, resolve, sep } from 'path' import { readSessionLines } from './fs-utils.js' -import { calculateCost, calculateLocalModelSavings, getShortModelName, isProxiedPath, getProxyPathsConfigHash, getModelAliasesConfigHash, getPriceOverridesConfigHash, getLocalModelSavingsConfigHash } from './models.js' +import { billableOutputTokens, calculateCost, calculateLocalModelSavings, getShortModelName, isProxiedPath, getProxyPathsConfigHash, getModelAliasesConfigHash, getPriceOverridesConfigHash, getLocalModelSavingsConfigHash } from './models.js' import { resolveSubagentAttribution, sessionIdentity } from './sessions-report.js' import { normalizeContentBlocks, flatSlice, flatString } from './content-utils.js' import { discoverAllSessions, getProvider } from './providers/index.js' @@ -2629,9 +2629,11 @@ function providerCallsToCachedTurns(calls: ParsedProviderCall[]): CachedTurn[] { function cachedCallToApiCall(call: CachedCall): ParsedApiCall { const u = call.usage - const outputForCost = call.provider === 'claude' - ? u.outputTokens - : u.outputTokens + u.reasoningTokens + // Cache-rehydration twin of the fresh-parse pricing in + // src/providers/codex.ts (and every other provider's parser): both go + // through billableOutputTokens so a cached read and a cold parse can never + // disagree about whether reasoning is already inside output (#1075). + const outputForCost = billableOutputTokens(call.provider, u.outputTokens, u.reasoningTokens) const costUSD = calculateCost( call.model, u.inputTokens, outputForCost, u.cacheCreationInputTokens, u.cacheReadInputTokens, diff --git a/src/providers/antigravity.ts b/src/providers/antigravity.ts index fad0f39d..7924d104 100644 --- a/src/providers/antigravity.ts +++ b/src/providers/antigravity.ts @@ -6,7 +6,7 @@ import { homedir } from 'os' import { fileURLToPath } from 'url' import https from 'https' -import { getCodeburnCacheDir } from '../cache-dir.js' +import { getCodeburnCacheDir, readExistingTextFile } from '../cache-dir.js' import { calculateCost } from '../models.js' import { isSqliteAvailable, isSqliteBusyError, openDatabase } from '../sqlite.js' import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' @@ -50,6 +50,11 @@ function conversationRoots(): readonly AntigravityConversationRoot[] { ] } const CACHE_VERSION = 5 +export const ANTIGRAVITY_CACHE_VERSION = CACHE_VERSION +export const ANTIGRAVITY_LEGACY_CACHE_FILE = 'antigravity-results.json' +export function antigravityCacheFileName(version = CACHE_VERSION): string { + return `antigravity-results.v${version}.json` +} const RPC_TIMEOUT_MS = 5000 const MAX_RESPONSE_BYTES = 16 * 1024 * 1024 @@ -190,7 +195,15 @@ function currentCacheDir(): string { } function getCachePath(cacheDir: string): string { - return join(cacheDir, 'antigravity-results.json') + return join(cacheDir, antigravityCacheFileName()) +} + +function getLegacyCachePath(cacheDir: string): string { + return join(cacheDir, ANTIGRAVITY_LEGACY_CACHE_FILE) +} + +function isCurrentCache(cache: AntigravityCache): boolean { + return cache.version === CACHE_VERSION && !!cache.cascades && typeof cache.cascades === 'object' } export function getAntigravityStatusLineEventsPath(): string { @@ -336,15 +349,42 @@ export function extractAntigravityGeneratorMetadata(resp: unknown): GeneratorMet async function loadCache(cacheDir: string): Promise { const inMemory = cacheStates.get(cacheDir) if (inMemory) return inMemory + const versioned = await readExistingTextFile(getCachePath(cacheDir)) + if (versioned.status === 'ok') { + try { + const cache = JSON.parse(versioned.text) as AntigravityCache + if (isCurrentCache(cache)) { + const state = { cache, dirty: false } + cacheStates.set(cacheDir, state) + return state + } + } catch { /* present but invalid */ } + const invalid: AntigravityCacheState = { + cache: { version: CACHE_VERSION, cascades: {} }, + dirty: false, + } + cacheStates.set(cacheDir, invalid) + return invalid + } + if (versioned.status === 'unreadable') { + const invalid: AntigravityCacheState = { + cache: { version: CACHE_VERSION, cascades: {} }, + dirty: false, + } + cacheStates.set(cacheDir, invalid) + return invalid + } + // Versioned file is absent (ENOENT). Adopt the unsuffixed file only when its + // version matches — old binaries still own that path; we never write or delete it. try { - const raw = await readFile(getCachePath(cacheDir), 'utf-8') + const raw = await readFile(getLegacyCachePath(cacheDir), 'utf-8') const cache = JSON.parse(raw) as AntigravityCache - if (cache.version === CACHE_VERSION && cache.cascades && typeof cache.cascades === 'object') { + if (isCurrentCache(cache)) { const state = { cache, dirty: false } cacheStates.set(cacheDir, state) return state } - } catch { /* no cache or invalid */ } + } catch { /* no legacy cache or invalid */ } const state: AntigravityCacheState = { cache: { version: CACHE_VERSION, cascades: {} }, dirty: false, diff --git a/src/providers/codex.ts b/src/providers/codex.ts index 439793b7..7693c7fa 100644 --- a/src/providers/codex.ts +++ b/src/providers/codex.ts @@ -5,7 +5,7 @@ import { basename, join } from 'path' import { homedir } from 'os' import { readSessionLines } from '../fs-utils.js' -import { calculateCost } from '../models.js' +import { billableOutputTokens, calculateCost, getModelCosts } from '../models.js' import { readCachedCodexResults, writeCachedCodexResults, getCachedCodexProject, fingerprintFile, type CodexFileFingerprint } from '../codex-cache.js' import { normalizeContentBlocks } from '../content-utils.js' import { estimateTokensFromChars } from '../token-estimate.js' @@ -126,6 +126,10 @@ type CodexEntry = { type CodexTokenUsage = { input_tokens?: number cached_input_tokens?: number + /// Portion of `input_tokens` that was WRITTEN to the prompt cache this call + /// (codex PR #33454). Like `cached_input_tokens`, it is carved out of + /// `input_tokens`, not added on top. + cache_write_input_tokens?: number output_tokens?: number reasoning_output_tokens?: number total_tokens?: number @@ -323,6 +327,7 @@ function getRawTokenUsage(head: string, field: 'last_token_usage' | 'total_token return { input_tokens: getRawJsonNumberField(body, 'input_tokens'), cached_input_tokens: getRawJsonNumberField(body, 'cached_input_tokens'), + cache_write_input_tokens: getRawJsonNumberField(body, 'cache_write_input_tokens'), output_tokens: getRawJsonNumberField(body, 'output_tokens'), reasoning_output_tokens: getRawJsonNumberField(body, 'reasoning_output_tokens'), total_tokens: getRawJsonNumberField(body, 'total_tokens'), @@ -593,6 +598,7 @@ type CodexResumeState = { prevCumulativeTotal: number | null prevInput: number prevCached: number + prevCacheWrite: number prevOutput: number prevReasoning: number pendingTools: string[] @@ -619,6 +625,7 @@ function isResumeState(value: unknown): value is CodexResumeState { && (v['prevCumulativeTotal'] === null || typeof v['prevCumulativeTotal'] === 'number') && typeof v['prevInput'] === 'number' && typeof v['prevCached'] === 'number' + && typeof v['prevCacheWrite'] === 'number' && typeof v['prevOutput'] === 'number' && typeof v['prevReasoning'] === 'number' && Array.isArray(v['pendingTools']) @@ -678,6 +685,7 @@ function createParser(source: SessionSource, seenKeys: Set, capture?: { let prevCumulativeTotal: number | null = resume?.state.prevCumulativeTotal ?? null let prevInput = resume?.state.prevInput ?? 0 let prevCached = resume?.state.prevCached ?? 0 + let prevCacheWrite = resume?.state.prevCacheWrite ?? 0 let prevOutput = resume?.state.prevOutput ?? 0 let prevReasoning = resume?.state.prevReasoning ?? 0 let pendingTools: string[] = resume ? [...resume.state.pendingTools] : [] @@ -795,6 +803,7 @@ function createParser(source: SessionSource, seenKeys: Set, capture?: { prevCumulativeTotal, prevInput, prevCached, + prevCacheWrite, prevOutput, prevReasoning, pendingTools: [...pendingTools], @@ -1014,12 +1023,14 @@ function createParser(source: SessionSource, seenKeys: Set, capture?: { const last = info.last_token_usage let inputTokens = 0 let cachedInputTokens = 0 + let cacheWriteTokens = 0 let outputTokens = 0 let reasoningTokens = 0 if (last) { inputTokens = last.input_tokens ?? 0 cachedInputTokens = last.cached_input_tokens ?? 0 + cacheWriteTokens = last.cache_write_input_tokens ?? 0 outputTokens = last.output_tokens ?? 0 reasoningTokens = last.reasoning_output_tokens ?? 0 } else if (cumulativeTotal > 0) { @@ -1027,6 +1038,7 @@ function createParser(source: SessionSource, seenKeys: Set, capture?: { if (!total) continue inputTokens = (total.input_tokens ?? 0) - prevInput cachedInputTokens = (total.cached_input_tokens ?? 0) - prevCached + cacheWriteTokens = (total.cache_write_input_tokens ?? 0) - prevCacheWrite outputTokens = (total.output_tokens ?? 0) - prevOutput reasoningTokens = (total.reasoning_output_tokens ?? 0) - prevReasoning } @@ -1042,6 +1054,7 @@ function createParser(source: SessionSource, seenKeys: Set, capture?: { if (total) { prevInput = total.input_tokens ?? 0 prevCached = total.cached_input_tokens ?? 0 + prevCacheWrite = total.cache_write_input_tokens ?? 0 prevOutput = total.output_tokens ?? 0 prevReasoning = total.reasoning_output_tokens ?? 0 } @@ -1053,7 +1066,22 @@ function createParser(source: SessionSource, seenKeys: Set, capture?: { // Normalize to Anthropic semantics: inputTokens = non-cached only. const uncachedInputTokens = Math.max(0, inputTokens - cachedInputTokens) + // Cache writes are carved out of the uncached input, never added to + // it: clamp so a malformed or lagging count can never drive the plain + // input bucket negative. + const cacheWriteInputTokens = Math.max(0, Math.min(cacheWriteTokens, uncachedInputTokens)) + const model = resolveModel(entry.payload, sessionModel) + // Only move tokens into the cache-write bucket when the pricing + // source publishes a real cache-write rate for this model (gpt-5.6+ + // charges 1.25x input; everything before it charges nothing extra). + // Otherwise buildCosts' fabricated 1.25x default would invent a + // surcharge that OpenAI never billed, so the tokens stay where they + // already were -- in plain input, priced exactly as before. + const billedCacheWriteTokens = cacheWriteInputTokens > 0 && getModelCosts(model)?.cacheWriteCostIsExplicit + ? cacheWriteInputTokens + : 0 + const billedInputTokens = uncachedInputTokens - billedCacheWriteTokens const timestamp = entry.timestamp ?? '' // Forked sessions copy the parent's entire token_count history // (re-timestamped), so replays must collide with the parent's events @@ -1074,11 +1102,15 @@ function createParser(source: SessionSource, seenKeys: Set, capture?: { if (seenKeys.has(dedupKey)) continue seenKeys.add(dedupKey) + // Reasoning tokens are already inside output_tokens, so they are NOT + // added here. The cache-rehydration twin of this line lives in + // src/parser.ts (cachedCallToApiCall); both call billableOutputTokens + // so a fresh parse and a cache read can never price differently. const costUSD = calculateCost( model, - uncachedInputTokens, - outputTokens + reasoningTokens, - 0, + billedInputTokens, + billableOutputTokens('codex', outputTokens, reasoningTokens), + billedCacheWriteTokens, cachedInputTokens, 0, ) @@ -1086,9 +1118,9 @@ function createParser(source: SessionSource, seenKeys: Set, capture?: { pendingTaskCalls.push({ provider: 'codex', model, - inputTokens: uncachedInputTokens, + inputTokens: billedInputTokens, outputTokens, - cacheCreationInputTokens: 0, + cacheCreationInputTokens: billedCacheWriteTokens, cacheReadInputTokens: cachedInputTokens, cachedInputTokens, reasoningTokens, diff --git a/src/providers/copilot.ts b/src/providers/copilot.ts index e472fdb7..10447cd7 100644 --- a/src/providers/copilot.ts +++ b/src/providers/copilot.ts @@ -759,8 +759,9 @@ function createJsonlParser( // CUMULATIVE per-model totals. Emitting each rollup whole would need the // cache to update a prior call in place — the durable merge is // append-only by dedup key — so we emit per-leg DELTAS keyed by - // occurrence instead: re-parses of a growing file append only the new - // leg, and each leg lands on its own timestamp. + // occurrence (`:n`): re-parses of a growing file append only the new + // leg, and each leg lands on its own timestamp. Discovery only yields + // `/events.jsonl`, so two journals cannot share a session id. const prevShutdownUsage = new Map() const shutdownCountByModel = new Map() @@ -841,8 +842,14 @@ function createJsonlParser( const modelMetrics = shutdownData.modelMetrics if (!isRecord(modelMetrics)) continue + // Prefer lastEventTimestamp over sessionStartTime. sessionStartTime + // is identical for every stampless leg of a resumed session, so + // using it for the call timestamp (or, previously, the key) collapsed + // those legs onto one date. lastEventTimestamp is the last stamped + // event in this journal — distinct per leg when intervening events + // are stamped, and still a real time when they are not. const shutdownTimestamp = - (event.timestamp ?? '') || timestampToISO(shutdownData.sessionStartTime) || lastEventTimestamp + (event.timestamp ?? '') || lastEventTimestamp || timestampToISO(shutdownData.sessionStartTime) for (const [model, metrics] of Object.entries(modelMetrics)) { if (!model || !isRecord(metrics)) continue diff --git a/src/providers/grok.ts b/src/providers/grok.ts index 4724a8af..804bb4f1 100644 --- a/src/providers/grok.ts +++ b/src/providers/grok.ts @@ -405,12 +405,15 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars inputTokens: parsed.usage.input, // Grok reports reasoning INSIDE outputTokens, but the repo contract is // the opposite: ParsedProviderCall.reasoningTokens is exclusive of - // outputTokens, and every consumer sums the two (parser.ts's - // cachedCallToApiCall for cost, modelBreakdown for tokens, and the - // models/audit reports). tests/providers/kiro.test.ts states it - // outright. So split it here rather than special-casing grok in five - // downstream places: subtracting reasoning makes `output + reasoning` - // reconstruct exactly the number Grok reported. + // outputTokens. Downstream consumers reconstitute the billable total + // through billableOutputTokens() (models.ts): it adds reasoning back on + // top for grok and every other provider, except the + // REASONING_INCLUDED_IN_OUTPUT set (claude, codex) whose reasoning is + // already inside output_tokens and must not be added again. + // tests/providers/kiro.test.ts states the exclusive contract outright. + // So split it here rather than special-casing grok in every downstream + // site: subtracting reasoning makes `output + reasoning` reconstruct + // exactly the number Grok reported. outputTokens: parsed.usage.output - reasoningTokens, cacheCreationInputTokens: parsed.usage.cacheCreation, cacheReadInputTokens: parsed.usage.cacheRead, diff --git a/src/providers/index.ts b/src/providers/index.ts index 70af252c..0eaadded 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -199,6 +199,29 @@ const coreProviders: Provider[] = [claude, cline, clineCli, codewhale, codebuff, // when an optional module fails to load. Must stay in sync with getAllProviders. const lazyProviderNames = ['antigravity', 'forge', 'goose', 'cursor', 'opencode', 'cursor-agent', 'crush', 'warp', 'vercel-gateway', 'zcode', 'zed'] +// Display names for lazy providers. Must match the `displayName` on the +// loaded Provider object; `providerDisplayName` + getAllProviders() test +// is the drift check. +const lazyProviderDisplayNames: Record = { + antigravity: 'Antigravity', + forge: 'Forge', + goose: 'Goose', + cursor: 'Cursor', + opencode: 'OpenCode', + 'cursor-agent': 'Cursor Agent', + crush: 'Crush', + warp: 'Warp', + 'vercel-gateway': 'Vercel AI Gateway', + zcode: 'ZCode', + zed: 'Zed', +} + +export function providerDisplayName(name: string): string { + const core = coreProviders.find(p => p.name === name) + if (core) return core.displayName + return lazyProviderDisplayNames[name] ?? name +} + // Canonical set of every provider name (core + lazy), used to validate the // --provider CLI flag. Computed lazily so importing this module never depends on // every provider object being defined at load time (e.g. under test mocks). diff --git a/src/session-cache.ts b/src/session-cache.ts index 9326bdcf..af6c275e 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -281,15 +281,25 @@ export const PROVIDER_PARSE_VERSIONS: Record = { // nested base_instructions provenance.model cannot overwrite turn_context. // session-meta-fields-v1: the same depth-1 window for cwd/name/originator/ // session_id/forked_from_id/model_provider, not just model. (#1055) + // codex-pricing-v1 (#1075): reasoning tokens are no longer added on top of + // output, and cache_write_input_tokens moves out of the plain input bucket on + // models with an explicit cache-write rate. The bucket move does NOT self-heal + // on read (cached entries store the buckets, not the raw event), so cached + // sessions must re-parse. // activity-price-v1: `codex-auto-review` now prices via the recommended // review model. session-cache.json would otherwise keep the pre-alias $0. - // Union both tokens — a take-ours merge would drop #1055's invalidation. - codex: 'mcp-attribution-v5-est-cost-active-timing-mcp-wait-rich-capture-v1-cross-provider-pr-v1-session-meta-model-v1-session-meta-fields-v1-activity-price-v1', + // Compose both — a take-ours merge would drop #1075's invalidation. + codex: 'mcp-attribution-v5-est-cost-active-timing-mcp-wait-rich-capture-v1-cross-provider-pr-v1-session-meta-model-v1-session-meta-fields-v1-codex-pricing-v1-activity-price-v1', cursor: 'composer-anchored-crediting-v1-est-cost', 'cursor-agent': 'workspaceless-transcript-v1', // source-provenance-v1 (#944): CLI sessions were misread as VS Code // transcripts (both carry producer 'copilot-agent'), skipping the shutdown // input/cache rollup; this bump re-parses them so the missing tokens land. + // #1051 does NOT bump this again. A fingerprint change drops every present + // Copilot source (parser.ts getOrCreateProviderSection) and would erase + // conversations already pruned from a still-present OTel DB. Old `:n` + // shutdown keys migrate via cachedFileNeedsProviderReparse + a durable + // strip of legacy shutdown calls on that JSONL file only. copilot: 'cli-shutdown-cost-v1-skills-source-provenance-v1', // authoritative-usage-v4: persist one Grok session call from top-level // authoritative totals, use modelUsage only for priced attribution, clamp diff --git a/src/sync/otlp.ts b/src/sync/otlp.ts index 8df36c59..03c7f11c 100644 --- a/src/sync/otlp.ts +++ b/src/sync/otlp.ts @@ -154,7 +154,7 @@ export const COMMIT_ATTRIBUTION_SPAN_NAME = 'codeburn.commit' * encodes the mutable state (inMain/wasReverted for commits; repo, PR links, * and commit set for sessions), so a state TRANSITION mints a new key and the * updated fact is re-sent on the next push — the receiver upserts by - * (repo, sha) / (session). Identical states dedupe via the sent-ledger. + * (repo, sha) / traceId. Identical states dedupe via the sent-ledger. */ export type AttributionItem = { kind: 'session' | 'commit' @@ -256,7 +256,6 @@ export function buildAttributionOtlpPayload(items: AttributionItem[]): OtlpPaylo const endNano = (rawEndNano > minEndNano ? rawEndNano : minEndNano).toString() const attributes: OtlpAttribute[] = [ - { key: 'ai.session_id', value: { stringValue: item.sessionId } }, { key: 'ai.project', value: { stringValue: item.project } }, ] if (item.repo) { diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index 4350413b..78b1d483 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -609,9 +609,9 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: } claudeConfigs = claudeConfigs ?? await claudeConfigSelector(scanProjects, null) - // Codex credits for the period. Reuses the models aggregation (folds reasoning - // into output, keeps non-cached input + cached-read separate) so the figure - // matches the official credit rates. + // Codex credits for the period. Reuses the models aggregation (billable output + // already includes reasoning for codex, keeps non-cached input + cached-read + // separate) so the figure matches the official credit rates. const modelRows = await aggregateModels(scanProjects) currentData.codexCredits = modelRows.reduce( (sum, r) => sum + (r.provider === 'codex' && r.credits != null ? r.credits : 0), diff --git a/tests/audit-report.test.ts b/tests/audit-report.test.ts index 1723712c..baf89440 100644 --- a/tests/audit-report.test.ts +++ b/tests/audit-report.test.ts @@ -86,8 +86,11 @@ describe('aggregateAudit', () => { expect(r.raw.reasoningTokens).toBe(10) expect(r.raw.cacheReadInputTokens).toBe(200) expect(r.raw.cachedInputTokens).toBe(300) - // reasoning folds into output for pricing - expect(r.displayed.outputTokens).toBe(110) + // Reasoning does NOT fold into output for claude or codex: both bill it + // as part of output_tokens already, so adding it would double-count + // (#1075). Providers that report reasoning as a separate bucket still get + // the additive treatment - see tests/codex-pricing-1075.test.ts. + expect(r.displayed.outputTokens).toBe(100) // cache read is the SUM of per-call max(anthropic, openai), not max of sums expect(r.displayed.cacheReadTokens).toBe(500) // attributed cost is preserved exactly diff --git a/tests/cache-directory-switch.test.ts b/tests/cache-directory-switch.test.ts index 175cd828..240c9831 100644 --- a/tests/cache-directory-switch.test.ts +++ b/tests/cache-directory-switch.test.ts @@ -7,12 +7,14 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { clearCodexMemCaches, + codexCacheFileName, fingerprintFile, flushCodexCache, readCachedCodexResults, writeCachedCodexResults, } from '../src/codex-cache.js' import { + antigravityCacheFileName, clearAntigravityCacheStates, createAntigravityProvider, flushAntigravityCache, @@ -53,7 +55,7 @@ async function seedAntigravityCache( ): Promise { const sourceStat = await stat(sourcePath) await mkdir(cacheDir, { recursive: true }) - await writeFile(join(cacheDir, 'antigravity-results.json'), JSON.stringify({ + await writeFile(join(cacheDir, antigravityCacheFileName()), JSON.stringify({ version: 5, cascades: { shared: { @@ -107,7 +109,7 @@ describe('call-time CODEBURN_CACHE_DIR isolation', () => { await writeCachedCodexResults(sourcePath, 'project-b', [call('codex', 'from-b')], fingerprint!) await flushCodexCache() - const diskB = JSON.parse(await readFile(join(cacheB, 'codex-results.json'), 'utf8')) + const diskB = JSON.parse(await readFile(join(cacheB, codexCacheFileName()), 'utf8')) expect(diskB.files[sourcePath].calls.map((entry: ParsedProviderCall) => entry.model)).toEqual(['from-b']) process.env['CODEBURN_CACHE_DIR'] = cacheA @@ -129,12 +131,12 @@ describe('call-time CODEBURN_CACHE_DIR isolation', () => { await writeCachedCodexResults(sourceB, 'project-b', [call('codex', 'dirty-b')], (await fingerprintFile(sourceB))!) await flushCodexCache() - const diskB = JSON.parse(await readFile(join(cacheB, 'codex-results.json'), 'utf8')) + const diskB = JSON.parse(await readFile(join(cacheB, codexCacheFileName()), 'utf8')) expect(Object.keys(diskB.files)).toEqual([sourceB]) process.env['CODEBURN_CACHE_DIR'] = cacheA await flushCodexCache() - const diskA = JSON.parse(await readFile(join(cacheA, 'codex-results.json'), 'utf8')) + const diskA = JSON.parse(await readFile(join(cacheA, codexCacheFileName()), 'utf8')) expect(Object.keys(diskA.files)).toEqual([sourceA]) }) @@ -200,9 +202,9 @@ describe('call-time CODEBURN_CACHE_DIR isolation', () => { expect(projects.some(project => project.sessions.some(session => session.turns.some(turn => turn.assistantCalls.some(entry => entry.provider === 'codex')) ))).toBe(true) - expect(existsSync(join(cacheA, 'codex-results.json'))).toBe(true) - expect(existsSync(join(cacheB, 'codex-results.json'))).toBe(false) - const diskA = JSON.parse(await readFile(join(cacheA, 'codex-results.json'), 'utf8')) + expect(existsSync(join(cacheA, codexCacheFileName()))).toBe(true) + expect(existsSync(join(cacheB, codexCacheFileName()))).toBe(false) + const diskA = JSON.parse(await readFile(join(cacheA, codexCacheFileName()), 'utf8')) expect(diskA.files[sourcePath].calls).toHaveLength(1) clearSessionCache() }) @@ -238,10 +240,10 @@ describe('call-time CODEBURN_CACHE_DIR isolation', () => { process.env['CODEBURN_CACHE_DIR'] = cacheB await flushAntigravityCache(new Set(), cacheA) - expect(existsSync(join(cacheB, 'antigravity-results.json'))).toBe(true) - const diskB = JSON.parse(await readFile(join(cacheB, 'antigravity-results.json'), 'utf8')) + expect(existsSync(join(cacheB, antigravityCacheFileName()))).toBe(true) + const diskB = JSON.parse(await readFile(join(cacheB, antigravityCacheFileName()), 'utf8')) expect(diskB.cascades.shared.calls[0].model).toBe('from-b') - const diskA = JSON.parse(await readFile(join(cacheA, 'antigravity-results.json'), 'utf8')) + const diskA = JSON.parse(await readFile(join(cacheA, antigravityCacheFileName()), 'utf8')) expect(diskA.cascades).toEqual({}) }) @@ -261,9 +263,9 @@ describe('call-time CODEBURN_CACHE_DIR isolation', () => { // Another process republishes both cache files. Without the clear, the // resident keeps serving its warm copies. await seedAntigravityCache(cacheDir, antigravitySource, 'after') - const codexDisk = JSON.parse(await readFile(join(cacheDir, 'codex-results.json'), 'utf8')) + const codexDisk = JSON.parse(await readFile(join(cacheDir, codexCacheFileName()), 'utf8')) codexDisk.files[codexSource].calls[0].model = 'after' - await writeFile(join(cacheDir, 'codex-results.json'), JSON.stringify(codexDisk)) + await writeFile(join(cacheDir, codexCacheFileName()), JSON.stringify(codexDisk)) expect((await readCachedCodexResults(codexSource))?.calls.map(entry => entry.model)).toEqual(['before']) expect(await readAntigravityModel(antigravitySource)).toBe('before') diff --git a/tests/cache-refresh-lock-process.test.ts b/tests/cache-refresh-lock-process.test.ts index d837f81e..752f332e 100644 --- a/tests/cache-refresh-lock-process.test.ts +++ b/tests/cache-refresh-lock-process.test.ts @@ -79,7 +79,12 @@ describe('warm refresh child-process regression', () => { const loserOutcome = await waitForAny(barriers, [ `${loser}.timed-out`, `${loser}.parsed`, `${loser}.completed-by-other`, `${loser}.unavailable`, ]) - expect(loserOutcome, (await readdir(barriers)).join(',')).toBe(`${loser}.timed-out`) + // Exactly one owner publishes. The loser of a stale-lock contest is not + // the owner: `timed-out` is the usual wait-out, but missing+missing during + // the winner's unlink-guard/create-successor gap is honestly + // `completed-by-other` (#904). Do not require timed-out. + expect(loserOutcome, (await readdir(barriers)).join(',')).not.toBe(`${loser}.parsed`) + expect([`${loser}.timed-out`, `${loser}.completed-by-other`, `${loser}.unavailable`]).toContain(loserOutcome) await writeFile(join(barriers, `${winner}.save`), '') await Promise.all([waitForExit(a), waitForExit(b)]) await expect(stat(join(cacheDir, 'session-refresh.lock.takeover'))).rejects.toMatchObject({ code: 'ENOENT' }) @@ -113,7 +118,12 @@ describe('warm refresh child-process regression', () => { const loserOutcome = await waitForAny(barriers, [ `${loser}.timed-out`, `${loser}.parsed`, `${loser}.completed-by-other`, `${loser}.unavailable`, ]) - expect(loserOutcome, (await readdir(barriers)).join(',')).toBe(`${loser}.timed-out`) + // Exactly one owner publishes. The loser of a stale-lock contest is not + // the owner: `timed-out` is the usual wait-out, but missing+missing during + // the winner's unlink-guard/create-successor gap is honestly + // `completed-by-other` (#904). Do not require timed-out. + expect(loserOutcome, (await readdir(barriers)).join(',')).not.toBe(`${loser}.parsed`) + expect([`${loser}.timed-out`, `${loser}.completed-by-other`, `${loser}.unavailable`]).toContain(loserOutcome) await writeFile(join(barriers, `${winner}.save`), '') await Promise.all([waitForExit(a), waitForExit(b)]) await expect(stat(join(cacheDir, 'session-refresh.lock.takeover'))).rejects.toMatchObject({ code: 'ENOENT' }) diff --git a/tests/codex-cache-invalidation.test.ts b/tests/codex-cache-invalidation.test.ts index 35b80f7f..1dabc9a1 100644 --- a/tests/codex-cache-invalidation.test.ts +++ b/tests/codex-cache-invalidation.test.ts @@ -88,7 +88,8 @@ describe('codex parser change invalidates stale session-cache (#478/#513)', () = } } await writeCacheOnDisk(cache) - const codexCachePath = join(CACHE_DIR, 'codex-results.json') + const { codexCacheFileName } = await import('../src/codex-cache.js') + const codexCachePath = join(CACHE_DIR, codexCacheFileName()) const codexCache = JSON.parse(await readFile(codexCachePath, 'utf8')) codexCache.version = 4 for (const f of Object.values(codexCache.files) as any[]) { diff --git a/tests/codex-credits.test.ts b/tests/codex-credits.test.ts index 29147dc7..f8bdc95b 100644 --- a/tests/codex-credits.test.ts +++ b/tests/codex-credits.test.ts @@ -38,11 +38,6 @@ describe('codexCredits', () => { expect(codexCredits('gpt-5.5', { inputTokens: 0, cachedReadTokens: 1_000_000, outputTokens: 0 })).toBe(12.5) }) - it('folds reasoning tokens into the output rate', () => { - // 500k output + 500k reasoning = 1M output-billed => 750 credits. - expect(codexCredits('gpt-5.5', { inputTokens: 0, cachedReadTokens: 0, outputTokens: 500_000, reasoningTokens: 500_000 })).toBe(750) - }) - it('sums a mixed record (gpt-5.4)', () => { // 2M input (125) + 1M cached (6.25) + 0.5M output (187.5) = 318.75 const credits = codexCredits('gpt-5.4', { inputTokens: 2_000_000, cachedReadTokens: 1_000_000, outputTokens: 500_000 }) diff --git a/tests/codex-pricing-1075-rehydrate.test.ts b/tests/codex-pricing-1075-rehydrate.test.ts new file mode 100644 index 00000000..4b112347 --- /dev/null +++ b/tests/codex-pricing-1075-rehydrate.test.ts @@ -0,0 +1,69 @@ +// #1075, cost site 2 of 2. Codex is NOT on parser.ts's reported-cost +// pass-through allowlist, so the session cache stores its calls with +// `costUSD: undefined` and every warm run re-prices them from the stored token +// buckets in cachedCallToApiCall. That line and the one in the codex provider +// are twins: if only one drops the reasoning double-count, a user's number +// changes between a cold and a warm run. This drives the full parseAllSessions +// pipeline twice against the same file to prove they agree. +// +// Own file because the codex provider captures CODEX_HOME when its module is +// first evaluated, so the env must be set before any import of it. + +import { afterAll, beforeEach, expect, it, vi } from 'vitest' +import { mkdir, rm, writeFile } from 'fs/promises' +import { join } from 'path' + +const testRoot = vi.hoisted(() => { + const root = `${process.env['TMPDIR'] || '/tmp'}/codex-1075-rehydrate-${process.pid}-${Date.now()}` + process.env['HOME'] = `${root}/home` + process.env['USERPROFILE'] = `${root}/home` + process.env['CODEX_HOME'] = `${root}/codex` + return root +}) + +const CODEX_HOME = join(testRoot, 'codex') +const CACHE_DIR = join(testRoot, 'cache') + +// gpt-5.5: input 5e-6, output 30e-6, cacheRead 5e-7 (src/data/litellm-snapshot.json). +// 800 uncached input + 200 cached + 1000 output, of which 400 are reasoning. +const EXPECTED = 800 * 5e-6 + 200 * 5e-7 + 1000 * 30e-6 + +beforeEach(() => { + process.env['HOME'] = join(testRoot, 'home') + process.env['USERPROFILE'] = join(testRoot, 'home') + process.env['CODEX_HOME'] = CODEX_HOME + process.env['CODEBURN_CACHE_DIR'] = CACHE_DIR +}) + +afterAll(async () => { + await rm(testRoot, { recursive: true, force: true }) +}) + +it('prices a codex call the same on a cold parse and a cache-rehydrated read', async () => { + const sessionDir = join(CODEX_HOME, 'sessions', '2026', '08', '16') + await mkdir(sessionDir, { recursive: true }) + await mkdir(CACHE_DIR, { recursive: true }) + const usage = { input_tokens: 1000, cached_input_tokens: 200, output_tokens: 1000, reasoning_output_tokens: 400, total_tokens: 2000 } + await writeFile(join(sessionDir, 'rollout-1075.jsonl'), [ + JSON.stringify({ type: 'session_meta', timestamp: '2026-08-16T10:00:00Z', payload: { session_id: 's1075', model: 'gpt-5.5', cwd: '/Users/test/proj', originator: 'codex_cli_rs' } }), + JSON.stringify({ type: 'response_item', timestamp: '2026-08-16T10:00:10Z', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'hello' }] } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-08-16T10:01:00Z', payload: { type: 'token_count', info: { model: 'gpt-5.5', last_token_usage: usage, total_token_usage: usage } } }), + ].join('\n') + '\n') + + const { clearSessionCache, parseAllSessions } = await import('../src/parser.js') + + clearSessionCache() + const cold = await parseAllSessions(undefined, 'codex') + const coldCost = cold.reduce((sum, p) => sum + p.totalCostUSD, 0) + + // Drop the in-memory cache only: session-cache.json on disk now serves the + // unchanged file, so this run's cost comes out of cachedCallToApiCall. + clearSessionCache() + const warm = await parseAllSessions(undefined, 'codex') + const warmCost = warm.reduce((sum, p) => sum + p.totalCostUSD, 0) + + // Revert only src/providers/codex.ts and the cold leg breaks; revert only + // src/parser.ts's outputForCost and the warm leg breaks. + expect(coldCost).toBeCloseTo(EXPECTED, 12) + expect(warmCost).toBeCloseTo(EXPECTED, 12) +}) diff --git a/tests/codex-pricing-1075.test.ts b/tests/codex-pricing-1075.test.ts new file mode 100644 index 00000000..2ed6f124 --- /dev/null +++ b/tests/codex-pricing-1075.test.ts @@ -0,0 +1,353 @@ +// Regression suite for #1075 (reported by chr-evensen). +// +// Two independent codex pricing bugs, each with the site that would silently +// drift from its twin if only one half were reverted: +// +// A. reasoning_output_tokens is a SUBSET of output_tokens (OpenAI bills +// reasoning as part of output; every token_count event in a 134k-event +// corpus satisfies input + output == total), but codeburn added the two. +// Priced in TWO places -- the fresh parse in src/providers/codex.ts and +// the cache-rehydration re-price in src/parser.ts -- plus three display +// sums. Both cost sites now go through billableOutputTokens(). The +// cache-rehydration half lives in codex-pricing-1075-rehydrate.test.ts, +// which needs CODEX_HOME set before the provider module is evaluated. +// +// B. cache_write_input_tokens was never read. It is now carved out of the +// uncached-input bucket, but ONLY on models whose pricing source carries +// an explicit cache-write rate: buildCosts() fabricates 1.25x input when +// the source omits one, which is right for Anthropic but would invent a +// surcharge OpenAI never charged on every pre-5.6 model. + +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { aggregateAudit } from '../src/audit-report.js' +import { aggregateModels } from '../src/models-report.js' +import { clearCodexMemCaches, readCachedCodexResults } from '../src/codex-cache.js' +import { currentTzKey, ensureCacheHydrated, toDateString, type DailyEntry } from '../src/daily-cache.js' +import { createCodexProvider } from '../src/providers/codex.js' +import type { ParsedProviderCall } from '../src/providers/types.js' +import type { + ClassifiedTurn, + ParsedApiCall, + ProjectSummary, + SessionSummary, + TaskCategory, + TokenUsage, +} from '../src/types.js' + +// Snapshot ground truth (src/data/litellm-snapshot.json), USD per token: +// gpt-5.6-terra input 2e-6 output 12e-6 cacheWrite 2.5e-6 (EXPLICIT) cacheRead 2e-7 +// gpt-5.5 input 5e-6 output 30e-6 cacheWrite null (fabricated) cacheRead 5e-7 +const TERRA = { input: 2e-6, output: 12e-6, cacheWrite: 2.5e-6, cacheRead: 2e-7 } +const GPT55 = { input: 5e-6, output: 30e-6, cacheRead: 5e-7 } + +let tmpDir: string +beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'codex-1075-')) }) +afterEach(async () => { await rm(tmpDir, { recursive: true, force: true }) }) + +type Usage = { + input_tokens: number + cached_input_tokens?: number + cache_write_input_tokens?: number + output_tokens: number + reasoning_output_tokens?: number +} + +async function parseOneEvent(model: string, usage: Usage): Promise { + const total = usage.input_tokens + usage.output_tokens + const sessionDir = join(tmpDir, 'sessions', '2026', '08', '16') + await mkdir(sessionDir, { recursive: true }) + const filePath = join(sessionDir, `rollout-${model}-${Math.random().toString(36).slice(2)}.jsonl`) + await writeFile(filePath, [ + JSON.stringify({ + type: 'session_meta', + timestamp: '2026-08-16T10:00:00Z', + payload: { cwd: '/Users/t/p', originator: 'codex-cli', session_id: 's1075', model }, + }), + JSON.stringify({ + type: 'event_msg', + timestamp: '2026-08-16T10:01:00Z', + payload: { + type: 'token_count', + info: { model, last_token_usage: { ...usage, total_tokens: total }, total_token_usage: { ...usage, total_tokens: total } }, + }, + }), + ].join('\n') + '\n') + + const provider = createCodexProvider(tmpDir) + const parser = provider.createSessionParser({ path: filePath, project: 'test', provider: 'codex' }, new Set()) + const calls: ParsedProviderCall[] = [] + for await (const call of parser.parse()) calls.push(call) + expect(calls).toHaveLength(1) + return calls[0]! +} + +// ── Fix A: reasoning is already inside output ───────────────────────────── + +describe('#1075 A - reasoning is not billed on top of output', () => { + it('prices a fresh codex parse from output_tokens alone', async () => { + const call = await parseOneEvent('gpt-5.5', { + input_tokens: 1000, + cached_input_tokens: 200, + output_tokens: 1000, + reasoning_output_tokens: 400, + }) + + // 800 uncached input + 200 cached + 1000 output. The 400 reasoning tokens + // are INSIDE the 1000, so they must not be priced again. + const expected = 800 * GPT55.input + 200 * GPT55.cacheRead + 1000 * GPT55.output + expect(call.costUSD).toBeCloseTo(expected, 12) + // Guard the direction: the pre-fix arithmetic charged 1400 output tokens. + const preFix = 800 * GPT55.input + 200 * GPT55.cacheRead + 1400 * GPT55.output + expect(call.costUSD).toBeLessThan(preFix) + // The raw fields are still reported untouched; only the pricing changed. + expect(call.outputTokens).toBe(1000) + expect(call.reasoningTokens).toBe(400) + }) + + it('does not double-count reasoning in the displayed output tokens', async () => { + const codex = makeApiCall('codex', 'gpt-5.5', { outputTokens: 1000, reasoningTokens: 400 }) + // A provider that really does report reasoning as a separate bucket keeps + // the additive behaviour, so this is a codex carve-out and not a blanket + // change to every display sum. Gemini documents "thoughts" as genuinely + // separate from output (src/providers/gemini.ts), unlike codex/claude. + const additive = makeApiCall('gemini', 'gemini-2.5-pro', { outputTokens: 1000, reasoningTokens: 400 }) + const projects = [makeProject([codex, additive])] + + const auditRows = await aggregateAudit(projects) + expect(auditRows.find(r => r.provider === 'codex')!.displayed.outputTokens).toBe(1000) + expect(auditRows.find(r => r.provider === 'gemini')!.displayed.outputTokens).toBe(1400) + + const modelRows = await aggregateModels(projects) + expect(modelRows.find(r => r.provider === 'codex')!.outputTokens).toBe(1000) + expect(modelRows.find(r => r.provider === 'gemini')!.outputTokens).toBe(1400) + }) +}) + +// ── Fix B: cache_write_input_tokens, guarded ────────────────────────────── + +describe('#1075 B - cache_write_input_tokens', () => { + it('prices cache writes at the explicit rate on gpt-5.6-terra', async () => { + const call = await parseOneEvent('gpt-5.6-terra', { + input_tokens: 1000, + cached_input_tokens: 200, + cache_write_input_tokens: 300, + output_tokens: 100, + }) + + expect(call.inputTokens).toBe(500) + expect(call.cacheCreationInputTokens).toBe(300) + expect(call.cacheReadInputTokens).toBe(200) + const expected = + 500 * TERRA.input + + 300 * TERRA.cacheWrite + + 200 * TERRA.cacheRead + + 100 * TERRA.output + expect(expected).toBeCloseTo(0.00299, 12) + expect(call.costUSD).toBeCloseTo(expected, 12) + }) + + it('THE GUARD: leaves cache writes in the input bucket when the model has no explicit rate', async () => { + // gpt-5.5 carries `null` for cache_creation_input_token_cost, so + // buildCosts fabricates 1.25x input for it. OpenAI charges nothing extra + // to write cache before gpt-5.6, so routing these tokens through that + // fabricated rate would invent a surcharge. Cost must be byte-identical to + // the pre-fix number. Delete the guard and this test fails. + const withWrite = await parseOneEvent('gpt-5.5', { + input_tokens: 1000, + cached_input_tokens: 200, + cache_write_input_tokens: 300, + output_tokens: 100, + }) + const withoutWrite = await parseOneEvent('gpt-5.5', { + input_tokens: 1000, + cached_input_tokens: 200, + output_tokens: 100, + }) + + expect(withWrite.inputTokens).toBe(800) + expect(withWrite.cacheCreationInputTokens).toBe(0) + const expected = 800 * GPT55.input + 200 * GPT55.cacheRead + 100 * GPT55.output + expect(withWrite.costUSD).toBeCloseTo(expected, 12) + expect(withWrite.costUSD).toBeCloseTo(withoutWrite.costUSD, 12) + // The fabricated rate is 1.25 x 5e-6; make sure not a cent of it landed. + expect(withWrite.costUSD).toBeLessThan(expected + 300 * GPT55.input * 1.25) + }) + + it('clamps a cache-write count larger than the uncached input', async () => { + const call = await parseOneEvent('gpt-5.6-terra', { + input_tokens: 1000, + cached_input_tokens: 200, + cache_write_input_tokens: 5000, + output_tokens: 100, + }) + + expect(call.inputTokens).toBe(0) + expect(call.cacheCreationInputTokens).toBe(800) + expect(call.costUSD).toBeCloseTo(800 * TERRA.cacheWrite + 200 * TERRA.cacheRead + 100 * TERRA.output, 12) + }) +}) + +// ── Cache invalidation: a cost change must not be served from stale bytes ── + +describe('#1075 cache invalidation', () => { + it('discards a v10 codex results cache (it stores costUSD verbatim)', async () => { + const cacheDir = join(tmpDir, 'cache') + await mkdir(cacheDir, { recursive: true }) + const sessionFile = join(tmpDir, 'rollout-stale.jsonl') + await writeFile(sessionFile, '{}\n') + + const { statSync } = await import('fs') + const s = statSync(sessionFile) + const stale: ParsedProviderCall = { + provider: 'codex', + model: 'gpt-5.5', + inputTokens: 800, + outputTokens: 1000, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 200, + cachedInputTokens: 200, + reasoningTokens: 400, + webSearchRequests: 0, + costUSD: 0.0445, // the pre-fix, reasoning-double-counted number + tools: [], + bashCommands: [], + timestamp: '2026-08-16T10:01:00Z', + speed: 'standard', + deduplicationKey: 'codex:stale', + } + await writeFile(join(cacheDir, 'codex-results.json'), JSON.stringify({ + version: 10, + files: { [sessionFile]: { dev: s.dev, ino: s.ino, mtimeMs: s.mtimeMs, sizeBytes: s.size, project: 'p', calls: [stale] } }, + })) + + const prevCacheDir = process.env['CODEBURN_CACHE_DIR'] + process.env['CODEBURN_CACHE_DIR'] = cacheDir + try { + clearCodexMemCaches() + // Revert CODEX_CACHE_VERSION to 10 and this returns the stale $0.0445 call. + expect(await readCachedCodexResults(sessionFile)).toBeNull() + } finally { + if (prevCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR']; else process.env['CODEBURN_CACHE_DIR'] = prevCacheDir + } + }) + + it('re-derives days finalized at daily-cache v20', async () => { + const cacheRoot = join(tmpDir, 'daily') + await mkdir(cacheRoot, { recursive: true }) + const prevCacheDir = process.env['CODEBURN_CACHE_DIR'] + process.env['CODEBURN_CACHE_DIR'] = cacheRoot + try { + const date = toDateString(new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)) + const yesterday = toDateString(new Date(Date.now() - 24 * 60 * 60 * 1000)) + const oldPath = join(cacheRoot, 'daily-cache.v20.json') + const oldCache = { + version: 20, + savingsConfigHash: 'cfg', + tzKey: currentTzKey(), + lastComputedDate: yesterday, + days: [codexDay(date, 99)], + complete: true, + watermarkTrusted: true, + } + await writeFile(oldPath, JSON.stringify(oldCache)) + + let parseCount = 0 + const hydrated = await ensureCacheHydrated( + async () => { parseCount++; return [] }, + () => [codexDay(date, 2)], + 'cfg', + () => true, + ) + + // Drop MIN_SUPPORTED_VERSION back to 20 and the v20 day is trusted as-is, + // so parseCount stays 0 and the day keeps its overstated $99. + expect(parseCount).toBe(1) + expect(hydrated.days.find(d => d.date === date)?.cost).toBe(2) + expect(JSON.parse(await readFile(oldPath, 'utf8'))).toEqual(oldCache) + } finally { + if (prevCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR']; else process.env['CODEBURN_CACHE_DIR'] = prevCacheDir + } + }) +}) + +// ── fixtures ────────────────────────────────────────────────────────────── + +function makeApiCall(provider: string, model: string, usage: Partial): ParsedApiCall { + return { + provider, + model, + usage: { + inputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + ...usage, + }, + costUSD: 0, + tools: [], + mcpTools: [], + skills: [], + hasAgentSpawn: false, + hasPlanMode: false, + speed: 'standard', + timestamp: '2026-08-16T00:00:00.000Z', + bashCommands: [], + deduplicationKey: `${provider}-${model}`, + } +} + +function makeProject(calls: ParsedApiCall[]): ProjectSummary { + const turn: ClassifiedTurn = { + userMessage: 't', + assistantCalls: calls, + timestamp: '2026-08-16T00:00:00.000Z', + sessionId: 's1', + category: 'feature' as TaskCategory, + retries: 0, + hasEdits: false, + } + const session: SessionSummary = { + sessionId: 's1', + project: 'p', + firstTimestamp: '2026-08-16T00:00:00.000Z', + lastTimestamp: '2026-08-16T00:00:00.000Z', + totalCostUSD: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + apiCalls: 0, + turns: [turn], + modelBreakdown: {}, + toolBreakdown: {}, + mcpBreakdown: {}, + bashBreakdown: {}, + categoryBreakdown: {} as SessionSummary['categoryBreakdown'], + skillBreakdown: {}, + } + return { project: 'p', projectPath: 'p', sessions: [session], totalCostUSD: 0, totalApiCalls: 0 } +} + +function codexDay(date: string, cost: number): DailyEntry { + const tokens = { inputTokens: 100, outputTokens: 20, cacheReadTokens: 30, cacheWriteTokens: 0 } + return { + date, + cost, + savingsUSD: 0, + calls: 1, + sessions: 1, + ...tokens, + editTurns: 0, + oneShotTurns: 0, + models: { 'GPT-5.5': { calls: 1, cost, savingsUSD: 0, ...tokens } }, + categories: {}, + providers: { codex: { calls: 1, cost, savingsUSD: 0, sessions: 1, ...tokens } }, + } +} diff --git a/tests/day-aggregator.test.ts b/tests/day-aggregator.test.ts index 7d70d14b..43159cd9 100644 --- a/tests/day-aggregator.test.ts +++ b/tests/day-aggregator.test.ts @@ -463,18 +463,19 @@ describe('buildPeriodDataFromDays', () => { }) describe('daily-cache ↔ report daily-bucket parity', () => { - // The daily cache (history.daily + provider breakdown) and the live report / - // headline (main.ts daily rollup) must bucket days by the SAME rule, or their - // per-day totals drift and their period sums diverge from current.cost at - // window boundaries — the V1 audit's constant -$3.45/-81-calls finding. Both - // are now PER-CALL for cost/savings/calls (issue #852) with turn-level stats - // still turn-anchored: this asserts per-day equality against a reference - // that mirrors main.ts buildJsonReport's dailyMap fallback (each call on its - // own date), plus the invariant history.daily Σ == report.daily Σ == total - // call cost. + // The daily cache (history.daily + provider breakdown) and JSON-report + // daily[] rows (durable.days from buildDurablePeriod) must bucket days by the + // SAME rule, or their per-day totals drift and their period sums diverge from + // current.cost at window boundaries — the V1 audit's constant -$3.45/-81-calls + // finding. Both are now PER-CALL for cost/savings/calls (issue #852) with + // turn-level stats still turn-anchored: this asserts per-day equality against + // an independent per-call oracle for the durable day aggregation used by + // durable.days (each call on its own date), plus the invariant + // history.daily Σ == report.daily Σ == total call cost. - // Mirrors the live report/headline daily rollup fallback in src/main.ts - // (cost/savings/calls bucket under each call's own date). + // Independent per-call reference for durable.days (cost/savings/calls bucket + // under each call's own date). Not a live buildJsonReport fallback — that + // path was deleted in #1067. function reportDailyByDate(projects: ProjectSummary[]): Record { const byDate: Record = {} for (const p of projects) { diff --git a/tests/env-isolation-declarations.test.ts b/tests/env-isolation-declarations.test.ts new file mode 100644 index 00000000..a4220509 --- /dev/null +++ b/tests/env-isolation-declarations.test.ts @@ -0,0 +1,29 @@ +// Static guard: every PROVIDER_ENV_VARS entry must be CLEARED or REDIRECTED +// by tests/setup/env-isolation.ts. A data-dir override that is fingerprinted +// for cache invalidation but not isolated in tests leaks the developer's real +// sessions into fixture parses — green on CI (no HERMES_HOME), red on a +// Hermes-shell laptop. The named hole was HERMES_HOME; the class is every +// sibling override that session-cache already knows about. +// +// The lists are imported from the same module applyIsolation() uses. Extra +// High #1064: scraping setup-file source treated a comment containing +// `'HERMES_HOME'` as isolation (false-green). Runtime membership cannot. +import { describe, expect, it } from 'vitest' + +import { PROVIDER_ENV_VARS } from '../src/session-cache.js' +import { CLEARED, REDIRECTED } from './setup/env-isolation-vars.js' + +describe('env-isolation covers PROVIDER_ENV_VARS', () => { + it('clears or redirects every provider data-dir override so a developer shell cannot leak real sessions into fixtures', () => { + const isolated = new Set([...CLEARED, ...REDIRECTED]) + + const missing: string[] = [] + for (const [provider, vars] of Object.entries(PROVIDER_ENV_VARS)) { + for (const varName of vars) { + if (!isolated.has(varName)) missing.push(`${provider}:${varName}`) + } + } + + expect(missing).toEqual([]) + }) +}) diff --git a/tests/granular-history.test.ts b/tests/granular-history.test.ts index bc6e40b3..8294abdd 100644 --- a/tests/granular-history.test.ts +++ b/tests/granular-history.test.ts @@ -120,7 +120,7 @@ describe('granular history', () => { ])], { start, end }, end) expect(history.sessionSeries.map(series => series.label)).toEqual([ - 'sessio…3456 (claude) · Refactor billing module', + 'Refactor billing module (claude)', 'sessio…3457 (claude) · repos/demo', 'sessio…3458 (claude) · repos/demo', 'sessio…3459 (claude) · repos/demo', @@ -157,7 +157,7 @@ describe('granular history', () => { // 160px at the chart's 10px font fits roughly 31-32 lowercase glyphs; // compare a conservative prefix that must be visible in that budget. const visibleCharacterBudget = 24 - const visiblePrefixes = history.sessionSeries.map(series => series.label.slice(0, visibleCharacterBudget)) + const visiblePrefixes = history.sessionSeries.map(series => Array.from(series.label).slice(0, visibleCharacterBudget).join('')) expect(new Set(visiblePrefixes).size).toBe(2) expect(history.sessionSeries.map(series => series.label)).toEqual(expect.arrayContaining([ expect.stringMatching(/^a1b2c3…7f01 \(claude\) · /), @@ -165,6 +165,25 @@ describe('granular history', () => { ])) }) + it('counts the visible title-lead window in code points, not UTF-16 units', () => { + const timestamp = '2026-07-15T12:05:00.000Z' + const start = new Date('2026-07-15T00:00:00.000Z') + const end = new Date('2026-07-15T23:59:59.999Z') + const emojiPrefix = '😀'.repeat(12) + const history = buildGranularHistory([project([ + { id: 'emoji-alpha-aaaaaa', title: `${emojiPrefix} alpha work`, calls: [apiCall({ timestamp, cost: 1 })] }, + { id: 'emoji-beta-bbbbbb', title: `${emojiPrefix} beta work`, calls: [apiCall({ timestamp, cost: 2 })] }, + ])], { start, end }, end) + + // 12 emoji = 12 glyphs / 24 UTF-16 units. A unit slice collides both + // titles on the emoji run and would id-first; a code-point window still + // sees " alpha" vs " beta" and can title-lead. + expect(history.sessionSeries.map(series => series.label).sort()).toEqual([ + `${emojiPrefix} alpha work (claude)`, + `${emojiPrefix} beta work (claude)`, + ].sort()) + }) + it('sanitises control characters and ANSI escapes in session titles', () => { const timestamp = '2026-07-15T12:05:00.000Z' const start = new Date('2026-07-15T00:00:00.000Z') @@ -175,7 +194,7 @@ describe('granular history', () => { calls: [apiCall({ timestamp, cost: 1 })], }])], { start, end }, end) - expect(history.sessionSeries[0]?.label).toBe('sessio…3456 (claude) · Refactor billing module') + expect(history.sessionSeries[0]?.label).toBe('Refactor billing module (claude)') expect(history.sessionSeries[0]?.label).not.toContain('\x1b') expect(history.sessionSeries[0]?.label).not.toContain('\x00') }) @@ -190,7 +209,7 @@ describe('granular history', () => { calls: [apiCall({ timestamp, cost: 1 })], }])], { start, end }, end) - expect(history.sessionSeries[0]?.label).toBe('sessio…3456 (claude) · ' + 'x'.repeat(80)) + expect(history.sessionSeries[0]?.label).toBe('x'.repeat(80) + ' (claude)') }) it('caps session titles by code point without splitting an emoji', () => { @@ -205,9 +224,8 @@ describe('granular history', () => { }])], { start, end }, end) const label = history.sessionSeries[0]?.label ?? '' - const titlePart = label.slice(label.indexOf(' · ') + 3) - expect(titlePart).toBe('x'.repeat(79) + '😀') - expect([...titlePart]).toEqual([...('x'.repeat(79) + '😀')]) + expect(label).toBe('x'.repeat(79) + '😀' + ' (claude)') + expect([...label]).toEqual([...'x'.repeat(79), '😀', ' ', '(', 'c', 'l', 'a', 'u', 'd', 'e', ')']) }) it('prefers a title from any duplicate session summary sharing a key', () => { @@ -230,7 +248,7 @@ describe('granular history', () => { ])], { start, end }, end) expect(history.sessionSeries).toHaveLength(1) - expect(history.sessionSeries[0]?.label).toBe('sessio…3456 (claude) · Z recovered session title') + expect(history.sessionSeries[0]?.label).toBe('Z recovered session title (claude)') }) it('fills idle buckets and keeps separate model and session lines from real call timestamps', () => { diff --git a/tests/mcp-coverage.test.ts b/tests/mcp-coverage.test.ts index e173f008..612d3c4f 100644 --- a/tests/mcp-coverage.test.ts +++ b/tests/mcp-coverage.test.ts @@ -769,6 +769,29 @@ describe('detectMcpProfileAdvisor', () => { } }) + it('scopes the remediation label to --provider codex', () => { + const hotTurns = [makeTurn([ + makeCall({ tools: ['mcp__github__t0'], cacheCreation: 10_000 }), + makeCall({ tools: ['mcp__github__t1'], cacheCreation: 10_000 }), + ])] + const coldTurns = [makeTurn([makeCall({ cacheCreation: 10_000 })])] + const projects = [ + projectNamed('api', [ + makeSession({ inventory: smallInventory, turns: hotTurns, mcpBreakdown: { github: { calls: 2 } } }), + ]), + projectNamed('web', [ + makeSession({ inventory: smallInventory, turns: coldTurns, mcpBreakdown: { github: { calls: 0 } } }), + ]), + projectNamed('docs', [ + makeSession({ inventory: smallInventory, turns: coldTurns, mcpBreakdown: { github: { calls: 0 } } }), + ]), + ] + const finding = detectMcpProfileAdvisor(projects, undefined, 'codex') + expect(finding!.fix.label).toBe('Ask Codex to turn this into a project-scoped MCP profile:') + expect(finding!.fix.label).not.toContain('Claude') + expect(finding!.fix.label).not.toContain('CLAUDE.md') + }) + it('does not flag servers used evenly across loaded projects', () => { const projects = ['api', 'web', 'docs'].map(name => projectNamed(name, [ makeSession({ diff --git a/tests/models-report.test.ts b/tests/models-report.test.ts index 4e1dba09..67b36f4e 100644 --- a/tests/models-report.test.ts +++ b/tests/models-report.test.ts @@ -237,12 +237,15 @@ describe('aggregateModels', () => { expect(above.find(r => r.provider === 'cursor')).toBeUndefined() }) + // Providers that report reasoning as a bucket SEPARATE from output still get + // it added in. Codex and claude do not - they bill reasoning inside + // output_tokens - and that carve-out is covered in codex-pricing-1075.test.ts. it('counts reasoning tokens as output tokens', async () => { const project = makeProject([ makeTurn('feature', [ { - provider: 'codex', - model: 'gpt-5', + provider: 'gemini', + model: 'gemini-2.5-pro', usage: { ...emptyTokens(), inputTokens: 100, outputTokens: 50, reasoningTokens: 200 }, costUSD: 1.0, tools: [], diff --git a/tests/models.test.ts b/tests/models.test.ts index f6e49cd8..3c0ac576 100644 --- a/tests/models.test.ts +++ b/tests/models.test.ts @@ -778,6 +778,7 @@ describe('DeepSeek v4 models resolve to pricing', () => { process.env['CODEBURN_CACHE_DIR'] = cacheRoot await mkdir(cacheRoot, { recursive: true }) await writeFile(join(cacheRoot, 'litellm-pricing.json'), JSON.stringify({ + version: 2, // must match models.ts's CACHE_SCHEMA_VERSION or the cache is treated as a miss timestamp: Date.now(), data: { 'gpt-4o-mini': { @@ -803,6 +804,42 @@ describe('DeepSeek v4 models resolve to pricing', () => { }) }) +describe('pricing cache schema version (#1075/#1078 follow-up)', () => { + it('discards a cache written by a pre-#1078 binary instead of reading its missing cacheWriteCostIsExplicit as false', async () => { + const cacheRoot = await mkdtemp(join(tmpdir(), 'codeburn-pricing-cache-')) + try { + process.env['CODEBURN_CACHE_DIR'] = cacheRoot + await mkdir(cacheRoot, { recursive: true }) + // Shape of a cache file written before #1078 added `version` and + // `cacheWriteCostIsExplicit`: no version field, and entries missing the + // key despite carrying a real (non-default) cache-write rate. + await writeFile(join(cacheRoot, 'litellm-pricing.json'), JSON.stringify({ + timestamp: Date.now(), + data: { + 'gpt-5.6': { + inputCostPerToken: 5e-6, + outputCostPerToken: 3e-5, + cacheWriteCostPerToken: 6.25e-6, + cacheReadCostPerToken: 5e-7, + webSearchCostPerRequest: 0.01, + fastMultiplier: 1, + }, + }, + }), 'utf-8') + + await loadPricing() + + // Pre-fix, loadCachedPricing had no version check: it would read this + // cache verbatim, and gpt-5.6's missing key would resolve to undefined + // (falsy) here instead of the true its LiteLLM entry actually carries. + expect(getModelCosts('gpt-5.6')!.cacheWriteCostIsExplicit).toBe(true) + } finally { + await rm(cacheRoot, { recursive: true, force: true }) + await loadPricing() + } + }) +}) + describe('provider pricing suffix variants', () => { const cases: Array<[string, string]> = [ ['GLM-4.7-TEE', 'glm-4.7'], @@ -940,6 +977,7 @@ describe('findUnpricedModels', () => { try { process.env['CODEBURN_CACHE_DIR'] = cacheRoot await writeFile(join(cacheRoot, 'litellm-pricing.json'), JSON.stringify({ + version: 2, // must match models.ts's CACHE_SCHEMA_VERSION or the cache is treated as a miss timestamp: Date.now(), data: { 'zz-zero-stub-model': { diff --git a/tests/optimize.test.ts b/tests/optimize.test.ts index def5d390..4c34cb74 100644 --- a/tests/optimize.test.ts +++ b/tests/optimize.test.ts @@ -28,6 +28,12 @@ import { buildOptimizeJsonReport, renderOptimize, findingBasis, + optimizeRemediationCopy, + optimizePasteHeader, + optimizeTuiPasteHeader, + optimizeEmptyScanLines, + sessionOpenerLabel, + askAgentLabel, type FindingId, type ToolCall, type ApiCallMeta, @@ -1443,3 +1449,137 @@ describe('renderOptimize applied-fixes section', () => { expect(render([], [])).not.toContain('Applied fixes') }) }) + +describe('provider-scoped remediation copy (#1044)', () => { + const strip = (s: string): string => s.replace(/\u001b\[[0-9;]*m/g, '') + + function promptFinding(label: string): WasteFinding { + return { + id: 'retry-heavy-capabilities', + title: 'retry-heavy', + explanation: 'why', + impact: 'medium', + tokensSaved: 1000, + fix: { type: 'paste', destination: 'prompt', label, text: 'audit' }, + } + } + + function openerFinding(label: string): WasteFinding { + return { + id: 'low-worth-sessions', + title: 'low-worth', + explanation: 'why', + impact: 'low', + tokensSaved: 1000, + fix: { type: 'paste', destination: 'session-opener', label, text: 'open' }, + } + } + + it('keeps the exact shipped Claude / CLAUDE.md strings', () => { + for (const provider of [undefined, 'all', 'claude'] as const) { + const copy = optimizeRemediationCopy(provider) + expect(copy).toEqual({ agent: 'Claude', instructionFile: 'CLAUDE.md' }) + expect(optimizePasteHeader('prompt', copy)).toBe('Ask Claude in the current session') + expect(optimizePasteHeader('session-opener', copy)).toBe('One-time session opener (do NOT add to CLAUDE.md)') + expect(sessionOpenerLabel(copy)).toBe('Paste at the start of your NEXT expensive thread (one-time, do not add to CLAUDE.md):') + expect(askAgentLabel(copy, 'audit the retry-heavy capability before changing config')) + .toBe('Ask Claude to audit the retry-heavy capability before changing config:') + expect(optimizeEmptyScanLines(provider)).toEqual([ + 'CodeBurn optimize scans your Claude Code sessions and config for', + 'token waste: junk directory reads, duplicate file reads, unused', + 'agents/skills/MCP servers, bloated CLAUDE.md, and more.', + ]) + expect(optimizeTuiPasteHeader('session-opener', provider)) + .toBe('── One-time session opener (do not add to CLAUDE.md) '.padEnd(64, '─')) + expect(optimizeTuiPasteHeader('prompt', provider)) + .toBe('── Ask Claude in the current session '.padEnd(64, '─')) + } + const empty = strip(renderOptimize([], 0, 'lifetime', 0, 0, 0, 100, 'A', [], [])) + expect(empty).toContain('CodeBurn optimize scans your Claude Code sessions and config for') + expect(empty).toContain('bloated CLAUDE.md') + expect(empty).not.toContain('Claude sessions') + }) + + it('uses Codex / AGENTS.md for --provider codex', () => { + const copy = optimizeRemediationCopy('codex') + expect(copy).toEqual({ agent: 'Codex', instructionFile: 'AGENTS.md' }) + expect(optimizePasteHeader('prompt', copy)).toBe('Ask Codex in the current session') + expect(optimizePasteHeader('session-opener', copy)).toBe('One-time session opener (do NOT add to AGENTS.md)') + expect(sessionOpenerLabel(copy)).toContain('AGENTS.md') + expect(sessionOpenerLabel(copy)).not.toContain('CLAUDE.md') + expect(optimizeEmptyScanLines('codex')[0]).toBe('Session-scan detectors do not cover Codex yet.') + expect(optimizeEmptyScanLines('codex')[2]).toContain('currently scan Claude Code only') + expect(optimizeEmptyScanLines('codex').join(' ')).not.toContain('scans your Codex') + expect(optimizeEmptyScanLines('codex').join(' ')).not.toContain('bloated AGENTS.md') + expect(optimizeTuiPasteHeader('prompt', 'codex')).toContain('Ask Codex in the current session') + expect(optimizeTuiPasteHeader('session-opener', 'codex')).toContain('do NOT add to AGENTS.md') + expect(optimizeTuiPasteHeader('session-opener', 'codex')).not.toContain('CLAUDE.md') + }) + + it('uses the canonical Provider.displayName, not a title-cased id', () => { + expect(optimizeRemediationCopy('hermes').agent).toBe('Hermes Agent') + expect(optimizeRemediationCopy('cursor').agent).toBe('Cursor') + expect(optimizeRemediationCopy('cursor').instructionFile).toBe('project instructions') + expect(optimizePasteHeader('session-opener', optimizeRemediationCopy('cursor'))) + .toBe('One-time session opener (do NOT add to project instructions)') + }) + + it('scopes every cross-provider detector label, including JSON', () => { + const lowWorth = detectLowWorthSessions([ + projectWithLowWorthSessions([lowWorthSession(4, 0, { turns: [lowWorthTurn({ hasEdits: false })] })]), + ], 'codex') + const context = detectContextBloat([ + projectWithContextSessions([contextSession(0, { + totalInputTokens: 90_000, + totalCacheReadTokens: 30_000, + totalOutputTokens: 2_000, + })]), + ], undefined, 'codex') + const outliers = detectSessionOutliers([projectWithSessions([1, 1, 1, 10])], undefined, 'codex') + const turns = Array.from({ length: 5 }, (_, i) => reliabilityTurn(i, { + retries: i < 3 ? 1 : 0, + call: { tools: ['Edit', 'Skill'], skills: ['reviewer'] }, + })) + const retry = detectCapabilityReliability([projectWithReliabilityTurns(turns)], 'codex') + + const findings = [lowWorth, context, outliers, retry] + expect(findings.every(Boolean)).toBe(true) + for (const finding of findings) { + expect(finding!.fix.label).not.toContain('Claude') + expect(finding!.fix.label).not.toContain('CLAUDE.md') + } + expect(lowWorth!.fix.label).toContain('AGENTS.md') + expect(context!.fix.label).toContain('AGENTS.md') + expect(outliers!.fix.label).toContain('AGENTS.md') + expect(retry!.fix.label).toBe('Ask Codex to audit the retry-heavy capability before changing config:') + + const json = buildOptimizeJsonReport( + [projectWithSessions([1])], + 'lifetime', + { findings: findings as WasteFinding[], costRate: 0.00001, healthScore: 80, healthGrade: 'B', modelRecommendations: [] }, + ) + expect(json.findings).toHaveLength(4) + for (const row of json.findings) { + expect(row.fix.label).not.toContain('Claude') + expect(row.fix.label).not.toContain('CLAUDE.md') + } + }) + + it('renders destination headers from the selected provider, not the finding text', () => { + const out = strip(renderOptimize( + [promptFinding('Ask Claude to audit the retry-heavy capability before changing config:')], + 0.00001, 'lifetime', 10, 5, 100, 80, 'B', [], [], + undefined, undefined, undefined, [], 'codex', + )) + expect(out).toContain('Ask Codex in the current session') + expect(out).not.toContain('Ask Claude in the current session') + + const opener = strip(renderOptimize( + [openerFinding('Paste at the start of your NEXT expensive thread (one-time, do not add to CLAUDE.md):')], + 0.00001, 'lifetime', 10, 5, 100, 80, 'B', [], [], + undefined, undefined, undefined, [], 'codex', + )) + expect(opener).toContain('do NOT add to AGENTS.md') + expect(opener).not.toContain('do NOT add to CLAUDE.md') + }) +}) diff --git a/tests/parse-workers.test.ts b/tests/parse-workers.test.ts index 4185be59..c182b1ff 100644 --- a/tests/parse-workers.test.ts +++ b/tests/parse-workers.test.ts @@ -236,7 +236,8 @@ async function shardBodies(cacheDir: string): Promise> { /// The Codex incremental cache is a single JSON file; both runs read the same /// rollouts, so it must come out identical byte for byte. async function codexResults(cacheDir: string): Promise { - return readFile(join(cacheDir, 'codex-results.json'), 'utf-8').catch(() => null) + const { codexCacheFileName } = await import('../src/codex-cache.js') + return readFile(join(cacheDir, codexCacheFileName()), 'utf-8').catch(() => null) } function runCli(args: string[], home: string, extraEnv: Record) { diff --git a/tests/parser.test.ts b/tests/parser.test.ts index 44f9db73..8bb73986 100644 --- a/tests/parser.test.ts +++ b/tests/parser.test.ts @@ -14,7 +14,7 @@ import { createRequire } from 'node:module' import { isSqliteAvailable } from '../src/sqlite.js' import { clearSessionCache, parseAllSessions, setParseReuseValidator } from '../src/parser.js' -import { loadCache, saveCache } from '../src/session-cache.js' +import { computeEnvFingerprint, loadCache, PROVIDER_PARSE_VERSIONS, saveCache } from '../src/session-cache.js' import { readCacheOnDisk, writeCacheOnDisk } from './fixtures/session-cache-io.js' import type { SessionSource, SessionParser, ParsedProviderCall } from '../src/providers/types.js' diff --git a/tests/provider-display-name.test.ts b/tests/provider-display-name.test.ts new file mode 100644 index 00000000..3e0e016e --- /dev/null +++ b/tests/provider-display-name.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest' +import { getAllProviders, providerDisplayName } from '../src/providers/index.js' + +describe('providerDisplayName', () => { + it('matches every loaded Provider.displayName', async () => { + const loaded = await getAllProviders() + expect(loaded.length).toBeGreaterThan(20) + for (const provider of loaded) { + expect(providerDisplayName(provider.name)).toBe(provider.displayName) + } + }) +}) diff --git a/tests/providers/codex-resume.test.ts b/tests/providers/codex-resume.test.ts index e63e41f8..a774fa0f 100644 --- a/tests/providers/codex-resume.test.ts +++ b/tests/providers/codex-resume.test.ts @@ -183,7 +183,8 @@ describe('codex incremental resume', () => { sessionPath = await writeRollout([meta(), ...tasks(1, 2)]) await parse(cacheDir) - const cachePath = join(cacheDir, 'codex-results.json') + const { codexCacheFileName } = await import('../../src/codex-cache.js') + const cachePath = join(cacheDir, codexCacheFileName()) const { readFile } = await import('fs/promises') const raw = JSON.parse(await readFile(cachePath, 'utf-8')) raw.files[sessionPath].resumeState = { garbage: true } diff --git a/tests/providers/codex.test.ts b/tests/providers/codex.test.ts index 1da3a16e..6b6e6c2e 100644 --- a/tests/providers/codex.test.ts +++ b/tests/providers/codex.test.ts @@ -4,7 +4,7 @@ import { join } from 'path' import { tmpdir } from 'os' import { createCodexProvider } from '../../src/providers/codex.js' -import { clearCodexMemCaches } from '../../src/codex-cache.js' +import { clearCodexMemCaches, CODEX_CACHE_VERSION, codexCacheFileName } from '../../src/codex-cache.js' import { calculateCost } from '../../src/models.js' import type { ParsedProviderCall } from '../../src/providers/types.js' @@ -1221,7 +1221,7 @@ describe('codex auto-review pricing (#1047)', () => { expect(calls[0]!.costUSD).toBe(calculateCost('gpt-5.5', 1_000_000, 1_000_000, 0, 0, 0)) }) - it('discards a warm v9 $0 exact hit so unchanged rollouts reprice', async () => { + it('discards a warm v11 versioned $0 exact hit so unchanged rollouts reprice', async () => { const cacheDir = join(tmpDir, 'cache') await mkdir(cacheDir, { recursive: true }) const prev = process.env['CODEBURN_CACHE_DIR'] @@ -1237,8 +1237,11 @@ describe('codex auto-review pricing (#1047)', () => { }), ]) const st = await stat(filePath) - await writeFile(join(cacheDir, 'codex-results.json'), JSON.stringify({ - version: 9, + // Main's #1075 already owns v11. A colliding v11 $0 file must not be + // treated as current after this PR takes v12. + expect(CODEX_CACHE_VERSION).toBeGreaterThan(11) + await writeFile(join(cacheDir, codexCacheFileName(11)), JSON.stringify({ + version: 11, files: { [filePath]: { mtimeMs: st.mtimeMs, diff --git a/tests/providers/copilot.test.ts b/tests/providers/copilot.test.ts index ae128be9..d145a3f2 100644 --- a/tests/providers/copilot.test.ts +++ b/tests/providers/copilot.test.ts @@ -706,9 +706,9 @@ describe('copilot provider - session.shutdown token/cost rollup', () => { it('emits per-leg deltas for a resumed session with cumulative shutdown rollups', async () => { // Numbers from a real resumed CLI 1.0.78 session (3 legs via --resume): // each leg appends a session.shutdown whose modelMetrics are CUMULATIVE. - // Emitting deltas keyed by occurrence keeps a growing file append-only - // under the durable union-by-key cache merge — re-parsing after each - // resume adds only the new leg, never double-counting earlier ones. + // Emitting deltas keyed by shutdown timestamp keeps a growing file + // append-only under the durable union-by-key cache merge — re-parsing + // after each resume adds only the new leg, never double-counting earlier ones. const legs = [ { inputTokens: 24672, outputTokens: 17, cacheReadTokens: 0, cacheWriteTokens: 24670 }, { inputTokens: 74463, outputTokens: 149, cacheReadTokens: 49489, cacheWriteTokens: 24968 }, @@ -803,6 +803,54 @@ describe('copilot provider - session.shutdown token/cost rollup', () => { expect(second).toHaveLength(0) }) + it('keeps three stampless shutdown legs as :n keys with lastEventTimestamp, not sessionStartTime', async () => { + // sessionStartTime is identical on every leg. Putting it in the key (or + // preferring it over lastEventTimestamp for the call timestamp) collapses + // a 3-leg journal onto one row. Discovery only yields events.jsonl, so + // two-journal fixtures are unreachable; this is the reachable class. + const lastEvent = '2026-08-01T10:00:15Z' + const sessionStartTime = 1784102040274 + const stamplessShutdown = (usage: { + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheWriteTokens: number + }) => JSON.stringify({ + type: 'session.shutdown', + data: { + shutdownType: 'routine', + sessionStartTime, + modelMetrics: { + 'claude-sonnet-5': { + requests: { count: 1, cost: 1 }, + usage: { ...usage, reasoningTokens: 0 }, + }, + }, + }, + }) + const eventsPath = await createSessionDir('sess-stampless', [ + modelChange('claude-sonnet-5'), + assistantMessage({ messageId: 'msg-1', outputTokens: 10, timestamp: lastEvent }), + stamplessShutdown({ inputTokens: 3000, outputTokens: 10, cacheReadTokens: 1000, cacheWriteTokens: 500 }), + stamplessShutdown({ inputTokens: 7000, outputTokens: 20, cacheReadTokens: 3000, cacheWriteTokens: 1000 }), + stamplessShutdown({ inputTokens: 10000, outputTokens: 30, cacheReadTokens: 5000, cacheWriteTokens: 1500 }), + ]) + const calls = await collectCalls({ path: eventsPath, project: 'myproject', provider: 'copilot', sourceType: 'jsonl' }) + const shutdowns = calls.filter(c => c.deduplicationKey.includes(':shutdown:')) + expect(shutdowns.map(c => c.deduplicationKey)).toEqual([ + 'copilot:sess-stampless:shutdown:claude-sonnet-5:1', + 'copilot:sess-stampless:shutdown:claude-sonnet-5:2', + 'copilot:sess-stampless:shutdown:claude-sonnet-5:3', + ]) + expect(shutdowns.map(c => c.timestamp)).toEqual([lastEvent, lastEvent, lastEvent]) + expect(shutdowns[0]!.inputTokens).toBe(1500) + expect(shutdowns[1]!.inputTokens).toBe(1500) + expect(shutdowns[2]!.inputTokens).toBe(500) + expect(shutdowns.reduce((a, c) => a + c.inputTokens, 0)).toBe(3500) + expect(shutdowns.reduce((a, c) => a + c.cacheReadInputTokens, 0)).toBe(5000) + expect(shutdowns.reduce((a, c) => a + c.cacheCreationInputTokens, 0)).toBe(1500) + }) + it('falls back to the last stamped event when shutdown carries no timestamp at all', async () => { // A shutdown with neither its own timestamp nor sessionStartTime must not // yield an empty-timestamp call: the date-range filters in parser.ts drop diff --git a/tests/providers/cursor.test.ts b/tests/providers/cursor.test.ts index 34c28d88..14a184ce 100644 --- a/tests/providers/cursor.test.ts +++ b/tests/providers/cursor.test.ts @@ -106,8 +106,9 @@ describe('cursor cache', () => { process.env['CODEBURN_CACHE_DIR'] = secondCacheDir await writeCachedResults(dbPath, [], secondFloor) - const firstPath = join(firstCacheDir, 'cursor-results.json') - const secondPath = join(secondCacheDir, 'cursor-results.json') + const { cursorCacheFileName } = await import('../../src/cursor-cache.js') + const firstPath = join(firstCacheDir, cursorCacheFileName()) + const secondPath = join(secondCacheDir, cursorCacheFileName()) const first = JSON.parse(await readFile(firstPath, 'utf-8')) as { lookbackFloor: string } const second = JSON.parse(await readFile(secondPath, 'utf-8')) as { lookbackFloor: string } expect(first.lookbackFloor).toBe(firstFloor) diff --git a/tests/result-cache-suffix.test.ts b/tests/result-cache-suffix.test.ts new file mode 100644 index 00000000..4cf7ea97 --- /dev/null +++ b/tests/result-cache-suffix.test.ts @@ -0,0 +1,317 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises' +import { existsSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { + CODEX_CACHE_VERSION, + CODEX_LEGACY_CACHE_FILE, + clearCodexMemCaches, + codexCacheFileName, + fingerprintFile, + flushCodexCache, + readCachedCodexResults, + writeCachedCodexResults, +} from '../src/codex-cache.js' +import { + CURSOR_CACHE_VERSION, + CURSOR_LEGACY_CACHE_FILE, + cursorCacheFileName, + readCachedResults, + writeCachedResults, +} from '../src/cursor-cache.js' +import { + ANTIGRAVITY_CACHE_VERSION, + ANTIGRAVITY_LEGACY_CACHE_FILE, + antigravityCacheFileName, + clearAntigravityCacheStates, + createAntigravityProvider, +} from '../src/providers/antigravity.js' +import type { ParsedProviderCall } from '../src/providers/types.js' + +const originalCacheDir = process.env['CODEBURN_CACHE_DIR'] +let root: string + +function call(provider: string, marker: string): ParsedProviderCall { + return { + provider, + model: marker, + inputTokens: 1, + outputTokens: 1, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD: 0, + tools: [], + bashCommands: [], + timestamp: '2026-08-22T00:00:00.000Z', + speed: 'standard', + deduplicationKey: `${provider}:${marker}`, + userMessage: '', + sessionId: marker, + } +} + +async function readAntigravityModel(sourcePath: string): Promise { + const parser = createAntigravityProvider().createSessionParser({ + path: sourcePath, + project: 'fixture', + provider: 'antigravity', + }, new Set()) + for await (const parsed of parser.parse()) return parsed.model + return undefined +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'codeburn-result-suffix-')) + process.env['CODEBURN_CACHE_DIR'] = root + clearCodexMemCaches() + clearAntigravityCacheStates() +}) + +afterEach(async () => { + if (originalCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR'] + else process.env['CODEBURN_CACHE_DIR'] = originalCacheDir + clearCodexMemCaches() + clearAntigravityCacheStates() + await rm(root, { recursive: true, force: true }) +}) + +describe('unsuffixed result caches coexist with mixed-version binaries', () => { + it('publishes Codex to the versioned file and leaves an older unsuffixed file intact', async () => { + const sourcePath = join(root, 'rollout.jsonl') + await writeFile(sourcePath, '{}\n') + const fingerprint = await fingerprintFile(sourcePath) + expect(fingerprint).not.toBeNull() + + const legacyPath = join(root, CODEX_LEGACY_CACHE_FILE) + await writeFile(legacyPath, JSON.stringify({ + version: CODEX_CACHE_VERSION - 1, + files: { [sourcePath]: { mtimeMs: 1, sizeBytes: 1, project: 'old', calls: [call('codex', 'old-binary')] } }, + })) + + await writeCachedCodexResults(sourcePath, 'project', [call('codex', 'new-binary')], fingerprint!) + await flushCodexCache() + + expect(existsSync(join(root, codexCacheFileName()))).toBe(true) + expect(existsSync(legacyPath)).toBe(true) + const versioned = JSON.parse(await readFile(join(root, codexCacheFileName()), 'utf8')) + const legacy = JSON.parse(await readFile(legacyPath, 'utf8')) + expect(versioned.version).toBe(CODEX_CACHE_VERSION) + expect(versioned.files[sourcePath].calls[0].model).toBe('new-binary') + expect(legacy.version).toBe(CODEX_CACHE_VERSION - 1) + expect(legacy.files[sourcePath].calls[0].model).toBe('old-binary') + }) + + it('adopts a matching-version unsuffixed Codex cache and ignores a mismatched one', async () => { + const sourcePath = join(root, 'rollout.jsonl') + await writeFile(sourcePath, '{}\n') + const fingerprint = await fingerprintFile(sourcePath) + expect(fingerprint).not.toBeNull() + + await writeFile(join(root, CODEX_LEGACY_CACHE_FILE), JSON.stringify({ + version: CODEX_CACHE_VERSION, + files: { + [sourcePath]: { + dev: fingerprint!.dev, + ino: fingerprint!.ino, + mtimeMs: fingerprint!.mtimeMs, + sizeBytes: fingerprint!.sizeBytes, + project: 'adopted', + calls: [call('codex', 'adopt-me')], + }, + }, + })) + + expect((await readCachedCodexResults(sourcePath))?.calls.map(entry => entry.model)).toEqual(['adopt-me']) + expect(existsSync(join(root, CODEX_LEGACY_CACHE_FILE))).toBe(true) + expect(existsSync(join(root, codexCacheFileName()))).toBe(false) + + clearCodexMemCaches() + await writeFile(join(root, CODEX_LEGACY_CACHE_FILE), JSON.stringify({ + version: CODEX_CACHE_VERSION - 1, + files: { + [sourcePath]: { + dev: fingerprint!.dev, + ino: fingerprint!.ino, + mtimeMs: fingerprint!.mtimeMs, + sizeBytes: fingerprint!.sizeBytes, + project: 'stale', + calls: [call('codex', 'do-not-adopt')], + }, + }, + })) + expect(await readCachedCodexResults(sourcePath)).toBeNull() + }) + + it('publishes Cursor to the versioned file and leaves an older unsuffixed file intact', async () => { + const dbPath = join(root, 'state.vscdb') + await writeFile(dbPath, 'cursor-db') + const floor = '2026-01-01T00:00:00.000Z' + const legacyPath = join(root, CURSOR_LEGACY_CACHE_FILE) + await writeFile(legacyPath, JSON.stringify({ + version: CURSOR_CACHE_VERSION - 1, + dbMtimeMs: 1, + dbSizeBytes: 1, + lookbackFloor: floor, + calls: [call('cursor', 'old-binary')], + })) + + await writeCachedResults(dbPath, [call('cursor', 'new-binary')], floor) + + expect(existsSync(join(root, cursorCacheFileName()))).toBe(true) + expect(existsSync(legacyPath)).toBe(true) + const versioned = JSON.parse(await readFile(join(root, cursorCacheFileName()), 'utf8')) + const legacy = JSON.parse(await readFile(legacyPath, 'utf8')) + expect(versioned.version).toBe(CURSOR_CACHE_VERSION) + expect(versioned.calls[0].model).toBe('new-binary') + expect(legacy.version).toBe(CURSOR_CACHE_VERSION - 1) + expect(legacy.calls[0].model).toBe('old-binary') + }) + + it('adopts a matching-version unsuffixed Cursor cache and ignores a mismatched one', async () => { + const dbPath = join(root, 'state.vscdb') + await writeFile(dbPath, 'cursor-db') + const { stat } = await import('fs/promises') + const fp = await stat(dbPath) + const floor = '2026-01-01T00:00:00.000Z' + + await writeFile(join(root, CURSOR_LEGACY_CACHE_FILE), JSON.stringify({ + version: CURSOR_CACHE_VERSION, + dbMtimeMs: fp.mtimeMs, + dbSizeBytes: fp.size, + lookbackFloor: floor, + calls: [call('cursor', 'adopt-me')], + })) + expect((await readCachedResults(dbPath, floor))?.map(entry => entry.model)).toEqual(['adopt-me']) + expect(existsSync(join(root, cursorCacheFileName()))).toBe(false) + + await writeFile(join(root, CURSOR_LEGACY_CACHE_FILE), JSON.stringify({ + version: CURSOR_CACHE_VERSION - 1, + dbMtimeMs: fp.mtimeMs, + dbSizeBytes: fp.size, + lookbackFloor: floor, + calls: [call('cursor', 'do-not-adopt')], + })) + expect(await readCachedResults(dbPath, floor)).toBeNull() + }) + + it('publishes Antigravity to the versioned file and leaves an older unsuffixed file intact', async () => { + const sourcePath = join(root, 'shared.pb') + await writeFile(sourcePath, 'fixture') + const { stat } = await import('fs/promises') + const sourceStat = await stat(sourcePath) + const legacyPath = join(root, ANTIGRAVITY_LEGACY_CACHE_FILE) + await mkdir(root, { recursive: true }) + await writeFile(legacyPath, JSON.stringify({ + version: ANTIGRAVITY_CACHE_VERSION - 1, + cascades: { + shared: { mtimeMs: sourceStat.mtimeMs, sizeBytes: sourceStat.size, calls: [call('antigravity', 'old-binary')] }, + }, + })) + + await writeFile(join(root, antigravityCacheFileName()), JSON.stringify({ + version: ANTIGRAVITY_CACHE_VERSION, + cascades: { + shared: { mtimeMs: sourceStat.mtimeMs, sizeBytes: sourceStat.size, calls: [call('antigravity', 'new-binary')] }, + }, + })) + + expect(await readAntigravityModel(sourcePath)).toBe('new-binary') + const legacy = JSON.parse(await readFile(legacyPath, 'utf8')) + expect(legacy.cascades.shared.calls[0].model).toBe('old-binary') + }) + + it('adopts a matching-version unsuffixed Antigravity cache and ignores a mismatched one', async () => { + const sourcePath = join(root, 'shared.pb') + await writeFile(sourcePath, 'fixture') + const { stat } = await import('fs/promises') + const sourceStat = await stat(sourcePath) + + await writeFile(join(root, ANTIGRAVITY_LEGACY_CACHE_FILE), JSON.stringify({ + version: ANTIGRAVITY_CACHE_VERSION, + cascades: { + shared: { mtimeMs: sourceStat.mtimeMs, sizeBytes: sourceStat.size, calls: [call('antigravity', 'adopt-me')] }, + }, + })) + expect(await readAntigravityModel(sourcePath)).toBe('adopt-me') + expect(existsSync(join(root, antigravityCacheFileName()))).toBe(false) + + clearAntigravityCacheStates() + await writeFile(join(root, ANTIGRAVITY_LEGACY_CACHE_FILE), JSON.stringify({ + version: ANTIGRAVITY_CACHE_VERSION - 1, + cascades: { + shared: { mtimeMs: sourceStat.mtimeMs, sizeBytes: sourceStat.size, calls: [call('antigravity', 'do-not-adopt')] }, + }, + })) + expect(await readAntigravityModel(sourcePath)).toBeUndefined() + }) + + it('never serves matching legacy when a versioned file is present but invalid', async () => { + const sourcePath = join(root, 'rollout.jsonl') + const dbPath = join(root, 'state.vscdb') + const pbPath = join(root, 'shared.pb') + await writeFile(sourcePath, '{}\n') + await writeFile(dbPath, 'cursor-db') + await writeFile(pbPath, 'fixture') + const fingerprint = await fingerprintFile(sourcePath) + const { stat } = await import('fs/promises') + const dbStat = await stat(dbPath) + const pbStat = await stat(pbPath) + const floor = '2026-01-01T00:00:00.000Z' + expect(fingerprint).not.toBeNull() + + await writeFile(join(root, CODEX_LEGACY_CACHE_FILE), JSON.stringify({ + version: CODEX_CACHE_VERSION, + files: { + [sourcePath]: { + dev: fingerprint!.dev, + ino: fingerprint!.ino, + mtimeMs: fingerprint!.mtimeMs, + sizeBytes: fingerprint!.sizeBytes, + project: 'legacy', + calls: [call('codex', 'legacy-served')], + }, + }, + })) + await writeFile(join(root, CURSOR_LEGACY_CACHE_FILE), JSON.stringify({ + version: CURSOR_CACHE_VERSION, + dbMtimeMs: dbStat.mtimeMs, + dbSizeBytes: dbStat.size, + lookbackFloor: floor, + calls: [call('cursor', 'legacy-served')], + })) + await writeFile(join(root, ANTIGRAVITY_LEGACY_CACHE_FILE), JSON.stringify({ + version: ANTIGRAVITY_CACHE_VERSION, + cascades: { + shared: { mtimeMs: pbStat.mtimeMs, sizeBytes: pbStat.size, calls: [call('antigravity', 'legacy-served')] }, + }, + })) + + await writeFile(join(root, codexCacheFileName()), '{not-json') + await writeFile(join(root, cursorCacheFileName()), '{not-json') + await writeFile(join(root, antigravityCacheFileName()), '{not-json') + expect(await readCachedCodexResults(sourcePath)).toBeNull() + expect(await readCachedResults(dbPath, floor)).toBeNull() + expect(await readAntigravityModel(pbPath)).toBeUndefined() + + clearCodexMemCaches() + clearAntigravityCacheStates() + await writeFile(join(root, codexCacheFileName()), JSON.stringify({ version: CODEX_CACHE_VERSION - 1, files: {} })) + await writeFile(join(root, cursorCacheFileName()), JSON.stringify({ + version: CURSOR_CACHE_VERSION - 1, + dbMtimeMs: dbStat.mtimeMs, + dbSizeBytes: dbStat.size, + lookbackFloor: floor, + calls: [call('cursor', 'wrong-version')], + })) + await writeFile(join(root, antigravityCacheFileName()), JSON.stringify({ version: ANTIGRAVITY_CACHE_VERSION - 1, cascades: {} })) + expect(await readCachedCodexResults(sourcePath)).toBeNull() + expect(await readCachedResults(dbPath, floor)).toBeNull() + expect(await readAntigravityModel(pbPath)).toBeUndefined() + }) +}) diff --git a/tests/setup/env-isolation-vars.ts b/tests/setup/env-isolation-vars.ts new file mode 100644 index 00000000..a776c4bb --- /dev/null +++ b/tests/setup/env-isolation-vars.ts @@ -0,0 +1,79 @@ +// Side-effect-free lists for tests/setup/env-isolation.ts. +// Imported by the setup file (which applies them) and by +// tests/env-isolation-declarations.test.ts (which asserts coverage). +// Do not put applyIsolation() here — importing this module from a test +// must not re-sandbox the process or register another beforeEach. +// +// A comment containing 'HERMES_HOME' is not isolation. The declaration +// test imports these arrays, so a commented-out name cannot false-green. + +export const REDIRECTED = [ + 'HOME', + 'XDG_CONFIG_HOME', + 'XDG_DATA_HOME', + 'XDG_CACHE_HOME', + 'XDG_STATE_HOME', + 'APPDATA', + 'LOCALAPPDATA', +] as const + +export const CLEARED = [ + // Provider session-discovery dirs + 'CLAUDE_CONFIG_DIR', + 'CLAUDE_CONFIG_DIRS', + 'CLINE_DIR', + 'CLINE_DATA_DIR', + 'CLINE_SESSION_DATA_DIR', + 'CODEX_HOME', + 'CODEWHALE_HOME', + 'CRUSH_GLOBAL_DATA', + 'CODEBUFF_DATA_DIR', + 'DSH_HOME', + 'FACTORY_DIR', + 'GOOSE_PATH_ROOT', + 'GROK_HOME', + 'HERMES_HOME', + 'KIRO_HOME', + 'KIMI_CODE_HOME', + 'KIMI_SHARE_DIR', + 'LINGTAI_HOME', + 'LINGTAI_TUI_GLOBAL_DIR', + 'LINGTAI_TUI_HOME', + 'MUX_ROOT', + 'OPENCODE_DATA_DIR', + 'OPENCODE_DB_PREFIX', + 'QUICKWORK_HOME', + 'QWEN_DATA_DIR', + 'VIBE_HOME', + 'WARP_DB_PATH', + 'ZS_DATA_DIR', + // codeburn override dirs / paths + 'CODEBURN_CACHE_DIR', + 'CODEBURN_COPILOT_GLOBAL_STORAGE_DIR', + 'CODEBURN_COPILOT_JETBRAINS_DIR', + 'CODEBURN_COPILOT_OTEL_DB', + 'CODEBURN_COPILOT_SESSION_STATE_DIR', + 'CODEBURN_COPILOT_WS_STORAGE_DIR', + 'CODEBURN_DESKTOP_SESSIONS_DIR', + 'CODEBURN_MUX_DIR', + 'CODEBURN_OPEN_DESIGN_DIR', + 'CODEBURN_OPENCLAUDE_DIR', + 'CODEBURN_ANTIGRAVITY_SETTINGS_PATH', + // codeburn behavior toggles (set by the dev to tweak local runs) + 'CODEBURN_COPILOT_DISABLE_OTEL', + 'CODEBURN_TZ', + 'CODEBURN_VERBOSE', + 'CODEBURN_CURSOR_MAX_BUBBLES', + 'CODEBURN_FORCE_MACOS_MAJOR', + // Provider model/credential overrides + 'KIMI_MODEL_NAME', + 'AI_GATEWAY_API_KEY', + 'VERCEL_OIDC_TOKEN', + // Read by detectBashBloat - a dev's real shell limit must not bleed in + 'BASH_MAX_OUTPUT_LENGTH', +] as const + +// Snapshotted from the dev's shell and restored every test. These can't be +// wiped (Node needs PATH for spawn / module resolution, dashboard/table layout +// reads COLUMNS) but a test that mutates them shouldn't leak. +export const PRESERVED = ['PATH', 'COLUMNS'] as const diff --git a/tests/setup/env-isolation.ts b/tests/setup/env-isolation.ts index 6a3326ec..af82aff4 100644 --- a/tests/setup/env-isolation.ts +++ b/tests/setup/env-isolation.ts @@ -1,12 +1,13 @@ // Vitest setup file: isolates every test from the developer's shell environment. // // codeburn discovers sessions through a long list of provider-specific env -// vars (CLAUDE_CONFIG_DIR, CODEX_HOME, CRUSH_GLOBAL_DATA, …) and via HOME / -// XDG_* / APPDATA / LOCALAPPDATA. Without this file, any value set in the -// developer's shell (e.g. CLAUDE_CONFIG_DIRS=/Users/me/.claude:…) bleeds into +// vars (CLAUDE_CONFIG_DIR, CODEX_HOME, HERMES_HOME, CRUSH_GLOBAL_DATA, …) and +// via HOME / XDG_* / APPDATA / LOCALAPPDATA. Without this file, any value set +// in the developer's shell (e.g. HERMES_HOME=/Users/me/.hermes) bleeds into // fixture-based tests: the parser reads the developer's REAL sessions instead // of the temp-dir fixture, producing nonsense totals and false failures that -// pass on a clean CI runner. +// pass on a clean CI runner. tests/env-isolation-declarations.test.ts fails +// closed if PROVIDER_ENV_VARS grows a data-dir override that is not listed. // // What this file does: // 1. Mints an empty sandbox temp dir once per worker. @@ -33,68 +34,10 @@ import { tmpdir } from 'os' import { join } from 'path' import { beforeEach } from 'vitest' +import { CLEARED, PRESERVED, REDIRECTED } from './env-isolation-vars.js' + const sandbox = mkdtempSync(join(tmpdir(), 'codeburn-test-env-')) -const REDIRECTED = [ - 'HOME', - 'XDG_CONFIG_HOME', - 'XDG_DATA_HOME', - 'XDG_CACHE_HOME', - 'XDG_STATE_HOME', - 'APPDATA', - 'LOCALAPPDATA', -] as const - -const CLEARED = [ - // Provider session-discovery dirs - 'CLAUDE_CONFIG_DIR', - 'CLAUDE_CONFIG_DIRS', - 'CLINE_DIR', - 'CLINE_DATA_DIR', - 'CLINE_SESSION_DATA_DIR', - 'CODEX_HOME', - 'CODEWHALE_HOME', - 'CRUSH_GLOBAL_DATA', - 'CODEBUFF_DATA_DIR', - 'FACTORY_DIR', - 'GOOSE_PATH_ROOT', - 'GROK_HOME', - 'KIRO_HOME', - 'KIMI_SHARE_DIR', - 'MUX_ROOT', - 'OPENCODE_DATA_DIR', - 'OPENCODE_DB_PREFIX', - 'QWEN_DATA_DIR', - 'VIBE_HOME', - 'WARP_DB_PATH', - 'ZS_DATA_DIR', - // codeburn override dirs / paths - 'CODEBURN_CACHE_DIR', - 'CODEBURN_COPILOT_JETBRAINS_DIR', - 'CODEBURN_COPILOT_OTEL_DB', - 'CODEBURN_COPILOT_SESSION_STATE_DIR', - 'CODEBURN_COPILOT_WS_STORAGE_DIR', - 'CODEBURN_DESKTOP_SESSIONS_DIR', - 'CODEBURN_MUX_DIR', - 'CODEBURN_ANTIGRAVITY_SETTINGS_PATH', - // codeburn behavior toggles (set by the dev to tweak local runs) - 'CODEBURN_COPILOT_DISABLE_OTEL', - 'CODEBURN_TZ', - 'CODEBURN_VERBOSE', - 'CODEBURN_CURSOR_MAX_BUBBLES', - 'CODEBURN_FORCE_MACOS_MAJOR', - // Provider model/credential overrides - 'KIMI_MODEL_NAME', - 'AI_GATEWAY_API_KEY', - 'VERCEL_OIDC_TOKEN', - // Read by detectBashBloat - a dev's real shell limit must not bleed in - 'BASH_MAX_OUTPUT_LENGTH', -] as const - -// Snapshotted from the dev's shell and restored every test. These can't be -// wiped (Node needs PATH for spawn / module resolution, dashboard/table layout -// reads COLUMNS) but a test that mutates them shouldn't leak. -const PRESERVED = ['PATH', 'COLUMNS'] as const const preservedSnapshot = new Map() for (const key of PRESERVED) preservedSnapshot.set(key, process.env[key]) diff --git a/tests/sync-attribution.test.ts b/tests/sync-attribution.test.ts index d4b8f6c1..33786f27 100644 --- a/tests/sync-attribution.test.ts +++ b/tests/sync-attribution.test.ts @@ -553,7 +553,7 @@ describe('buildAttributionOtlpPayload', () => { expect(sessionSpan.spanId).not.toBe(commitSpan.spanId) const sessionAttrs = attrMap(sessionSpan.attributes) - expect(sessionAttrs['ai.session_id']).toEqual({ stringValue: 'sess-1' }) + expect(sessionAttrs['ai.session_id']).toBeUndefined() expect(sessionAttrs['ai.project']).toEqual({ stringValue: 'app' }) expect(sessionAttrs['git.repo']).toEqual({ stringValue: 'github.com/acme/widget' }) expect(sessionAttrs['git.commit_count']).toEqual({ intValue: '1' }) @@ -565,12 +565,24 @@ describe('buildAttributionOtlpPayload', () => { expect(sessionSpan.endTimeUnixNano).toBe((BigInt(new Date('2026-01-01T11:00:00.000Z').getTime()) * 1_000_000n).toString()) const commitAttrs = attrMap(commitSpan.attributes) + expect(commitAttrs['ai.session_id']).toBeUndefined() expect(commitAttrs['git.sha']).toEqual({ stringValue: 'a'.repeat(40) }) expect(commitAttrs['git.in_main']).toEqual({ boolValue: true }) expect(commitAttrs['git.was_reverted']).toEqual({ boolValue: false }) expect(commitAttrs['git.repo']).toEqual({ stringValue: 'github.com/acme/widget' }) }) + it('keeps session identity off the wire; join is the shared traceId', () => { + const items = flattenAttributionRecords([makeRecord()]) + const payload = buildAttributionOtlpPayload(items) + const spans = payload.resourceSpans[0]!.scopeSpans[0]!.spans + expect(spans.length).toBeGreaterThan(0) + for (const span of spans) { + expect(attrMap(span.attributes)['ai.session_id']).toBeUndefined() + expect(span.traceId).toBe(deriveTraceId('sess-1')) + } + }) + it('omits git.repo when null and pr_links when empty', () => { const items = flattenAttributionRecords([makeRecord({ repo: null, prLinks: [], commits: [] })]) const payload = buildAttributionOtlpPayload(items) diff --git a/tests/sync-ledger-otlp.test.ts b/tests/sync-ledger-otlp.test.ts index b7c72ca6..53fa9656 100644 --- a/tests/sync-ledger-otlp.test.ts +++ b/tests/sync-ledger-otlp.test.ts @@ -156,6 +156,7 @@ describe('buildOtlpPayload', () => { expect(attrMap['ai.cost_usd']).toEqual({ doubleValue: 0.05 }) expect(attrMap['ai.project']).toEqual({ stringValue: 'my-project' }) expect(attrMap['ai.speed']).toEqual({ stringValue: 'standard' }) + expect(attrMap['ai.session_id']).toBeUndefined() }) it('includes tools as array attribute', () => {