mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-17 12:20:43 +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.
113 lines
4.2 KiB
TypeScript
113 lines
4.2 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
|
|
import { formatCost } from '../src/format.js'
|
|
import type { ProjectSummary, SessionSummary } from '../src/types.js'
|
|
|
|
const EMPTY_CATEGORY_BREAKDOWN = {
|
|
coding: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
|
debugging: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
|
feature: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
|
refactoring: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
|
testing: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
|
exploration: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
|
planning: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
|
delegation: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
|
git: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
|
'build/deploy': { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
|
conversation: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
|
brainstorming: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
|
general: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
|
} satisfies SessionSummary['categoryBreakdown']
|
|
|
|
function makeSession(id: string, cost: number, timestamp = '2026-04-14T10:00:00Z'): SessionSummary {
|
|
return {
|
|
sessionId: id,
|
|
project: 'test-project',
|
|
firstTimestamp: timestamp,
|
|
lastTimestamp: timestamp,
|
|
totalCostUSD: cost,
|
|
totalInputTokens: 0,
|
|
totalOutputTokens: 0,
|
|
totalCacheReadTokens: 0,
|
|
totalCacheWriteTokens: 0,
|
|
apiCalls: 1,
|
|
turns: [],
|
|
modelBreakdown: {},
|
|
toolBreakdown: {},
|
|
mcpBreakdown: {},
|
|
bashBreakdown: {},
|
|
categoryBreakdown: { ...EMPTY_CATEGORY_BREAKDOWN },
|
|
skillBreakdown: {},
|
|
}
|
|
}
|
|
|
|
function makeProject(name: string, sessions: SessionSummary[]): ProjectSummary {
|
|
return {
|
|
project: name,
|
|
projectPath: name,
|
|
sessions,
|
|
totalCostUSD: sessions.reduce((s, x) => s + x.totalCostUSD, 0),
|
|
totalApiCalls: sessions.reduce((s, x) => s + x.apiCalls, 0),
|
|
}
|
|
}
|
|
|
|
// Logic replicated from TopSessions component
|
|
function getTopSessions(projects: ProjectSummary[], n = 5) {
|
|
const all = projects.flatMap(p => p.sessions.map(s => ({ ...s, projectName: p.project })))
|
|
return [...all].sort((a, b) => b.totalCostUSD - a.totalCostUSD).slice(0, n)
|
|
}
|
|
|
|
// Logic replicated from ProjectBreakdown component
|
|
function avgCostLabel(project: ProjectSummary): string {
|
|
return project.sessions.length > 0
|
|
? formatCost(project.totalCostUSD / project.sessions.length)
|
|
: '-'
|
|
}
|
|
|
|
describe('TopSessions - top-5 selection', () => {
|
|
it('returns all sessions when fewer than 5 exist', () => {
|
|
const project = makeProject('proj', [
|
|
makeSession('s1', 1.0),
|
|
makeSession('s2', 2.0),
|
|
])
|
|
const top = getTopSessions([project])
|
|
expect(top).toHaveLength(2)
|
|
expect(top[0].totalCostUSD).toBe(2.0)
|
|
expect(top[1].totalCostUSD).toBe(1.0)
|
|
})
|
|
|
|
it('returns exactly 5 when more than 5 sessions exist', () => {
|
|
const sessions = [0.1, 0.5, 3.0, 1.0, 0.8, 2.0].map((cost, i) =>
|
|
makeSession(`s${i}`, cost)
|
|
)
|
|
const project = makeProject('proj', sessions)
|
|
const top = getTopSessions([project])
|
|
expect(top).toHaveLength(5)
|
|
expect(top[0].totalCostUSD).toBe(3.0)
|
|
expect(top[4].totalCostUSD).toBe(0.5)
|
|
})
|
|
|
|
it('is stable on tied costs - preserves input order for equal values', () => {
|
|
const sessions = [
|
|
makeSession('s1', 1.0),
|
|
makeSession('s2', 1.0),
|
|
makeSession('s3', 1.0),
|
|
]
|
|
const project = makeProject('proj', sessions)
|
|
const top = getTopSessions([project])
|
|
expect(top.map(s => s.sessionId)).toEqual(['s1', 's2', 's3'])
|
|
})
|
|
})
|
|
|
|
describe('avg/s in ProjectBreakdown', () => {
|
|
it('returns dash for a project with no sessions', () => {
|
|
const project = makeProject('proj', [])
|
|
expect(avgCostLabel(project)).toBe('-')
|
|
})
|
|
|
|
it('returns formatted average cost across sessions', () => {
|
|
const sessions = [makeSession('s1', 2.0), makeSession('s2', 4.0)]
|
|
const project = makeProject('proj', sessions)
|
|
expect(avgCostLabel(project)).toBe(formatCost(3.0))
|
|
})
|
|
})
|