Track OpenCode child sessions (#343)
Some checks are pending
CI / semgrep (push) Waiting to run

This commit is contained in:
ozymandiashh 2026-05-18 15:51:08 +03:00 committed by GitHub
parent 2013ecbfd9
commit 5a837c94e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 137 additions and 6 deletions

View file

@ -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)

View file

@ -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

View file

@ -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<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],
)
@ -210,7 +233,7 @@ function createParser(
}
}
let currentUserMessage = ''
const currentUserMessageBySession = new Map<string, string>()
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,
}
}

View file

@ -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) => {