diff --git a/README.md b/README.md index ddf315f6..4231ea9a 100644 --- a/README.md +++ b/README.md @@ -447,6 +447,7 @@ Sync sends token counts, costs, models, and projects — never prompts or code. |---------|--------------| | `codeburn models` | Per-model token + cost table (last 30 days) | | `codeburn models --by-task` | Break each model into per-task-type rows | +| `codeburn models --by-agent` | Break each model into per-agent rows (which agent drove which model's spend); Claude subagent transcripts only, other providers and main sessions bucket under "main" | | `codeburn models --top 10` | Only the 10 most expensive models | | `codeburn models --format markdown` | Emit a paste-friendly markdown table | | `codeburn models --task feature` | Filter to feature-development work | diff --git a/src/main.ts b/src/main.ts index 1e622315..8c9f0f81 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1895,19 +1895,24 @@ program program .command('models') - .description('Per-model token + cost table, optionally exploded by task type') + .description('Per-model token + cost table, optionally exploded by task type or agent') .option('-p, --period ', 'Analysis period: today, week, 30days, month, all', '30days') .option('--from ', 'Custom range start (YYYY-MM-DD)') .option('--to ', 'Custom range end (YYYY-MM-DD)') .option('--provider ', 'Filter by provider (e.g. claude, codex, cursor)', 'all') .option('--task ', 'Filter to one task type (e.g. feature, debugging, refactoring)') .option('--by-task', 'One row per (provider, model, task) instead of one row per (provider, model)') + .option('--by-agent', 'One row per (provider, model, agent) instead of one row per (provider, model). Claude subagent transcripts only; other providers and main sessions bucket under "main"') .option('--top ', 'Show only the top N rows', (v: string) => parseInt(v, 10)) .option('--min-cost ', 'Hide rows below this cost threshold', (v: string) => parseFloat(v)) .option('--no-totals', 'Suppress the footer totals row') .option('--format ', 'Output format: table, markdown, json, csv', 'table') .action(async (opts) => { assertProvider(opts.provider, 'models') + if (opts.byTask && opts.byAgent) { + process.stderr.write('codeburn: --by-task and --by-agent cannot be combined. Pick one breakdown.\n') + process.exit(1) + } const { aggregateModels, renderTable, renderMarkdown, renderJson, renderCsv } = await import('./models-report.js') await loadPricing() @@ -1926,6 +1931,7 @@ program const projects = await parseAllSessions(range, opts.provider) const rows = await aggregateModels(projects, { byTask: !!opts.byTask, + byAgent: !!opts.byAgent, taskFilter: opts.task, topN: typeof opts.top === 'number' && Number.isFinite(opts.top) ? opts.top : undefined, minCost: typeof opts.minCost === 'number' && Number.isFinite(opts.minCost) ? opts.minCost : 0.01, @@ -1939,11 +1945,11 @@ program if (fmt === 'json') { process.stdout.write(renderJson(rows) + '\n') } else if (fmt === 'csv') { - process.stdout.write(renderCsv(rows, { byTask: !!opts.byTask }) + '\n') + process.stdout.write(renderCsv(rows, { byTask: !!opts.byTask, byAgent: !!opts.byAgent }) + '\n') } else if (fmt === 'markdown' || fmt === 'md') { - process.stdout.write(renderMarkdown(rows, { byTask: !!opts.byTask, showTotals: opts.totals !== false }) + '\n') + process.stdout.write(renderMarkdown(rows, { byTask: !!opts.byTask, byAgent: !!opts.byAgent, showTotals: opts.totals !== false }) + '\n') } else if (fmt === 'table') { - process.stdout.write(renderTable(rows, { byTask: !!opts.byTask, showTotals: opts.totals !== false }) + '\n') + process.stdout.write(renderTable(rows, { byTask: !!opts.byTask, byAgent: !!opts.byAgent, showTotals: opts.totals !== false }) + '\n') } else { process.stderr.write(`codeburn: unknown --format "${opts.format}". Choose table, markdown, json, or csv.\n`) process.exit(1) diff --git a/src/models-report.ts b/src/models-report.ts index fc041eca..393e3de1 100644 --- a/src/models-report.ts +++ b/src/models-report.ts @@ -12,6 +12,11 @@ export type ModelReportRow = { model: string modelDisplayName: string category: TaskCategory | null + /// Set only in `byAgent` mode: the Claude subagent type this row's spend ran + /// under (`general-purpose`, `Explore`, a workflow agent, …), or `'main'` for + /// ordinary non-agent sessions and every non-Claude provider. `null` in the + /// default and `byTask` views. + agentType?: string | null inputTokens: number outputTokens: number cacheWriteTokens: number @@ -31,6 +36,10 @@ export type ModelReportRow = { export type AggregateOptions = { byTask?: boolean + /// One row per (provider, model, agent). Mutually exclusive with `byTask`; + /// the caller enforces that. Non-Claude providers and ordinary sessions bucket + /// under `'main'`. + byAgent?: boolean taskFilter?: TaskCategory topN?: number /// Threshold for the `cost`-based filter. The default `0.01` would @@ -44,6 +53,7 @@ type Bucket = { provider: string model: string category: TaskCategory | null + agentType: string | null inputTokens: number outputTokens: number cacheWriteTokens: number @@ -57,18 +67,21 @@ type Bucket = { type ModelKey = string type CategoryKey = TaskCategory -function bucketKey(provider: string, model: string, category: TaskCategory | null): string { - return `${provider} ${model} ${category ?? ''}` +function bucketKey(provider: string, model: string, category: TaskCategory | null, agentType: string | null): string { + return `${provider} ${model} ${category ?? ''} ${agentType ?? ''}` } /// Walks every parsed turn, attributes each assistant call to a -/// (provider, model, category) bucket, and returns rows keyed by either -/// (provider, model) when `byTask` is false or (provider, model, category) when true. +/// (provider, model, category, agent) bucket, and returns rows keyed by +/// (provider, model) by default, (provider, model, category) under `byTask`, or +/// (provider, model, agent) under `byAgent`. /// /// Default view: rows sorted by cost descending. -/// byTask view: rows grouped by (provider, model) so the renderer can blank -/// repeated provider/model cells. Group order follows total cost across that -/// model; within each group, rows go by cost descending. +/// byTask / byAgent view: rows grouped by (provider, model) so the renderer can +/// blank repeated provider/model cells. Group order follows total cost across +/// that model; within each group, rows go by cost descending. The agent label +/// comes from the subagent transcript's own `session.agentType`; ordinary +/// sessions and every non-Claude provider bucket under `'main'`. export async function aggregateModels(projects: ProjectSummary[], opts: AggregateOptions = {}): Promise { const buckets = new Map() const perModelCategoryCost = new Map>() @@ -82,13 +95,18 @@ export async function aggregateModels(projects: ProjectSummary[], opts: Aggregat const provider = call.provider || 'unknown' const model = call.model || 'unknown' const category: TaskCategory | null = opts.byTask ? turn.category : null - const key = bucketKey(provider, model, category) + // Non-Claude sessions and ordinary main sessions have no agentType, so + // their spend all lands under 'main'. That is correct: the report never + // fabricates an agent for calls that were not driven by one. + const agentType: string | null = opts.byAgent ? (session.agentType ?? 'main') : null + const key = bucketKey(provider, model, category, agentType) let bucket = buckets.get(key) if (!bucket) { bucket = { provider, model, category, + agentType, inputTokens: 0, outputTokens: 0, cacheWriteTokens: 0, @@ -151,6 +169,7 @@ export async function aggregateModels(projects: ProjectSummary[], opts: Aggregat model: bucket.model, modelDisplayName: meta.formatModel(bucket.model), category: bucket.category, + agentType: bucket.agentType, inputTokens: bucket.inputTokens, outputTokens: bucket.outputTokens, cacheWriteTokens: bucket.cacheWriteTokens, @@ -172,7 +191,7 @@ export async function aggregateModels(projects: ProjectSummary[], opts: Aggregat : null, } - if (!opts.byTask) { + if (!opts.byTask && !opts.byAgent) { const perCat = perModelCategoryCost.get(`${bucket.provider} ${bucket.model}`) if (perCat && perCat.size > 0) { let topCat: TaskCategory = 'general' @@ -194,7 +213,7 @@ export async function aggregateModels(projects: ProjectSummary[], opts: Aggregat rows.push(row) } - if (opts.byTask) { + if (opts.byTask || opts.byAgent) { rows.sort((a, b) => { const aTotal = perModelTotalCost.get(`${a.provider} ${a.model}`) ?? 0 const bTotal = perModelTotalCost.get(`${b.provider} ${b.model}`) ?? 0 @@ -263,11 +282,19 @@ type Column = { type TableRenderOptions = { byTask?: boolean + byAgent?: boolean showTotals?: boolean terminalWidth?: number fullWidth?: boolean } +/// The third column reuses the `task` slot for all three modes: 'Top Task' in +/// the default view, 'Task' under byTask, 'Agent' under byAgent. +function thirdColumnHeader(byTask: boolean, byAgent: boolean): string { + if (byAgent) return 'Agent' + return byTask ? 'Task' : 'Top Task' +} + const DROP_COLUMN_GROUPS: Array> = [ ['cacheWrite', 'cacheRead'], ['input', 'output'], @@ -275,7 +302,7 @@ const DROP_COLUMN_GROUPS: Array> = [ ['saved'], ] -function defaultColumns(byTask: boolean, showSaved: boolean): Column[] { +function defaultColumns(byTask: boolean, byAgent: boolean, showSaved: boolean): Column[] { // Higher priority numbers drop FIRST when the terminal is narrow. // Cache columns are the cheapest to lose, then input/output, then top-task. // Provider/Model/Total/Cost stay regardless. The Saved column only appears @@ -284,9 +311,9 @@ function defaultColumns(byTask: boolean, showSaved: boolean): Column[] { // dashes for the majority of users, so it is omitted entirely. // Widths are MINIMUMS; sizeColumnsToContent() expands them to fit cell text. return [ - { key: 'provider', header: 'Provider', align: 'left', width: 8, priority: 0 }, - { key: 'model', header: 'Model', align: 'left', width: 8, priority: 0 }, - { key: 'task', header: byTask ? 'Task' : 'Top Task', align: 'left', width: 8, priority: 1 }, + { key: 'provider', header: 'Provider', align: 'left', width: 8, priority: 0 }, + { key: 'model', header: 'Model', align: 'left', width: 8, priority: 0 }, + { key: 'task', header: thirdColumnHeader(byTask, byAgent), align: 'left', width: 8, priority: 1 }, { key: 'input', header: 'Input', align: 'right', width: 6, priority: 2 }, { key: 'output', header: 'Output', align: 'right', width: 6, priority: 2 }, { key: 'cacheWrite', header: 'Cache Write', align: 'right', width: 11, priority: 3 }, @@ -318,8 +345,8 @@ function frameWidth(columns: Column[]): number { return 2 + columns.reduce((acc, c) => acc + c.width + 2, 0) + (columns.length - 1) } -function chooseColumns(byTask: boolean, available: number, showSaved: boolean): Column[] { - const all = defaultColumns(byTask, showSaved) +function chooseColumns(byTask: boolean, byAgent: boolean, available: number, showSaved: boolean): Column[] { + const all = defaultColumns(byTask, byAgent, showSaved) if (frameWidth(all) <= available) return all // Drop in this order so the table degrades sensibly. Cache columns drop as @@ -417,6 +444,10 @@ export function renderTable( opts: TableRenderOptions = {}, ): string { const byTask = opts.byTask ?? false + const byAgent = opts.byAgent ?? false + // Both byTask and byAgent group rows under (provider, model) and blank the + // repeated provider/model cells; the default view keeps them on every row. + const grouped = byTask || byAgent const showTotals = opts.showTotals ?? true const available = opts.terminalWidth ?? defaultTerminalWidth() const fullWidth = opts.fullWidth ?? true @@ -428,6 +459,7 @@ export function renderTable( case 'provider': return isNewGroup ? row.providerDisplayName : '' case 'model': return isNewGroup ? row.modelDisplayName : '' case 'task': + if (byAgent) return row.agentType ?? '' if (byTask) return row.category ? categoryLabel(row.category) : '' return row.topCategory ? `${categoryLabel(row.topCategory)} ${chalk.dim(`(${Math.round((row.topCategoryShare ?? 0) * 100)}%)`)}` @@ -448,9 +480,9 @@ export function renderTable( let prevProviderModel = '' for (const row of rows) { const groupKey = `${row.provider} ${row.model}` - const isNewGroup = !byTask || groupKey !== prevProviderModel + const isNewGroup = !grouped || groupKey !== prevProviderModel prevProviderModel = groupKey - const allCells = defaultColumns(byTask, showSaved).map(col => { + const allCells = defaultColumns(byTask, byAgent, showSaved).map(col => { const raw = valueOf(row, col.key, isNewGroup) if (col.key === 'provider' && raw) return chalk.dim(raw) return raw @@ -473,7 +505,7 @@ export function renderTable( }, { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, total: 0, cost: 0, savings: 0 }, ) - const cells = defaultColumns(byTask, showSaved).map(col => { + const cells = defaultColumns(byTask, byAgent, showSaved).map(col => { switch (col.key) { case 'provider': return '' case 'model': return chalk.yellow.bold('Total') @@ -493,9 +525,9 @@ export function renderTable( // Pick which columns to include based on terminal width, then size them. // We index into `cells` by the column key to avoid object-identity pitfalls // across defaultColumns() invocations. - const allKeys = defaultColumns(byTask, showSaved).map(c => c.key) + const allKeys = defaultColumns(byTask, byAgent, showSaved).map(c => c.key) const indexByKey = new Map(allKeys.map((k, i) => [k, i])) - const columns = chooseColumns(byTask, available, showSaved) + const columns = chooseColumns(byTask, byAgent, available, showSaved) const projectColumns = (cols: Column[], entry: RowCells) => cols.map(c => entry.cells[indexByKey.get(c.key)!] ?? '') const cellMatrix = [ @@ -532,7 +564,7 @@ export function renderTable( let isFirstRow = true for (const entry of rowEntries) { - if (byTask && entry.isNewGroup && !isFirstRow) { + if (grouped && entry.isNewGroup && !isFirstRow) { lines.push(renderBorder(final, BOX.leftT, BOX.cross, BOX.rightT)) } isFirstRow = false @@ -556,6 +588,7 @@ export function renderJson(rows: ModelReportRow[]): string { model: r.model, modelDisplayName: r.modelDisplayName, category: r.category ?? r.topCategory ?? null, + agentType: r.agentType ?? null, topCategory: r.topCategory ?? null, topCategoryShare: r.topCategoryShare ?? null, inputTokens: r.inputTokens, @@ -591,13 +624,12 @@ function mdEscape(value: string): string { /// chat platforms that understand markdown. Always shows provider/model on /// every row (no blank-repeat trick) so the table remains useful when copied /// into a context that loses whitespace alignment. -export function renderMarkdown(rows: ModelReportRow[], opts: { byTask?: boolean; showTotals?: boolean } = {}): string { +export function renderMarkdown(rows: ModelReportRow[], opts: { byTask?: boolean; byAgent?: boolean; showTotals?: boolean } = {}): string { const byTask = opts.byTask ?? false + const byAgent = opts.byAgent ?? false const showTotals = opts.showTotals ?? true - const header = byTask - ? ['Provider', 'Model', 'Task', 'Input', 'Output', 'Cache Write', 'Cache Read', 'Total', 'Cost', 'Saved'] - : ['Provider', 'Model', 'Top Task', 'Input', 'Output', 'Cache Write', 'Cache Read', 'Total', 'Cost', 'Saved'] + const header = ['Provider', 'Model', thirdColumnHeader(byTask, byAgent), 'Input', 'Output', 'Cache Write', 'Cache Read', 'Total', 'Cost', 'Saved'] const align = ['---', '---', '---', '---:', '---:', '---:', '---:', '---:', '---:', '---:'] const lines: string[] = [] @@ -605,11 +637,13 @@ export function renderMarkdown(rows: ModelReportRow[], opts: { byTask?: boolean; lines.push(`| ${align.join(' | ')} |`) for (const row of rows) { - const taskCell = byTask - ? row.category ? categoryLabel(row.category) : '' - : row.topCategory - ? `${categoryLabel(row.topCategory)} (${Math.round((row.topCategoryShare ?? 0) * 100)}%)` - : '-' + const taskCell = byAgent + ? row.agentType ?? '' + : byTask + ? row.category ? categoryLabel(row.category) : '' + : row.topCategory + ? `${categoryLabel(row.topCategory)} (${Math.round((row.topCategoryShare ?? 0) * 100)}%)` + : '-' const cells = [ mdEscape(row.providerDisplayName), `\`${mdEscape(row.modelDisplayName)}\``, @@ -657,16 +691,34 @@ export function renderMarkdown(rows: ModelReportRow[], opts: { byTask?: boolean; return lines.join('\n') } -export function renderCsv(rows: ModelReportRow[], opts: { byTask?: boolean } = {}): string { +export function renderCsv(rows: ModelReportRow[], opts: { byTask?: boolean; byAgent?: boolean } = {}): string { const byTask = opts.byTask ?? false + const byAgent = opts.byAgent ?? false // CSV intentionally repeats provider/model on every row so downstream // consumers can sort/filter without first reconstructing the grouping. - const header = byTask + const header = byAgent + ? ['provider', 'model', 'agent', 'input_tokens', 'output_tokens', 'cache_write_tokens', 'cache_read_tokens', 'total_tokens', 'calls', 'cost_usd', 'savings_usd', 'savings_baseline_model'] + : byTask ? ['provider', 'model', 'task', 'input_tokens', 'output_tokens', 'cache_write_tokens', 'cache_read_tokens', 'total_tokens', 'calls', 'cost_usd', 'savings_usd', 'savings_baseline_model'] : ['provider', 'model', 'top_task', 'top_task_share', 'input_tokens', 'output_tokens', 'cache_write_tokens', 'cache_read_tokens', 'total_tokens', 'calls', 'cost_usd', 'savings_usd', 'savings_baseline_model'] const lines: string[] = [header.join(',')] for (const r of rows) { - const cells = byTask + const cells = byAgent + ? [ + csvEscape(r.providerDisplayName), + csvEscape(r.modelDisplayName), + csvEscape(r.agentType ?? ''), + String(r.inputTokens), + String(r.outputTokens), + String(r.cacheWriteTokens), + String(r.cacheReadTokens), + String(r.totalTokens), + String(r.calls), + r.costUSD.toFixed(6), + (r.savingsUSD ?? 0).toFixed(6), + csvEscape(r.savingsBaselineModel), + ] + : byTask ? [ csvEscape(r.providerDisplayName), csvEscape(r.modelDisplayName), diff --git a/tests/models-report.test.ts b/tests/models-report.test.ts index 0d3c7804..b6f992cf 100644 --- a/tests/models-report.test.ts +++ b/tests/models-report.test.ts @@ -1,3 +1,5 @@ +import { spawnSync } from 'node:child_process' + import { describe, it, expect } from 'vitest' import chalk from 'chalk' import stripAnsi from 'strip-ansi' @@ -100,6 +102,22 @@ function makeProject(turns: ClassifiedTurn[]): ProjectSummary { } } +// A session carrying an explicit agentType (Claude subagent transcript). Left +// undefined for ordinary main sessions. +function makeAgentSession(opts: { sessionId: string; agentType?: string; turns: ClassifiedTurn[] }): SessionSummary { + return { ...makeSession(opts.turns), sessionId: opts.sessionId, agentType: opts.agentType } +} + +function projectFromSessions(sessions: SessionSummary[]): ProjectSummary { + return { + project: 'p', + projectPath: '/tmp/p', + sessions, + totalCostUSD: 0, + totalApiCalls: 0, + } +} + describe('aggregateModels', () => { it('groups by (provider, model) and sorts by cost descending in default mode', async () => { const project = makeProject([ @@ -241,6 +259,77 @@ describe('aggregateModels', () => { }) }) +describe('aggregateModels byAgent', () => { + // One project, four sessions: a planner agent on two models, a reviewer agent + // sharing one of those models, an ordinary main session, and a non-Claude + // provider session (no agentType). + function crossProject(): ProjectSummary { + return projectFromSessions([ + makeAgentSession({ sessionId: 'a', agentType: 'planner', turns: [ + makeTurn('planning', [makeCall({ provider: 'claude', model: 'claude-opus-4-8', costUSD: 6.0, input: 100, output: 20 })]), + makeTurn('planning', [makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', costUSD: 2.0, input: 50, output: 10 })]), + ] }), + makeAgentSession({ sessionId: 'b', agentType: 'reviewer', turns: [ + makeTurn('exploration', [makeCall({ provider: 'claude', model: 'claude-opus-4-8', costUSD: 3.0, input: 40, output: 8 })]), + ] }), + // no agentType -> ordinary main session + makeAgentSession({ sessionId: 'c', turns: [ + makeTurn('feature', [makeCall({ provider: 'claude', model: 'claude-opus-4-8', costUSD: 1.0, input: 30, output: 5 })]), + ] }), + // non-Claude provider, no agentType -> also 'main' + makeAgentSession({ sessionId: 'd', turns: [ + makeTurn('feature', [makeCall({ provider: 'codex', model: 'gpt-5', costUSD: 0.5, input: 20, output: 4 })]), + ] }), + ]) + } + + it('crosses model x agent, labelling main/undefined and non-Claude sessions "main"', async () => { + const rows = await aggregateModels([crossProject()], { byAgent: true }) + const byKey = Object.fromEntries(rows.map(r => [`${r.provider}:${r.model}:${r.agentType}`, r])) + // one agent (planner) split across two models + expect(byKey['claude:claude-opus-4-8:planner']!.costUSD).toBeCloseTo(6.0, 6) + expect(byKey['claude:claude-sonnet-4-6:planner']!.costUSD).toBeCloseTo(2.0, 6) + // two agents (planner + reviewer) on the same model + expect(byKey['claude:claude-opus-4-8:reviewer']!.costUSD).toBeCloseTo(3.0, 6) + // ordinary main session buckets under 'main' + expect(byKey['claude:claude-opus-4-8:main']!.costUSD).toBeCloseTo(1.0, 6) + // non-Claude provider (no agentType) also buckets under 'main' + expect(byKey['codex:gpt-5:main']!.agentType).toBe('main') + expect(byKey['codex:gpt-5:main']!.costUSD).toBeCloseTo(0.5, 6) + // three distinct agent rows share claude-opus-4-8 + expect(rows.filter(r => r.model === 'claude-opus-4-8')).toHaveLength(3) + }) + + it('groups rows by (provider, model) ordered by total model cost, agents by cost desc within a group', async () => { + const project = projectFromSessions([ + makeAgentSession({ sessionId: 'a', agentType: 'planner', turns: [ + makeTurn('planning', [makeCall({ provider: 'claude', model: 'claude-opus-4-8', costUSD: 6.0, input: 10, output: 2 })]), + makeTurn('planning', [makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', costUSD: 2.0, input: 10, output: 2 })]), + ] }), + makeAgentSession({ sessionId: 'b', agentType: 'reviewer', turns: [ + makeTurn('exploration', [makeCall({ provider: 'claude', model: 'claude-opus-4-8', costUSD: 3.0, input: 10, output: 2 })]), + ] }), + ]) + const rows = await aggregateModels([project], { byAgent: true }) + // opus group total (9) sorts before sonnet (2); within opus, planner (6) before reviewer (3). + expect(rows.map(r => `${r.model}:${r.agentType}`)).toEqual([ + 'claude-opus-4-8:planner', + 'claude-opus-4-8:reviewer', + 'claude-sonnet-4-6:planner', + ]) + }) + + it('leaves agentType null in the default and byTask views', async () => { + const project = projectFromSessions([ + makeAgentSession({ sessionId: 'a', agentType: 'planner', turns: [ + makeTurn('planning', [makeCall({ provider: 'claude', model: 'claude-opus-4-8', costUSD: 6.0, input: 10, output: 2 })]), + ] }), + ]) + expect((await aggregateModels([project]))[0]!.agentType).toBeNull() + expect((await aggregateModels([project], { byTask: true }))[0]!.agentType).toBeNull() + }) +}) + describe('renderTable', () => { function visibleWidth(line: string): number { return stripAnsi(line).length @@ -283,6 +372,22 @@ describe('renderTable', () => { expect(dataLines[1]).toContain('Debugging') }) + it('renders an Agent column and blanks repeated provider/model in byAgent mode', () => { + const rows: ModelReportRow[] = [ + row({ agentType: 'planner', costUSD: 6.0, inputTokens: 100, outputTokens: 20, totalTokens: 120 }), + row({ agentType: 'reviewer', costUSD: 3.0, inputTokens: 40, outputTokens: 8, totalTokens: 48 }), + ] + const out = renderTable(rows, { byAgent: true, showTotals: false, terminalWidth: 200 }) + expect(out).toContain('Agent') + const dataLines = out.split('\n').slice(3, -1) + expect(dataLines[0]).toContain('Sonnet 4.6') + expect(dataLines[0]).toContain('planner') + // same (provider, model) group -> model/provider blanked, agent still shown + expect(dataLines[1]).not.toContain('Sonnet 4.6') + expect(dataLines[1]).not.toContain('Claude') + expect(dataLines[1]).toContain('reviewer') + }) + it('keeps provider/model cells on every row in default mode', () => { const rows: ModelReportRow[] = [ row({ topCategory: 'feature', topCategoryShare: 0.6, costUSD: 5.0 }), @@ -376,6 +481,30 @@ describe('renderMarkdown', () => { expect(lines[2]).toContain('Feature Dev (60%)') }) + it('uses an Agent header and the agent value in byAgent mode', () => { + const rows: ModelReportRow[] = [ + { + provider: 'claude', + providerDisplayName: 'Claude', + model: 'claude-opus-4-8', + modelDisplayName: 'Opus 4.8', + category: null, + agentType: 'planner', + inputTokens: 100, + outputTokens: 50, + cacheWriteTokens: 0, + cacheReadTokens: 0, + totalTokens: 150, + costUSD: 6.0, + calls: 1, + }, + ] + const md = renderMarkdown(rows, { byAgent: true, showTotals: false }) + const lines = md.split('\n') + expect(lines[0]).toBe('| Provider | Model | Agent | Input | Output | Cache Write | Cache Read | Total | Cost | Saved |') + expect(lines[2]).toContain('| planner |') + }) + it('escapes pipe characters in provider/model names', () => { const rows: ModelReportRow[] = [ { @@ -457,6 +586,30 @@ describe('renderJson', () => { totalTokens: 150, calls: 1, }) + // agentType is null outside byAgent mode + expect(parsed[0]!['agentType']).toBeNull() + }) + + it('emits the agentType field in byAgent rows', () => { + const rows: ModelReportRow[] = [ + { + provider: 'claude', + providerDisplayName: 'Claude', + model: 'claude-opus-4-8', + modelDisplayName: 'Opus 4.8', + category: null, + agentType: 'planner', + inputTokens: 100, + outputTokens: 50, + cacheWriteTokens: 0, + cacheReadTokens: 0, + totalTokens: 150, + costUSD: 6.0, + calls: 1, + }, + ] + const parsed = JSON.parse(renderJson(rows)) as Array> + expect(parsed[0]!['agentType']).toBe('planner') }) }) @@ -487,6 +640,32 @@ describe('renderCsv', () => { expect(lines[1]).toBe('Claude,Sonnet 4.6,Feature Dev,0.6000,100,50,0,0,150,1,1.500000,0.000000,') }) + it('emits an agent column in byAgent mode', () => { + const rows: ModelReportRow[] = [ + { + provider: 'claude', + providerDisplayName: 'Claude', + model: 'claude-opus-4-8', + modelDisplayName: 'Opus 4.8', + category: null, + agentType: 'planner', + inputTokens: 100, + outputTokens: 50, + cacheWriteTokens: 0, + cacheReadTokens: 0, + totalTokens: 150, + costUSD: 6.0, + savingsUSD: 0, + savingsBaselineModel: '', + calls: 1, + }, + ] + const csv = renderCsv(rows, { byAgent: true }) + const lines = csv.split('\n') + expect(lines[0]).toBe('provider,model,agent,input_tokens,output_tokens,cache_write_tokens,cache_read_tokens,total_tokens,calls,cost_usd,savings_usd,savings_baseline_model') + expect(lines[1]).toBe('Claude,Opus 4.8,planner,100,50,0,0,150,1,6.000000,0.000000,') + }) + it('escapes commas in provider/model cells', () => { const rows: ModelReportRow[] = [ { @@ -511,3 +690,15 @@ describe('renderCsv', () => { expect(csv.split('\n')[1]).toContain('"Weird, Co."') }) }) + +describe('models CLI breakdown flags', () => { + it('rejects --by-task and --by-agent together with a clear error and exit 1', () => { + const res = spawnSync( + process.execPath, + ['--import', 'tsx', 'src/cli.ts', 'models', '--by-agent', '--by-task', '-p', 'today'], + { cwd: process.cwd(), env: { ...process.env, TZ: 'UTC' }, encoding: 'utf-8', timeout: 30_000 }, + ) + expect(res.status).toBe(1) + expect(res.stderr).toContain('--by-task and --by-agent cannot be combined') + }) +})