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.
103 lines
3.6 KiB
TypeScript
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()
|
|
})
|
|
})
|