mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-17 03:56:45 +00:00
Turns whose only assistant tool is `Skill` collapse to category `general`
because `classifyByToolPattern` returns `'general'` and `refineByKeywords`
only operates on `coding`/`exploration`. In environments that lean on Claude
Code skills, the per-activity dashboard column flattens — every `/init`,
`/review`, `/security-review`, `/claude-api`, plus user-defined skills, all
land in `general` with no signal about which workflow ran.
Implements Option A from the issue:
- `ParsedApiCall.skills: string[]` populated in the Anthropic-path parser
via a new `extractSkillNames` helper that reads `input.skill || input.name`
from each `Skill` ToolUseBlock (mirrors `detectGhostSkills` extraction at
optimize.ts:765 so the two stay in sync).
- `ClassifiedTurn.subCategory?: string` set to the first skill name when the
resolved category is `general` AND any skill identifier was extracted.
Top-level category stays `general` — existing aggregations, exports, and
category-keyed code paths unchanged.
- `SessionSummary.skillBreakdown: Record<string, {turns,costUSD,editTurns,
oneShotTurns}>` populated in the same per-turn loop that builds
`categoryBreakdown`. Provider sessions (Codex/Cursor/etc.) keep `skills:
[]` — they don't expose the Skill tool surface today.
- Dashboard `ActivityBreakdown` renders top-N skill sub-rows beneath the
`general` row when present (indented `/skill-name`, dimmed). Other
categories render exactly as before; if no skills were invoked, the panel
is byte-identical to current output.
Existing 419 tests still pass. New `tests/classifier.test.ts` adds 8 cases:
single skill via `input.skill`, single via `input.name`, first-wins for
multi-skill turns, aggregation across multiple assistant calls in one turn,
no-name fallback (`subCategory` stays undefined), `Skill+Edit` promoting to
`coding` and dropping subCategory, non-Skill general turns, and a legacy
ParsedApiCall shape with `skills` field absent (forward-compat). Pre-fix
verification by stashing the source change reproduces 4/8 failures with the
exact "expected 'init', received undefined" diff; restoring → 8/8 pass.
Closes #203.
🤖 AI assistance disclosure: assistant-scaffolded by Claude (Opus 4.7);
author of record reviewed every line, ran the full vitest suite locally
(`npm test` → 32 files / 427 tests pass), `npx tsc --noEmit` clean, and
`npm run build` produces a clean ESM bundle.
309 lines
10 KiB
TypeScript
309 lines
10 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
|
|
import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKey } from '../src/day-aggregator.js'
|
|
import type { ProjectSummary } from '../src/types.js'
|
|
|
|
function makeProject(overrides: Partial<ProjectSummary> & { sessions: ProjectSummary['sessions'] }): ProjectSummary {
|
|
return {
|
|
project: 'p',
|
|
projectPath: '/p',
|
|
totalCostUSD: overrides.sessions.reduce((s, sess) => s + sess.totalCostUSD, 0),
|
|
totalApiCalls: overrides.sessions.reduce((s, sess) => s + sess.apiCalls, 0),
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function makeCall(timestamp: string, costUSD: number, model = 'Opus 4.7', provider = 'claude') {
|
|
return {
|
|
provider,
|
|
model,
|
|
usage: {
|
|
inputTokens: 100,
|
|
outputTokens: 200,
|
|
cacheCreationInputTokens: 0,
|
|
cacheReadInputTokens: 50,
|
|
cachedInputTokens: 0,
|
|
reasoningTokens: 0,
|
|
webSearchRequests: 0,
|
|
},
|
|
costUSD,
|
|
tools: [],
|
|
mcpTools: [],
|
|
skills: [],
|
|
hasAgentSpawn: false,
|
|
hasPlanMode: false,
|
|
speed: 'standard' as const,
|
|
timestamp,
|
|
bashCommands: [],
|
|
deduplicationKey: `dk-${timestamp}-${costUSD}`,
|
|
}
|
|
}
|
|
|
|
describe('aggregateProjectsIntoDays', () => {
|
|
it('buckets api calls by calendar date derived from timestamp', () => {
|
|
const projects: ProjectSummary[] = [
|
|
makeProject({
|
|
sessions: [{
|
|
sessionId: 's1',
|
|
project: 'p',
|
|
firstTimestamp: '2026-04-09T10:00:00Z',
|
|
lastTimestamp: '2026-04-10T08:00:00Z',
|
|
totalCostUSD: 10,
|
|
totalInputTokens: 0,
|
|
totalOutputTokens: 0,
|
|
totalCacheReadTokens: 0,
|
|
totalCacheWriteTokens: 0,
|
|
apiCalls: 2,
|
|
turns: [
|
|
{
|
|
userMessage: 'hi',
|
|
timestamp: '2026-04-09T10:00:00Z',
|
|
sessionId: 's1',
|
|
category: 'coding',
|
|
retries: 0,
|
|
hasEdits: true,
|
|
assistantCalls: [
|
|
makeCall('2026-04-09T10:00:00Z', 4),
|
|
makeCall('2026-04-10T08:00:00Z', 6),
|
|
],
|
|
},
|
|
],
|
|
modelBreakdown: {},
|
|
toolBreakdown: {},
|
|
mcpBreakdown: {},
|
|
bashBreakdown: {},
|
|
categoryBreakdown: {} as never,
|
|
skillBreakdown: {} as never,
|
|
}],
|
|
}),
|
|
]
|
|
|
|
const days = aggregateProjectsIntoDays(projects)
|
|
expect(days.map(d => d.date)).toEqual(['2026-04-09', '2026-04-10'])
|
|
expect(days[0]!.cost).toBe(4)
|
|
expect(days[0]!.calls).toBe(1)
|
|
expect(days[1]!.cost).toBe(6)
|
|
expect(days[1]!.calls).toBe(1)
|
|
})
|
|
|
|
it('attributes category turns + editTurns + oneShotTurns to the first call date of the turn', () => {
|
|
const projects: ProjectSummary[] = [
|
|
makeProject({
|
|
sessions: [{
|
|
sessionId: 's1',
|
|
project: 'p',
|
|
firstTimestamp: '2026-04-09T10:00:00Z',
|
|
lastTimestamp: '2026-04-09T10:05:00Z',
|
|
totalCostUSD: 3,
|
|
totalInputTokens: 0,
|
|
totalOutputTokens: 0,
|
|
totalCacheReadTokens: 0,
|
|
totalCacheWriteTokens: 0,
|
|
apiCalls: 1,
|
|
turns: [
|
|
{
|
|
userMessage: 'hi',
|
|
timestamp: '2026-04-09T10:00:00Z',
|
|
sessionId: 's1',
|
|
category: 'coding',
|
|
retries: 0,
|
|
hasEdits: true,
|
|
assistantCalls: [makeCall('2026-04-09T10:00:00Z', 3)],
|
|
},
|
|
],
|
|
modelBreakdown: {},
|
|
toolBreakdown: {},
|
|
mcpBreakdown: {},
|
|
bashBreakdown: {},
|
|
categoryBreakdown: {} as never,
|
|
skillBreakdown: {} as never,
|
|
}],
|
|
}),
|
|
]
|
|
const days = aggregateProjectsIntoDays(projects)
|
|
const day = days[0]!
|
|
expect(day.editTurns).toBe(1)
|
|
expect(day.oneShotTurns).toBe(1)
|
|
expect(day.categories['coding']).toEqual({
|
|
turns: 1,
|
|
cost: 3,
|
|
editTurns: 1,
|
|
oneShotTurns: 1,
|
|
})
|
|
})
|
|
|
|
it('counts a session under its firstTimestamp date', () => {
|
|
const projects: ProjectSummary[] = [
|
|
makeProject({
|
|
sessions: [{
|
|
sessionId: 's1',
|
|
project: 'p',
|
|
firstTimestamp: '2026-04-09T23:59:00Z',
|
|
lastTimestamp: '2026-04-10T00:10:00Z',
|
|
totalCostUSD: 1,
|
|
totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0,
|
|
apiCalls: 0,
|
|
turns: [],
|
|
modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {},
|
|
categoryBreakdown: {} as never,
|
|
skillBreakdown: {} as never,
|
|
}],
|
|
}),
|
|
]
|
|
const days = aggregateProjectsIntoDays(projects)
|
|
const expectedDate = dateKey('2026-04-09T23:59:00Z')
|
|
expect(days[0]!.date).toBe(expectedDate)
|
|
expect(days[0]!.sessions).toBe(1)
|
|
})
|
|
|
|
it('aggregates per-model and per-provider totals inside each day', () => {
|
|
const projects: ProjectSummary[] = [
|
|
makeProject({
|
|
sessions: [{
|
|
sessionId: 's1',
|
|
project: 'p',
|
|
firstTimestamp: '2026-04-10T10:00:00Z',
|
|
lastTimestamp: '2026-04-10T10:00:00Z',
|
|
totalCostUSD: 10,
|
|
totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0,
|
|
apiCalls: 2,
|
|
turns: [
|
|
{
|
|
userMessage: 'x', timestamp: '2026-04-10T10:00:00Z', sessionId: 's1',
|
|
category: 'coding', retries: 0, hasEdits: false,
|
|
assistantCalls: [
|
|
makeCall('2026-04-10T10:00:00Z', 7, 'Opus 4.7', 'claude'),
|
|
makeCall('2026-04-10T10:00:00Z', 3, 'gpt-5', 'codex'),
|
|
],
|
|
},
|
|
],
|
|
modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {},
|
|
categoryBreakdown: {} as never,
|
|
skillBreakdown: {} as never,
|
|
}],
|
|
}),
|
|
]
|
|
const days = aggregateProjectsIntoDays(projects)
|
|
const day = days[0]!
|
|
expect(day.models['Opus 4.7']).toEqual({
|
|
calls: 1, cost: 7,
|
|
inputTokens: 100, outputTokens: 200,
|
|
cacheReadTokens: 50, cacheWriteTokens: 0,
|
|
})
|
|
expect(day.models['gpt-5']).toEqual({
|
|
calls: 1, cost: 3,
|
|
inputTokens: 100, outputTokens: 200,
|
|
cacheReadTokens: 50, cacheWriteTokens: 0,
|
|
})
|
|
expect(day.providers['claude']).toEqual({ calls: 1, cost: 7 })
|
|
expect(day.providers['codex']).toEqual({ calls: 1, cost: 3 })
|
|
})
|
|
})
|
|
|
|
describe('buildPeriodDataFromDays', () => {
|
|
function makeDay(date: string, cost: number) {
|
|
return {
|
|
date,
|
|
cost,
|
|
calls: 10,
|
|
sessions: 2,
|
|
inputTokens: 100,
|
|
outputTokens: 200,
|
|
cacheReadTokens: 300,
|
|
cacheWriteTokens: 0,
|
|
editTurns: 3,
|
|
oneShotTurns: 2,
|
|
models: {
|
|
'Opus 4.7': { calls: 8, cost: cost * 0.8, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
|
|
'Haiku 4.5': { calls: 2, cost: cost * 0.2, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
|
|
},
|
|
categories: { 'coding': { turns: 2, cost: cost * 0.5, editTurns: 2, oneShotTurns: 1 } },
|
|
providers: { 'claude': { calls: 10, cost } },
|
|
}
|
|
}
|
|
|
|
it('sums cost, calls, sessions, tokens across days', () => {
|
|
const days = [makeDay('2026-04-09', 10), makeDay('2026-04-10', 20)]
|
|
const pd = buildPeriodDataFromDays(days, '7 Days')
|
|
expect(pd.label).toBe('7 Days')
|
|
expect(pd.cost).toBe(30)
|
|
expect(pd.calls).toBe(20)
|
|
expect(pd.sessions).toBe(4)
|
|
expect(pd.inputTokens).toBe(200)
|
|
expect(pd.outputTokens).toBe(400)
|
|
expect(pd.cacheReadTokens).toBe(600)
|
|
})
|
|
|
|
it('merges per-model totals across days and sorts by cost desc', () => {
|
|
const days = [makeDay('2026-04-09', 10), makeDay('2026-04-10', 20)]
|
|
const pd = buildPeriodDataFromDays(days, 'Today')
|
|
expect(pd.models[0]!.name).toBe('Opus 4.7')
|
|
expect(pd.models[0]!.cost).toBeCloseTo(24)
|
|
expect(pd.models[1]!.name).toBe('Haiku 4.5')
|
|
expect(pd.models[1]!.cost).toBeCloseTo(6)
|
|
})
|
|
|
|
it('merges per-category totals and keeps editTurns + oneShotTurns per category', () => {
|
|
const days = [makeDay('2026-04-09', 10), makeDay('2026-04-10', 20)]
|
|
const pd = buildPeriodDataFromDays(days, 'Today')
|
|
const coding = pd.categories.find(c => c.name === 'Coding')!
|
|
expect(coding.turns).toBe(4)
|
|
expect(coding.editTurns).toBe(4)
|
|
expect(coding.oneShotTurns).toBe(2)
|
|
expect(coding.cost).toBeCloseTo(15)
|
|
})
|
|
|
|
it('returns empty period totals when no days supplied', () => {
|
|
const pd = buildPeriodDataFromDays([], 'Today')
|
|
expect(pd.cost).toBe(0)
|
|
expect(pd.calls).toBe(0)
|
|
expect(pd.sessions).toBe(0)
|
|
expect(pd.categories).toEqual([])
|
|
expect(pd.models).toEqual([])
|
|
})
|
|
|
|
it('attributes a midnight-straddling turn to the first assistant call date, not the user message date', () => {
|
|
// Regression for the bug that shipped in 0.8.2-0.8.4: when a user message
|
|
// sat on one side of midnight and the assistant response landed on the other,
|
|
// day-aggregator.ts bucketed by assistant time but renderStatusBar bucketed
|
|
// by user time, so the menubar and `codeburn status` disagreed on Today.
|
|
// The invariant for both surfaces: a turn is counted on the day its first
|
|
// assistant call actually ran.
|
|
const userTs = '2026-04-20T23:58:00Z'
|
|
const assistantTs = '2026-04-21T00:30:00Z'
|
|
const assistantLocal = new Date(assistantTs)
|
|
const expectedDate = `${assistantLocal.getFullYear()}-${String(assistantLocal.getMonth() + 1).padStart(2, '0')}-${String(assistantLocal.getDate()).padStart(2, '0')}`
|
|
|
|
const projects: ProjectSummary[] = [
|
|
makeProject({
|
|
sessions: [{
|
|
sessionId: 's1',
|
|
project: 'p',
|
|
firstTimestamp: userTs,
|
|
lastTimestamp: assistantTs,
|
|
totalCostUSD: 5,
|
|
totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0,
|
|
apiCalls: 1,
|
|
turns: [{
|
|
userMessage: 'ask',
|
|
timestamp: userTs,
|
|
sessionId: 's1',
|
|
category: 'coding',
|
|
retries: 0,
|
|
hasEdits: false,
|
|
assistantCalls: [makeCall(assistantTs, 5)],
|
|
}],
|
|
modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {},
|
|
categoryBreakdown: {} as never,
|
|
skillBreakdown: {} as never,
|
|
}],
|
|
}),
|
|
]
|
|
|
|
const days = aggregateProjectsIntoDays(projects)
|
|
const costDay = days.find(d => d.cost === 5)
|
|
expect(costDay, 'turn cost must be bucketed somewhere').toBeDefined()
|
|
expect(costDay!.date).toBe(expectedDate)
|
|
expect(costDay!.calls).toBe(1)
|
|
})
|
|
})
|