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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@
`Shell`, `ReadFile`, and `WriteFile`, and maps hidden managed Kimi Code
model aliases to priced Kimi K2 entries.

### Fixed (CLI)
- **OpenCode child sessions are attributed to their root session.** The
OpenCode parser now walks the unarchived `session.parent_id` subtree so
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.

## 0.9.9 - 2026-05-15

### Added (CLI)
Expand Down
4 changes: 4 additions & 0 deletions docs/providers/opencode.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ Per `<sessionId>:<messageId>`.

- **Schema validation is loud.** When a required table is missing, the parser logs an actionable warning telling the user which table is gone and what version of OpenCode it expects. This is the right behavior; do not silently swallow these.
- Source paths are encoded as `<dbPath>:<sessionId>`.
- Discovery only emits root sessions (`parent_id IS NULL`) to avoid double
counting. Parsing a root session walks the unarchived `session.parent_id`
subtree, so child and grandchild agent sessions contribute their message,
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.
- External MCP tools are stored as `<server>_<tool>` names (for example
Expand Down
35 changes: 29 additions & 6 deletions src/providers/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
} from './types.js'

type MessageRow = {
session_id: string
id: string
time_created: number
data: Uint8Array | string
Expand Down Expand Up @@ -189,12 +190,34 @@ function createParser(
}

const messages = db.query<MessageRow>(
'SELECT id, time_created, CAST(data AS BLOB) AS data FROM message WHERE session_id = ? ORDER BY time_created ASC',
`WITH RECURSIVE session_tree(id) AS (
SELECT id FROM session WHERE id = ?
UNION
SELECT child.id
FROM session child
JOIN session_tree parent ON child.parent_id = parent.id
WHERE child.time_archived IS NULL
)
SELECT session_id, id, time_created, CAST(data AS BLOB) AS data
FROM message
WHERE session_id IN (SELECT id FROM session_tree)
ORDER BY time_created ASC, id ASC`,
[sessionId],
)

const parts = db.query<PartRow>(
'SELECT message_id, CAST(data AS BLOB) AS data FROM part WHERE session_id = ? ORDER BY message_id, id',
`WITH RECURSIVE session_tree(id) AS (
SELECT id FROM session WHERE id = ?
UNION
SELECT child.id
FROM session child
JOIN session_tree parent ON child.parent_id = parent.id
WHERE child.time_archived IS NULL
)
SELECT message_id, CAST(data AS BLOB) AS data
FROM part
WHERE session_id IN (SELECT id FROM session_tree)
ORDER BY message_id, id`,
[sessionId],
)

Expand All @@ -210,7 +233,7 @@ function createParser(
}
}

let currentUserMessage = ''
const currentUserMessageBySession = new Map<string, string>()

