mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-11-01 12:01:05 +00:00
* Mark estimated costs with the same ~ in every view The ~ marker and legend that report already used now appear wherever a dollar figure is mostly a guess: the models and sessions CLI output, the desktop Models, Overview, Sessions and Settings provider rows, the macOS menu bar model rows and provider tabs, and the Windows tray. One rule decides the marker everywhere: the estimated portion is at least 1% of the figure. report, overview and the MCP tables switch to it from "any estimated cent", so a metered Codex row holding a few text-estimated turns stays unmarked. JSON is additive: estimatedCostUSD and isEstimated on models rows, estimatedCost and isEstimated on sessions rows, estimatedCostUSD on menubar providerDetails rows. Totals are unchanged. * Skip the ~ marker on figures that print as zero A sub-cent row that is fully estimated printed as "~$0.00": nothing visible to qualify. The shared helper now also requires the amount, as the surface prints it in the active currency, to show a non-zero digit. CLI, desktop, macOS and Windows helpers all apply it.
232 lines
8.4 KiB
TypeScript
232 lines
8.4 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
|
|
import { aggregateByBranch, aggregateSessions, attributeSessionPrSpend, renderJson, renderTable } from '../src/sessions-report.js'
|
|
import type { ClassifiedTurn, ParsedApiCall, ProjectSummary, SessionSummary } from '../src/types.js'
|
|
|
|
function makeProject(): ProjectSummary {
|
|
const turn: ClassifiedTurn = {
|
|
userMessage: 'build it',
|
|
timestamp: '2026-07-10T10:00:00.000Z',
|
|
sessionId: 'session-1',
|
|
category: 'feature',
|
|
retries: 0,
|
|
hasEdits: true,
|
|
assistantCalls: [{
|
|
provider: 'claude',
|
|
model: 'claude-sonnet-4-5',
|
|
usage: {
|
|
inputTokens: 100,
|
|
outputTokens: 20,
|
|
cacheCreationInputTokens: 30,
|
|
cacheReadInputTokens: 400,
|
|
cachedInputTokens: 0,
|
|
reasoningTokens: 0,
|
|
webSearchRequests: 0,
|
|
},
|
|
costUSD: 0.12,
|
|
tools: [],
|
|
mcpTools: [],
|
|
skills: [],
|
|
subagentTypes: [],
|
|
hasAgentSpawn: false,
|
|
hasPlanMode: false,
|
|
speed: 'standard',
|
|
timestamp: '2026-07-10T10:01:00.000Z',
|
|
bashCommands: [],
|
|
deduplicationKey: 'call-1',
|
|
}],
|
|
}
|
|
const session: SessionSummary = {
|
|
sessionId: 'session-1',
|
|
project: 'codeburn',
|
|
firstTimestamp: '2026-07-10T10:00:00.000Z',
|
|
lastTimestamp: '2026-07-10T10:05:00.000Z',
|
|
totalCostUSD: 0.12,
|
|
totalSavingsUSD: 0.03,
|
|
totalInputTokens: 100,
|
|
totalOutputTokens: 20,
|
|
totalReasoningTokens: 0,
|
|
totalCacheReadTokens: 400,
|
|
totalCacheWriteTokens: 30,
|
|
apiCalls: 1,
|
|
turns: [turn],
|
|
modelBreakdown: {
|
|
'claude-sonnet-4-5': { calls: 1, costUSD: 0.12, tokens: turn.assistantCalls[0]!.usage, savingsUSD: 0.03 },
|
|
},
|
|
toolBreakdown: {},
|
|
mcpBreakdown: {},
|
|
bashBreakdown: {},
|
|
categoryBreakdown: {} as SessionSummary['categoryBreakdown'],
|
|
skillBreakdown: {},
|
|
subagentBreakdown: {},
|
|
}
|
|
return {
|
|
project: 'codeburn',
|
|
projectPath: '/tmp/codeburn',
|
|
sessions: [session],
|
|
totalCostUSD: 0.12,
|
|
totalSavingsUSD: 0.03,
|
|
totalApiCalls: 1,
|
|
totalProxiedCostUSD: 0,
|
|
}
|
|
}
|
|
|
|
describe('sessions JSON emitter', () => {
|
|
it('flattens SessionSummary fields into the exact JSON row shape', () => {
|
|
const rows = aggregateSessions([makeProject()])
|
|
const parsed = JSON.parse(renderJson(rows))
|
|
|
|
expect(parsed).toEqual([{
|
|
sessionId: 'session-1',
|
|
title: '',
|
|
project: 'codeburn',
|
|
provider: 'claude',
|
|
models: ['claude-sonnet-4-5'],
|
|
cost: 0.12,
|
|
savingsUSD: 0.03,
|
|
calls: 1,
|
|
turns: 1,
|
|
inputTokens: 100,
|
|
outputTokens: 20,
|
|
cacheReadTokens: 400,
|
|
cacheWriteTokens: 30,
|
|
startedAt: '2026-07-10T10:00:00.000Z',
|
|
endedAt: '2026-07-10T10:05:00.000Z',
|
|
durationMs: 300_000,
|
|
estimatedCost: 0,
|
|
isEstimated: false,
|
|
}])
|
|
})
|
|
|
|
it('marks an estimated session row and explains the marker under the table', () => {
|
|
const project = makeProject()
|
|
project.sessions[0]!.totalEstimatedCostUSD = 0.12
|
|
const rows = aggregateSessions([project])
|
|
expect(rows[0]).toMatchObject({ cost: 0.12, estimatedCost: 0.12, isEstimated: true })
|
|
const output = renderTable(rows, { terminalWidth: 120 })
|
|
expect(output).toContain('~$0.12')
|
|
expect(output.split('\n').at(-1)).toBe('~ estimated cost (priced from estimated tokens)')
|
|
expect(renderTable(aggregateSessions([makeProject()]), { terminalWidth: 120 })).not.toContain('estimated')
|
|
})
|
|
|
|
it('does not mark a fully estimated session whose cost prints as $0.00', () => {
|
|
const project = makeProject()
|
|
project.sessions[0]!.totalCostUSD = 0.0003
|
|
project.sessions[0]!.totalEstimatedCostUSD = 0.0003
|
|
const rows = aggregateSessions([project])
|
|
expect(rows[0]).toMatchObject({ estimatedCost: 0.0003, isEstimated: false })
|
|
const output = renderTable(rows, { terminalWidth: 120 })
|
|
expect(output).toContain('$0.00')
|
|
expect(output).not.toContain('~')
|
|
})
|
|
|
|
it('renders a simple table', () => {
|
|
const output = renderTable(aggregateSessions([makeProject()]), { terminalWidth: 120 })
|
|
expect(output).toContain('Session')
|
|
expect(output).toContain('session-1')
|
|
expect(output).toContain('Sonnet 4.5')
|
|
expect(output).toContain('1 sessions')
|
|
})
|
|
|
|
it('hides home-directory slugs and renders compact agent/worktree names', () => {
|
|
const rows = aggregateSessions([makeProject()])
|
|
rows[0]!.sessionId = 'agent-a72cc958e305e4957'
|
|
rows[0]!.project = '-Users-devuser-Projects-eywa-eywa--claude-worktrees-issue-131'
|
|
const output = renderTable(rows, { terminalWidth: 160 })
|
|
|
|
expect(output).toContain('Agent a72cc958')
|
|
expect(output).toContain('eywa · issue-131')
|
|
expect(output).not.toContain('Users-devuser')
|
|
})
|
|
|
|
it('normalizes Claude dot-worktree slugs without exposing the home directory', () => {
|
|
const rows = aggregateSessions([makeProject()])
|
|
rows[0]!.project = 'Users-devuser-codeburn-.claude-worktrees-agent-a213f7c77871f483f'
|
|
const output = renderTable(rows, { terminalWidth: 120 })
|
|
|
|
expect(output).toContain('codeburn · agent a213f7c7')
|
|
expect(output).not.toContain('Users-devuser')
|
|
expect(output).not.toContain('.claude-worktrees')
|
|
})
|
|
|
|
it('uses captured titles, sorts newest first, and fits the requested width', () => {
|
|
const rows = aggregateSessions([makeProject()])
|
|
const older = { ...rows[0]!, sessionId: 'old', title: 'Older task', startedAt: '2026-07-01T10:00:00.000Z' }
|
|
const newer = {
|
|
...rows[0]!,
|
|
sessionId: 'new',
|
|
title: 'Review the authentication migration without exposing filesystem details',
|
|
project: '-Users-private-Projects-codeburn',
|
|
startedAt: '2026-07-20T10:00:00.000Z',
|
|
}
|
|
const output = renderTable([older, newer], { terminalWidth: 80 })
|
|
const lines = output.split('\n')
|
|
|
|
expect(output.indexOf('Review the')).toBeLessThan(output.indexOf('Older task'))
|
|
expect(output).not.toContain('Users-private')
|
|
expect(Math.max(...lines.slice(0, -1).map(line => line.length))).toBeLessThanOrEqual(80)
|
|
})
|
|
})
|
|
|
|
// A copilot serve set pairs some calls with an already-counted per-turn call
|
|
// (shutdown rollups, residuals, store rows). Their tokens and cost are real and
|
|
// must survive into every spend surface, but they carry no behavioral weight, so
|
|
// no user-visible calls/turns counter may count them.
|
|
function copilotCall(overrides: Partial<ParsedApiCall> & { deduplicationKey: string }): ParsedApiCall {
|
|
return { ...makeProject().sessions[0]!.turns[0]!.assistantCalls[0]!, provider: 'copilot', ...overrides }
|
|
}
|
|
|
|
function makeSupplementaryProject(): ProjectSummary {
|
|
const project = makeProject()
|
|
const session = project.sessions[0]!
|
|
const mixed = session.turns[0]!
|
|
mixed.gitBranch = 'feat/copilot'
|
|
mixed.assistantCalls = [
|
|
copilotCall({ deduplicationKey: 'behavioral-1', costUSD: 0.10 }),
|
|
copilotCall({ deduplicationKey: 'supp-1', costUSD: 0.02, supplementaryAccounting: true }),
|
|
]
|
|
session.turns.push({
|
|
...mixed,
|
|
gitBranch: undefined,
|
|
timestamp: '2026-07-10T10:02:00.000Z',
|
|
assistantCalls: [copilotCall({ deduplicationKey: 'supp-2', costUSD: 0.05, supplementaryAccounting: true })],
|
|
})
|
|
session.apiCalls = 1
|
|
session.totalCostUSD = 0.17
|
|
return project
|
|
}
|
|
|
|
describe('supplementary accounting weight', () => {
|
|
it('counts only turns with a behavioral call in the session rows', () => {
|
|
const rows = aggregateSessions([makeSupplementaryProject()])
|
|
expect(rows[0]!.turns).toBe(1)
|
|
expect(rows[0]!.calls).toBe(1)
|
|
expect(rows[0]!.cost).toBeCloseTo(0.17)
|
|
})
|
|
|
|
it('attributes supplementary spend to a PR while counting only behavioral calls', () => {
|
|
const url = 'https://github.com/acme/app/pull/7'
|
|
const { perUrl } = attributeSessionPrSpend({
|
|
turns: [
|
|
{ prRefs: [url], assistantCalls: [{ costUSD: 0.10 }, { costUSD: 0.02, supplementaryAccounting: true }] },
|
|
// Supplementary-only turn: no calls, but its cost still belongs to the PR.
|
|
{ assistantCalls: [{ costUSD: 0.05, supplementaryAccounting: true }] },
|
|
],
|
|
totalCostUSD: 0.17,
|
|
apiCalls: 1,
|
|
totalSavingsUSD: 0,
|
|
})
|
|
|
|
const pr = perUrl.get(url)!
|
|
expect(pr.calls).toBe(1)
|
|
expect(pr.cost).toBeCloseTo(0.17)
|
|
})
|
|
|
|
it('attributes supplementary spend to a branch while counting only behavioral calls', () => {
|
|
const rows = aggregateByBranch([makeSupplementaryProject()])
|
|
expect(rows).toHaveLength(1)
|
|
expect(rows[0]!.branch).toBe('feat/copilot')
|
|
expect(rows[0]!.calls).toBe(1)
|
|
expect(rows[0]!.cost).toBeCloseTo(0.17)
|
|
})
|
|
})
|