codeburn/tests/model-efficiency.test.ts
Resham Joshi b8cc0edcbf
Some checks are pending
CI / semgrep (push) Waiting to run
macOS Menubar CI / test (push) Waiting to run
Tests / desktop (push) Waiting to run
Tests / test (22) (push) Waiting to run
Tests / test (22.13.0) (push) Waiting to run
Tests / test (24) (push) Waiting to run
Tests / test (26) (push) Waiting to run
Tests / test-platforms (macos-latest) (push) Waiting to run
Tests / test-platforms (windows-latest) (push) Waiting to run
Windows Menubar CI / check (windows-latest) (push) Waiting to run
Windows Menubar CI / check (ubuntu-latest) (push) Waiting to run
0.9.25 round 2: fixes found testing the built apps (#1517)
* fix: open menu bar Settings centered on the active screen

The window was positioned at a 520x380 placeholder while SwiftUI's own
minimum is 880x620, so the grown window kept its top-left corner and ran
off the bottom-right edge. Size the window from the view's minimum, place
it with a tested pure function, and honour a saved frame only while it
still fits on one of today's screens.

* fix: say where the telemetry toggle is managed when the desktop app owns it

A desktop telemetry.v1.json with enabled:false locks the menu bar toggle
off, so clicking it does nothing. Name the desktop app's Privacy & data
pane in the caption, and pin the four desktop/veto combinations plus the
standalone case in tests.

* fix(desktop): export the provider id, and never swallow a rejected setter

The export pane built its provider list from `current.providers`, which is
keyed on the lowercased display name. Five of the seventeen providers on a
real corpus ("cursor agent", "grok build", "kimi code", "hermes agent",
"grok bot") are not valid ids, so vProvider rejected them as bad-args.
`exportNow` had a try/finally and no catch, so the rejection produced no
toast and no file: the button did nothing. Use detectedProviders, which
already resolves ids from providerDetails, and toast on rejection.

The telemetry consent toggle swallowed its rejection the same way.

* fix(desktop): warm only the selected period, and only while visible

The post-paint sweep warmed all six standard periods x seven reports plus
the provider overviews: up to 48 serve-routed queries, ~18 CPU-seconds a
minute for five minutes and 2.79 GB peak RSS on a real corpus, and it ran
with the window hidden. It now warms the selected period's reports (its
own overview is already under that memo key from the visible poll) and
the other providers' headlines for that same period, holding before every
request while a user fetch is in flight or the window is hidden. Other
periods take the ordinary on-demand path.

Also corrects two comments that claimed background priority lets a click
jump ahead: spawnCli takes the serve fast path before priority is read,
and the resident child answers FIFO.

* fix(desktop): answer the click, report the write, tell the truth about the Claude login

Nine bridge setters in Settings attached only .then(), so a rejected
envelope produced no toast and the control read as if it had ignored the
click. One shared helper toasts the rejection, per pane fallback wording.

Telemetry save() swallowed write errors while setEnabled returned the
in-memory status, so the UI could show ON while the file the macOS menu
bar app reads still said OFF. The setters now carry persisted:boolean and
the renderer toasts when it is false; the write goes through the existing
atomic writeFileAtomic (temp file, rename) so a crash cannot tear it.

The Claude card called a logged-in Claude Code 2.x user "disconnected"
forever, because the credential lives only in the keychain and the first
poll never reads it. A new darwin-only keychainUnchecked state says we
have not looked yet and offers the forced read, keeping the login
instructions for the genuinely-logged-out case. ConnectAffordance's
toggle is renamed to what it does: it discloses instructions.

* fix: retry tax is the cost of retried edit turns, not a multiple of spend

* stop the change guard watching a Warp path it can never stat

* apply a menu bar language change in place instead of relaunching

* keep honoring WARP_DB_PATH in the change guard

* restart a menu bar too old to watch AppleLanguages

* one rebuild per language change, and no global bundle in tests

* fix(desktop): a forced quota refresh must not join the poll it just aborted