for (const msg of messages) {
let data: MessageData
Expand All @@ -226,7 +249,7 @@ function createParser(
.map((p) => p.text ?? '')
.filter(Boolean)
if (textParts.length > 0) {
currentUserMessage = textParts.join(' ')
currentUserMessageBySession.set(msg.session_id, textParts.join(' '))
}
continue
}
Expand Down Expand Up @@ -259,7 +282,7 @@ function createParser(
.filter((p) => p.tool === 'bash' && typeof p.state?.input?.command === 'string')
.flatMap((p) => extractBashCommands(p.state!.input!.command!))

const dedupKey = `opencode:${sessionId}:${msg.id}`
const dedupKey = `opencode:${msg.session_id}:${msg.id}`
if (seenKeys.has(dedupKey)) continue
seenKeys.add(dedupKey)

Expand Down Expand Up @@ -293,7 +316,7 @@ function createParser(
timestamp: parseTimestamp(msg.time_created),
speed: 'standard',
deduplicationKey: dedupKey,
userMessage: currentUserMessage,
userMessage: currentUserMessageBySession.get(msg.session_id) ?? '',
sessionId,
}
}
Expand Down
97 changes: 97 additions & 0 deletions tests/providers/opencode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,103 @@ skipUnlessSqlite('opencode provider - session parsing', () => {
expect(calls[1]!.userMessage).toBe('second question')
})

it('attributes child and grandchild session calls back to the root session', async () => {
const dbPath = createTestDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, 'root')
insertSession(db, 'child', { parentId: 'root' })
insertSession(db, 'grandchild', { parentId: 'child' })

insertMessage(db, 'msg-root-user', 'root', 1700000000000, { role: 'user' })
insertPart(db, 'part-root-user', 'msg-root-user', 'root', { type: 'text', text: 'root prompt' })
insertMessage(db, 'msg-root-assistant', 'root', 1700000001000, {
role: 'assistant',
modelID: 'claude-opus-4-6',
cost: 0.01,
tokens: { input: 10, output: 20, reasoning: 0, cache: { read: 0, write: 0 } },
})
insertPart(db, 'part-root-tool', 'msg-root-assistant', 'root', {
type: 'tool',
tool: 'read',
state: { status: 'completed', input: {} },
})

insertMessage(db, 'msg-child-user', 'child', 1700000002000, { role: 'user' })
insertPart(db, 'part-child-user', 'msg-child-user', 'child', { type: 'text', text: 'child prompt' })
insertMessage(db, 'msg-child-assistant', 'child', 1700000003000, {
role: 'assistant',
modelID: 'claude-opus-4-6',
cost: 0.02,
tokens: { input: 30, output: 40, reasoning: 5, cache: { read: 0, write: 0 } },
})
insertPart(db, 'part-child-tool', 'msg-child-assistant', 'child', {
type: 'tool',
tool: 'task',
state: { status: 'completed', input: {} },
})

insertMessage(db, 'msg-grand-user', 'grandchild', 1700000004000, { role: 'user' })
insertPart(db, 'part-grand-user', 'msg-grand-user', 'grandchild', { type: 'text', text: 'grandchild prompt' })
insertMessage(db, 'msg-grand-assistant', 'grandchild', 1700000005000, {
role: 'assistant',
modelID: 'claude-opus-4-6',
cost: 0.03,
tokens: { input: 50, output: 60, reasoning: 0, cache: { read: 0, write: 0 } },
})
insertPart(db, 'part-grand-tool', 'msg-grand-assistant', 'grandchild', {
type: 'tool',
tool: 'bash',
state: { status: 'completed', input: { command: 'npm test' } },
})
})

const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'root')

expect(calls).toHaveLength(3)
expect(calls.map(call => call.sessionId)).toEqual(['root', 'root', 'root'])
expect(calls.map(call => call.deduplicationKey)).toEqual([
'opencode:root:msg-root-assistant',
'opencode:child:msg-child-assistant',
'opencode:grandchild:msg-grand-assistant',
])
expect(calls.map(call => call.userMessage)).toEqual([
'root prompt',
'child prompt',
'grandchild prompt',
])
expect(calls[0]!.tools).toEqual(['Read'])
expect(calls[1]!.tools).toEqual(['Agent'])
expect(calls[2]!.tools).toEqual(['Bash'])
expect(calls[2]!.bashCommands).toEqual(['npm'])
})

it('does not include archived child sessions in the root subtree', async () => {
const dbPath = createTestDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, 'root')
insertSession(db, 'archived-child', { parentId: 'root', archived: 1700000002500 })

insertMessage(db, 'msg-root-assistant', 'root', 1700000001000, {
role: 'assistant',
modelID: 'claude-opus-4-6',
cost: 0.01,
tokens: { input: 10, output: 20, reasoning: 0, cache: { read: 0, write: 0 } },
})

insertMessage(db, 'msg-child-assistant', 'archived-child', 1700000003000, {
role: 'assistant',
modelID: 'claude-opus-4-6',
cost: 0.02,
tokens: { input: 30, output: 40, reasoning: 0, cache: { read: 0, write: 0 } },
})
})

const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'root')

expect(calls).toHaveLength(1)
expect(calls[0]!.deduplicationKey).toBe('opencode:root:msg-root-assistant')
})

it('joins multiple text parts in user messages', async () => {
const dbPath = createTestDb(tmpDir)
withTestDb(dbPath, (db) => {
Expand Down
Loading