mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-19 07:43:09 +00:00
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:
parent
3612c5a994
commit
2d114d9393
10 changed files with 927 additions and 5 deletions
|
|
@ -20,6 +20,7 @@
|
|||
"claude-code",
|
||||
"cursor",
|
||||
"codex",
|
||||
"opencode",
|
||||
"ai-coding",
|
||||
"token-usage",
|
||||
"cost-tracking",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -259,6 +259,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
|
|||
webSearchRequests: 0,
|
||||
costUSD,
|
||||
tools: pendingTools,
|
||||
bashCommands: [],
|
||||
timestamp,
|
||||
speed: 'standard',
|
||||
deduplicationKey: dedupKey,
|
||||
|
|
|
|||
|
|
@ -188,6 +188,7 @@ function parseBubbles(db: SqliteDatabase, seenKeys: Set<string>): { calls: Parse
|
|||
webSearchRequests: 0,
|
||||
costUSD,
|
||||
tools: cursorTools,
|
||||
bashCommands: [],
|
||||
timestamp,
|
||||
speed: 'standard',
|
||||
deduplicationKey: dedupKey,
|
||||
|
|
|
|||
|
|
@ -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
320
src/providers/opencode.ts
Normal 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()
|
||||
|
|
@ -20,6 +20,7 @@ export type ParsedProviderCall = {
|
|||
webSearchRequests: number
|
||||
costUSD: number
|
||||
tools: string[]
|
||||
bashCommands: string[]
|
||||
timestamp: string
|
||||
speed: 'standard' | 'fast'
|
||||
deduplicationKey: string
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
558
tests/providers/opencode.test.ts
Normal file
558
tests/providers/opencode.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue