feat: add OpenCode provider

Reads session data from OpenCode's SQLite databases at
~/.local/share/opencode/. Reuses the existing better-sqlite3
adapter (same as Cursor), lazy-loaded so users without OpenCode
see no difference. Adds bashCommands to the provider interface
so shell command breakdowns work across all providers.

31 tests, schema validation, diagnostic stderr on failures.
Also fixes a pre-existing tsc error in currency.ts.
This commit is contained in:
AgentSeal 2026-04-15 14:24:37 -07:00
parent 3612c5a994
commit 2d114d9393
10 changed files with 927 additions and 5 deletions

View file

@ -20,6 +20,7 @@
"claude-code",
"cursor",
"codex",
"opencode",
"ai-coding",
"token-usage",
"cost-tracking",

View file

@ -40,7 +40,7 @@ function getFractionDigits(code: string): number {
return new Intl.NumberFormat('en', {
style: 'currency',
currency: code,
}).resolvedOptions().maximumFractionDigits
}).resolvedOptions().maximumFractionDigits ?? 2
}
function getCacheDir(): string {

View file

@ -367,7 +367,7 @@ function providerCallToTurn(call: ParsedProviderCall): ParsedTurn {
hasPlanMode: tools.includes('EnterPlanMode'),
speed: call.speed,
timestamp: call.timestamp,
bashCommands: [],
bashCommands: call.bashCommands,
deduplicationKey: call.deduplicationKey,
}

View file

@ -259,6 +259,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
webSearchRequests: 0,
costUSD,
tools: pendingTools,
bashCommands: [],
timestamp,
speed: 'standard',
deduplicationKey: dedupKey,

View file

@ -188,6 +188,7 @@ function parseBubbles(db: SqliteDatabase, seenKeys: Set<string>): { calls: Parse
webSearchRequests: 0,
costUSD,
tools: cursorTools,
bashCommands: [],
timestamp,
speed: 'standard',
deduplicationKey: dedupKey,

View file

@ -17,11 +17,29 @@ async function loadCursor(): Promise<Provider | null> {
}
}
let opencodeProvider: Provider | null = null
let opencodeLoadAttempted = false
async function loadOpenCode(): Promise<Provider | null> {
if (opencodeLoadAttempted) return opencodeProvider
opencodeLoadAttempted = true
try {
const { opencode } = await import('./opencode.js')
opencodeProvider = opencode
return opencode
} catch {
return null
}
}
const coreProviders: Provider[] = [claude, codex]
export async function getAllProviders(): Promise<Provider[]> {
const cursor = await loadCursor()
return cursor ? [...coreProviders, cursor] : [...coreProviders]
const [cursor, opencode] = await Promise.all([loadCursor(), loadOpenCode()])
const all = [...coreProviders]
if (cursor) all.push(cursor)
if (opencode) all.push(opencode)
return all
}
export const providers = coreProviders
@ -44,6 +62,10 @@ export async function getProvider(name: string): Promise<Provider | undefined> {
const cursor = await loadCursor()
return cursor ?? undefined
}
if (name === 'opencode') {
const oc = await loadOpenCode()
return oc ?? undefined
}
return coreProviders.find(p => p.name === name)
}

320
src/providers/opencode.ts Normal file
View file

