Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 15 additions & 1 deletion src/providers/session-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -144,6 +156,8 @@ export function buildAssistantCall(opts: {
costUSD,
tools,
bashCommands,
skills,
subagentTypes,
timestamp: parseTimestamp(opts.timeCreatedMs),
speed: 'standard',
deduplicationKey: opts.dedupKey,
Expand Down
3 changes: 3 additions & 0 deletions src/providers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions tests/providers/opencode-file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [{
Expand Down