Populate OpenCode skills and subagents breakdowns (#557)

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

Co-authored-by: Kevin Addison <kevin.addison3@tesco.com>
This commit is contained in:
Mr Kevin D Addison 2026-06-28 18:46:09 +01:00 committed by GitHub
parent 7cd1f90631
commit d5002f1deb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 61 additions and 3 deletions

View file

@ -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,

View file

@ -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<string, string> = {
@ -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,

View file

@ -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

View file

@ -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: [{