fix(classifier): attribute skills to skillBreakdown regardless of turn category

Skill attribution was gated on the turn classifying as 'general', but
classifyByToolPattern checks hasSkill last, so any turn invoking the Skill
tool alongside Read/Bash/Edit/MCP got a real category and its skill was
silently dropped. Skill turns virtually always carry other tools, which is
why skills came back empty in all 25 usage snapshots (#741) while mcpServers
populated.

subCategory is now set for any turn with Skill tool_use; category assignment
is untouched. The claude parse version is bumped so cached session summaries
(which lack skill attribution) re-parse on upgrade.
This commit is contained in:
ozymandiashh 2026-07-18 22:09:05 +03:00
parent 1850276bd1
commit 56e836fa9e
4 changed files with 38 additions and 7 deletions

View file

@ -208,10 +208,8 @@ export function classifyTurn(turn: ParsedTurn): ClassifiedTurn {
const result: ClassifiedTurn = { ...turn, category, retries: countRetries(turn), hasEdits: turnHasEdits(turn) }
if (category === 'general') {
const skills = getAllSkills(turn)
if (skills.length > 0) result.subCategory = skills[0]
}
const skills = getAllSkills(turn)
if (skills.length > 0) result.subCategory = skills[0]
return result
}

View file

@ -141,7 +141,7 @@ export const DURABLE_PROVIDER_NAMES: ReadonlySet<string> = new Set(['copilot'])
// re-parse, which lands the flag too, and durable orphans now survive
// fingerprint changes (the carry-forward in getOrCreateProviderSection).
export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
claude: 'advisor-usage-v1',
claude: 'advisor-usage-v1-skills',
cline: 'worktree-project-grouping-v1',
codewhale: 'aggregate-session-v1-est-cost',
// Bump when the Codex parser changes attribution so unchanged, already-cached

View file

@ -79,11 +79,11 @@ describe('classifyTurn — Skill subCategory', () => {
expect(c.subCategory).toBeUndefined()
})
it('does not attach subCategory when category is not general (e.g. Skill alongside Edit promotes to coding)', () => {
it('attaches subCategory without changing the category when Skill fires alongside Edit', () => {
const turn = makeTurn([makeCall({ tools: ['Skill', 'Edit'], skills: ['init'] })])
const c = classifyTurn(turn)
expect(c.category).toBe('coding')
expect(c.subCategory).toBeUndefined()
expect(c.subCategory).toBe('init')
})
it('does not attach subCategory for non-Skill general turns', () => {

View file

@ -455,3 +455,36 @@ describe('(f) durable orphans survive a parse-version bump', () => {
expect(again).toBe(200)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// (g) Skill attribution is independent of turn category
// ═══════════════════════════════════════════════════════════════════════════
describe('(g) skill attribution is independent of turn category', () => {
it('puts a Skill + Edit turn in skillBreakdown while preserving coding category', async () => {
const synthFile = join(tmpHome, 'synth-skill.txt')
await writeFile(synthFile, 'placeholder')
_synthSources = [{ path: synthFile, project: 'test', provider: 'test-synthetic' }]
_synthYields = [{
provider: 'test-synthetic', model: 'gpt-4o',
inputTokens: 10, outputTokens: 5,
cacheCreationInputTokens: 0, cacheReadInputTokens: 0,
cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0,
costUSD: 0.001, tools: ['Skill', 'Edit'], bashCommands: [],
skills: ['telemetry-review'],
timestamp: '2026-07-18T12:00:00.000Z',
speed: 'standard',
deduplicationKey: 'synth-skill-edit',
userMessage: '', sessionId: 'synth-skill-session',
}]
const projects = await parseAllSessions(undefined, 'test-synthetic')
const session = projects.flatMap(project => project.sessions)[0]
expect(session).toBeDefined()
expect(session!.turns[0]!.category).toBe('coding')
expect(session!.turns[0]!.subCategory).toBe('telemetry-review')
expect(session!.categoryBreakdown.coding.turns).toBe(1)
expect(session!.skillBreakdown['telemetry-review']?.turns).toBe(1)
})
})