Check now / Refresh / ⌘R invalidated the quota service and then handed the
caller the background fetch it had just aborted, so every provider failed
its generation check and resolved disconnected: the click repainted every
card as logged out without ever reading the keychain, and Claude showed
"Not connected. Log in with the Claude CLI." A call now joins an in-flight
run only when that run is at least as privileged, so a keychain-allowed
call starts its own; a second forced click still joins the first, which
keeps the single macOS prompt. A superseded run hands its caller the run
that replaced it instead of its own abort artefacts.

retainOnFailure also holds accessDenied through a keychainUnchecked poll,
so the "Allow in keychain" guidance is not replaced by "Check now" one
cycle after the user denied the prompt.

* fix(desktop): stop the sweep churning, and never spend a query on an abandoned one

The prefetch effect depended on the detected-provider entries, whose costs
move on every overview poll, so it was torn down and restarted each cadence
tick: a warm slower than the interval was discarded and re-issued forever.
It now depends on the provider ids alone, and a finished warm is kept for
its own period/provider key even if the effect was torn down meanwhile —
only a config-generation change makes a result wrong.

Both loops re-test cancellation and the memo after the hold, which has no
bound, so an abandoned sweep issues nothing and a section the user visited
meanwhile is not parsed again. The hidden half of the hold waits on
visibilitychange instead of waking the renderer every two seconds for a
window nobody is looking at.

* fix(desktop): never offer a provider id the CLI will reject, and keep the keychain note

detectedProviders applied the round-trip filter only to the legacy
providers map; a providerDetails id with a space reached the picker and the
export as a --provider value the main process rejects.

The Plans settings row showed the "not read your login yet" line without
the note that macOS may ask once for keychain access, which the Plans card
does show.
2026-09-21 13:53:45 +02:00

