From 5a837c94e9c2f46efaec22efb8a122d962f7edfe Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Mon, 18 May 2026 15:51:08 +0300 Subject: [PATCH] Track OpenCode child sessions (#343) --- CHANGELOG.md | 7 +++ docs/providers/opencode.md | 4 ++ src/providers/opencode.ts | 35 ++++++++++-- tests/providers/opencode.test.ts | 97 ++++++++++++++++++++++++++++++++ 4 files changed, 137 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a936606..7b1f1dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,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) diff --git a/docs/providers/opencode.md b/docs/providers/opencode.md index 0148cc9..4a13246 100644 --- a/docs/providers/opencode.md +++ b/docs/providers/opencode.md @@ -26,6 +26,10 @@ Per `:`. - **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 `:`. +- 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 `_` names (for example diff --git a/src/providers/opencode.ts b/src/providers/opencode.ts index 6b0f8ed..b027ed3 100644 --- a/src/providers/opencode.ts +++ b/src/providers/opencode.ts @@ -13,6 +13,7 @@ import type { } from './types.js' type MessageRow = { + session_id: string id: string time_created: number data: Uint8Array | string @@ -189,12 +190,34 @@ function createParser( } const messages = db.query( - '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( - '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], ) @@ -210,7 +233,7 @@ function createParser( } } - let currentUserMessage = '' + const currentUserMessageBySession = new Map() for (const msg of messages) { let data: MessageData @@ -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 } @@ -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) @@ -293,7 +316,7 @@ function createParser( timestamp: parseTimestamp(msg.time_created), speed: 'standard', deduplicationKey: dedupKey, - userMessage: currentUserMessage, + userMessage: currentUserMessageBySession.get(msg.session_id) ?? '', sessionId, } } diff --git a/tests/providers/opencode.test.ts b/tests/providers/opencode.test.ts index 3637b79..7d64ca9 100644 --- a/tests/providers/opencode.test.ts +++ b/tests/providers/opencode.test.ts @@ -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) => {