diff --git a/apps/ccusage/config-schema.json b/apps/ccusage/config-schema.json index 34392dde..58aacf10 100644 --- a/apps/ccusage/config-schema.json +++ b/apps/ccusage/config-schema.json @@ -104,6 +104,12 @@ "description": "Force compact mode for narrow displays (better for screenshots)", "markdownDescription": "Force compact mode for narrow displays (better for screenshots)", "default": false + }, + "breakdownSubagents": { + "type": "boolean", + "description": "Show subagent usage as separate breakdown rows (default: aggregated with main usage)", + "markdownDescription": "Show subagent usage as separate breakdown rows (default: aggregated with main usage)", + "default": false } }, "additionalProperties": false, @@ -209,6 +215,12 @@ "markdownDescription": "Force compact mode for narrow displays (better for screenshots)", "default": false }, + "breakdownSubagents": { + "type": "boolean", + "description": "Show subagent usage as separate breakdown rows (default: aggregated with main usage)", + "markdownDescription": "Show subagent usage as separate breakdown rows (default: aggregated with main usage)", + "default": false + }, "instances": { "type": "boolean", "description": "Show usage breakdown by project/instance", @@ -323,6 +335,12 @@ "description": "Force compact mode for narrow displays (better for screenshots)", "markdownDescription": "Force compact mode for narrow displays (better for screenshots)", "default": false + }, + "breakdownSubagents": { + "type": "boolean", + "description": "Show subagent usage as separate breakdown rows (default: aggregated with main usage)", + "markdownDescription": "Show subagent usage as separate breakdown rows (default: aggregated with main usage)", + "default": false } }, "additionalProperties": false @@ -423,6 +441,12 @@ "markdownDescription": "Force compact mode for narrow displays (better for screenshots)", "default": false }, + "breakdownSubagents": { + "type": "boolean", + "description": "Show subagent usage as separate breakdown rows (default: aggregated with main usage)", + "markdownDescription": "Show subagent usage as separate breakdown rows (default: aggregated with main usage)", + "default": false + }, "startOfWeek": { "type": "string", "enum": [ @@ -527,6 +551,12 @@ "markdownDescription": "Force compact mode for narrow displays (better for screenshots)", "default": false }, + "breakdownSubagents": { + "type": "boolean", + "description": "Show subagent usage as separate breakdown rows (default: aggregated with main usage)", + "markdownDescription": "Show subagent usage as separate breakdown rows (default: aggregated with main usage)", + "default": false + }, "id": { "type": "string", "description": "Load usage data for a specific session ID", @@ -631,6 +661,12 @@ "markdownDescription": "Force compact mode for narrow displays (better for screenshots)", "default": false }, + "breakdownSubagents": { + "type": "boolean", + "description": "Show subagent usage as separate breakdown rows (default: aggregated with main usage)", + "markdownDescription": "Show subagent usage as separate breakdown rows (default: aggregated with main usage)", + "default": false + }, "active": { "type": "boolean", "description": "Show only active block with projections", diff --git a/apps/ccusage/src/_daily-grouping.ts b/apps/ccusage/src/_daily-grouping.ts index 18ecbe9d..f02aa9c3 100644 --- a/apps/ccusage/src/_daily-grouping.ts +++ b/apps/ccusage/src/_daily-grouping.ts @@ -31,6 +31,7 @@ export function groupByProject(dailyData: DailyData): Record 0 + ? { + totalTokens: subagentTokenCounts.inputTokens + subagentTokenCounts.outputTokens + + subagentTokenCounts.cacheCreationInputTokens + subagentTokenCounts.cacheReadInputTokens, + inputTokens: subagentTokenCounts.inputTokens, + outputTokens: subagentTokenCounts.outputTokens, + cacheCreationTokens: subagentTokenCounts.cacheCreationInputTokens, + cacheReadTokens: subagentTokenCounts.cacheReadInputTokens, + totalCost: subagentCost, + taskCount: subagentCount, + modelsUsed: uniq(subagentModels) as ModelName[], + } + : undefined; + return { id: startTime.toISOString(), startTime, @@ -200,6 +243,7 @@ function createBlock(startTime: Date, entries: LoadedUsageEntry[], now: Date, se costUSD, models: uniq(models), usageLimitResetTime, + ...(subagentUsage != null && { subagentUsage }), }; } @@ -1002,5 +1046,90 @@ if (import.meta.vitest != null) { expect(blocksDefault[0]!.endTime).toEqual(blocksExplicit[0]!.endTime); expect(blocksDefault[0]!.endTime).toEqual(new Date(baseTime.getTime() + 5 * 60 * 60 * 1000)); }); + + it('calculates subagent usage when isSidechain entries are present', () => { + const baseTime = new Date('2024-01-01T10:00:00Z'); + const entries: LoadedUsageEntry[] = [ + createMockEntry(baseTime), + { + ...createMockEntry(new Date(baseTime.getTime() + 60 * 1000)), + isSidechain: true, + usage: { + inputTokens: 500, + outputTokens: 250, + cacheCreationInputTokens: 50, + cacheReadInputTokens: 100, + }, + costUSD: 0.3, + }, + { + ...createMockEntry(new Date(baseTime.getTime() + 120 * 1000)), + isSidechain: true, + usage: { + inputTokens: 300, + outputTokens: 150, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 50, + }, + costUSD: 0.2, + }, + ]; + + const blocks = identifySessionBlocks(entries); + + expect(blocks).toHaveLength(1); + const block = blocks[0]; + expect(block?.subagentUsage).toBeDefined(); + expect(block?.subagentUsage?.taskCount).toBe(2); + expect(block?.subagentUsage?.inputTokens).toBe(800); // 500 + 300 + expect(block?.subagentUsage?.outputTokens).toBe(400); // 250 + 150 + expect(block?.subagentUsage?.cacheCreationTokens).toBe(50); + expect(block?.subagentUsage?.cacheReadTokens).toBe(150); // 100 + 50 + expect(block?.subagentUsage?.totalTokens).toBe(1400); // 800 + 400 + 50 + 150 + expect(block?.subagentUsage?.totalCost).toBe(0.5); // 0.3 + 0.2 + }); + + it('does not include subagentUsage when no subagent entries', () => { + const baseTime = new Date('2024-01-01T10:00:00Z'); + const entries: LoadedUsageEntry[] = [ + createMockEntry(baseTime), + createMockEntry(new Date(baseTime.getTime() + 60 * 1000)), + ]; + + const blocks = identifySessionBlocks(entries); + + expect(blocks).toHaveLength(1); + const block = blocks[0]; + expect(block?.subagentUsage).toBeUndefined(); + }); + + it('aggregates both main and subagent tokens in total counts', () => { + const baseTime = new Date('2024-01-01T10:00:00Z'); + const mainEntry = createMockEntry(baseTime); + const subagentEntry = { + ...createMockEntry(new Date(baseTime.getTime() + 60 * 1000)), + isSidechain: true, + usage: { + inputTokens: 500, + outputTokens: 250, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + }, + costUSD: 0.3, + }; + + const blocks = identifySessionBlocks([mainEntry, subagentEntry]); + + expect(blocks).toHaveLength(1); + const block = blocks[0]; + // Total should include both main and subagent + expect(block?.tokenCounts.inputTokens).toBe(mainEntry.usage.inputTokens + 500); + expect(block?.tokenCounts.outputTokens).toBe(mainEntry.usage.outputTokens + 250); + expect(block?.costUSD).toBe((mainEntry.costUSD ?? 0) + 0.3); + // Subagent usage should only include subagent entry + expect(block?.subagentUsage?.inputTokens).toBe(500); + expect(block?.subagentUsage?.outputTokens).toBe(250); + expect(block?.subagentUsage?.totalCost).toBe(0.3); + }); }); } diff --git a/apps/ccusage/src/_shared-args.ts b/apps/ccusage/src/_shared-args.ts index e6ec5f27..785f4431 100644 --- a/apps/ccusage/src/_shared-args.ts +++ b/apps/ccusage/src/_shared-args.ts @@ -108,6 +108,12 @@ export const sharedArgs = { description: 'Force compact mode for narrow displays (better for screenshots)', default: false, }, + breakdownSubagents: { + type: 'boolean', + short: 'a', + description: 'Show subagent usage as separate breakdown rows (default: aggregated with main usage)', + default: false, + }, } as const satisfies Args; /** diff --git a/apps/ccusage/src/commands/_session_id.ts b/apps/ccusage/src/commands/_session_id.ts index 999caf02..1ca463c3 100644 --- a/apps/ccusage/src/commands/_session_id.ts +++ b/apps/ccusage/src/commands/_session_id.ts @@ -3,6 +3,7 @@ import type { UsageData } from '../data-loader.ts'; import process from 'node:process'; import { formatCurrency, formatNumber, ResponsiveTable } from '@ccusage/terminal/table'; import { Result } from '@praha/byethrow'; +import pc from 'picocolors'; import { formatDateCompact } from '../_date-utils.ts'; import { processWithJq } from '../_jq-processor.ts'; import { loadSessionUsageById } from '../data-loader.ts'; @@ -51,6 +52,7 @@ export async function handleSessionIdLookup(ctx: SessionIdContext, useJson: bool cacheReadTokens: entry.message.usage.cache_read_input_tokens ?? 0, model: entry.message.model ?? 'unknown', costUSD: entry.costUSD ?? 0, + ...(entry.isSidechain === true && { isSubagent: true }), })), }; @@ -71,9 +73,17 @@ export async function handleSessionIdLookup(ctx: SessionIdContext, useJson: bool const totalTokens = calculateSessionTotalTokens(sessionUsage.entries); + // Calculate subagent summary + const subagentEntries = sessionUsage.entries.filter(entry => entry.isSidechain === true); + const hasSubagents = subagentEntries.length > 0; + log(`Total Cost: ${formatCurrency(sessionUsage.totalCost)}`); log(`Total Tokens: ${formatNumber(totalTokens)}`); log(`Total Entries: ${sessionUsage.entries.length}`); + // Note: session --id is a detail command, so we always show subagent count in header + if (hasSubagents) { + log(`Subagent Tasks: ${subagentEntries.length}`); + } log(''); if (sessionUsage.entries.length > 0) { @@ -92,9 +102,13 @@ export async function handleSessionIdLookup(ctx: SessionIdContext, useJson: bool }); for (const entry of sessionUsage.entries) { + const modelName = entry.message.model ?? 'unknown'; + const isSubagent = entry.isSidechain === true; + const displayModel = isSubagent ? `[subagent] ${modelName}` : modelName; + table.push([ formatDateCompact(entry.timestamp, ctx.values.timezone, ctx.values.locale), - entry.message.model ?? 'unknown', + displayModel, formatNumber(entry.message.usage.input_tokens), formatNumber(entry.message.usage.output_tokens), formatNumber(entry.message.usage.cache_creation_input_tokens ?? 0), @@ -104,6 +118,18 @@ export async function handleSessionIdLookup(ctx: SessionIdContext, useJson: bool } log(table.toString()); + + // Show subagent summary if there are subagents (session --id always shows detail) + if (hasSubagents) { + const subagentStats = calculateSubagentStats(subagentEntries); + log(''); + log(pc.cyan(pc.bold('Subagent Usage Summary:'))); + log(` Tasks Executed: ${subagentStats.count}`); + log(` Input Tokens: ${formatNumber(subagentStats.inputTokens)}`); + log(` Output Tokens: ${formatNumber(subagentStats.outputTokens)}`); + log(` Total Tokens: ${formatNumber(subagentStats.totalTokens)}`); + log(` Total Cost: ${formatCurrency(subagentStats.totalCost)}`); + } } } } @@ -120,3 +146,139 @@ function calculateSessionTotalTokens(entries: UsageData[]): number { ); }, 0); } + +function calculateSubagentStats(entries: UsageData[]): { + count: number; + inputTokens: number; + outputTokens: number; + totalTokens: number; + totalCost: number; +} { + let inputTokens = 0; + let outputTokens = 0; + let totalCost = 0; + + for (const entry of entries) { + const usage = entry.message.usage; + inputTokens += usage.input_tokens; + outputTokens += usage.output_tokens; + totalCost += entry.costUSD ?? 0; + } + + const totalTokens = entries.reduce((sum, entry) => { + const usage = entry.message.usage; + return ( + sum + + usage.input_tokens + + usage.output_tokens + + (usage.cache_creation_input_tokens ?? 0) + + (usage.cache_read_input_tokens ?? 0) + ); + }, 0); + + return { + count: entries.length, + inputTokens, + outputTokens, + totalTokens, + totalCost, + }; +} + +if (import.meta.vitest != null) { + describe('calculateSubagentStats', () => { + it('calculates stats correctly for subagent entries', () => { + const entries = [ + { + timestamp: '2024-01-01T00:00:00Z', + message: { + usage: { + input_tokens: 100, + output_tokens: 50, + cache_creation_input_tokens: 10, + cache_read_input_tokens: 20, + }, + }, + costUSD: 0.5, + isSidechain: true, + }, + { + timestamp: '2024-01-01T00:01:00Z', + message: { + usage: { + input_tokens: 200, + output_tokens: 100, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 50, + }, + }, + costUSD: 0.3, + isSidechain: true, + }, + ] as unknown as UsageData[]; + + const stats = calculateSubagentStats(entries); + + expect(stats.count).toBe(2); + expect(stats.inputTokens).toBe(300); + expect(stats.outputTokens).toBe(150); + expect(stats.totalTokens).toBe(530); // 300 + 150 + 10 + 20 + 0 + 50 + expect(stats.totalCost).toBe(0.8); + }); + + it('handles empty entries array', () => { + const stats = calculateSubagentStats([] as unknown as UsageData[]); + + expect(stats.count).toBe(0); + expect(stats.inputTokens).toBe(0); + expect(stats.outputTokens).toBe(0); + expect(stats.totalTokens).toBe(0); + expect(stats.totalCost).toBe(0); + }); + + it('handles entries without cache tokens', () => { + const entries = [ + { + timestamp: '2024-01-01T00:00:00Z', + message: { + usage: { + input_tokens: 100, + output_tokens: 50, + }, + }, + costUSD: 0.5, + isSidechain: true, + }, + ] as unknown as UsageData[]; + + const stats = calculateSubagentStats(entries); + + expect(stats.count).toBe(1); + expect(stats.inputTokens).toBe(100); + expect(stats.outputTokens).toBe(50); + expect(stats.totalTokens).toBe(150); + expect(stats.totalCost).toBe(0.5); + }); + + it('handles entries with null costUSD', () => { + const entries = [ + { + timestamp: '2024-01-01T00:00:00Z', + message: { + usage: { + input_tokens: 100, + output_tokens: 50, + }, + }, + costUSD: null, + isSidechain: true, + }, + ] as unknown as UsageData[]; + + const stats = calculateSubagentStats(entries); + + expect(stats.count).toBe(1); + expect(stats.totalCost).toBe(0); + }); + }); +} diff --git a/apps/ccusage/src/commands/blocks.ts b/apps/ccusage/src/commands/blocks.ts index 57ef1520..bb76a8c9 100644 --- a/apps/ccusage/src/commands/blocks.ts +++ b/apps/ccusage/src/commands/blocks.ts @@ -295,6 +295,7 @@ export const blocksCommand = define({ })() : undefined, usageLimitResetTime: block.usageLimitResetTime, + ...(block.subagentUsage != null && { subagentUsage: block.subagentUsage }), }; }), }; @@ -352,7 +353,18 @@ export const blocksCommand = define({ log(pc.bold('Projected Usage (if current rate continues):')); log(` Total Tokens: ${formatNumber(projection.totalTokens)}`); log(` Total Cost: ${formatCurrency(projection.totalCost)}\n`); + } + + // Show subagent usage summary if present and flag enabled + if (block.subagentUsage != null && ctx.values.breakdownSubagents) { + log(pc.bold('Subagent Usage:')); + log(` Tasks Executed: ${block.subagentUsage.taskCount}`); + log(` Input Tokens: ${formatNumber(block.subagentUsage.inputTokens)}`); + log(` Output Tokens: ${formatNumber(block.subagentUsage.outputTokens)}`); + log(` Total Cost: ${formatCurrency(block.subagentUsage.totalCost)}\n`); + } + if (projection != null) { if (ctx.values.tokenLimit != null) { // Parse token limit const limit = parseTokenLimit(ctx.values.tokenLimit, maxTokensFromAll); @@ -445,6 +457,24 @@ export const blocksCommand = define({ row.push(formatCurrency(block.costUSD)); table.push(row); + // Add subagent usage row if present and flag enabled (show actual data before predictions) + if (block.subagentUsage != null && ctx.values.breakdownSubagents) { + const subagentRow = [ + '', + pc.cyan(` └─ Subagents (${block.subagentUsage.taskCount})`), + '', + formatNumber(block.subagentUsage.totalTokens), + ]; + + // Add empty cell if token limit column is present + if (actualTokenLimit != null && actualTokenLimit > 0) { + subagentRow.push(''); + } + + subagentRow.push(formatCurrency(block.subagentUsage.totalCost)); + table.push(subagentRow); + } + // Add REMAINING and PROJECTED rows for active blocks if (block.isActive) { // REMAINING row - only show if token limit is set diff --git a/apps/ccusage/src/commands/daily.ts b/apps/ccusage/src/commands/daily.ts index 03dafc89..499b7778 100644 --- a/apps/ccusage/src/commands/daily.ts +++ b/apps/ccusage/src/commands/daily.ts @@ -1,6 +1,6 @@ import type { UsageReportConfig } from '@ccusage/terminal/table'; import process from 'node:process'; -import { addEmptySeparatorRow, createUsageReportTable, formatTotalsRow, formatUsageDataRow, pushBreakdownRows } from '@ccusage/terminal/table'; +import { addEmptySeparatorRow, createUsageReportTable, formatTotalsRow, formatUsageDataRow, pushBreakdownRows, pushSubagentSummaryRow } from '@ccusage/terminal/table'; import { Result } from '@praha/byethrow'; import { define } from 'gunshi'; import pc from 'picocolors'; @@ -112,6 +112,7 @@ export const dailyCommand = define({ modelsUsed: data.modelsUsed, modelBreakdowns: data.modelBreakdowns, ...(data.project != null && { project: data.project }), + ...(data.subagentUsage != null && { subagentUsage: data.subagentUsage }), })), totals: createTotalsObject(totals), }; @@ -180,7 +181,12 @@ export const dailyCommand = define({ // Add model breakdown rows if flag is set if (mergedOptions.breakdown) { - pushBreakdownRows(table, data.modelBreakdowns); + pushBreakdownRows(table, data.modelBreakdowns, 1, 0, mergedOptions.breakdownSubagents); + } + + // Add subagent usage summary if present and flag enabled + if (data.subagentUsage != null && mergedOptions.breakdownSubagents) { + pushSubagentSummaryRow(table, data.subagentUsage); } } @@ -203,7 +209,12 @@ export const dailyCommand = define({ // Add model breakdown rows if flag is set if (mergedOptions.breakdown) { - pushBreakdownRows(table, data.modelBreakdowns); + pushBreakdownRows(table, data.modelBreakdowns, 1, 0, mergedOptions.breakdownSubagents); + } + + // Add subagent usage summary if present and flag enabled + if (data.subagentUsage != null && mergedOptions.breakdownSubagents) { + pushSubagentSummaryRow(table, data.subagentUsage); } } } diff --git a/apps/ccusage/src/commands/monthly.ts b/apps/ccusage/src/commands/monthly.ts index 1d7b4410..5dfea737 100644 --- a/apps/ccusage/src/commands/monthly.ts +++ b/apps/ccusage/src/commands/monthly.ts @@ -1,6 +1,6 @@ import type { UsageReportConfig } from '@ccusage/terminal/table'; import process from 'node:process'; -import { addEmptySeparatorRow, createUsageReportTable, formatTotalsRow, formatUsageDataRow, pushBreakdownRows } from '@ccusage/terminal/table'; +import { addEmptySeparatorRow, createUsageReportTable, formatTotalsRow, formatUsageDataRow, pushBreakdownRows, pushSubagentSummaryRow } from '@ccusage/terminal/table'; import { Result } from '@praha/byethrow'; import { define } from 'gunshi'; import { loadConfig, mergeConfigWithArgs } from '../_config-loader-tokens.ts'; @@ -77,6 +77,7 @@ export const monthlyCommand = define({ totalCost: data.totalCost, modelsUsed: data.modelsUsed, modelBreakdowns: data.modelBreakdowns, + ...(data.subagentUsage != null && { subagentUsage: data.subagentUsage }), })), totals: createTotalsObject(totals), }; @@ -121,7 +122,12 @@ export const monthlyCommand = define({ // Add model breakdown rows if flag is set if (mergedOptions.breakdown) { - pushBreakdownRows(table, data.modelBreakdowns); + pushBreakdownRows(table, data.modelBreakdowns, 1, 0, mergedOptions.breakdownSubagents); + } + + // Add subagent usage summary if present and flag enabled + if (data.subagentUsage != null && mergedOptions.breakdownSubagents) { + pushSubagentSummaryRow(table, data.subagentUsage); } } diff --git a/apps/ccusage/src/commands/session.ts b/apps/ccusage/src/commands/session.ts index 0b19f98e..70602aed 100644 --- a/apps/ccusage/src/commands/session.ts +++ b/apps/ccusage/src/commands/session.ts @@ -1,6 +1,6 @@ import type { UsageReportConfig } from '@ccusage/terminal/table'; import process from 'node:process'; -import { addEmptySeparatorRow, createUsageReportTable, formatTotalsRow, formatUsageDataRow, pushBreakdownRows } from '@ccusage/terminal/table'; +import { addEmptySeparatorRow, createUsageReportTable, formatTotalsRow, formatUsageDataRow, pushBreakdownRows, pushSubagentSummaryRow } from '@ccusage/terminal/table'; import { Result } from '@praha/byethrow'; import { define } from 'gunshi'; import { loadConfig, mergeConfigWithArgs } from '../_config-loader-tokens.ts'; @@ -103,6 +103,7 @@ export const sessionCommand = define({ modelsUsed: data.modelsUsed, modelBreakdowns: data.modelBreakdowns, projectPath: data.projectPath, + ...(data.subagentUsage != null && { subagentUsage: data.subagentUsage }), })), totals: createTotalsObject(totals), }; @@ -154,7 +155,13 @@ export const sessionCommand = define({ // Add model breakdown rows if flag is set if (ctx.values.breakdown) { // Session has 1 extra column before data and 1 trailing column - pushBreakdownRows(table, data.modelBreakdowns, 1, 1); + pushBreakdownRows(table, data.modelBreakdowns, 1, 1, ctx.values.breakdownSubagents); + } + + // Add subagent usage summary if present and flag enabled + if (data.subagentUsage != null && ctx.values.breakdownSubagents) { + // Session has 1 extra column before data and 1 trailing column + pushSubagentSummaryRow(table, data.subagentUsage, 1, 1); } } diff --git a/apps/ccusage/src/commands/statusline.ts b/apps/ccusage/src/commands/statusline.ts index cd1a9a20..6f54d22d 100644 --- a/apps/ccusage/src/commands/statusline.ts +++ b/apps/ccusage/src/commands/statusline.ts @@ -347,7 +347,7 @@ export const statuslineCommand = define({ ); // Load session block data to find active block - const { blockInfo, burnRateInfo } = await Result.pipe( + const { blockInfo, burnRateInfo, subagentInfo } = await Result.pipe( Result.try({ try: async () => loadSessionBlockData({ mode: 'auto', @@ -421,13 +421,18 @@ export const statuslineCommand = define({ })() : ''; - return { blockInfo, burnRateInfo }; + // Get subagent info from active block (only if tasks > 0) + const subagentInfo = activeBlock.subagentUsage != null && activeBlock.subagentUsage.taskCount > 0 + ? ` (+${activeBlock.subagentUsage.taskCount} subagents)` + : ''; + + return { blockInfo, burnRateInfo, subagentInfo }; } - return { blockInfo: 'No active block', burnRateInfo: '' }; + return { blockInfo: 'No active block', burnRateInfo: '', subagentInfo: '' }; }), Result.inspectError(error => logger.error('Failed to load block data:', error)), - Result.unwrap({ blockInfo: 'No active block', burnRateInfo: '' }), + Result.unwrap({ blockInfo: 'No active block', burnRateInfo: '', subagentInfo: '' }), ); // Calculate context tokens from transcript with model-specific limits @@ -471,7 +476,7 @@ export const statuslineCommand = define({ // Single cost display return sessionCost != null ? formatCurrency(sessionCost) : 'N/A'; })(); - const statusLine = `🤖 ${modelName} | 💰 ${sessionDisplay} session / ${formatCurrency(todayCost)} today / ${blockInfo}${burnRateInfo} | 🧠 ${contextInfo ?? 'N/A'}`; + const statusLine = `🤖 ${modelName}${subagentInfo} | 💰 ${sessionDisplay} session / ${formatCurrency(todayCost)} today / ${blockInfo}${burnRateInfo} | 🧠 ${contextInfo ?? 'N/A'}`; return statusLine; }, catch: error => error, diff --git a/apps/ccusage/src/commands/weekly.ts b/apps/ccusage/src/commands/weekly.ts index 876d2dfd..b8703e59 100644 --- a/apps/ccusage/src/commands/weekly.ts +++ b/apps/ccusage/src/commands/weekly.ts @@ -1,6 +1,6 @@ import type { UsageReportConfig } from '@ccusage/terminal/table'; import process from 'node:process'; -import { addEmptySeparatorRow, createUsageReportTable, formatTotalsRow, formatUsageDataRow, pushBreakdownRows } from '@ccusage/terminal/table'; +import { addEmptySeparatorRow, createUsageReportTable, formatTotalsRow, formatUsageDataRow, pushBreakdownRows, pushSubagentSummaryRow } from '@ccusage/terminal/table'; import { Result } from '@praha/byethrow'; import { define } from 'gunshi'; import { loadConfig, mergeConfigWithArgs } from '../_config-loader-tokens.ts'; @@ -87,6 +87,7 @@ export const weeklyCommand = define({ totalCost: data.totalCost, modelsUsed: data.modelsUsed, modelBreakdowns: data.modelBreakdowns, + ...(data.subagentUsage != null && { subagentUsage: data.subagentUsage }), })), totals: createTotalsObject(totals), }; @@ -131,7 +132,12 @@ export const weeklyCommand = define({ // Add model breakdown rows if flag is set if (mergedOptions.breakdown) { - pushBreakdownRows(table, data.modelBreakdowns); + pushBreakdownRows(table, data.modelBreakdowns, 1, 0, mergedOptions.breakdownSubagents); + } + + // Add subagent usage summary if present and flag enabled + if (data.subagentUsage != null && mergedOptions.breakdownSubagents) { + pushSubagentSummaryRow(table, data.subagentUsage); } } diff --git a/apps/ccusage/src/data-loader.ts b/apps/ccusage/src/data-loader.ts index 4a856cab..57c79c98 100644 --- a/apps/ccusage/src/data-loader.ts +++ b/apps/ccusage/src/data-loader.ts @@ -183,8 +183,66 @@ export const usageDataSchema = v.object({ costUSD: v.optional(v.number()), // Made optional for new schema requestId: v.optional(requestIdSchema), // Request ID for deduplication isApiErrorMessage: v.optional(v.boolean()), + isSidechain: v.optional(v.boolean()), // Flag indicating this is a subagent/Task tool entry }); +/** + * Valibot schema for subagent/tool use result usage data + */ +export const toolUseResultUsageSchema = v.object({ + input_tokens: v.number(), + output_tokens: v.number(), + cache_creation_input_tokens: v.optional(v.number()), + cache_read_input_tokens: v.optional(v.number()), + service_tier: v.optional(v.string()), +}); + +/** + * Valibot schema for subagent/tool use result data (from Task tool) + */ +export const toolUseResultSchema = v.object({ + content: v.optional(v.array(v.object({ + type: v.string(), + text: v.optional(v.string()), + }))), + totalDurationMs: v.optional(v.number()), + totalTokens: v.optional(v.number()), + totalToolUseCount: v.optional(v.number()), + usage: v.optional(toolUseResultUsageSchema), +}); + +/** + * Type definition for subagent/tool use result data + */ +export type ToolUseResult = v.InferOutput; + +/** + * Valibot schema for entries with toolUseResult (subagent summary) + * These entries contain aggregated totals from subagent execution + */ +export const toolUseResultEntrySchema = v.object({ + sessionId: v.optional(sessionIdSchema), + timestamp: isoTimestampSchema, + type: v.optional(v.string()), // Usually "user" for tool results + message: v.optional(v.object({ + role: v.optional(v.string()), + content: v.optional(v.array(v.object({ + tool_use_id: v.optional(v.string()), + type: v.optional(v.string()), + content: v.optional(v.array(v.object({ + type: v.optional(v.string()), + text: v.optional(v.string()), + }))), + }))), + })), + toolUseResult: toolUseResultSchema, +}); + +/** + * Type definition for tool use result entry + */ +export type ToolUseResultEntry = v.InferOutput; + /** * Valibot schema for transcript usage data from Claude messages */ @@ -220,6 +278,7 @@ export const modelBreakdownSchema = v.object({ cacheCreationTokens: v.number(), cacheReadTokens: v.number(), cost: v.number(), + isSubagent: v.optional(v.boolean()), // True if this breakdown is from subagent/Task tool usage }); /** @@ -227,6 +286,26 @@ export const modelBreakdownSchema = v.object({ */ export type ModelBreakdown = v.InferOutput; +/** + * Valibot schema for subagent usage summary + * Tracks total tokens and cost from subagent (Task tool) executions + */ +export const subagentUsageSummarySchema = v.object({ + totalTokens: v.number(), + inputTokens: v.number(), + outputTokens: v.number(), + cacheCreationTokens: v.number(), + cacheReadTokens: v.number(), + totalCost: v.number(), + taskCount: v.number(), // Number of subagent tasks + modelsUsed: v.array(modelNameSchema), // Models used by subagents +}); + +/** + * Type definition for subagent usage summary + */ +export type SubagentUsageSummary = v.InferOutput; + /** * Valibot schema for daily usage aggregation data */ @@ -240,6 +319,7 @@ export const dailyUsageSchema = v.object({ modelsUsed: v.array(modelNameSchema), modelBreakdowns: v.array(modelBreakdownSchema), project: v.optional(v.string()), // Project name when groupByProject is enabled + subagentUsage: v.optional(subagentUsageSummarySchema), // Summary of subagent/Task tool usage }); /** @@ -262,6 +342,7 @@ export const sessionUsageSchema = v.object({ versions: v.array(versionSchema), // List of unique versions used in this session modelsUsed: v.array(modelNameSchema), modelBreakdowns: v.array(modelBreakdownSchema), + subagentUsage: v.optional(subagentUsageSummarySchema), // Summary of subagent/Task tool usage }); /** @@ -282,6 +363,7 @@ export const monthlyUsageSchema = v.object({ modelsUsed: v.array(modelNameSchema), modelBreakdowns: v.array(modelBreakdownSchema), project: v.optional(v.string()), // Project name when groupByProject is enabled + subagentUsage: v.optional(subagentUsageSummarySchema), // Summary of subagent/Task tool usage }); /** @@ -302,6 +384,7 @@ export const weeklyUsageSchema = v.object({ modelsUsed: v.array(modelNameSchema), modelBreakdowns: v.array(modelBreakdownSchema), project: v.optional(v.string()), // Project name when groupByProject is enabled + subagentUsage: v.optional(subagentUsageSummarySchema), // Summary of subagent/Task tool usage }); /** @@ -322,6 +405,7 @@ export const bucketUsageSchema = v.object({ modelsUsed: v.array(modelNameSchema), modelBreakdowns: v.array(modelBreakdownSchema), project: v.optional(v.string()), // Project name when groupByProject is enabled + subagentUsage: v.optional(subagentUsageSummarySchema), // Summary of subagent/Task tool usage }); /** @@ -341,54 +425,13 @@ type TokenStats = { }; /** - * Aggregates token counts and costs by model name - */ -function aggregateByModel( - entries: T[], - getModel: (entry: T) => string | undefined, - getUsage: (entry: T) => UsageData['message']['usage'], - getCost: (entry: T) => number, -): Map { - const modelAggregates = new Map(); - const defaultStats: TokenStats = { - inputTokens: 0, - outputTokens: 0, - cacheCreationTokens: 0, - cacheReadTokens: 0, - cost: 0, - }; - - for (const entry of entries) { - const modelName = getModel(entry) ?? 'unknown'; - // Skip synthetic model - if (modelName === '') { - continue; - } - - const usage = getUsage(entry); - const cost = getCost(entry); - - const existing = modelAggregates.get(modelName) ?? defaultStats; - - modelAggregates.set(modelName, { - inputTokens: existing.inputTokens + (usage.input_tokens ?? 0), - outputTokens: existing.outputTokens + (usage.output_tokens ?? 0), - cacheCreationTokens: existing.cacheCreationTokens + (usage.cache_creation_input_tokens ?? 0), - cacheReadTokens: existing.cacheReadTokens + (usage.cache_read_input_tokens ?? 0), - cost: existing.cost + cost, - }); - } - - return modelAggregates; -} - -/** - * Aggregates model breakdowns from multiple sources + * Aggregates model breakdowns from multiple sources preserving isSubagent flag */ -function aggregateModelBreakdowns( +function aggregateModelBreakdownsWithSubagent( breakdowns: ModelBreakdown[], -): Map { - const modelAggregates = new Map(); +): { mainAgent: Map; subagent: Map } { + const mainAgentAggregates = new Map(); + const subagentAggregates = new Map(); const defaultStats: TokenStats = { inputTokens: 0, outputTokens: 0, @@ -403,9 +446,11 @@ function aggregateModelBreakdowns( continue; } - const existing = modelAggregates.get(breakdown.modelName) ?? defaultStats; + const isSubagent = breakdown.isSubagent === true; + const targetMap = isSubagent ? subagentAggregates : mainAgentAggregates; + const existing = targetMap.get(breakdown.modelName) ?? defaultStats; - modelAggregates.set(breakdown.modelName, { + targetMap.set(breakdown.modelName, { inputTokens: existing.inputTokens + breakdown.inputTokens, outputTokens: existing.outputTokens + breakdown.outputTokens, cacheCreationTokens: existing.cacheCreationTokens + breakdown.cacheCreationTokens, @@ -414,21 +459,7 @@ function aggregateModelBreakdowns( }); } - return modelAggregates; -} - -/** - * Converts model aggregates to sorted model breakdowns - */ -function createModelBreakdowns( - modelAggregates: Map, -): ModelBreakdown[] { - return Array.from(modelAggregates.entries()) - .map(([modelName, stats]) => ({ - modelName: modelName as ModelName, - ...stats, - })) - .sort((a, b) => b.cost - a.cost); // Sort by cost descending + return { mainAgent: mainAgentAggregates, subagent: subagentAggregates }; } /** @@ -464,6 +495,159 @@ function calculateTotals( ); } +/** + * Calculates subagent usage summary from entries marked as subagent + */ +function calculateSubagentUsage( + entries: T[], + getIsSubagent: (entry: T) => boolean, + getUsage: (entry: T) => UsageData['message']['usage'], + getCost: (entry: T) => number, + getModel: (entry: T) => string | undefined, +): SubagentUsageSummary | undefined { + const subagentEntries = entries.filter(getIsSubagent); + if (subagentEntries.length === 0) { + return undefined; + } + + const totals = calculateTotals(subagentEntries, getUsage, getCost); + const totalTokens = totals.inputTokens + totals.outputTokens + + totals.cacheCreationTokens + totals.cacheReadTokens; + + // Extract unique models used by subagents + const modelsUsed = extractUniqueModels(subagentEntries, getModel); + + return { + totalTokens, + inputTokens: totals.inputTokens, + outputTokens: totals.outputTokens, + cacheCreationTokens: totals.cacheCreationTokens, + cacheReadTokens: totals.cacheReadTokens, + totalCost: totals.totalCost, + taskCount: subagentEntries.length, // Approximate: count of sidechain entries + modelsUsed: modelsUsed as ModelName[], + }; +} + +/** + * Aggregates subagent summaries from multiple sources + */ +function aggregateSubagentUsage( + summaries: (SubagentUsageSummary | undefined)[], +): SubagentUsageSummary | undefined { + const validSummaries = summaries.filter((s): s is SubagentUsageSummary => s != null); + if (validSummaries.length === 0) { + return undefined; + } + + // Aggregate all summaries + const aggregated = validSummaries.reduce( + (acc, summary) => ({ + totalTokens: acc.totalTokens + summary.totalTokens, + inputTokens: acc.inputTokens + summary.inputTokens, + outputTokens: acc.outputTokens + summary.outputTokens, + cacheCreationTokens: acc.cacheCreationTokens + summary.cacheCreationTokens, + cacheReadTokens: acc.cacheReadTokens + summary.cacheReadTokens, + totalCost: acc.totalCost + summary.totalCost, + taskCount: acc.taskCount + summary.taskCount, + modelsUsed: [...acc.modelsUsed, ...summary.modelsUsed], + }), + { + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalCost: 0, + taskCount: 0, + modelsUsed: [] as ModelName[], + }, + ); + + // Return with unique models + return { + ...aggregated, + modelsUsed: uniq(aggregated.modelsUsed), + }; +} + +/** + * Aggregates by model with separate tracking for subagent usage + */ +function aggregateByModelWithSubagent( + entries: T[], + getModel: (entry: T) => string | undefined, + getUsage: (entry: T) => UsageData['message']['usage'], + getCost: (entry: T) => number, + getIsSubagent: (entry: T) => boolean, +): { mainAgent: Map; subagent: Map } { + const mainAgentAggregates = new Map(); + const subagentAggregates = new Map(); + const defaultStats: TokenStats = { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: 0, + }; + + for (const entry of entries) { + const modelName = getModel(entry) ?? 'unknown'; + // Skip synthetic model + if (modelName === '') { + continue; + } + + const usage = getUsage(entry); + const cost = getCost(entry); + const isSubagent = getIsSubagent(entry); + const targetMap = isSubagent ? subagentAggregates : mainAgentAggregates; + + const existing = targetMap.get(modelName) ?? defaultStats; + + targetMap.set(modelName, { + inputTokens: existing.inputTokens + (usage.input_tokens ?? 0), + outputTokens: existing.outputTokens + (usage.output_tokens ?? 0), + cacheCreationTokens: existing.cacheCreationTokens + (usage.cache_creation_input_tokens ?? 0), + cacheReadTokens: existing.cacheReadTokens + (usage.cache_read_input_tokens ?? 0), + cost: existing.cost + cost, + }); + } + + return { mainAgent: mainAgentAggregates, subagent: subagentAggregates }; +} + +/** + * Creates model breakdowns with subagent flag + */ +function createModelBreakdownsWithSubagent( + mainAgentAggregates: Map, + subagentAggregates: Map, +): ModelBreakdown[] { + const breakdowns: ModelBreakdown[] = []; + + // Add main agent breakdowns + for (const [modelName, stats] of mainAgentAggregates.entries()) { + breakdowns.push({ + modelName: modelName as ModelName, + ...stats, + isSubagent: false, + }); + } + + // Add subagent breakdowns + for (const [modelName, stats] of subagentAggregates.entries()) { + breakdowns.push({ + modelName: modelName as ModelName, + ...stats, + isSubagent: true, + }); + } + + // Sort by cost descending + return breakdowns.sort((a, b) => b.cost - a.cost); +} + /** * Filters items by project name */ @@ -776,8 +960,8 @@ export async function loadDailyUsageData( // Track processed message+request combinations for deduplication const processedHashes = new Set(); - // Collect all valid data entries first - const allEntries: { data: UsageData; date: string; cost: number; model: string | undefined; project: string }[] = []; + // Collect all valid data entries first (with isSubagent flag) + const allEntries: { data: UsageData; date: string; cost: number; model: string | undefined; project: string; isSubagent: boolean }[] = []; for (const file of sortedFiles) { // Extract project name from file path once per file @@ -810,7 +994,10 @@ export async function loadDailyUsageData( ? await calculateCostForEntry(data, mode, fetcher) : data.costUSD ?? 0; - allEntries.push({ data, date, cost, model: data.message.model, project }); + // Check if this is a subagent/sidechain entry + const isSubagent = data.isSidechain === true; + + allEntries.push({ data, date, cost, model: data.message.model, project, isSubagent }); } catch { // Skip invalid JSON lines @@ -839,24 +1026,34 @@ export async function loadDailyUsageData( const date = parts[0] ?? groupKey; const project = parts.length > 1 ? parts[1] : undefined; - // Aggregate by model first - const modelAggregates = aggregateByModel( + // Aggregate by model with subagent separation + const { mainAgent, subagent } = aggregateByModelWithSubagent( entries, entry => entry.model, entry => entry.data.message.usage, entry => entry.cost, + entry => entry.isSubagent, ); - // Create model breakdowns - const modelBreakdowns = createModelBreakdowns(modelAggregates); + // Create model breakdowns with subagent flag + const modelBreakdowns = createModelBreakdownsWithSubagent(mainAgent, subagent); - // Calculate totals + // Calculate totals (includes both main and subagent) const totals = calculateTotals( entries, entry => entry.data.message.usage, entry => entry.cost, ); + // Calculate subagent-specific usage + const subagentUsage = calculateSubagentUsage( + entries, + entry => entry.isSubagent, + entry => entry.data.message.usage, + entry => entry.cost, + entry => entry.model, + ); + const modelsUsed = extractUniqueModels(entries, e => e.model); return { @@ -865,6 +1062,7 @@ export async function loadDailyUsageData( modelsUsed: modelsUsed as ModelName[], modelBreakdowns, ...(project != null && { project }), + ...(subagentUsage != null && { subagentUsage }), }; }) .filter(item => item != null); @@ -926,7 +1124,7 @@ export async function loadSessionData( // Track processed message+request combinations for deduplication const processedHashes = new Set(); - // Collect all valid data entries with session info first + // Collect all valid data entries with session info first (with isSubagent flag) const allEntries: Array<{ data: UsageData; sessionKey: string; @@ -935,6 +1133,7 @@ export async function loadSessionData( cost: number; timestamp: string; model: string | undefined; + isSubagent: boolean; }> = []; for (const { file, baseDir } of sortedFilesWithBase) { @@ -972,6 +1171,9 @@ export async function loadSessionData( ? await calculateCostForEntry(data, mode, fetcher) : data.costUSD ?? 0; + // Check if this is a subagent/sidechain entry + const isSubagent = data.isSidechain === true; + allEntries.push({ data, sessionKey, @@ -980,6 +1182,7 @@ export async function loadSessionData( cost, timestamp: data.timestamp, model: data.message.model, + isSubagent, }); } catch { @@ -1014,24 +1217,34 @@ export async function loadSessionData( } } - // Aggregate by model - const modelAggregates = aggregateByModel( + // Aggregate by model with subagent separation + const { mainAgent, subagent } = aggregateByModelWithSubagent( entries, entry => entry.model, entry => entry.data.message.usage, entry => entry.cost, + entry => entry.isSubagent, ); - // Create model breakdowns - const modelBreakdowns = createModelBreakdowns(modelAggregates); + // Create model breakdowns with subagent flag + const modelBreakdowns = createModelBreakdownsWithSubagent(mainAgent, subagent); - // Calculate totals + // Calculate totals (includes both main and subagent) const totals = calculateTotals( entries, entry => entry.data.message.usage, entry => entry.cost, ); + // Calculate subagent-specific usage + const subagentUsage = calculateSubagentUsage( + entries, + entry => entry.isSubagent, + entry => entry.data.message.usage, + entry => entry.cost, + entry => entry.model, + ); + const modelsUsed = extractUniqueModels(entries, e => e.model); return { @@ -1043,6 +1256,7 @@ export async function loadSessionData( versions: uniq(versions).sort() as Version[], modelsUsed: modelsUsed as ModelName[], modelBreakdowns, + ...(subagentUsage != null && { subagentUsage }), }; }) .filter(item => item != null); @@ -1174,14 +1388,14 @@ export async function loadBucketUsageData( const bucket = createBucket(parts[0] ?? groupKey); const project = parts.length > 1 ? parts[1] : undefined; - // Aggregate model breakdowns across all days + // Aggregate model breakdowns across all days with subagent separation const allBreakdowns = dailyEntries.flatMap( daily => daily.modelBreakdowns, ); - const modelAggregates = aggregateModelBreakdowns(allBreakdowns); + const { mainAgent, subagent } = aggregateModelBreakdownsWithSubagent(allBreakdowns); - // Create model breakdowns - const modelBreakdowns = createModelBreakdowns(modelAggregates); + // Create model breakdowns with subagent flag + const modelBreakdowns = createModelBreakdownsWithSubagent(mainAgent, subagent); // Collect unique models const models: string[] = []; @@ -1208,6 +1422,12 @@ export async function loadBucketUsageData( totalCacheReadTokens += daily.cacheReadTokens; totalCost += daily.totalCost; } + + // Aggregate subagent usage summaries from daily entries + const subagentUsage = aggregateSubagentUsage( + dailyEntries.map(daily => daily.subagentUsage), + ); + const bucketUsage: BucketUsage = { bucket, inputTokens: totalInputTokens, @@ -1218,6 +1438,7 @@ export async function loadBucketUsageData( modelsUsed: uniq(models) as ModelName[], modelBreakdowns, ...(project != null && { project }), + ...(subagentUsage != null && { subagentUsage }), }; buckets.push(bucketUsage); @@ -1398,6 +1619,7 @@ export async function loadSessionBlockData( model: data.message.model ?? 'unknown', version: data.version, usageLimitResetTime: usageLimitResetTime ?? undefined, + isSidechain: data.isSidechain, }); } catch (error) { @@ -1952,6 +2174,7 @@ invalid json line cacheCreationTokens: 0, cacheReadTokens: 0, cost: 0.015, + isSubagent: false, }], }); expect(result[1]).toEqual({ @@ -1969,6 +2192,7 @@ invalid json line cacheCreationTokens: 0, cacheReadTokens: 0, cost: 0.03, + isSubagent: false, }], }); }); @@ -2024,6 +2248,7 @@ invalid json line cacheCreationTokens: 0, cacheReadTokens: 0, cost: 0.03, + isSubagent: false, }], }); }); @@ -2298,6 +2523,7 @@ invalid json line cacheCreationTokens: 0, cacheReadTokens: 0, cost: 0.015, + isSubagent: false, }], }); expect(result[1]).toEqual({ @@ -2315,6 +2541,7 @@ invalid json line cacheCreationTokens: 0, cacheReadTokens: 0, cost: 0.03, + isSubagent: false, }], }); }); @@ -2370,6 +2597,7 @@ invalid json line cacheCreationTokens: 0, cacheReadTokens: 0, cost: 0.03, + isSubagent: false, }], }); }); @@ -4212,7 +4440,7 @@ invalid json line }); expect(processedCount).toBe(lineCount); - }); + }, 60000); // 60 second timeout for large file processing }); }); } @@ -4724,4 +4952,272 @@ if (import.meta.vitest != null) { expect(res?.percentage).toBe(100); // Should be clamped to 100 }); }); + + describe('subagent/isSidechain token tracking', () => { + it('parses entries with isSidechain flag', async () => { + // Test that the schema correctly parses isSidechain field + const mainEntry = { + timestamp: '2024-01-01T00:00:00Z', + sessionId: 'test-session', + message: { + usage: { + input_tokens: 100, + output_tokens: 50, + }, + model: 'claude-opus-4-20250514', + }, + costUSD: 0.5, + }; + + const subagentEntry = { + isSidechain: true, + timestamp: '2024-01-01T00:01:00Z', + sessionId: 'test-session', + message: { + usage: { + input_tokens: 200, + output_tokens: 100, + }, + model: 'claude-sonnet-4-20250514', + }, + costUSD: 0.1, + }; + + // Parse both entries + const mainResult = v.safeParse(usageDataSchema, mainEntry); + const subagentResult = v.safeParse(usageDataSchema, subagentEntry); + + expect(mainResult.success).toBe(true); + expect(subagentResult.success).toBe(true); + + if (mainResult.success) { + expect(mainResult.output.isSidechain).toBeUndefined(); + } + if (subagentResult.success) { + expect(subagentResult.output.isSidechain).toBe(true); + } + }); + + it('tracks subagent tokens in daily usage data', async () => { + await using fixture = await createFixture({ + projects: { + 'test-project': { + session1: { + 'usage.jsonl': [ + // Main agent entry + JSON.stringify({ + timestamp: '2024-01-01T00:00:00Z', + sessionId: 'session-1', + message: { + id: 'msg_main_1', + usage: { + input_tokens: 100, + output_tokens: 50, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + model: 'claude-opus-4-20250514', + }, + requestId: 'req_main_1', + costUSD: 0.5, + }), + // Subagent entry (isSidechain: true) + JSON.stringify({ + isSidechain: true, + timestamp: '2024-01-01T00:01:00Z', + sessionId: 'session-1', + message: { + id: 'msg_sub_1', + usage: { + input_tokens: 200, + output_tokens: 100, + cache_creation_input_tokens: 10, + cache_read_input_tokens: 500, + }, + model: 'claude-sonnet-4-20250514', + }, + requestId: 'req_sub_1', + costUSD: 0.1, + }), + // Another subagent entry + JSON.stringify({ + isSidechain: true, + timestamp: '2024-01-01T00:02:00Z', + sessionId: 'session-1', + message: { + id: 'msg_sub_2', + usage: { + input_tokens: 300, + output_tokens: 150, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 1000, + }, + model: 'claude-sonnet-4-20250514', + }, + requestId: 'req_sub_2', + costUSD: 0.15, + }), + ].join('\n'), + }, + }, + }, + }); + + const result = await loadDailyUsageData({ + claudePath: fixture.path, + mode: 'display', + }); + + expect(result).toHaveLength(1); + const dailyUsage = result[0]; + + // Verify total tokens include both main and subagent + expect(dailyUsage?.inputTokens).toBe(600); // 100 + 200 + 300 + expect(dailyUsage?.outputTokens).toBe(300); // 50 + 100 + 150 + + // Verify subagent usage summary is present + expect(dailyUsage?.subagentUsage).toBeDefined(); + expect(dailyUsage?.subagentUsage?.inputTokens).toBe(500); // 200 + 300 + expect(dailyUsage?.subagentUsage?.outputTokens).toBe(250); // 100 + 150 + expect(dailyUsage?.subagentUsage?.cacheReadTokens).toBe(1500); // 500 + 1000 + expect(dailyUsage?.subagentUsage?.totalCost).toBe(0.25); // 0.1 + 0.15 + expect(dailyUsage?.subagentUsage?.taskCount).toBe(2); // 2 subagent entries + + // Verify model breakdowns include isSubagent flag + const opusBreakdown = dailyUsage?.modelBreakdowns.find( + b => b.modelName === 'claude-opus-4-20250514' && b.isSubagent !== true, + ); + const sonnetSubagentBreakdown = dailyUsage?.modelBreakdowns.find( + b => b.modelName === 'claude-sonnet-4-20250514' && b.isSubagent === true, + ); + + expect(opusBreakdown).toBeDefined(); + expect(opusBreakdown?.inputTokens).toBe(100); + expect(opusBreakdown?.isSubagent).toBe(false); + + expect(sonnetSubagentBreakdown).toBeDefined(); + expect(sonnetSubagentBreakdown?.inputTokens).toBe(500); // 200 + 300 + expect(sonnetSubagentBreakdown?.isSubagent).toBe(true); + }); + + it('tracks subagent tokens in session usage data', async () => { + await using fixture = await createFixture({ + projects: { + 'test-project': { + 'session-abc': { + 'log.jsonl': [ + // Main agent entry + JSON.stringify({ + timestamp: '2024-01-01T00:00:00Z', + sessionId: 'session-abc', + message: { + id: 'msg_main_1', + usage: { + input_tokens: 1000, + output_tokens: 500, + }, + model: 'claude-opus-4-20250514', + }, + requestId: 'req_main_1', + costUSD: 1.0, + }), + // Subagent entry + JSON.stringify({ + isSidechain: true, + timestamp: '2024-01-01T00:05:00Z', + sessionId: 'session-abc', + message: { + id: 'msg_sub_1', + usage: { + input_tokens: 5000, + output_tokens: 2000, + }, + model: 'claude-sonnet-4-20250514', + }, + requestId: 'req_sub_1', + costUSD: 0.5, + }), + ].join('\n'), + }, + }, + }, + }); + + const result = await loadSessionData({ + claudePath: fixture.path, + mode: 'display', + }); + + expect(result).toHaveLength(1); + const sessionUsage = result[0]; + + // Verify total tokens include both main and subagent + expect(sessionUsage?.inputTokens).toBe(6000); // 1000 + 5000 + expect(sessionUsage?.outputTokens).toBe(2500); // 500 + 2000 + + // Verify subagent usage summary + expect(sessionUsage?.subagentUsage).toBeDefined(); + expect(sessionUsage?.subagentUsage?.inputTokens).toBe(5000); + expect(sessionUsage?.subagentUsage?.outputTokens).toBe(2000); + expect(sessionUsage?.subagentUsage?.totalCost).toBe(0.5); + expect(sessionUsage?.subagentUsage?.taskCount).toBe(1); + + // Verify model breakdowns + const opusBreakdown = sessionUsage?.modelBreakdowns.find( + b => b.modelName === 'claude-opus-4-20250514' && b.isSubagent !== true, + ); + const sonnetSubagentBreakdown = sessionUsage?.modelBreakdowns.find( + b => b.modelName === 'claude-sonnet-4-20250514' && b.isSubagent === true, + ); + + expect(opusBreakdown).toBeDefined(); + expect(opusBreakdown?.inputTokens).toBe(1000); + + expect(sonnetSubagentBreakdown).toBeDefined(); + expect(sonnetSubagentBreakdown?.inputTokens).toBe(5000); + }); + + it('handles entries without subagent data correctly', async () => { + await using fixture = await createFixture({ + projects: { + 'test-project': { + session1: { + 'usage.jsonl': [ + JSON.stringify({ + timestamp: '2024-01-01T00:00:00Z', + sessionId: 'session-1', + message: { + id: 'msg_1', + usage: { + input_tokens: 100, + output_tokens: 50, + }, + model: 'claude-opus-4-20250514', + }, + requestId: 'req_1', + costUSD: 0.5, + }), + ].join('\n'), + }, + }, + }, + }); + + const result = await loadDailyUsageData({ + claudePath: fixture.path, + mode: 'display', + }); + + expect(result).toHaveLength(1); + const dailyUsage = result[0]; + + // Verify no subagent usage when there are no subagent entries + expect(dailyUsage?.subagentUsage).toBeUndefined(); + + // All model breakdowns should have isSubagent: false + for (const breakdown of dailyUsage?.modelBreakdowns ?? []) { + expect(breakdown.isSubagent).toBe(false); + } + }); + }); } diff --git a/packages/terminal/src/table.ts b/packages/terminal/src/table.ts index 1874e76b..7ed956c5 100644 --- a/packages/terminal/src/table.ts +++ b/packages/terminal/src/table.ts @@ -365,6 +365,7 @@ export function formatModelsDisplayMultiline(models: string[]): string { * @param breakdowns - Array of model breakdowns * @param extraColumns - Number of extra empty columns before the data (default: 1 for models column) * @param trailingColumns - Number of extra empty columns after the data (default: 0) + * @param showSubagentsSeparately - Whether to show subagents as separate rows (default: false) */ export function pushBreakdownRows( table: { push: (row: (string | number)[]) => void }, @@ -375,12 +376,38 @@ export function pushBreakdownRows( cacheCreationTokens: number; cacheReadTokens: number; cost: number; + isSubagent?: boolean; }>, extraColumns = 1, trailingColumns = 0, + showSubagentsSeparately = false, ): void { - for (const breakdown of breakdowns) { - const row: (string | number)[] = [` └─ ${formatModelName(breakdown.modelName)}`]; + // Aggregate by model name if not showing subagents separately + const aggregatedBreakdowns = showSubagentsSeparately + ? breakdowns + : Object.values( + breakdowns.reduce( + (acc, breakdown) => { + const key = breakdown.modelName; + if (acc[key] == null) { + acc[key] = { ...breakdown, isSubagent: false }; + } + else { + acc[key].inputTokens += breakdown.inputTokens; + acc[key].outputTokens += breakdown.outputTokens; + acc[key].cacheCreationTokens += breakdown.cacheCreationTokens; + acc[key].cacheReadTokens += breakdown.cacheReadTokens; + acc[key].cost += breakdown.cost; + } + return acc; + }, + {} as Record, + ), + ); + + for (const breakdown of aggregatedBreakdowns) { + const subagentIndicator = breakdown.isSubagent === true && showSubagentsSeparately ? ' [subagent]' : ''; + const row: (string | number)[] = [` └─ ${formatModelName(breakdown.modelName)}${subagentIndicator}`]; // Add extra empty columns before data for (let i = 0; i < extraColumns; i++) { @@ -409,6 +436,60 @@ export function pushBreakdownRows( } } +/** + * Pushes a subagent usage summary row to a table + * @param table - The table to push the row to + * @param table.push - Method to add rows to the table + * @param subagentUsage - Subagent usage summary data + * @param subagentUsage.totalTokens - Total tokens used by subagents + * @param subagentUsage.inputTokens - Input tokens used by subagents + * @param subagentUsage.outputTokens - Output tokens used by subagents + * @param subagentUsage.cacheCreationTokens - Cache creation tokens used by subagents + * @param subagentUsage.cacheReadTokens - Cache read tokens used by subagents + * @param subagentUsage.totalCost - Total cost of subagent usage + * @param subagentUsage.taskCount - Number of subagent tasks executed + * @param extraColumns - Number of extra empty columns before the data (default: 1 for models column) + * @param trailingColumns - Number of extra empty columns after the data (default: 0) + */ +export function pushSubagentSummaryRow( + table: { push: (row: (string | number)[]) => void }, + subagentUsage: { + totalTokens: number; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + totalCost: number; + taskCount: number; + }, + extraColumns = 1, + trailingColumns = 0, +): void { + const row: (string | number)[] = [pc.cyan(`Subagents (${subagentUsage.taskCount} tasks)`)]; + + // Add extra empty columns before data + for (let i = 0; i < extraColumns; i++) { + row.push(''); + } + + // Add data columns with cyan styling to highlight subagent usage + row.push( + pc.cyan(formatNumber(subagentUsage.inputTokens)), + pc.cyan(formatNumber(subagentUsage.outputTokens)), + pc.cyan(formatNumber(subagentUsage.cacheCreationTokens)), + pc.cyan(formatNumber(subagentUsage.cacheReadTokens)), + pc.cyan(formatNumber(subagentUsage.totalTokens)), + pc.cyan(formatCurrency(subagentUsage.totalCost)), + ); + + // Add trailing empty columns + for (let i = 0; i < trailingColumns; i++) { + row.push(''); + } + + table.push(row); +} + /** * Configuration options for creating usage report tables */ @@ -981,4 +1062,152 @@ if (import.meta.vitest != null) { expect(formatModelsDisplayMultiline(models)).toBe('- opus-4-1\n- sonnet-4\n- sonnet-4-5'); }); }); + + describe('pushSubagentSummaryRow', () => { + it('pushes subagent summary row with correct formatting', () => { + const mockTable: { push: (row: (string | number)[]) => void; rows: (string | number)[][] } = { + rows: [], + push(row: (string | number)[]) { + this.rows.push(row); + }, + }; + + const subagentUsage = { + totalTokens: 1500, + inputTokens: 1000, + outputTokens: 500, + cacheCreationTokens: 100, + cacheReadTokens: 200, + totalCost: 0.5, + taskCount: 5, + }; + + pushSubagentSummaryRow(mockTable, subagentUsage); + + expect(mockTable.rows).toHaveLength(1); + const row = mockTable.rows[0]; + expect(row).toBeDefined(); + expect(row![0]).toContain('Subagents'); + expect(row![0]).toContain('5 tasks'); + }); + + it('respects extraColumns parameter', () => { + const mockTable: { push: (row: (string | number)[]) => void; rows: (string | number)[][] } = { + rows: [], + push(row: (string | number)[]) { + this.rows.push(row); + }, + }; + + const subagentUsage = { + totalTokens: 1000, + inputTokens: 600, + outputTokens: 400, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalCost: 0.3, + taskCount: 3, + }; + + pushSubagentSummaryRow(mockTable, subagentUsage, 2, 0); + + expect(mockTable.rows).toHaveLength(1); + const row = mockTable.rows[0]; + expect(row).toBeDefined(); + // First column is label, next 2 are empty (extraColumns=2) + expect(row![1]).toBe(''); + expect(row![2]).toBe(''); + }); + + it('respects trailingColumns parameter', () => { + const mockTable: { push: (row: (string | number)[]) => void; rows: (string | number)[][] } = { + rows: [], + push(row: (string | number)[]) { + this.rows.push(row); + }, + }; + + const subagentUsage = { + totalTokens: 1000, + inputTokens: 600, + outputTokens: 400, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalCost: 0.3, + taskCount: 3, + }; + + pushSubagentSummaryRow(mockTable, subagentUsage, 1, 2); + + expect(mockTable.rows).toHaveLength(1); + const row = mockTable.rows[0]; + expect(row).toBeDefined(); + // Last 2 columns should be empty (trailingColumns=2) + expect(row![row!.length - 1]).toBe(''); + expect(row![row!.length - 2]).toBe(''); + }); + }); + + describe('pushBreakdownRows with isSubagent', () => { + it('adds 🤖 emoji for subagent models', () => { + const mockTable: { push: (row: (string | number)[]) => void; rows: (string | number)[][] } = { + rows: [], + push(row: (string | number)[]) { + this.rows.push(row); + }, + }; + + const breakdowns = [ + { + modelName: 'claude-opus-4-20250514', + inputTokens: 100, + outputTokens: 50, + cacheCreationTokens: 10, + cacheReadTokens: 20, + cost: 0.5, + isSubagent: false, + }, + { + modelName: 'claude-sonnet-4-20250514', + inputTokens: 200, + outputTokens: 100, + cacheCreationTokens: 0, + cacheReadTokens: 50, + cost: 0.2, + isSubagent: true, + }, + ]; + + pushBreakdownRows(mockTable, breakdowns, 1, 0, true); + + expect(mockTable.rows).toHaveLength(2); + expect(mockTable.rows[0]![0]).not.toContain('[subagent]'); + expect(mockTable.rows[1]![0]).toContain('[subagent]'); + }); + + it('handles models without isSubagent flag', () => { + const mockTable: { push: (row: (string | number)[]) => void; rows: (string | number)[][] } = { + rows: [], + push(row: (string | number)[]) { + this.rows.push(row); + }, + }; + + const breakdowns = [ + { + modelName: 'claude-opus-4-20250514', + inputTokens: 100, + outputTokens: 50, + cacheCreationTokens: 10, + cacheReadTokens: 20, + cost: 0.5, + }, + ]; + + pushBreakdownRows(mockTable, breakdowns); + + expect(mockTable.rows).toHaveLength(1); + expect(mockTable.rows[0]![0]).not.toContain('[subagent]'); + }); + }); }