From a5d10e74f7ef125ed572041f6127f635829698e1 Mon Sep 17 00:00:00 2001 From: AgentSeal Date: Fri, 17 Jul 2026 00:26:02 +0200 Subject: [PATCH] feat(models): agent x model cross-breakdown (--by-agent) Orchestrator setups pin different agents to different models (planner on Opus, implementers on Sonnet, scouts on Haiku). The dashboard shows "Skills & Agents" and "By Model" as separate panels, so there was no way to see which agent drove which model's spend, or catch an agent that silently switched models. `codeburn models --by-agent` closes that gap: one row per (provider, model, agent), mirroring the existing --by-task pattern across the table, markdown, CSV, and JSON renderers. The agent label comes from each Claude subagent transcript session's own agentType crossed with that session's model calls, aggregated through the same assistantCalls path --by-task uses (not modelBreakdown), so cache-tier dedup, savings, and Codex-credit logic all carry over unchanged. This is the authoritative pairing; main-session subagentTypes spawn markers are deliberately not joined to models, since a spawn marker names an agent but not the model that agent actually ran on. Ordinary main sessions (no agentType) and every non-Claude provider have no agent, so their spend buckets under the label "main" rather than being dropped or fabricated into a false agent. --by-agent is mutually exclusive with --by-task; passing both errors politely. Exposing subagentType + per-record model on `codeburn export` (so users can build custom crosses over the export) is a separate follow-up and is intentionally out of scope here. --- README.md | 1 + src/main.ts | 14 ++- src/models-report.ts | 120 +++++++++++++++------- tests/models-report.test.ts | 191 ++++++++++++++++++++++++++++++++++++ 4 files changed, 288 insertions(+), 38 deletions(-) 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') + }) +})