@ -0,0 +1,320 @@
import { readdir } from 'fs/promises'
import { join } from 'path'
import { homedir } from 'os'
import { calculateCost, getShortModelName } from '../models.js'
import { extractBashCommands } from '../bash-utils.js'
import { isSqliteAvailable, getSqliteLoadError, openDatabase, type SqliteDatabase } from '../sqlite.js'
import type {
Provider,
SessionSource,
SessionParser,
ParsedProviderCall,
} from './types.js'
type MessageRow = {
id: string
time_created: number
data: string
}
type PartRow = {
message_id: string
data: string
}
type SessionRow = {
id: string
directory: string
title: string
time_created: number
}
type MessageData = {
role: string
modelID?: string
cost?: number
tokens?: {
input?: number
output?: number
reasoning?: number
cache?: { read?: number; write?: number }
}
}
type PartData = {
type: string
text?: string
tool?: string
state?: { input?: { command?: string } }
}
const toolNameMap: Record<string, string> = {
bash: 'Bash',
read: 'Read',
edit: 'Edit',
write: 'Write',
glob: 'Glob',
grep: 'Grep',
task: 'Agent',
fetch: 'WebFetch',
search: 'WebSearch',
todo: 'TodoWrite',
skill: 'Skill',
patch: 'Patch',
}
function sanitize(dir: string): string {
return dir.replace(/^\//, '').replace(/\//g, '-')
}
function getDataDir(dataDir?: string): string {
const base =
dataDir ??
process.env['XDG_DATA_HOME'] ??
join(homedir(), '.local', 'share')
return join(base, 'opencode')
}
async function findDbFiles(dir: string): Promise<string[]> {
try {
const entries = await readdir(dir)
return entries
.filter((f) => f.startsWith('opencode') && f.endsWith('.db'))
.map((f) => join(dir, f))
} catch {
return []
}
}
function parseTimestamp(raw: number): string {
const ms = raw < 1e12 ? raw * 1000 : raw
return new Date(ms).toISOString()
}
function validateSchema(db: SqliteDatabase): boolean {
try {
db.query<{ cnt: number }>(
"SELECT COUNT(*) as cnt FROM session LIMIT 1"
)
db.query<{ cnt: number }>(
"SELECT COUNT(*) as cnt FROM message LIMIT 1"
)
return true
} catch {
return false
}
}
function createParser(
source: SessionSource,
seenKeys: Set<string>,
): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
if (!isSqliteAvailable()) {
process.stderr.write(getSqliteLoadError() + '\n')
return
}
// Path is encoded as `${dbPath}:${sessionId}`. Session IDs are UUIDs
// (no colons), so the last segment after splitting on ':' is always
// the session ID. Rejoining handles Windows drive letters (C:\...).
const segments = source.path.split(':')
const sessionId = segments[segments.length - 1]!
const dbPath = segments.slice(0, -1).join(':')
let db: SqliteDatabase
try {
db = openDatabase(dbPath)
} catch (err) {
process.stderr.write(`codeburn: cannot open OpenCode database: ${err instanceof Error ? err.message : err}\n`)
return
}
try {
if (!validateSchema(db)) {
process.stderr.write('codeburn: OpenCode storage format not recognized. You may need to update CodeBurn.\n')
return
}
const messages = db.query<MessageRow>(
'SELECT id, time_created, data FROM message WHERE session_id = ? ORDER BY time_created ASC',
[sessionId],
)
const parts = db.query<PartRow>(
'SELECT message_id, data FROM part WHERE session_id = ? ORDER BY message_id, id',
[sessionId],
)
const partsByMsg = new Map<string, PartData[]>()
for (const part of parts) {
try {
const parsed = JSON.parse(part.data) as PartData
const list = partsByMsg.get(part.message_id) ?? []
list.push(parsed)
partsByMsg.set(part.message_id, list)
} catch {
// skip corrupt part data
}
}
let currentUserMessage = ''
for (const msg of messages) {
let data: MessageData
try {
data = JSON.parse(msg.data) as MessageData
} catch {
continue
}
if (data.role === 'user') {
const textParts = (partsByMsg.get(msg.id) ?? [])
.filter((p) => p.type === 'text')
.map((p) => p.text ?? '')
.filter(Boolean)
if (textParts.length > 0) {
currentUserMessage = textParts.join(' ')
}
continue
}
if (data.role !== 'assistant') continue
const tokens = {
input: data.tokens?.input ?? 0,
output: data.tokens?.output ?? 0,
reasoning: data.tokens?.reasoning ?? 0,
cacheRead: data.tokens?.cache?.read ?? 0,
cacheWrite: data.tokens?.cache?.write ?? 0,
}
const allZero =
tokens.input === 0 &&
tokens.output === 0 &&
tokens.reasoning === 0 &&
tokens.cacheRead === 0 &&
tokens.cacheWrite === 0
if (allZero && (data.cost ?? 0) === 0) continue
const msgParts = partsByMsg.get(msg.id) ?? []
const toolParts = msgParts.filter((p) => p.type === 'tool')
const tools = toolParts
.map((p) => toolNameMap[p.tool ?? ''] ?? p.tool ?? '')
.filter(Boolean)
const bashCommands = toolParts
.filter((p) => p.tool === 'bash' && typeof p.state?.input?.command === 'string')
.flatMap((p) => extractBashCommands(p.state!.input!.command!))
const dedupKey = `opencode:${sessionId}:${msg.id}`
if (seenKeys.has(dedupKey)) continue
seenKeys.add(dedupKey)
const model = data.modelID ?? 'unknown'
let costUSD = calculateCost(
model,
tokens.input,
tokens.output + tokens.reasoning,
tokens.cacheWrite,
tokens.cacheRead,
0,
)
if (costUSD === 0 && typeof data.cost === 'number' && data.cost > 0) {
costUSD = data.cost
}
yield {
provider: 'opencode',
model,
inputTokens: tokens.input,
outputTokens: tokens.output,
cacheCreationInputTokens: tokens.cacheWrite,
cacheReadInputTokens: tokens.cacheRead,
cachedInputTokens: tokens.cacheRead,
reasoningTokens: tokens.reasoning,
webSearchRequests: 0,
costUSD,
tools,
bashCommands,
timestamp: parseTimestamp(msg.time_created),
speed: 'standard',
deduplicationKey: dedupKey,
userMessage: currentUserMessage,
sessionId,
}
}
} finally {
db.close()
}
},
}
}
async function discoverFromDb(dbPath: string): Promise<SessionSource[]> {
let db: SqliteDatabase
try {
db = openDatabase(dbPath)
} catch {
return []
}
try {
const rows = db.query<SessionRow>(
'SELECT id, directory, title, time_created FROM session WHERE time_archived IS NULL AND parent_id IS NULL ORDER BY time_created DESC',
)
return rows.map((row) => ({
path: `${dbPath}:${row.id}`,
project: row.directory ? sanitize(row.directory) : sanitize(row.title),
provider: 'opencode',
}))
} catch {
return []
} finally {
db.close()
}
}
export function createOpenCodeProvider(dataDir?: string): Provider {
const dir = getDataDir(dataDir)
return {
name: 'opencode',
displayName: 'OpenCode',
modelDisplayName(model: string): string {
const stripped = model.replace(/^[^/]+\//, '')
return getShortModelName(stripped)
},
toolDisplayName(rawTool: string): string {
return toolNameMap[rawTool] ?? rawTool
},
async discoverSessions(): Promise<SessionSource[]> {
if (!isSqliteAvailable()) return []
const dbPaths = await findDbFiles(dir)
if (dbPaths.length === 0) return []
const sessions: SessionSource[] = []
for (const dbPath of dbPaths) {
sessions.push(...await discoverFromDb(dbPath))
}
return sessions
},
createSessionParser(
source: SessionSource,
seenKeys: Set<string>,
): SessionParser {
return createParser(source, seenKeys)
},
}
}
export const opencode = createOpenCodeProvider()

View file

@ -20,6 +20,7 @@ export type ParsedProviderCall = {
webSearchRequests: number
costUSD: number
tools: string[]
bashCommands: string[]
timestamp: string
speed: 'standard' | 'fast'
deduplicationKey: string

View file

@ -6,7 +6,7 @@ describe('provider registry', () => {
expect(providers.map(p => p.name)).toEqual(['claude', 'codex'])
})
it('includes cursor after async load', async () => {
it('includes sqlite providers after async load', async () => {
const all = await getAllProviders()
const names = all.map(p => p.name)
expect(names).toContain('claude')
@ -14,6 +14,24 @@ describe('provider registry', () => {
expect(names.length).toBeGreaterThanOrEqual(2)
})
it('opencode model display names strip provider prefix', async () => {
const all = await getAllProviders()
const oc = all.find(p => p.name === 'opencode')
if (!oc) return
expect(oc.modelDisplayName('anthropic/claude-opus-4-6-20260205')).toBe('Opus 4.6')
expect(oc.modelDisplayName('google/gemini-2.5-pro')).toBe('Gemini 2.5 Pro')
})
it('opencode tool display names normalize builtins', async () => {
const all = await getAllProviders()
const oc = all.find(p => p.name === 'opencode')
if (!oc) return
expect(oc.toolDisplayName('bash')).toBe('Bash')
expect(oc.toolDisplayName('edit')).toBe('Edit')
expect(oc.toolDisplayName('task')).toBe('Agent')
expect(oc.toolDisplayName('unknown_tool')).toBe('unknown_tool')
})
it('claude tool display names are identity', () => {
const claude = providers.find(p => p.name === 'claude')!
expect(claude.toolDisplayName('Bash')).toBe('Bash')

View file

@ -0,0 +1,558 @@
import { mkdtemp, rm, mkdir, writeFile } from 'fs/promises'
import { mkdirSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { isSqliteAvailable } from '../../src/sqlite.js'
import { createOpenCodeProvider } from '../../src/providers/opencode.js'
import type { ParsedProviderCall } from '../../src/providers/types.js'
type TestDb = {
exec(sql: string): void
prepare(sql: string): { run(...params: unknown[]): void }
close(): void
}
let tmpDir: string
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'opencode-test-'))
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
})
function createTestDb(dir: string): string {
const ocDir = join(dir, 'opencode')
mkdirSync(ocDir, { recursive: true })
const dbPath = join(ocDir, 'opencode.db')
const Database = require('better-sqlite3')
const db = new Database(dbPath)
db.exec(`
CREATE TABLE session (
id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT,
slug TEXT NOT NULL, directory TEXT NOT NULL, title TEXT NOT NULL,
version TEXT NOT NULL, time_created INTEGER, time_updated INTEGER,
time_archived INTEGER
)
`)
db.exec(`
CREATE TABLE message (
id TEXT PRIMARY KEY, session_id TEXT NOT NULL,
time_created INTEGER, time_updated INTEGER, data TEXT NOT NULL
)
`)
db.exec(`
CREATE TABLE part (
id TEXT PRIMARY KEY, message_id TEXT NOT NULL,
session_id TEXT NOT NULL, time_created INTEGER,
time_updated INTEGER, data TEXT NOT NULL
)
`)
db.close()
return dbPath
}
function withTestDb(dbPath: string, fn: (db: TestDb) => void): void {
const Database = require('better-sqlite3')
const db = new Database(dbPath)
fn(db)
db.close()
}
function insertSession(
db: TestDb,
id: string,
opts: { directory?: string; title?: string; parentId?: string | null; archived?: number | null } = {},
): void {
db.prepare(`
INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_archived, parent_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, 'proj-1', 'slug-1', opts.directory ?? '/home/user/myproject', opts.title ?? 'My Project', '1.0', 1700000000000, opts.archived ?? null, opts.parentId ?? null)
}
type MessageFixture = {
role: string
modelID?: string
cost?: number
tokens?: {
input: number
output: number
reasoning: number
cache: { read: number; write: number }
}
}
type PartFixture = {
type: string
text?: string
tool?: string
state?: { status: string; input: { command?: string } }
}
function insertMessage(db: TestDb, id: string, sessionId: string, timeCreated: number, data: MessageFixture): void {
db.prepare(`INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)`)
.run(id, sessionId, timeCreated, JSON.stringify(data))
}
function insertPart(db: TestDb, id: string, messageId: string, sessionId: string, data: PartFixture): void {
db.prepare(`INSERT INTO part (id, message_id, session_id, data) VALUES (?, ?, ?, ?)`)
.run(id, messageId, sessionId, JSON.stringify(data))
}
async function collectCalls(provider: ReturnType<typeof createOpenCodeProvider>, dbPath: string, sessionId: string, seenKeys?: Set<string>): Promise<ParsedProviderCall[]> {
const source = { path: `${dbPath}:${sessionId}`, project: 'myproject', provider: 'opencode' }
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(source, seenKeys ?? new Set()).parse()) {
calls.push(call)
}
return calls
}
const skipUnlessSqlite = isSqliteAvailable() ? describe : describe.skip
skipUnlessSqlite('opencode provider - model display names', () => {
it('strips provider prefix and delegates to shared lookup', () => {
const provider = createOpenCodeProvider()
expect(provider.modelDisplayName('claude-opus-4-6-20260205')).toBe('Opus 4.6')
})
it('strips google provider prefix', () => {
const provider = createOpenCodeProvider()
expect(provider.modelDisplayName('google/gemini-2.5-pro')).toBe('Gemini 2.5 Pro')
})
it('strips openai provider prefix', () => {
const provider = createOpenCodeProvider()
expect(provider.modelDisplayName('openai/gpt-4o')).toBe('GPT-4o')
})
it('passes through models without prefix unchanged', () => {
const provider = createOpenCodeProvider()
expect(provider.modelDisplayName('gpt-4o')).toBe('GPT-4o')
expect(provider.modelDisplayName('gpt-4o-mini')).toBe('GPT-4o Mini')
})
it('returns unknown models as-is', () => {
const provider = createOpenCodeProvider()
expect(provider.modelDisplayName('big-pickle')).toBe('big-pickle')
})
it('has correct displayName', () => {
const provider = createOpenCodeProvider()
expect(provider.displayName).toBe('OpenCode')
expect(provider.name).toBe('opencode')
})
})
skipUnlessSqlite('opencode provider - tool display names', () => {
it('maps opencode builtins', () => {
const provider = createOpenCodeProvider()
expect(provider.toolDisplayName('bash')).toBe('Bash')
expect(provider.toolDisplayName('edit')).toBe('Edit')
expect(provider.toolDisplayName('task')).toBe('Agent')
expect(provider.toolDisplayName('fetch')).toBe('WebFetch')
expect(provider.toolDisplayName('grep')).toBe('Grep')
expect(provider.toolDisplayName('write')).toBe('Write')
expect(provider.toolDisplayName('skill')).toBe('Skill')
})
it('returns unknown tools as-is', () => {
const provider = createOpenCodeProvider()
expect(provider.toolDisplayName('github_search_code')).toBe('github_search_code')
})
})
skipUnlessSqlite('opencode provider - session discovery', () => {
it('discovers sessions with correct path format', async () => {
const dbPath = createTestDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, 'sess-1')
})
const provider = createOpenCodeProvider(tmpDir)
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(1)
expect(sessions[0]!.provider).toBe('opencode')
expect(sessions[0]!.project).toBe('home-user-myproject')
expect(sessions[0]!.path).toBe(`${dbPath}:sess-1`)
})
it('excludes archived sessions', async () => {
const dbPath = createTestDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, 'sess-archived', { archived: 1700000001000 })
})
const provider = createOpenCodeProvider(tmpDir)
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(0)
})
it('excludes child sessions', async () => {
const dbPath = createTestDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, 'sess-child', { parentId: 'parent-id' })
})
const provider = createOpenCodeProvider(tmpDir)
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(0)
})
it('returns empty for non-existent path', async () => {
const provider = createOpenCodeProvider('/nonexistent/path')
const sessions = await provider.discoverSessions()
expect(sessions).toEqual([])
})
it('returns empty for empty database', async () => {
createTestDb(tmpDir)
const provider = createOpenCodeProvider(tmpDir)
const sessions = await provider.discoverSessions()
expect(sessions).toEqual([])
})
it('discovers sessions across multiple channel databases', async () => {
const ocDir = join(tmpDir, 'opencode')
await mkdir(ocDir, { recursive: true })
const Database = require('better-sqlite3')
for (const file of ['opencode.db', 'opencode-dev.db']) {
const dbPath = join(ocDir, file)
const db = new Database(dbPath)
db.exec(`
CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT,
slug TEXT NOT NULL, directory TEXT NOT NULL, title TEXT NOT NULL,
version TEXT NOT NULL, time_created INTEGER, time_updated INTEGER, time_archived INTEGER)
`)
db.exec(`CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL,
time_created INTEGER, time_updated INTEGER, data TEXT NOT NULL)`)
db.exec(`CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT NOT NULL,
session_id TEXT NOT NULL, time_created INTEGER, time_updated INTEGER, data TEXT NOT NULL)`)
db.prepare(`INSERT INTO session (id, project_id, slug, directory, title, version, time_created)
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(`sess-${file}`, 'proj-1', 'slug-1', '/home/user/myproject', 'My Project', '1.0', 1700000000000)
db.close()
}
const provider = createOpenCodeProvider(tmpDir)
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(2)
expect(sessions.map(s => s.path)).toEqual(
expect.arrayContaining([
expect.stringContaining('opencode.db:sess-opencode.db'),
expect.stringContaining('opencode-dev.db:sess-opencode-dev.db'),
]),
)
expect(sessions.every(s => s.provider === 'opencode')).toBe(true)
})
it('ignores non-opencode db files in the directory', async () => {
const dbPath = createTestDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, 'sess-1')
})
await writeFile(join(tmpDir, 'opencode', 'other.db'), '')
await writeFile(join(tmpDir, 'opencode', 'opencode.txt'), '')
const provider = createOpenCodeProvider(tmpDir)
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(1)
})
it('sanitizes title when directory is empty', async () => {
const dbPath = createTestDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, 'sess-1', { directory: '', title: 'My Session Title' })
})
const provider = createOpenCodeProvider(tmpDir)
const sessions = await provider.discoverSessions()
expect(sessions[0]!.project).toBe('My Session Title')
})
it('discovers multiple sessions in one database', async () => {
const dbPath = createTestDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, 'sess-1', { directory: '/home/user/project-a', title: 'A' })
insertSession(db, 'sess-2', { directory: '/home/user/project-b', title: 'B' })
})
const provider = createOpenCodeProvider(tmpDir)
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(2)
})
})
skipUnlessSqlite('opencode provider - session parsing', () => {
it('parses assistant messages with all fields', async () => {
const dbPath = createTestDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, 'sess-1')
insertMessage(db, 'msg-1', 'sess-1', 1700000000000, { role: 'user' })
insertPart(db, 'part-1', 'msg-1', 'sess-1', { type: 'text', text: 'fix the login bug' })
insertMessage(db, 'msg-2', 'sess-1', 1700000001000, {
role: 'assistant',
modelID: 'claude-opus-4-6',
cost: 0.05,
tokens: { input: 100, output: 200, reasoning: 50, cache: { read: 500, write: 300 } },
})
insertPart(db, 'part-2', 'msg-2', 'sess-1', {
type: 'tool', tool: 'bash',
state: { status: 'completed', input: { command: 'npm test && git push' } },
})
insertPart(db, 'part-3', 'msg-2', 'sess-1', {
type: 'tool', tool: 'edit', state: { status: 'completed', input: {} },
})
})
const provider = createOpenCodeProvider(tmpDir)
const calls = await collectCalls(provider, dbPath, 'sess-1')
expect(calls).toHaveLength(1)
const call = calls[0]!
expect(call.provider).toBe('opencode')
expect(call.model).toBe('claude-opus-4-6')
expect(call.inputTokens).toBe(100)
expect(call.outputTokens).toBe(200)
expect(call.reasoningTokens).toBe(50)
expect(call.cacheReadInputTokens).toBe(500)
expect(call.cacheCreationInputTokens).toBe(300)
expect(call.cachedInputTokens).toBe(500)
expect(call.webSearchRequests).toBe(0)
expect(call.speed).toBe('standard')
expect(call.costUSD).toBeGreaterThan(0)
expect(call.tools).toEqual(['Bash', 'Edit'])
expect(call.bashCommands).toEqual(['npm', 'git'])
expect(call.userMessage).toBe('fix the login bug')
expect(call.sessionId).toBe('sess-1')
expect(call.timestamp).toBe(new Date(1700000001000).toISOString())
expect(call.deduplicationKey).toBe('opencode:sess-1:msg-2')
})
it('skips zero-token messages with zero cost', async () => {
const dbPath = createTestDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, 'sess-1')
insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
role: 'assistant', modelID: 'claude-opus-4-6', cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
})
})
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
expect(calls).toHaveLength(0)
})
it('deduplicates messages across parses', async () => {
const dbPath = createTestDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, 'sess-1')
insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.05,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
})
})
const provider = createOpenCodeProvider(tmpDir)
const seenKeys = new Set<string>()
const calls1 = await collectCalls(provider, dbPath, 'sess-1', seenKeys)
const calls2 = await collectCalls(provider, dbPath, 'sess-1', seenKeys)
expect(calls1).toHaveLength(1)
expect(calls2).toHaveLength(0)
expect(seenKeys.has('opencode:sess-1:msg-1')).toBe(true)
})
it('falls back to pre-calculated cost for unknown models', async () => {
const dbPath = createTestDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, 'sess-1')
insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
role: 'assistant', modelID: 'totally-unknown-model-xyz', cost: 0.42,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
})
})
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
expect(calls).toHaveLength(1)
expect(calls[0]!.costUSD).toBe(0.42)
})
it('uses calculated cost over pre-calculated for known models', async () => {
const dbPath = createTestDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, 'sess-1')
insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
role: 'assistant', modelID: 'claude-opus-4-6', cost: 999.99,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
})
})
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
expect(calls).toHaveLength(1)
expect(calls[0]!.costUSD).toBeGreaterThan(0)
expect(calls[0]!.costUSD).not.toBe(999.99)
})
it('handles missing tokens field gracefully', async () => {
const dbPath = createTestDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, 'sess-1')
insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.10,
})
})
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
expect(calls).toHaveLength(1)
expect(calls[0]!.inputTokens).toBe(0)
expect(calls[0]!.outputTokens).toBe(0)
expect(calls[0]!.costUSD).toBe(0.10)
})
it('uses "unknown" for missing modelID', async () => {
const dbPath = createTestDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, 'sess-1')
insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
role: 'assistant', cost: 0.05,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
})
})
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
expect(calls).toHaveLength(1)
expect(calls[0]!.model).toBe('unknown')
})
it('handles corrupt JSON in message and part data', async () => {
const dbPath = createTestDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, 'sess-1')
db.prepare(`INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)`)
.run('msg-corrupt', 'sess-1', 1700000000500, 'not valid json {]')
insertMessage(db, 'msg-valid', 'sess-1', 1700000001000, {
role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.05,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
})
db.prepare(`INSERT INTO part (id, message_id, session_id, data) VALUES (?, ?, ?, ?)`)
.run('part-corrupt', 'msg-valid', 'sess-1', 'corrupt {[}')
insertPart(db, 'part-valid', 'msg-valid', 'sess-1', {
type: 'tool', tool: 'bash', state: { status: 'completed', input: {} },
})
})
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
expect(calls).toHaveLength(1)
expect(calls[0]!.model).toBe('claude-opus-4-6')
expect(calls[0]!.tools).toEqual(['Bash'])
})
it('converts seconds-epoch timestamps to milliseconds', async () => {
const dbPath = createTestDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, 'sess-1')
insertMessage(db, 'msg-1', 'sess-1', 1700000001, {
role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.05,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
})
})
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
expect(calls).toHaveLength(1)
expect(calls[0]!.timestamp).toBe(new Date(1700000001 * 1000).toISOString())
})
it('skips non-user non-assistant roles', async () => {
const dbPath = createTestDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, 'sess-1')
insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
role: 'system', modelID: 'claude-opus-4-6',
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
})
})
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
expect(calls).toHaveLength(0)
})
it('returns empty for invalid db path', async () => {
const provider = createOpenCodeProvider(tmpDir)
const source = { path: '/nonexistent/db.db:sess-1', project: 'test', provider: 'opencode' }
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
expect(calls).toHaveLength(0)
})
it('tracks user messages per assistant response', async () => {
const dbPath = createTestDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, 'sess-1')
insertMessage(db, 'msg-u1', 'sess-1', 1700000000000, { role: 'user' })
insertPart(db, 'part-u1', 'msg-u1', 'sess-1', { type: 'text', text: 'first question' })
insertMessage(db, 'msg-a1', 'sess-1', 1700000001000, {
role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.01,
tokens: { input: 50, output: 50, reasoning: 0, cache: { read: 0, write: 0 } },
})
insertMessage(db, 'msg-u2', 'sess-1', 1700000002000, { role: 'user' })
insertPart(db, 'part-u2', 'msg-u2', 'sess-1', { type: 'text', text: 'second question' })
insertMessage(db, 'msg-a2', 'sess-1', 1700000003000, {
role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.02,
tokens: { input: 80, output: 80, reasoning: 0, cache: { read: 0, write: 0 } },
})
})
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
expect(calls).toHaveLength(2)
expect(calls[0]!.userMessage).toBe('first question')
expect(calls[1]!.userMessage).toBe('second question')
})
it('joins multiple text parts in user messages', async () => {
const dbPath = createTestDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, 'sess-1')
insertMessage(db, 'msg-u1', 'sess-1', 1700000000000, { role: 'user' })
insertPart(db, 'part-a', 'msg-u1', 'sess-1', { type: 'text', text: 'hello' })
insertPart(db, 'part-b', 'msg-u1', 'sess-1', { type: 'text', text: 'world' })
insertMessage(db, 'msg-a1', 'sess-1', 1700000001000, {
role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.01,
tokens: { input: 50, output: 50, reasoning: 0, cache: { read: 0, write: 0 } },
})
})
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
expect(calls[0]!.userMessage).toBe('hello world')
})
it('yields nothing for session with only user messages', async () => {
const dbPath = createTestDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, 'sess-1')
insertMessage(db, 'msg-u1', 'sess-1', 1700000000000, { role: 'user' })
insertPart(db, 'part-u1', 'msg-u1', 'sess-1', { type: 'text', text: 'hello?' })
})
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
expect(calls).toHaveLength(0)
})
})