mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-10 01:29:41 +00:00
Track OpenCode child sessions (#343)
Some checks are pending
CI / semgrep (push) Waiting to run
Some checks are pending
CI / semgrep (push) Waiting to run
This commit is contained in:
parent
2013ecbfd9
commit
5a837c94e9
4 changed files with 137 additions and 6 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue