From 087656baccf7b610d7f0add758ad964151c78ab9 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Tue, 18 Aug 2026 11:59:21 -0700 Subject: [PATCH 1/3] cache: CODEBURN_CACHE_SCOPE=all forces a full shard read The month-scoped load a ranged query takes is a behaviour change on a warm cache with no way back except deleting it. Drop the scope in loadCache, the one place every caller (including the resident serve) routes through, so a suspect scoped read can be compared against a full one in place. Read policy only: deliberately not in PROVIDER_ENV_VARS, so setting or unsetting it invalidates nothing. --- CHANGELOG.md | 2 ++ docs/architecture.md | 2 ++ src/session-cache.ts | 7 +++++++ tests/session-cache-shards.test.ts | 18 ++++++++++++++++++ 4 files changed, 29 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f623e3f..7173565b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ - **Applied fixes get re-measured on every `optimize` run, and told plainly whether they worked.** After `codeburn optimize --apply`, every still-applied fix comes back in an `Applied fixes` section on subsequent `codeburn optimize` runs, carrying the verdict `act report` already computes from the same reconciliation: `worked` (at least 70% of its window-scaled estimate realized), `partial` (something, but under that), `no-effect` (no measured reduction, printed with the exact `codeburn act undo ` that puts it back), or `measuring` for anything younger than the 3-day measurement window. The numbers are measured — provider-counted usage over the post-apply window — not re-estimated. `--apply` now says when the re-measure will happen, `--format json` gains `appliedFixes[]` (add-only), and the same section appears in the dashboard TUI and the desktop app. New `codeburn optimize --auto-revert` undoes the fixes that measured no reduction at all through the same code path as `codeburn act undo`; it never touches `partial` or still-measuring fixes, and never auto-reverts a `CLAUDE.md` rule (it prints the undo command instead), matching the `--yes` guardrail. - **Optimize findings say what to do with them and where their number came from.** Every finding now carries a class and a basis, and every surface groups by it: `Fix now (apply-able)` for findings `codeburn optimize --apply` can write itself, `Habits` for the behavioural ones, `FYI` for informational ones whose cost may be justified. A finding only counts as apply-able when a plan can actually be built for that instance, so an `mcp-deferral-off` caused by Vertex policy or a shell-profile override is grouped as a habit rather than promising a fix that does not exist. Alongside it, each finding is marked `measured` (summed from provider-counted usage) or `estimated` (a schema-size or recovery-fraction model), with the split reported in the header as `N measured · M estimated` in place of the blanket "Estimates only." footer. Sessions whose cost the provider never reported are kept out of the `cost-outliers` peer comparison, and a provider that only ever estimates gets the finding marked `estimated` rather than dropped. `--format json` gains `class` and `basis` per finding plus `summary.measuredSavingsUSD` (existing fields unchanged), and the new `docs/optimize.md` covers what is scanned, exactly what `--apply` may write, and how to read the health grade. +- **`CODEBURN_CACHE_SCOPE=all` forces a full session-cache read.** A ranged query reads only the month shards that can contribute a turn to it, which is a real behaviour change on a warm cache; this is the escape hatch for the case where a number looks wrong and you want to know whether the scoped read is why. Set it and every load ignores its scope and reads every shard, one-shot runs and the resident `codeburn serve` alike. It is a read policy, not an input to any cache fingerprint: setting or unsetting it re-parses nothing and invalidates nothing. + ### Added (Windows) - **`codeburn menubar` installs and launches the tray app on Windows.** The same command that installs the macOS menubar now does the Windows one, through the same pinned-release path: it resolves `windows-v`, falls back to a scan of the newest `windows-v*` release carrying both assets when that tag has none, downloads the `.msi` with the same retry and backoff, and verifies its sha256 before anything executes it — a mismatch aborts without ever handing the file to the installer. It then runs `msiexec` out of `%SystemRoot%\System32` (never a bare name, so nothing dropped next to the CLI can impersonate it) with `/i /passive /norestart`, treats exit 3010 as installed-pending-restart and 1602 as a cancelled install rather than failures, and launches the exe named by the product's Uninstall registry key. An already-installed matching version skips the download and just launches; `--force` reinstalls. - **A menubar app for Windows.** `windows/` is a Tauri 2 tray app — Rust binary, React popover — that puts today's spend in the notification area and mirrors the macOS menubar screen for screen: agent tabs, period switcher, Trend, Forecast, Pulse, Stats and Plan insights, activity and model breakdowns, optimize findings, CSV/JSON export, launch at login, currency, and theme. Windows has no menubar title, so the number lives in a second tray icon rendered from the system font at the panel's native icon size (Settings can turn it off; the tooltip always carries it). It reads everything through the CLI like the macOS and GNOME clients do, and gates on **codeburn 0.9.9 or newer** — the first release accepting `status --format menubar-json --no-optimize` — showing a setup screen with the install command until it finds one. Refresh follows popover visibility the way the macOS app does: 60 s with optimize findings while open, 2 minutes for today's total while closed, and immediately on open when what you are looking at has gone stale. The Claude quota view never spends Claude's single-use refresh token; on a 401 it re-reads Claude Code's own credential file for a token it has already rotated, matching the macOS client. Ships as an unsigned `.msi` from the `windows-v*` tag, which `codeburn menubar` now installs for you. The same crate still builds and runs a tray on Linux, but that stays experimental and unreleased — `gnome/` is the supported Linux surface. diff --git a/docs/architecture.md b/docs/architecture.md index 3b949bb4..5ec39e28 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -142,6 +142,8 @@ Three caches under `~/.cache/codeburn/` (override with `CODEBURN_CACHE_DIR`): 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. +The session cache (`src/session-cache.ts`) sits beside them as a directory of per-provider-month shards. A date-ranged query reads only the shards whose months can contribute a turn to that range; `CODEBURN_CACHE_SCOPE=all` turns that off and reads every shard, whatever the range. It is a read policy only — it is not part of any provider's env fingerprint, so setting or unsetting it never invalidates the cache. + ### Optimize Detectors `src/optimize.ts` exports 20 detectors. Each returns a `WasteFinding | null`. They are composed by `runOptimize()` which collects findings, ranks them by impact, and returns them with `WasteAction` objects (paste-to-CLAUDE.md, paste-to-session-opener, prompt-now, edit shell config). diff --git a/src/session-cache.ts b/src/session-cache.ts index 3bb37344..c4d0f9e0 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -792,8 +792,15 @@ async function loadShard(path: string): Promise | nul * full: the first because its cache is the only surviving record of pruned * usage, the second because a fingerprint change discards the whole section and * must see every entry it is discarding. + * + * `CODEBURN_CACHE_SCOPE=all` is the escape hatch: it drops the scope here, at + * the one place every caller routes through, so a suspect scoped read can be + * compared against a full one without a rebuild. It is a READ policy and + * deliberately not part of any env fingerprint (PROVIDER_ENV_VARS) — setting or + * unsetting it must never invalidate a cache, only change how much of it is read. */ export async function loadCache(scope?: CacheLoadScope): Promise { + if (process.env['CODEBURN_CACHE_SCOPE'] === 'all') scope = undefined const dir = sessionCacheDir() const envelope = await readEnvelope(dir) if (!envelope) return afterMissingShardCache() diff --git a/tests/session-cache-shards.test.ts b/tests/session-cache-shards.test.ts index ff53c9b6..60ef069b 100644 --- a/tests/session-cache-shards.test.ts +++ b/tests/session-cache-shards.test.ts @@ -516,6 +516,24 @@ describe('scoped load', () => { .toEqual(['/live/apr.jsonl', '/live/jun.jsonl', '/live/mar.jsonl']) }) + it('CODEBURN_CACHE_SCOPE=all reads every month and memoizes as unscoped', async () => { + await seedThreeMonths() + clearLoadCacheMemo() + const unscoped = await loadCache() + + clearLoadCacheMemo() + process.env['CODEBURN_CACHE_SCOPE'] = 'all' + try { + const forced = await loadCache(juneScope) + expect(forced).toEqual(unscoped) + // Memoized as a full load, so a resident serve reuses it for any range. + delete process.env['CODEBURN_CACHE_SCOPE'] + expect(await loadCache(juneScope)).toBe(forced) + } finally { + delete process.env['CODEBURN_CACHE_SCOPE'] + } + }) + it('never scopes a provider whose fingerprint moved, or a durable one', async () => { const cache: SessionCache = { version: CACHE_VERSION, From 39075edd502bc4568c8ab664202eb212bb492bc9 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Tue, 18 Aug 2026 12:00:29 -0700 Subject: [PATCH 2/3] parser: keep promptSource on lines over 32 KB parseLargeJsonl dropped promptSource for exactly the lines SDK-generated prompts live on, so the recurring-context detector regex-scanned the ends of the raw line for it. Add the field to LARGE_ROOT_FIELDS (tiny scalar, add-only, isSidechain already there) and delete the workaround: it read only 2 KB from each end, so a flag further in was missed. No cache change: optimize scans the raw JSONL each run, so promptSource never has to persist on CachedFile. Fixes #1030. With #994 this closes #1023. --- CHANGELOG.md | 2 +- src/optimize.ts | 22 ++++++---------------- src/parser.ts | 4 +++- tests/optimize-fs.test.ts | 8 +++----- tests/parser-large-json-scanner.test.ts | 18 ++++++++++++++++++ 5 files changed, 31 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7173565b..19269865 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added - **`codeburn models --unpriced`.** The dashboard warns about models that price at $0 and points at `codeburn model-alias`, but the list itself was hard to get out of the TUI. This filters the plain-stdout `models` report to exactly those rows, reusing `findUnpricedModels` so local, free, aliased and price-overridden models are treated the same way the warning treats them, and defaulting that mode's min-cost to 0 so $0 rows are not pre-filtered away. Thanks @kocaemre. (#969) -- **`optimize` spots the same long block pasted at the start of many sessions.** The new `recurring-context` detector groups sessions by their opening block — normalized for whitespace and ANSI, hashed over the first 2 KB — and reports a block of at least 1.5 KB that opens 5 or more sessions, with the top three by tokens, their session counts and the project each is confined to. It is a habit, not an apply-able fix: CodeBurn will not move your own text into `CLAUDE.md` for you, so the finding asks Claude to give the block a permanent home (a `CLAUDE.md` rule, or a file read on demand) and hand back a one-line pointer to open sessions with instead. Savings count the repeats only, never the first paste, and are marked `estimated`: provider usage is counted per API call, where the pasted block is mixed in with the system prompt, tool schemas and `CLAUDE.md`, so the block is sized from its own bytes. Injected system reminders and slash-command wrappers are not pastes and are skipped, and neither is a prompt a program wrote — an SDK session or a subagent task — read from the entry's flags, or off the ends of the raw line when the entry is too large for the parser to keep them. The opening block comes from the session scan that already runs, so nothing extra is read from disk. +- **`optimize` spots the same long block pasted at the start of many sessions.** The new `recurring-context` detector groups sessions by their opening block — normalized for whitespace and ANSI, hashed over the first 2 KB — and reports a block of at least 1.5 KB that opens 5 or more sessions, with the top three by tokens, their session counts and the project each is confined to. It is a habit, not an apply-able fix: CodeBurn will not move your own text into `CLAUDE.md` for you, so the finding asks Claude to give the block a permanent home (a `CLAUDE.md` rule, or a file read on demand) and hand back a one-line pointer to open sessions with instead. Savings count the repeats only, never the first paste, and are marked `estimated`: provider usage is counted per API call, where the pasted block is mixed in with the system prompt, tool schemas and `CLAUDE.md`, so the block is sized from its own bytes. Injected system reminders and slash-command wrappers are not pastes and are skipped, and neither is a prompt a program wrote — an SDK session or a subagent task — read from the entry's flags, which survive the parser's large-line path. The opening block comes from the session scan that already runs, so nothing extra is read from disk. - **Applied fixes get re-measured on every `optimize` run, and told plainly whether they worked.** After `codeburn optimize --apply`, every still-applied fix comes back in an `Applied fixes` section on subsequent `codeburn optimize` runs, carrying the verdict `act report` already computes from the same reconciliation: `worked` (at least 70% of its window-scaled estimate realized), `partial` (something, but under that), `no-effect` (no measured reduction, printed with the exact `codeburn act undo ` that puts it back), or `measuring` for anything younger than the 3-day measurement window. The numbers are measured — provider-counted usage over the post-apply window — not re-estimated. `--apply` now says when the re-measure will happen, `--format json` gains `appliedFixes[]` (add-only), and the same section appears in the dashboard TUI and the desktop app. New `codeburn optimize --auto-revert` undoes the fixes that measured no reduction at all through the same code path as `codeburn act undo`; it never touches `partial` or still-measuring fixes, and never auto-reverts a `CLAUDE.md` rule (it prints the undo command instead), matching the `--yes` guardrail. - **Optimize findings say what to do with them and where their number came from.** Every finding now carries a class and a basis, and every surface groups by it: `Fix now (apply-able)` for findings `codeburn optimize --apply` can write itself, `Habits` for the behavioural ones, `FYI` for informational ones whose cost may be justified. A finding only counts as apply-able when a plan can actually be built for that instance, so an `mcp-deferral-off` caused by Vertex policy or a shell-profile override is grouped as a habit rather than promising a fix that does not exist. Alongside it, each finding is marked `measured` (summed from provider-counted usage) or `estimated` (a schema-size or recovery-fraction model), with the split reported in the header as `N measured · M estimated` in place of the blanket "Estimates only." footer. Sessions whose cost the provider never reported are kept out of the `cost-outliers` peer comparison, and a provider that only ever estimates gets the finding marked `estimated` rather than dropped. `--format json` gains `class` and `basis` per finding plus `summary.measuredSavingsUSD` (existing fields unchanged), and the new `docs/optimize.md` covers what is scanned, exactly what `--apply` may write, and how to read the health grade. diff --git a/src/optimize.ts b/src/optimize.ts index 425792e8..2ecd348b 100644 --- a/src/optimize.ts +++ b/src/optimize.ts @@ -663,22 +663,12 @@ function normalizeOpener(text: string): string { return stripAnsi(text).replace(/\s+/g, ' ').trim() } -const MACHINE_PROMPT_HEAD_BYTES = 2048 -const MACHINE_PROMPT_PATTERN = /"promptSource"\s*:\s*"sdk"|"isSidechain"\s*:\s*true/ - /// True when a program wrote this prompt rather than a person pasting it: an /// SDK caller, or a parent agent writing a subagent's task. Either repeats by -/// design and has no home in CLAUDE.md. A user entry over the parser's -/// large-line threshold — routine for generated prompts — comes back without -/// its root flags, so those are read off the raw line instead: the ends of it, -/// since the fields sit either side of the message that made the line large. -function isMachineWrittenPrompt(entry: Record, line: string | Buffer): boolean { - if (entry['promptSource'] === 'sdk' || entry['isSidechain'] === true) return true - const edge = (start: number, end: number): string => - typeof line === 'string' ? line.slice(start, end) : line.subarray(start, end).toString('utf-8') - const head = edge(0, MACHINE_PROMPT_HEAD_BYTES) - const tail = edge(Math.max(MACHINE_PROMPT_HEAD_BYTES, line.length - MACHINE_PROMPT_HEAD_BYTES), line.length) - return MACHINE_PROMPT_PATTERN.test(head) || MACHINE_PROMPT_PATTERN.test(tail) +/// design and has no home in CLAUDE.md. Both flags survive the parser's +/// large-line path, which is where generated prompts routinely land. +function isMachineWrittenPrompt(entry: Record): boolean { + return entry['promptSource'] === 'sdk' || entry['isSidechain'] === true } /// A session's opening block, or null when it is too small to matter or is @@ -768,7 +758,7 @@ export async function scanJsonlFile( userMessages.push(msgContent.slice(0, OPTIMIZE_TEXT_CAP)) if (!sawUserText) { sawUserText = true - const opener = isMachineWrittenPrompt(entry, line) ? null : toSessionOpener(msgContent, project) + const opener = isMachineWrittenPrompt(entry) ? null : toSessionOpener(msgContent, project) if (opener) openers.push(opener) } } else if (Array.isArray(msgContent)) { @@ -781,7 +771,7 @@ export async function scanJsonlFile( remaining -= text.length if (!sawUserText) { sawUserText = true - const opener = isMachineWrittenPrompt(entry, line) ? null : toSessionOpener(block.text, project) + const opener = isMachineWrittenPrompt(entry) ? null : toSessionOpener(block.text, project) if (opener) openers.push(opener) } } diff --git a/src/parser.ts b/src/parser.ts index 6b8a75fc..d67aaab4 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -557,7 +557,7 @@ function extractObjectFields( return captured } -const LARGE_ROOT_FIELDS = ['type', 'timestamp', 'sessionId', 'cwd', 'gitBranch', 'attachment', 'message', 'isSidechain'] as const +const LARGE_ROOT_FIELDS = ['type', 'timestamp', 'sessionId', 'cwd', 'gitBranch', 'attachment', 'message', 'isSidechain', 'promptSource'] as const const LARGE_ASSISTANT_MESSAGE_FIELDS = ['model', 'usage', 'id', 'content'] as const function parseLargeJsonl(line: string | Buffer): JournalEntry | null { @@ -578,10 +578,12 @@ function parseLargeJsonl(line: string | Buffer): JournalEntry | null { const sessionId = readJsonString(source, root['sessionId']) const cwd = readJsonString(source, root['cwd']) const gitBranch = readJsonString(source, root['gitBranch']) + const promptSource = readJsonString(source, root['promptSource']) if (timestamp !== undefined) entry.timestamp = timestamp if (sessionId !== undefined) entry.sessionId = sessionId if (cwd !== undefined) entry.cwd = cwd if (gitBranch !== undefined) entry.gitBranch = gitBranch + if (promptSource !== undefined) entry.promptSource = promptSource const addedNames = extractLargeAddedNames(source, root['attachment']) if (addedNames.length > 0) { ;(entry as Record)['attachment'] = { type: 'deferred_tools_delta', addedNames } diff --git a/tests/optimize-fs.test.ts b/tests/optimize-fs.test.ts index 60b64b4f..96110ba5 100644 --- a/tests/optimize-fs.test.ts +++ b/tests/optimize-fs.test.ts @@ -551,17 +551,15 @@ describe('detectRecurringContext', () => { expect(detectRecurringContext(openers)).toBeNull() }) - // Over 32 KB the JSONL parser returns a reduced entry without the root - // flags, so the markers have to be read off the raw line. - it('skips machine-written prompts too large for the parser to keep flags on', async () => { + // Over 32 KB the JSONL parser returns a reduced entry; the root flags are + // part of that reduction, so the markers survive. + it('skips machine-written prompts on lines too large for a full parse', async () => { const root = makeFixtureRoot() const now = new Date().toISOString() const huge = BRIEF + 'x'.repeat(40_000) const openers: SessionOpener[] = [] for (let i = 0; i < 6; i++) { const filePath = join(root, `huge-${i}.jsonl`) - // Field order matters: the flags land past the head, behind the very - // message that made the line large. writeFile(filePath, JSON.stringify({ isSidechain: false, type: 'user', message: { content: huge }, timestamp: now, promptSource: 'sdk', })) diff --git a/tests/parser-large-json-scanner.test.ts b/tests/parser-large-json-scanner.test.ts index 00ebe5de..d483eeee 100644 --- a/tests/parser-large-json-scanner.test.ts +++ b/tests/parser-large-json-scanner.test.ts @@ -44,7 +44,25 @@ function largeAssistantLine(): string { }) } +// The fields sit either side of the message that makes the line large, which +// is where a generated prompt puts them in the wild. +function largeMachineWrittenLine(): string { + return JSON.stringify({ + isSidechain: true, + type: 'user', + message: { role: 'user', content: 'brief ' + 'x'.repeat(40_000) }, + timestamp: '2026-05-01T00:00:00Z', + promptSource: 'sdk', + }) +} + describe('large JSONL compact scanner', () => { + it('keeps the flags marking a program-written prompt', () => { + const parsed = parseJsonlLine(largeMachineWrittenLine()) + expect(parsed?.promptSource).toBe('sdk') + expect(parsed?.isSidechain).toBe(true) + }) + it('extracts user text from array content without full JSON.parse', () => { const parsed = parseJsonlLine(largeUserLine()) expect(parsed?.type).toBe('user') From 37796a568e83376d0b1a6121f461b5ebb2fa781b Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Tue, 18 Aug 2026 12:01:12 -0700 Subject: [PATCH 3/3] models: distinguish grok-4.5-build from grok-4.5 in reports Reports bucket rows by model id and label them afterwards, so the two ids collapsing onto one display name printed what looked like the same row twice with different numbers. Give the variant its own SHORT_NAMES entry, which the longest-first match picks over the grok-4.5 prefix. Display only: ids are untouched, so nothing re-parses and no cost moves. Fixed in the shared table rather than the grok provider so the menubar and model-breakdown, which call getShortModelName directly, get it too. Fixes #1029. --- CHANGELOG.md | 1 + src/models.ts | 4 ++++ tests/providers/grok.test.ts | 7 +++++++ 3 files changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19269865..04390407 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ - **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 +- **`models` and `audit` no longer show two identical `Grok 4.5` rows.** `grok-4.5-build` — the Grok Build harness's variant id — fell into the `grok-4.5` display entry by prefix, and since rows bucket by model id, not display name, the two came out as visually identical rows with different numbers. The variant now shows as `Grok 4.5 (build)`. Display only: no id is rewritten and no cost moves. (#1029) - **The session chart legend now leads with a visible session disambiguator and title instead of the project path.** Every series in a monorepo shared the same project prefix, so the only thing separating them was a truncated hex fragment — and per-application cost attribution is the main reason to open that chart. `SessionSummary.title` is already parsed and already rendered in the Context tab; the legend now puts the short session id first, prefers the title, and falls back to the previous project-based label when a session never produced one. Titles come from transcripts, so they are stripped of ANSI and control characters and capped before they reach either the legend or the tooltip. (#997) - **Context-bloat detection now counts reasoning tokens as generated output.** `detectContextBloat` divided context by `totalOutputTokens` alone, but reasoning is stored beside output rather than inside it, so for every reasoning-bearing provider the detector saw a fraction of the tokens actually generated and invented findings - a session whose real ratio was 20:1, under the 25:1 threshold, was reported as 133:1 and "high impact". It now uses the same `output + reasoning` sum the reports use, which corrects grok, codex, kiro, hermes, qwen and cursor-agent alike. - **The unpriced-models warning in the dashboard is now readable at every terminal width.** It lived in a fixed-width panel with an inline model list and a fix command, so it clipped mid-name at 80 columns and clipped *earlier* at 200, where the three-column layout narrows each panel - neither the affected models nor a runnable command survived. The panel line is now a pointer, `! N unpriced: codeburn models --unpriced` (shortened to `! N: codeburn models --unpriced` below 45 columns of panel), and the model list moves to that command's plain output, which is full width, copyable, and lists every model rather than the first two. The command's hint no longer reads as an unconditional instruction to alias: a subscription or flat-rate model is correctly $0, and mapping it onto another model's per-token rate would invent spend that was never billed. Provider-supplied model IDs are now stripped of terminal control characters in every human-readable report rather than only on the unpriced path, and `--unpriced` shows raw IDs instead of friendly names because `model-alias` keys on the raw ID. (#969) diff --git a/src/models.ts b/src/models.ts index dbe1c49d..c0e6746f 100644 --- a/src/models.ts +++ b/src/models.ts @@ -947,6 +947,10 @@ const SHORT_NAMES: Record = { // The Grok Build harness reports the model it runs (`grok-4.5`), so this is // the model's own name; `grok-build*` ids still resolve to "Grok Build". 'grok-4.5': 'Grok 4.5', + // The harness also reports a `-build` variant of that model. It is a distinct + // id and reports bucket by id, so without its own entry the prefix match gave + // it the same name as `grok-4.5` and the report showed two identical rows. + 'grok-4.5-build': 'Grok 4.5 (build)', // ClinePass routes models as `cline-pass/`; getShortModelName's path // fallback strips the prefix and re-resolves the bare slug through this // table, the same way it handles `accounts/fireworks/models/`. diff --git a/tests/providers/grok.test.ts b/tests/providers/grok.test.ts index 24585e29..eba7dc2b 100644 --- a/tests/providers/grok.test.ts +++ b/tests/providers/grok.test.ts @@ -490,6 +490,13 @@ describe('grok provider - display names', () => { expect(provider.modelDisplayName('grok-build')).toBe('Grok Build') }) + // Two distinct ids, so two rows; identical names made them look like one row + // printed twice (#1029). + it('distinguishes the build variant of a model from the model itself', () => { + expect(provider.modelDisplayName('grok-4.5')).toBe('Grok 4.5') + expect(provider.modelDisplayName('grok-4.5-build')).toBe('Grok 4.5 (build)') + }) + it('normalizes tool names', () => { expect(provider.toolDisplayName('run_terminal_command')).toBe('Bash') expect(provider.toolDisplayName('mystery_tool')).toBe('mystery_tool')