180 lines
5.8 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { aggregateModelEfficiency, buildRetryTax } from '../src/model-efficiency.js'
import type { ClassifiedTurn, ParsedApiCall, ProjectSummary, SessionSummary } from '../src/types.js'
function call(model: string, costUSD = 1): ParsedApiCall {
return {
provider: 'claude',
model,
usage: {
inputTokens: 100,
outputTokens: 50,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
},
costUSD,
tools: ['Edit'],
mcpTools: [],
skills: [],
hasAgentSpawn: false,
hasPlanMode: false,
speed: 'standard',
timestamp: '2026-05-05T00:00:00Z',
bashCommands: [],
deduplicationKey: `${model}-${costUSD}`,
}
}
function turn(model: string, opts: { hasEdits?: boolean; retries?: number; costUSD?: number } = {}): ClassifiedTurn {
return {
userMessage: '',
assistantCalls: [call(model, opts.costUSD ?? 1)],
timestamp: '2026-05-05T00:00:00Z',
sessionId: 's1',
category: 'coding',
retries: opts.retries ?? 0,
hasEdits: opts.hasEdits ?? true,
}
}
function multiModelTurn(calls: ParsedApiCall[], opts: { retries?: number; hasEdits?: boolean } = {}): ClassifiedTurn {
return {
userMessage: '',
assistantCalls: calls,
timestamp: '2026-05-05T00:00:00Z',
sessionId: 's1',
category: 'coding',
retries: opts.retries ?? 0,
hasEdits: opts.hasEdits ?? true,
}
}
function project(turns: ClassifiedTurn[]): ProjectSummary {
const session: SessionSummary = {
sessionId: 's1',
project: 'app',
firstTimestamp: '2026-05-05T00:00:00Z',
lastTimestamp: '2026-05-05T00:00:00Z',
totalCostUSD: turns.reduce((sum, t) => sum + t.assistantCalls.reduce((s, c) => s + c.costUSD, 0), 0),
totalInputTokens: 0,
totalOutputTokens: 0,
totalCacheReadTokens: 0,
totalCacheWriteTokens: 0,
apiCalls: turns.reduce((sum, t) => sum + t.assistantCalls.length, 0),
turns,
modelBreakdown: {},
toolBreakdown: {},
mcpBreakdown: {},
bashBreakdown: {},
categoryBreakdown: {} as SessionSummary['categoryBreakdown'],
skillBreakdown: {},
}
return {
project: 'app',
projectPath: '/app',
sessions: [session],
totalCostUSD: session.totalCostUSD,
totalApiCalls: session.apiCalls,
}
}
describe('aggregateModelEfficiency', () => {
it('computes one-shot, retry, and cost-per-edit metrics by display model', () => {
const stats = aggregateModelEfficiency([project([
turn('claude-sonnet-4-5', { hasEdits: true, retries: 0, costUSD: 2 }),
turn('claude-sonnet-4-5', { hasEdits: true, retries: 2, costUSD: 4 }),
turn('claude-opus-4-6', { hasEdits: true, retries: 0, costUSD: 10 }),
turn('claude-sonnet-4-5', { hasEdits: false, retries: 0, costUSD: 3 }),
])])
const sonnet = stats.get('Sonnet 4.5')
expect(sonnet?.editTurns).toBe(2)
expect(sonnet?.oneShotTurns).toBe(1)
expect(sonnet?.oneShotRate).toBe(50)
expect(sonnet?.retriesPerEdit).toBe(1)
expect(sonnet?.costPerEditUSD).toBe(3)
const opus = stats.get('Opus 4.6')
expect(opus?.oneShotRate).toBe(100)
})
it('returns no stats for non-edit turns', () => {
const stats = aggregateModelEfficiency([project([
turn('claude-sonnet-4-5', { hasEdits: false }),
])])
expect(stats.size).toBe(0)
})
it('attributes a multi-model turn to the first non-synthetic model', () => {
const stats = aggregateModelEfficiency([project([
multiModelTurn([
call('<synthetic>', 0),
call('claude-opus-4-6', 2),
call('claude-sonnet-4-5', 1),
], { retries: 0, hasEdits: true }),
])])
expect(stats.has('Opus 4.6')).toBe(true)
expect(stats.has('Sonnet 4.5')).toBe(false)
expect(stats.has('<synthetic>')).toBe(false)
const opus = stats.get('Opus 4.6')!
expect(opus.editTurns).toBe(1)
expect(opus.oneShotTurns).toBe(1)
expect(opus.costPerEditUSD).toBe(3)
})
it('skips a turn whose calls are all synthetic', () => {
const stats = aggregateModelEfficiency([project([
multiModelTurn([
call('<synthetic>', 0),
call('<synthetic>', 0),
], { retries: 0, hasEdits: true }),
])])
expect(stats.size).toBe(0)
})
})
describe('buildRetryTax', () => {
it('charges the real cost of a retried edit turn, not a multiple of it', () => {
const tax = buildRetryTax(aggregateModelEfficiency([project([
turn('claude-sonnet-4-5', { retries: 5, costUSD: 10 }),
])]).values())
expect(tax.totalUSD).toBe(10)
expect(tax.retries).toBe(5)
expect(tax.editTurns).toBe(1)
expect(tax.byModel[0]).toMatchObject({ name: 'Sonnet 4.5', taxUSD: 10, retries: 5 })
})
it('stays a slice of edit spend when retries outnumber edit turns', () => {
const projects = [project([
turn('claude-sonnet-4-5', { retries: 3, costUSD: 10 }),
turn('claude-sonnet-4-5', { retries: 2, costUSD: 5 }),
turn('claude-opus-4-6', { retries: 0, costUSD: 20 }),
turn('claude-opus-4-6', { retries: 4, costUSD: 8 }),
turn('claude-sonnet-4-5', { hasEdits: false, retries: 0, costUSD: 7 }),
])]
const stats = [...aggregateModelEfficiency(projects).values()]
const tax = buildRetryTax(stats)
const editCost = stats.reduce((s, m) => s + m.editCostUSD, 0)
const totalCost = projects[0]!.sessions[0]!.totalCostUSD
expect(tax.totalUSD).toBe(23)
expect(tax.totalUSD).toBeLessThanOrEqual(editCost)
expect(editCost).toBeLessThanOrEqual(totalCost)
expect(tax.editTurns).toBe(3)
expect(tax.retries).toBe(9)
for (const m of stats) {
expect(m.retriedTurns).toBeLessThanOrEqual(m.editTurns)
expect(m.retries).toBeGreaterThanOrEqual(m.retriedTurns)
expect(m.retries === 0).toBe(m.retriedTurns === 0)
}
})
})