codeburn/tests/classifier.test.ts
voidborne-d c16b21ec50 fix(classifier): surface skill name as subCategory for general turns (#203)
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.
2026-05-04 06:26:45 +08:00

103 lines
3.6 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { classifyTurn } from '../src/classifier.js'
import type { ParsedApiCall, ParsedTurn } from '../src/types.js'
function makeCall(opts: Partial<ParsedApiCall> & { tools?: string[]; skills?: string[] }): ParsedApiCall {
const tools = opts.tools ?? []
return {
provider: 'claude',
model: 'Opus 4.7',
usage: {
inputTokens: 0,
outputTokens: 0,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
},
costUSD: 0,
tools,
mcpTools: tools.filter(t => t.startsWith('mcp__')),
skills: opts.skills ?? [],
hasAgentSpawn: tools.includes('Agent'),
hasPlanMode: tools.includes('EnterPlanMode'),
speed: 'standard',
timestamp: '2026-05-04T00:00:00Z',
bashCommands: [],
deduplicationKey: 'k',
...opts,
}
}
function makeTurn(calls: ParsedApiCall[], userMessage = ''): ParsedTurn {
return {
userMessage,
assistantCalls: calls,
timestamp: '2026-05-04T00:00:00Z',
sessionId: 's1',
}
}
describe('classifyTurn — Skill subCategory', () => {
it('attaches subCategory when a Skill tool fires alone (input.skill)', () => {
const turn = makeTurn([makeCall({ tools: ['Skill'], skills: ['init'] })])
const c = classifyTurn(turn)
expect(c.category).toBe('general')
expect(c.subCategory).toBe('init')
})
it('attaches subCategory when skill identifier comes via input.name (extracted upstream)', () => {
const turn = makeTurn([makeCall({ tools: ['Skill'], skills: ['atelier'] })])
const c = classifyTurn(turn)
expect(c.category).toBe('general')
expect(c.subCategory).toBe('atelier')
})
it('uses the first skill identifier when a single turn invokes multiple skills', () => {
const turn = makeTurn([makeCall({ tools: ['Skill', 'Skill'], skills: ['review', 'security-review'] })])
const c = classifyTurn(turn)
expect(c.category).toBe('general')
expect(c.subCategory).toBe('review')
})
it('aggregates skills across multiple assistant calls in the same turn', () => {
const turn = makeTurn([
makeCall({ tools: ['Skill'], skills: ['claude-api'] }),
makeCall({ tools: ['Skill'], skills: ['init'] }),
])
const c = classifyTurn(turn)
expect(c.category).toBe('general')
expect(c.subCategory).toBe('claude-api')
})
it('does not attach subCategory when the Skill tool fires but no skill name was extracted', () => {
const turn = makeTurn([makeCall({ tools: ['Skill'], skills: [] })])
const c = classifyTurn(turn)
expect(c.category).toBe('general')
expect(c.subCategory).toBeUndefined()
})
it('does not attach subCategory when category is not general (e.g. Skill alongside Edit promotes to coding)', () => {
const turn = makeTurn([makeCall({ tools: ['Skill', 'Edit'], skills: ['init'] })])
const c = classifyTurn(turn)
expect(c.category).toBe('coding')
expect(c.subCategory).toBeUndefined()
})
it('does not attach subCategory for non-Skill general turns', () => {
const turn = makeTurn([makeCall({ tools: [] })], 'just chatting')
const c = classifyTurn(turn)
expect(c.subCategory).toBeUndefined()
})
it('tolerates missing skills field on legacy ParsedApiCall shape', () => {
const baseCall = makeCall({ tools: ['Skill'], skills: ['init'] })
const legacyCall = { ...baseCall } as unknown as ParsedApiCall & { skills?: string[] }
delete (legacyCall as { skills?: string[] }).skills
const c = classifyTurn(makeTurn([legacyCall]))
expect(c.category).toBe('general')
expect(c.subCategory).toBeUndefined()
})
})