From 12920e14fd5071e8bc06074c34c3a6069842b46c Mon Sep 17 00:00:00 2001 From: Kevin Addison Date: Mon, 22 Jun 2026 11:00:15 +0100 Subject: [PATCH] Populate OpenCode skills and subagents breakdowns The Skills & Agents panel was always empty for OpenCode sessions even when skills and subagents were used. buildAssistantCall (shared by the OpenCode SQLite and file-based parsers, and Kilo Code) counted the skill/task tool invocations but never read the skill name or subagent type from the tool-call input, so the dedicated breakdowns had no names to aggregate. In OpenCode's part files the identifier lives in state.input: the skill tool carries input.name and the task tool carries input.subagent_type. Extract both in buildAssistantCall and surface them on ParsedProviderCall.skills / .subagentTypes, then stop hard-coding skills: [] in providerCallToTurn and providerCallToCachedCall so the classifier's subCategory and the subagent breakdown receive the data. Verified against real OpenCode data: the previously empty skills[] and subagents[] now populate (pipeline-investigation, plan-spec, splunk, ... and explore/general subagents). Fixes #556 --- src/parser.ts | 4 +-- src/providers/session-message.ts | 16 ++++++++++- src/providers/types.ts | 3 ++ tests/providers/opencode-file.test.ts | 41 +++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/parser.ts b/src/parser.ts index aa40e6dc..a2fa13d8 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1707,7 +1707,7 @@ function providerCallToTurn(call: ParsedProviderCall): ParsedTurn { costUSD: call.costUSD, tools, mcpTools: extractMcpTools(tools), - skills: [], + skills: call.skills ?? [], subagentTypes: call.subagentTypes ?? [], hasAgentSpawn: tools.includes('Agent'), hasPlanMode: tools.includes('EnterPlanMode'), @@ -1746,7 +1746,7 @@ function providerCallToCachedCall(call: ParsedProviderCall): CachedCall { timestamp: call.timestamp, tools: call.tools, bashCommands: call.bashCommands, - skills: [], + skills: call.skills ?? [], subagentTypes: call.subagentTypes ?? [], deduplicationKey: call.deduplicationKey, project: call.project, diff --git a/src/providers/session-message.ts b/src/providers/session-message.ts index b330f859..f4e4eeac 100644 --- a/src/providers/session-message.ts +++ b/src/providers/session-message.ts @@ -28,7 +28,7 @@ export type PartData = { type: string text?: string tool?: string - state?: { input?: { command?: string } } + state?: { input?: { command?: string; name?: string; subagent_type?: string } } } const toolNameMap: Record = { @@ -117,6 +117,18 @@ export function buildAssistantCall(opts: { .filter((p) => p.tool === 'bash' && typeof p.state?.input?.command === 'string') .flatMap((p) => extractBashCommands(p.state!.input!.command!)) + // The skill/subagent name lives in the tool-call input, not the tool name, so + // the Skills & Agents breakdown needs these extracted alongside the tool list. + const skills = toolParts + .filter((p) => p.tool === 'skill' && typeof p.state?.input?.name === 'string') + .map((p) => p.state!.input!.name!) + .filter(Boolean) + + const subagentTypes = toolParts + .filter((p) => p.tool === 'task' && typeof p.state?.input?.subagent_type === 'string') + .map((p) => p.state!.input!.subagent_type!) + .filter(Boolean) + const model = data.modelID ?? data.model ?? 'unknown' let costUSD = calculateCost( model, @@ -144,6 +156,8 @@ export function buildAssistantCall(opts: { costUSD, tools, bashCommands, + skills, + subagentTypes, timestamp: parseTimestamp(opts.timeCreatedMs), speed: 'standard', deduplicationKey: opts.dedupKey, diff --git a/src/providers/types.ts b/src/providers/types.ts index dff95fbe..f2425c9c 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -27,6 +27,9 @@ export type ParsedProviderCall = { // Subagent types spawned in this call (e.g. 'general-purpose'). Feeds the // Skills & Agents breakdown; optional since most providers don't expose it. subagentTypes?: string[] + // Skill names invoked in this call (e.g. 'commit'). Feeds the Skills & Agents + // breakdown; optional since most providers don't expose it. + skills?: string[] timestamp: string speed: 'standard' | 'fast' deduplicationKey: string diff --git a/tests/providers/opencode-file.test.ts b/tests/providers/opencode-file.test.ts index 891dd75b..ed3718e2 100644 --- a/tests/providers/opencode-file.test.ts +++ b/tests/providers/opencode-file.test.ts @@ -138,6 +138,47 @@ describe('opencode file-based provider - parsing', () => { expect(c.deduplicationKey).toBe('opencode:ses_test1:msg_a') }) + it('extracts skill names and subagent types from skill/task tool parts', async () => { + await writeSession({ + messages: [{ + id: 'msg_a', + data: { + role: 'assistant', modelID: 'gpt-5.3-codex-spark', cost: 0, + tokens: { input: 100, output: 20, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1 }, + }, + parts: [ + { type: 'tool', tool: 'skill', state: { input: { name: 'commit' } } }, + { type: 'tool', tool: 'task', state: { input: { description: 'find files', subagent_type: 'explore' } } }, + { type: 'text', text: 'done' }, + ], + }], + }) + const calls = await parseAll() + expect(calls).toHaveLength(1) + const c = calls[0]! + expect(c.tools).toEqual(['Skill', 'Agent']) + expect(c.skills).toEqual(['commit']) + expect(c.subagentTypes).toEqual(['explore']) + }) + + it('leaves skills and subagentTypes empty when no skill/task parts are present', async () => { + await writeSession({ + messages: [{ + id: 'msg_a', + data: { + role: 'assistant', modelID: 'gpt-5.3-codex-spark', cost: 0, + tokens: { input: 100, output: 20, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1 }, + }, + parts: [{ type: 'tool', tool: 'bash', state: { input: { command: 'ls' } } }], + }], + }) + const calls = await parseAll() + expect(calls[0]!.skills).toEqual([]) + expect(calls[0]!.subagentTypes).toEqual([]) + }) + it('skips an errored or empty assistant turn (all-zero tokens, no parts)', async () => { await writeSession({ messages: [{