diff --git a/CHANGELOG.md b/CHANGELOG.md index bd13088..90b80b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/providers/opencode.md b/docs/providers/opencode.md index 4a13246..8a04efe 100644 --- a/docs/providers/opencode.md +++ b/docs/providers/opencode.md @@ -32,6 +32,9 @@ Per `:`. 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 `_` names (for example `clickup_clickup_get_task`). The provider normalizes those to CodeBurn's canonical `mcp____` names before aggregation so shared MCP diff --git a/src/providers/opencode.ts b/src/providers/opencode.ts index b027ed3..5bce6b6 100644 --- a/src/providers/opencode.ts +++ b/src/providers/opencode.ts @@ -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) diff --git a/tests/providers/opencode.test.ts b/tests/providers/opencode.test.ts index 7d64ca9..acc0bff 100644 --- a/tests/providers/opencode.test.ts +++ b/tests/providers/opencode.test.ts @@ -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) => {