From 3e53502a68e52f3daa30c875e71a60e13b156da9 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:54:49 +0300 Subject: [PATCH] feat(export): subagentType and model on per-record exports; --by-agent follow-ups Follow-ups to #710, completing issue #708's second ask plus the two review notes: - export: new records.csv with one row per call incl. subagentType and model; sessions.csv gains both fields APPENDED after all legacy columns so by-index consumers are unaffected; JSON fields additive; formula- injection escaping preserved - models --by-agent: the main-session bucket sentinel becomes '(main)' - agentType is free-form text, so a real subagent literally named 'main' no longer merges into the main bucket (test-pinned across all formats) - README: note that the --min-cost 0.01 default can hide sub-cent agent buckets; --min-cost 0 shows the complete agent inventory --- README.md | 2 +- src/export.ts | 39 +++++++++++++++++++++++++++- src/models-report.ts | 10 +++---- tests/export.test.ts | 52 ++++++++++++++++++++++++++++++++++++- tests/models-report.test.ts | 45 +++++++++++++++++++++++--------- 5 files changed, 128 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 4231ea9a..639cabf6 100644 --- a/README.md +++ b/README.md @@ -447,7 +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 --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)`. The default `--min-cost 0.01` hides sub-cent agent buckets; use `--min-cost 0` for a complete inventory. | | `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/export.ts b/src/export.ts index f946621c..08cd795b 100644 --- a/src/export.ts +++ b/src/export.ts @@ -14,7 +14,7 @@ function escCsv(s: string): string { return sanitized } -type Row = Record +type Row = Record function rowsToCsv(rows: Row[]): string { if (rows.length === 0) return '' @@ -83,6 +83,35 @@ function buildDailyRows(projects: ProjectSummary[], period: string): Row[] { })) } +function buildRecordRows(projects: ProjectSummary[]): Row[] { + const rows: Row[] = [] + for (const project of projects) { + for (const session of project.sessions) { + for (const turn of session.turns) { + for (const call of turn.assistantCalls) { + rows.push({ + project: project.projectPath, + sessionId: session.sessionId, + timestamp: call.timestamp || turn.timestamp || undefined, + category: turn.category, + provider: call.provider, + subagentType: session.agentType?.trim() || undefined, + model: call.model || undefined, + inputTokens: call.usage.inputTokens, + outputTokens: call.usage.outputTokens, + reasoningTokens: call.usage.reasoningTokens, + cacheWriteTokens: call.usage.cacheCreationInputTokens, + cacheReadTokens: Math.max(call.usage.cacheReadInputTokens, call.usage.cachedInputTokens), + cost: roundForActiveCurrency(convertCost(call.costUSD)), + savings: roundForActiveCurrency(convertCost(call.savingsUSD ?? 0)), + }) + } + } + } + } + return rows +} + function buildActivityRows(projects: ProjectSummary[], period: string): Row[] { const catTotals: Record = {} for (const project of projects) { @@ -231,6 +260,9 @@ function buildSessionRows(projects: ProjectSummary[]): Row[] { const rows: Row[] = [] for (const p of projects) { for (const s of p.sessions) { + const models = new Set( + s.turns.flatMap(turn => turn.assistantCalls.map(call => call.model).filter(Boolean)), + ) rows.push({ Project: p.projectPath, 'Session ID': s.sessionId, @@ -239,6 +271,8 @@ function buildSessionRows(projects: ProjectSummary[]): Row[] { [`Saved (${code})`]: roundForActiveCurrency(convertCost(s.totalSavingsUSD)), 'API Calls': s.apiCalls, Turns: s.turns.length, + subagentType: s.agentType?.trim() || undefined, + model: models.size === 1 ? [...models][0] : undefined, }) } } @@ -286,6 +320,7 @@ function buildReadme(periods: PeriodExport[]): string { ' daily.csv Day-by-day breakdown, Period column distinguishes the window.', ' activity.csv Time spent per task category (Coding, Debugging, Exploration, etc.).', ' models.csv Spend per model with token totals and cache usage.', + ' records.csv One row per API call, including optional subagentType and model.', ' projects.csv Spend per project folder for the selected detail period.', ' sessions.csv One row per session for the selected detail period.', ' tools.csv Tool invocations and share for the selected detail period.', @@ -355,6 +390,7 @@ export async function exportCsv(periods: PeriodExport[], outputPath: string): Pr await writeFile(join(folder, 'daily.csv'), rowsToCsv(dailyRows), 'utf-8') await writeFile(join(folder, 'activity.csv'), rowsToCsv(activityRows), 'utf-8') await writeFile(join(folder, 'models.csv'), rowsToCsv(modelRows), 'utf-8') + await writeFile(join(folder, 'records.csv'), rowsToCsv(buildRecordRows(thirtyDayProjects)), 'utf-8') await writeFile(join(folder, 'projects.csv'), rowsToCsv(buildProjectRows(thirtyDayProjects)), 'utf-8') await writeFile(join(folder, 'sessions.csv'), rowsToCsv(buildSessionRows(thirtyDayProjects)), 'utf-8') await writeFile(join(folder, 'tools.csv'), rowsToCsv(buildToolRows(thirtyDayProjects)), 'utf-8') @@ -382,6 +418,7 @@ export async function exportJson(periods: PeriodExport[], outputPath: string): P })), projects: buildProjectRows(thirtyDayProjects), sessions: buildSessionRows(thirtyDayProjects), + records: buildRecordRows(thirtyDayProjects), tools: buildToolRows(thirtyDayProjects), mcp: buildMcpRows(thirtyDayProjects), shellCommands: buildBashRows(thirtyDayProjects), diff --git a/src/models-report.ts b/src/models-report.ts index 393e3de1..aaeb2235 100644 --- a/src/models-report.ts +++ b/src/models-report.ts @@ -13,7 +13,7 @@ export type ModelReportRow = { 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 + /// 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 @@ -38,7 +38,7 @@ 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'`. + /// under `'(main)'`. byAgent?: boolean taskFilter?: TaskCategory topN?: number @@ -81,7 +81,7 @@ function bucketKey(provider: string, model: string, category: TaskCategory | nul /// 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'`. +/// 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>() @@ -96,9 +96,9 @@ export async function aggregateModels(projects: ProjectSummary[], opts: Aggregat const model = call.model || 'unknown' const category: TaskCategory | null = opts.byTask ? turn.category : null // Non-Claude sessions and ordinary main sessions have no agentType, so - // their spend all lands under 'main'. That is correct: the report never + // 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 agentType: string | null = opts.byAgent ? (session.agentType ?? '(main)') : null const key = bucketKey(provider, model, category, agentType) let bucket = buckets.get(key) if (!bucket) { diff --git a/tests/export.test.ts b/tests/export.test.ts index 1980322e..81bc97e9 100644 --- a/tests/export.test.ts +++ b/tests/export.test.ts @@ -16,7 +16,7 @@ afterEach(async () => { await rm(tmpDir, { recursive: true, force: true }) }) -function makeProject(projectPath: string): ProjectSummary { +function makeProject(projectPath: string, agentType?: string): ProjectSummary { return { project: projectPath, projectPath, @@ -24,6 +24,7 @@ function makeProject(projectPath: string): ProjectSummary { { sessionId: 'sess-001', project: projectPath, + agentType, firstTimestamp: '2026-04-14T10:00:00Z', lastTimestamp: '2026-04-14T10:01:00Z', totalCostUSD: 1.23, @@ -57,6 +58,7 @@ function makeProject(projectPath: string): ProjectSummary { tools: ['Read'], mcpTools: [], skills: [], + subagentTypes: [], hasAgentSpawn: false, hasPlanMode: false, speed: 'standard', @@ -205,9 +207,57 @@ describe('exportCsv', () => { expect(mcp).toContain('Server,Calls,Share (%)') expect(mcp).toContain('node_repl,5,100') }) + + it('writes optional subagentType and model fields to per-call records.csv', async () => { + const periods: PeriodExport[] = [{ label: '30 Days', projects: [makeProject('app', 'planner')] }] + + const folder = await exportCsv(periods, join(tmpDir, 'records.csv')) + const records = await readFile(join(folder, 'records.csv'), 'utf-8') + const lines = records.trimEnd().split('\n') + + expect(lines[0]).toContain('subagentType,model') + expect(lines[1]).toContain('planner') + expect(lines[1]).toContain("'+danger-model") + }) + + it('adds optional subagentType and unambiguous model fields to sessions.csv', async () => { + const periods: PeriodExport[] = [{ label: '30 Days', projects: [makeProject('app', 'planner')] }] + + const folder = await exportCsv(periods, join(tmpDir, 'sessions.csv')) + const sessions = await readFile(join(folder, 'sessions.csv'), 'utf-8') + + expect(sessions.split('\n')[0]).toBe('Project,Session ID,Started At,Cost (USD),Saved (USD),API Calls,Turns,subagentType,model') + expect(sessions.split('\n')[1]).toContain('planner') + expect(sessions.split('\n')[1]).toContain("'+danger-model") + }) }) describe('exportJson', () => { + it('adds per-call records with optional subagentType and model fields', async () => { + const periods: PeriodExport[] = [{ + label: '30 Days', + projects: [makeProject('agent-project', 'planner'), makeProject('main-project')], + }] + + const outputPath = join(tmpDir, 'records.json') + const saved = await exportJson(periods, outputPath) + const data = JSON.parse(await readFile(saved, 'utf-8')) + + expect(data.records[0]).toMatchObject({ + project: 'agent-project', + subagentType: 'planner', + model: '+danger-model', + inputTokens: 100, + outputTokens: 50, + cost: 1.23, + }) + expect(data.records[1]).toMatchObject({ project: 'main-project', model: '+danger-model' }) + expect(data.records[1]).not.toHaveProperty('subagentType') + expect(data.sessions[0]).toMatchObject({ subagentType: 'planner', model: '+danger-model' }) + expect(data.sessions[1]).toMatchObject({ model: '+danger-model' }) + expect(data.sessions[1]).not.toHaveProperty('subagentType') + }) + it('includes an mcp section with per-server usage', async () => { const project = makeProject('app') project.sessions[0]!.mcpBreakdown = { node_repl: { calls: 3 }, github: { calls: 1 } } diff --git a/tests/models-report.test.ts b/tests/models-report.test.ts index b6f992cf..33317fd8 100644 --- a/tests/models-report.test.ts +++ b/tests/models-report.test.ts @@ -260,9 +260,9 @@ 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). + // One project: a planner agent on two models, a reviewer agent sharing one of + // those models, a real agent named main, an ordinary main session, and a + // non-Claude provider session (no agentType). function crossProject(): ProjectSummary { return projectFromSessions([ makeAgentSession({ sessionId: 'a', agentType: 'planner', turns: [ @@ -272,18 +272,21 @@ describe('aggregateModels byAgent', () => { makeAgentSession({ sessionId: 'b', agentType: 'reviewer', turns: [ makeTurn('exploration', [makeCall({ provider: 'claude', model: 'claude-opus-4-8', costUSD: 3.0, input: 40, output: 8 })]), ] }), + makeAgentSession({ sessionId: 'real-main', agentType: 'main', turns: [ + makeTurn('exploration', [makeCall({ provider: 'claude', model: 'claude-opus-4-8', costUSD: 0.75, input: 25, output: 4 })]), + ] }), // 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' + // 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 () => { + it('keeps a real agent named main distinct from the (main) sentinel across all formats', 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 @@ -291,13 +294,31 @@ describe('aggregateModels byAgent', () => { 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) + // real agent named main and the ordinary-session sentinel remain separate + expect(byKey['claude:claude-opus-4-8:main']!.costUSD).toBeCloseTo(0.75, 6) + 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) + // four distinct agent rows share claude-opus-4-8 + const collisionRows = rows.filter(r => r.model === 'claude-opus-4-8') + expect(collisionRows).toHaveLength(4) + + const table = stripAnsi(renderTable(collisionRows, { byAgent: true, showTotals: false, terminalWidth: 200 })) + expect(table.split('\n').some(line => /│\s*\(main\)\s*│/.test(line))).toBe(true) + expect(table.split('\n').some(line => /│\s*main\s*│/.test(line))).toBe(true) + + const markdown = renderMarkdown(collisionRows, { byAgent: true, showTotals: false }) + expect(markdown).toContain('| (main) |') + expect(markdown).toContain('| main |') + + const jsonAgents = (JSON.parse(renderJson(collisionRows)) as Array<{ agentType: string }>).map(row => row.agentType) + expect(jsonAgents).toContain('(main)') + expect(jsonAgents).toContain('main') + + const csvAgents = renderCsv(collisionRows, { byAgent: true }).trimEnd().split('\n').slice(1).map(line => line.split(',')[2]) + expect(csvAgents).toContain('(main)') + expect(csvAgents).toContain('main') }) it('groups rows by (provider, model) ordered by total model cost, agents by cost desc within a group', async () => {