From 56e836fa9e9c28417fa7c7857e3f9533b7e52844 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:09:05 +0300 Subject: [PATCH] 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. --- src/classifier.ts | 6 ++---- src/session-cache.ts | 2 +- tests/classifier.test.ts | 4 ++-- tests/parser.test.ts | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 38 insertions(+), 7 deletions(-) diff --git a/src/classifier.ts b/src/classifier.ts index ca7854b..9255f84 100644 --- a/src/classifier.ts +++ b/src/classifier.ts @@ -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 } diff --git a/src/session-cache.ts b/src/session-cache.ts index d189251..41efc1e 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -141,7 +141,7 @@ export const DURABLE_PROVIDER_NAMES: ReadonlySet = 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 = { - 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 diff --git a/tests/classifier.test.ts b/tests/classifier.test.ts index 3ef82b5..6b4730c 100644 --- a/tests/classifier.test.ts +++ b/tests/classifier.test.ts @@ -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', () => { diff --git a/tests/parser.test.ts b/tests/parser.test.ts index 56ffc26..5de2dad 100644 --- a/tests/parser.test.ts +++ b/tests/parser.test.ts @@ -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) + }) +})