Skip to content
Merged
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@
child and grandchild agent sessions contribute token and tool usage under
the discovered root session while still excluding child sessions from
top-level discovery to avoid double counting.
- **OpenCode router sessions with missing usage are still reported.**
Some OpenCode router/provider combinations can persist assistant messages
with text or tool activity but zero token and cost fields. The OpenCode
parser now keeps those turns as zero-cost calls instead of dropping the
session entirely. Closes #341.

## 0.9.9 - 2026-05-15

Expand Down
3 changes: 3 additions & 0 deletions docs/providers/opencode.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ Per `<sessionId>:<messageId>`.
token, and tool usage back to the root session.
- Each message's `parts` are indexed; preserving the order matters for reasoning-token correctness.
- Tokens are reported across `input`, `output`, `reasoning`, `cache.read`, and `cache.write`. Anthropic semantics.
- Assistant messages with missing router usage are kept as zero-cost calls
when their parts contain non-empty text or tool activity. Empty zero-usage
assistant placeholders are still skipped.
- External MCP tools are stored as `<server>_<tool>` names (for example
`clickup_clickup_get_task`). The provider normalizes those to CodeBurn's
canonical `mcp__<server>__<tool>` names before aggregation so shared MCP
Expand Down
9 changes: 6 additions & 3 deletions src/providers/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,16 +264,19 @@ function createParser(
cacheWrite: data.tokens?.cache?.write ?? 0,
}

const msgParts = partsByMsg.get(msg.id) ?? []
const toolParts = msgParts.filter((p) => p.type === 'tool' && normalizeToolName(p.tool))
const hasTextOutput = msgParts.some((p) => p.type === 'text' && typeof p.text === 'string' && p.text.trim().length > 0)
const hasActivity = hasTextOutput || toolParts.length > 0

const allZero =
tokens.input === 0 &&
tokens.output === 0 &&
tokens.reasoning === 0 &&
tokens.cacheRead === 0 &&
tokens.cacheWrite === 0
if (allZero && (data.cost ?? 0) === 0) continue
if (allZero && (data.cost ?? 0) === 0 && !hasActivity) continue

const msgParts = partsByMsg.get(msg.id) ?? []
const toolParts = msgParts.filter((p) => p.type === 'tool')
const tools = toolParts
.map((p) => normalizeToolName(p.tool))
.filter(Boolean)
Expand Down
43 changes: 43 additions & 0 deletions tests/providers/opencode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,49 @@ skipUnlessSqlite('opencode provider - session parsing', () => {
expect(calls).toHaveLength(0)
})

it('keeps zero-usage assistant messages when router responses contain text', async () => {
const dbPath = createTestDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, 'sess-1')
insertMessage(db, 'msg-u1', 'sess-1', 1700000000000, { role: 'user' })
insertPart(db, 'part-u1', 'msg-u1', 'sess-1', { type: 'text', text: 'use the configured router' })
insertMessage(db, 'msg-a1', 'sess-1', 1700000001000, {
role: 'assistant', modelID: 'edenai/router-model', cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
})
insertPart(db, 'part-a1', 'msg-a1', 'sess-1', { type: 'text', text: 'router response text' })
})

const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
expect(calls).toHaveLength(1)
expect(calls[0]!.model).toBe('edenai/router-model')
expect(calls[0]!.inputTokens).toBe(0)
expect(calls[0]!.outputTokens).toBe(0)
expect(calls[0]!.costUSD).toBe(0)
expect(calls[0]!.userMessage).toBe('use the configured router')
})

it('keeps zero-usage assistant messages when router responses contain tool calls', async () => {
const dbPath = createTestDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, 'sess-1')
insertMessage(db, 'msg-a1', 'sess-1', 1700000001000, {
role: 'assistant', modelID: 'edenai/router-model', cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
})
insertPart(db, 'part-a1', 'msg-a1', 'sess-1', {
type: 'tool', tool: 'bash',
state: { status: 'completed', input: { command: 'npm test' } },
})
})

const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
expect(calls).toHaveLength(1)
expect(calls[0]!.tools).toEqual(['Bash'])
expect(calls[0]!.bashCommands).toEqual(['npm'])
expect(calls[0]!.costUSD).toBe(0)
})

it('deduplicates messages across parses', async () => {
const dbPath = createTestDb(tmpDir)
withTestDb(dbPath, (db) => {
Expand Down
Loading