Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 5a837c9

Browse files
authored
Track OpenCode child sessions (getagentseal#343)
1 parent 2013ecb commit 5a837c9

4 files changed

Lines changed: 137 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,13 @@
3737
`Shell`, `ReadFile`, and `WriteFile`, and maps hidden managed Kimi Code
3838
model aliases to priced Kimi K2 entries.
3939

40+
### Fixed (CLI)
41+
- **OpenCode child sessions are attributed to their root session.** The
42+
OpenCode parser now walks the unarchived `session.parent_id` subtree so
43+
child and grandchild agent sessions contribute token and tool usage under
44+
the discovered root session while still excluding child sessions from
45+
top-level discovery to avoid double counting.
46+
4047
## 0.9.9 - 2026-05-15
4148

4249
### Added (CLI)

docs/providers/opencode.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ Per `<sessionId>:<messageId>`.
2626

2727
- **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.
2828
- Source paths are encoded as `<dbPath>:<sessionId>`.
29+
- Discovery only emits root sessions (`parent_id IS NULL`) to avoid double
30+
counting. Parsing a root session walks the unarchived `session.parent_id`
31+
subtree, so child and grandchild agent sessions contribute their message,
32+
token, and tool usage back to the root session.
2933
- Each message's `parts` are indexed; preserving the order matters for reasoning-token correctness.
3034
- Tokens are reported across `input`, `output`, `reasoning`, `cache.read`, and `cache.write`. Anthropic semantics.
3135
- External MCP tools are stored as `<server>_<tool>` names (for example

src/providers/opencode.ts

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
} from './types.js'
1414

1515
type MessageRow = {
16+
session_id: string
1617
id: string
1718
time_created: number
1819
data: Uint8Array | string
@@ -189,12 +190,34 @@ function createParser(
189190
}
190191

191192
const messages = db.query<MessageRow>(
192-
'SELECT id, time_created, CAST(data AS BLOB) AS data FROM message WHERE session_id = ? ORDER BY time_created ASC',
193+
`WITH RECURSIVE session_tree(id) AS (
194+
SELECT id FROM session WHERE id = ?
195+
UNION
196+
SELECT child.id
197+
FROM session child
198+
JOIN session_tree parent ON child.parent_id = parent.id
199+
WHERE child.time_archived IS NULL
200+
)
201+
SELECT session_id, id, time_created, CAST(data AS BLOB) AS data
202+
FROM message
203+
WHERE session_id IN (SELECT id FROM session_tree)
204+
ORDER BY time_created ASC, id ASC`,
193205
[sessionId],
194206
)
195207

196208
const parts = db.query<PartRow>(
197-
'SELECT message_id, CAST(data AS BLOB) AS data FROM part WHERE session_id = ? ORDER BY message_id, id',
209+
`WITH RECURSIVE session_tree(id) AS (
210+
SELECT id FROM session WHERE id = ?
211+
UNION
212+
SELECT child.id
213+
FROM session child
214+
JOIN session_tree parent ON child.parent_id = parent.id
215+
WHERE child.time_archived IS NULL
216+
)
217+
SELECT message_id, CAST(data AS BLOB) AS data
218+
FROM part
219+
WHERE session_id IN (SELECT id FROM session_tree)
220+
ORDER BY message_id, id`,
198221
[sessionId],
199222
)
200223

@@ -210,7 +233,7 @@ function createParser(
210233
}
211234
}
212235

213-
let currentUserMessage = ''
236+
const currentUserMessageBySession = new Map<string, string>()
214237

215238
for (const msg of messages) {
216239
let data: MessageData
@@ -226,7 +249,7 @@ function createParser(
226249
.map((p) => p.text ?? '')
227250
.filter(Boolean)
228251
if (textParts.length > 0) {
229-
currentUserMessage = textParts.join(' ')
252+
currentUserMessageBySession.set(msg.session_id, textParts.join(' '))
230253
}
231254
continue
232255
}
@@ -259,7 +282,7 @@ function createParser(
259282
.filter((p) => p.tool === 'bash' && typeof p.state?.input?.command === 'string')
260283
.flatMap((p) => extractBashCommands(p.state!.input!.command!))
261284

262-
const dedupKey = `opencode:${sessionId}:${msg.id}`
285+
const dedupKey = `opencode:${msg.session_id}:${msg.id}`
263286
if (seenKeys.has(dedupKey)) continue
264287
seenKeys.add(dedupKey)
265288

@@ -293,7 +316,7 @@ function createParser(
293316
timestamp: parseTimestamp(msg.time_created),
294317
speed: 'standard',
295318
deduplicationKey: dedupKey,
296-
userMessage: currentUserMessage,
319+
userMessage: currentUserMessageBySession.get(msg.session_id) ?? '',
297320
sessionId,
298321
}
299322
}

tests/providers/opencode.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,103 @@ skipUnlessSqlite('opencode provider - session parsing', () => {
643643
expect(calls[1]!.userMessage).toBe('second question')
644644
})
645645

646+
it('attributes child and grandchild session calls back to the root session', async () => {
647+
const dbPath = createTestDb(tmpDir)
648+
withTestDb(dbPath, (db) => {
649+
insertSession(db, 'root')
650+
insertSession(db, 'child', { parentId: 'root' })
651+
insertSession(db, 'grandchild', { parentId: 'child' })
652+
653+
insertMessage(db, 'msg-root-user', 'root', 1700000000000, { role: 'user' })
654+
insertPart(db, 'part-root-user', 'msg-root-user', 'root', { type: 'text', text: 'root prompt' })
655+
insertMessage(db, 'msg-root-assistant', 'root', 1700000001000, {
656+
role: 'assistant',
657+
modelID: 'claude-opus-4-6',
658+
cost: 0.01,
659+
tokens: { input: 10, output: 20, reasoning: 0, cache: { read: 0, write: 0 } },
660+
})
661+
insertPart(db, 'part-root-tool', 'msg-root-assistant', 'root', {
662+
type: 'tool',
663+
tool: 'read',
664+
state: { status: 'completed', input: {} },
665+
})
666+
667+
insertMessage(db, 'msg-child-user', 'child', 1700000002000, { role: 'user' })
668+
insertPart(db, 'part-child-user', 'msg-child-user', 'child', { type: 'text', text: 'child prompt' })
669+
insertMessage(db, 'msg-child-assistant', 'child', 1700000003000, {
670+
role: 'assistant',
671+
modelID: 'claude-opus-4-6',
672+
cost: 0.02,
673+
tokens: { input: 30, output: 40, reasoning: 5, cache: { read: 0, write: 0 } },
674+
})
675+
insertPart(db, 'part-child-tool', 'msg-child-assistant', 'child', {
676+
type: 'tool',
677+
tool: 'task',
678+
state: { status: 'completed', input: {} },
679+
})
680+
681+
insertMessage(db, 'msg-grand-user', 'grandchild', 1700000004000, { role: 'user' })
682+
insertPart(db, 'part-grand-user', 'msg-grand-user', 'grandchild', { type: 'text', text: 'grandchild prompt' })
683+
insertMessage(db, 'msg-grand-assistant', 'grandchild', 1700000005000, {
684+
role: 'assistant',
685+
modelID: 'claude-opus-4-6',
686+
cost: 0.03,
687+
tokens: { input: 50, output: 60, reasoning: 0, cache: { read: 0, write: 0 } },
688+
})
689+
insertPart(db, 'part-grand-tool', 'msg-grand-assistant', 'grandchild', {
690+
type: 'tool',
691+
tool: 'bash',
692+
state: { status: 'completed', input: { command: 'npm test' } },
693+
})
694+
})
695+
696+
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'root')
697+
698+
expect(calls).toHaveLength(3)
699+
expect(calls.map(call => call.sessionId)).toEqual(['root', 'root', 'root'])
700+
expect(calls.map(call => call.deduplicationKey)).toEqual([
701+
'opencode:root:msg-root-assistant',
702+
'opencode:child:msg-child-assistant',
703+
'opencode:grandchild:msg-grand-assistant',
704+
])
705+
expect(calls.map(call => call.userMessage)).toEqual([
706+
'root prompt',
707+
'child prompt',
708+
'grandchild prompt',
709+
])
710+
expect(calls[0]!.tools).toEqual(['Read'])
711+
expect(calls[1]!.tools).toEqual(['Agent'])
712+
expect(calls[2]!.tools).toEqual(['Bash'])
713+
expect(calls[2]!.bashCommands).toEqual(['npm'])
714+
})
715+
716+
it('does not include archived child sessions in the root subtree', async () => {
717+
const dbPath = createTestDb(tmpDir)
718+
withTestDb(dbPath, (db) => {
719+
insertSession(db, 'root')
720+
insertSession(db, 'archived-child', { parentId: 'root', archived: 1700000002500 })
721+
722+
insertMessage(db, 'msg-root-assistant', 'root', 1700000001000, {
723+
role: 'assistant',
724+
modelID: 'claude-opus-4-6',
725+
cost: 0.01,
726+
tokens: { input: 10, output: 20, reasoning: 0, cache: { read: 0, write: 0 } },
727+
})
728+
729+
insertMessage(db, 'msg-child-assistant', 'archived-child', 1700000003000, {
730+
role: 'assistant',
731+
modelID: 'claude-opus-4-6',
732+
cost: 0.02,
733+
tokens: { input: 30, output: 40, reasoning: 0, cache: { read: 0, write: 0 } },
734+
})
735+
})
736+
737+
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'root')
738+
739+
expect(calls).toHaveLength(1)
740+
expect(calls[0]!.deduplicationKey).toBe('opencode:root:msg-root-assistant')
741+
})
742+
646743
it('joins multiple text parts in user messages', async () => {
647744
const dbPath = createTestDb(tmpDir)
648745
withTestDb(dbPath, (db) => {

0 commit comments

Comments
 (0)