From a8bb0507a0bc74b45929bb42bf79195eb24d776b Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Mon, 18 May 2026 16:13:50 +0300 Subject: [PATCH] Use real DB paths for OpenCode cache --- CHANGELOG.md | 4 + docs/providers/opencode.md | 19 +- src/providers/opencode.ts | 317 +++++++++++++------------ tests/providers/opencode-cache.test.ts | 220 +++++++++++++++++ tests/providers/opencode.test.ts | 135 +++++++++-- 5 files changed, 509 insertions(+), 186 deletions(-) create mode 100644 tests/providers/opencode-cache.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b1f1dc..2e14651 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,10 @@ model aliases to priced Kimi K2 entries. ### Fixed (CLI) +- **OpenCode session cache keys now use real database paths.** Discovery now + emits one source per `opencode*.db` file and the parser iterates root sessions + inside that database, so shared session-cache fingerprinting stats the actual + SQLite file instead of a synthetic `:` identifier. - **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 diff --git a/docs/providers/opencode.md b/docs/providers/opencode.md index 4a13246..c2a7bb3 100644 --- a/docs/providers/opencode.md +++ b/docs/providers/opencode.md @@ -4,11 +4,11 @@ OpenCode (sst/opencode). - **Source:** `src/providers/opencode.ts` - **Loading:** lazy (`src/providers/index.ts:59-75`) -- **Test:** `tests/providers/opencode.test.ts` (676 lines, the largest provider test) +- **Test:** `tests/providers/opencode.test.ts` (868 lines) ## Where it reads from -Default `~/.local/share/opencode/` or `$XDG_DATA_HOME/opencode/`. The discovery walk picks up `opencode*.db` files (`opencode.ts:71-88`). +Default `~/.local/share/opencode/` or `$XDG_DATA_HOME/opencode/`. The discovery walk picks up `opencode*.db` files (`opencode.ts:99-108`). ## Storage format @@ -16,7 +16,7 @@ SQLite. ## Caching -None. +Uses the shared session cache keyed by the real SQLite database file path. ## Deduplication @@ -25,11 +25,12 @@ Per `:`. ## Quirks - **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 `:`. -- 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. +- Discovery emits one source per `opencode*.db` file so the shared cache can + fingerprint the actual SQLite file. The parser then iterates unarchived root + sessions (`parent_id IS NULL`) inside the database. +- 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 without double counting. - 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 `_` names (for example @@ -39,6 +40,6 @@ Per `:`. ## When fixing a bug here -1. The 558-line test suite catches a lot. Run `npm test -- tests/providers/opencode.test.ts` before and after any change. +1. The provider test suite catches a lot. Run `npm test -- tests/providers/opencode.test.ts` before and after any change. 2. If the bug is "missing table" warning, do not catch and silence it. Either upgrade the version expectation in the parser or document the breaking schema change. 3. If the bug is "reasoning tokens off by one", check the parts index ordering. diff --git a/src/providers/opencode.ts b/src/providers/opencode.ts index b027ed3..5d53256 100644 --- a/src/providers/opencode.ts +++ b/src/providers/opencode.ts @@ -112,6 +112,36 @@ function parseTimestamp(raw: number): string { return new Date(ms).toISOString() } +function projectFromSession(row: SessionRow): string { + const dir = blobToText(row.directory) + const title = blobToText(row.title) + return dir ? sanitize(dir) : sanitize(title) +} + +function parseSourcePath(path: string): { dbPath: string; rootSessionId?: string } { + const segments = path.split(':') + if (segments.length > 1) { + const rootSessionId = segments[segments.length - 1] + const dbPath = segments.slice(0, -1).join(':') + if (rootSessionId && dbPath.endsWith('.db')) return { dbPath, rootSessionId } + } + return { dbPath: path } +} + +function getRootSessions(db: SqliteDatabase, rootSessionId?: string): SessionRow[] { + const select = + 'SELECT id, CAST(directory AS BLOB) AS directory, CAST(title AS BLOB) AS title, time_created FROM session' + if (rootSessionId) { + return db.query( + `${select} WHERE id = ? AND time_archived IS NULL ORDER BY time_created DESC`, + [rootSessionId], + ) + } + return db.query( + `${select} WHERE time_archived IS NULL AND parent_id IS NULL ORDER BY time_created DESC`, + ) +} + type SchemaCheckResult = | { ok: true } | { ok: false; missing: string[] } @@ -162,12 +192,7 @@ function createParser( 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(':') + const { dbPath, rootSessionId } = parseSourcePath(source.path) let db: SqliteDatabase try { @@ -189,135 +214,142 @@ function createParser( return } - const messages = db.query( - `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 + const roots = getRootSessions(db, rootSessionId) + for (const root of roots) { + const sessionId = root.id + const project = projectFromSession(root) || source.project + + const messages = db.query( + `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], ) - 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( - `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 + + const parts = db.query( + `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], ) - 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], - ) - - const partsByMsg = new Map() - for (const part of parts) { - try { - const parsed = JSON.parse(blobToText(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 + + const partsByMsg = new Map() + for (const part of parts) { + try { + const parsed = JSON.parse(blobToText(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 + } } - } - const currentUserMessageBySession = new Map() + const currentUserMessageBySession = new Map() - for (const msg of messages) { - let data: MessageData - try { - data = JSON.parse(blobToText(msg.data)) as MessageData - } catch { - continue - } + for (const msg of messages) { + let data: MessageData + try { + data = JSON.parse(blobToText(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) { - currentUserMessageBySession.set(msg.session_id, textParts.join(' ')) + 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) { + currentUserMessageBySession.set(msg.session_id, textParts.join(' ')) + } + continue } - continue - } - if (data.role !== 'assistant') 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 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) => normalizeToolName(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:${msg.session_id}:${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, - ) + 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) => normalizeToolName(p.tool)) + .filter(Boolean) - if (costUSD === 0 && typeof data.cost === 'number' && data.cost > 0) { - costUSD = data.cost - } + const bashCommands = toolParts + .filter((p) => p.tool === 'bash' && typeof p.state?.input?.command === 'string') + .flatMap((p) => extractBashCommands(p.state!.input!.command!)) + + const dedupKey = `opencode:${msg.session_id}:${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: currentUserMessageBySession.get(msg.session_id) ?? '', - sessionId, + 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: currentUserMessageBySession.get(msg.session_id) ?? '', + sessionId, + project, + } } } } finally { @@ -327,35 +359,6 @@ function createParser( } } -async function discoverFromDb(dbPath: string): Promise { - let db: SqliteDatabase - try { - db = openDatabase(dbPath) - } catch { - return [] - } - - try { - const rows = db.query( - 'SELECT id, CAST(directory AS BLOB) AS directory, CAST(title AS BLOB) AS title, time_created FROM session WHERE time_archived IS NULL AND parent_id IS NULL ORDER BY time_created DESC', - ) - - return rows.map((row) => { - const dir = blobToText(row.directory) - const title = blobToText(row.title) - return { - path: `${dbPath}:${row.id}`, - project: dir ? sanitize(dir) : sanitize(title), - provider: 'opencode', - } - }) - } catch { - return [] - } finally { - db.close() - } -} - export function createOpenCodeProvider(dataDir?: string): Provider { const dir = getDataDir(dataDir) @@ -378,11 +381,11 @@ export function createOpenCodeProvider(dataDir?: string): Provider { 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 + return dbPaths.map((dbPath) => ({ + path: dbPath, + project: 'opencode', + provider: 'opencode', + })) }, createSessionParser( diff --git a/tests/providers/opencode-cache.test.ts b/tests/providers/opencode-cache.test.ts new file mode 100644 index 0000000..7eb52cc --- /dev/null +++ b/tests/providers/opencode-cache.test.ts @@ -0,0 +1,220 @@ +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { isSqliteAvailable } from '../../src/sqlite.js' + +type TestDb = { + exec(sql: string): void + prepare(sql: string): { run(...params: unknown[]): void } + close(): void +} + +let home: string +let dataHome: string +let cacheDir: string +let previousHome: string | undefined +let previousXdgDataHome: string | undefined +let previousCacheDir: string | undefined + +beforeEach(async () => { + vi.resetModules() + home = await mkdtemp(join(tmpdir(), 'opencode-cache-home-')) + dataHome = await mkdtemp(join(tmpdir(), 'opencode-cache-data-')) + cacheDir = await mkdtemp(join(tmpdir(), 'opencode-cache-store-')) + + previousHome = process.env['HOME'] + previousXdgDataHome = process.env['XDG_DATA_HOME'] + previousCacheDir = process.env['CODEBURN_CACHE_DIR'] + + process.env['HOME'] = home + process.env['XDG_DATA_HOME'] = dataHome + process.env['CODEBURN_CACHE_DIR'] = cacheDir +}) + +afterEach(async () => { + try { + const parser = await import('../../src/parser.js') + parser.clearSessionCache() + } catch {} + vi.resetModules() + + if (previousHome === undefined) delete process.env['HOME'] + else process.env['HOME'] = previousHome + if (previousXdgDataHome === undefined) delete process.env['XDG_DATA_HOME'] + else process.env['XDG_DATA_HOME'] = previousXdgDataHome + if (previousCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR'] + else process.env['CODEBURN_CACHE_DIR'] = previousCacheDir + + await rm(home, { recursive: true, force: true }) + await rm(dataHome, { recursive: true, force: true }) + await rm(cacheDir, { recursive: true, force: true }) +}) + +function createDb(dbPath: string, baseTs: number): void { + const { DatabaseSync: Database } = require('node:sqlite') + const db: TestDb = 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 + ); + CREATE TABLE message ( + id TEXT PRIMARY KEY, session_id TEXT NOT NULL, + time_created INTEGER, time_updated INTEGER, data TEXT NOT NULL + ); + 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 + ); + `) + + const insertSession = db.prepare(` + INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, time_created, time_updated, time_archived) + VALUES (?, 'proj', ?, ?, ?, ?, '1.0', ?, ?, NULL) + `) + insertSession.run('root-a', null, 'root-a', '/tmp/project-a', 'Project A', baseTs, baseTs) + insertSession.run('child-a', 'root-a', 'child-a', '/tmp/project-a', 'Child A', baseTs + 1000, baseTs + 1000) + insertSession.run('root-b', null, 'root-b', '/tmp/project-b', 'Project B', baseTs + 2000, baseTs + 2000) + + const insertMessage = db.prepare(` + INSERT INTO message (id, session_id, time_created, time_updated, data) + VALUES (?, ?, ?, ?, ?) + `) + const assistant = (tokens: number, cost: number) => JSON.stringify({ + role: 'assistant', + modelID: 'claude-opus-4-6', + cost, + tokens: { + input: tokens, + output: tokens * 2, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + }) + insertMessage.run('msg-root-a-user', 'root-a', baseTs, baseTs, JSON.stringify({ role: 'user' })) + insertMessage.run('msg-root-a-assistant', 'root-a', baseTs + 100, baseTs + 100, assistant(10, 0.01)) + insertMessage.run('msg-child-a-user', 'child-a', baseTs + 1000, baseTs + 1000, JSON.stringify({ role: 'user' })) + insertMessage.run('msg-child-a-assistant', 'child-a', baseTs + 1100, baseTs + 1100, assistant(20, 0.02)) + insertMessage.run('msg-root-b-user', 'root-b', baseTs + 2000, baseTs + 2000, JSON.stringify({ role: 'user' })) + insertMessage.run('msg-root-b-assistant', 'root-b', baseTs + 2100, baseTs + 2100, assistant(30, 0.03)) + + const insertPart = db.prepare(` + INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) + VALUES (?, ?, ?, ?, ?, ?) + `) + insertPart.run('part-root-a-user', 'msg-root-a-user', 'root-a', baseTs, baseTs, JSON.stringify({ type: 'text', text: 'root a prompt' })) + insertPart.run('part-root-a-tool', 'msg-root-a-assistant', 'root-a', baseTs + 100, baseTs + 100, JSON.stringify({ type: 'tool', tool: 'read', state: { input: {} } })) + insertPart.run('part-child-a-user', 'msg-child-a-user', 'child-a', baseTs + 1000, baseTs + 1000, JSON.stringify({ type: 'text', text: 'child a prompt' })) + insertPart.run('part-child-a-tool', 'msg-child-a-assistant', 'child-a', baseTs + 1100, baseTs + 1100, JSON.stringify({ type: 'tool', tool: 'bash', state: { input: { command: 'npm test' } } })) + insertPart.run('part-root-b-user', 'msg-root-b-user', 'root-b', baseTs + 2000, baseTs + 2000, JSON.stringify({ type: 'text', text: 'root b prompt' })) + insertPart.run('part-root-b-tool', 'msg-root-b-assistant', 'root-b', baseTs + 2100, baseTs + 2100, JSON.stringify({ type: 'tool', tool: 'edit', state: { input: {} } })) + db.close() +} + +const skipUnlessSqlite = isSqliteAvailable() ? describe : describe.skip + +skipUnlessSqlite('OpenCode shared session cache', () => { + it('keys the cache by the real database path while preserving root and child calls', async () => { + const opencodeDir = join(dataHome, 'opencode') + await mkdir(opencodeDir, { recursive: true }) + const dbPath = join(opencodeDir, 'opencode.db') + const baseTs = Date.parse('2026-05-16T12:00:00.000Z') + createDb(dbPath, baseTs) + + const { clearSessionCache, parseAllSessions } = await import('../../src/parser.js') + const range = { + start: new Date(baseTs - 1000), + end: new Date(baseTs + 10_000), + } + + const projects = await parseAllSessions(range, 'opencode') + expect(projects.map(project => ({ + project: project.project, + calls: project.totalApiCalls, + sessions: project.sessions.length, + })).sort((a, b) => a.project.localeCompare(b.project))).toEqual([ + { project: 'tmp-project-a', calls: 2, sessions: 1 }, + { project: 'tmp-project-b', calls: 1, sessions: 1 }, + ]) + + const cachePath = join(cacheDir, 'session-cache.json') + const coldCacheRaw = await readFile(cachePath, 'utf8') + const coldStat = await stat(cachePath) + const coldCache = JSON.parse(coldCacheRaw) + const files = coldCache.providers?.opencode?.files ?? {} + expect(Object.keys(files)).toEqual([dbPath]) + + const turns = files[dbPath].turns + expect(turns).toHaveLength(3) + expect(turns.map((turn: { sessionId: string }) => turn.sessionId).sort()).toEqual([ + 'root-a', + 'root-a', + 'root-b', + ]) + expect(turns.flatMap((turn: { calls: Array<{ deduplicationKey: string; project?: string }> }) => + turn.calls.map(call => call.deduplicationKey), + ).sort()).toEqual([ + 'opencode:child-a:msg-child-a-assistant', + 'opencode:root-a:msg-root-a-assistant', + 'opencode:root-b:msg-root-b-assistant', + ]) + expect(turns.flatMap((turn: { calls: Array<{ project?: string }> }) => + turn.calls.map(call => call.project), + ).sort()).toEqual([ + 'tmp-project-a', + 'tmp-project-a', + 'tmp-project-b', + ]) + + clearSessionCache() + await parseAllSessions(range, 'opencode') + expect((await stat(cachePath)).mtimeMs).toBe(coldStat.mtimeMs) + expect(await readFile(cachePath, 'utf8')).toBe(coldCacheRaw) + }) + + it('prunes legacy database/session cache keys during upgrade', async () => { + const opencodeDir = join(dataHome, 'opencode') + await mkdir(opencodeDir, { recursive: true }) + const dbPath = join(opencodeDir, 'opencode.db') + const baseTs = Date.parse('2026-05-16T12:00:00.000Z') + createDb(dbPath, baseTs) + + const cachePath = join(cacheDir, 'session-cache.json') + const { computeEnvFingerprint } = await import('../../src/session-cache.js') + await writeFile(cachePath, JSON.stringify({ + version: 1, + providers: { + opencode: { + envFingerprint: computeEnvFingerprint('opencode'), + files: { + [`${dbPath}:root-a`]: { + fingerprint: { dev: 1, ino: 1, mtimeMs: 1, sizeBytes: 1 }, + mcpInventory: [], + turns: [], + }, + [`${dbPath}:root-b`]: { + fingerprint: { dev: 1, ino: 2, mtimeMs: 1, sizeBytes: 1 }, + mcpInventory: [], + turns: [], + }, + }, + }, + }, + })) + + const { parseAllSessions } = await import('../../src/parser.js') + await parseAllSessions({ + start: new Date(baseTs - 1000), + end: new Date(baseTs + 10_000), + }, 'opencode') + + const cache = JSON.parse(await readFile(cachePath, 'utf8')) + expect(Object.keys(cache.providers.opencode.files).sort()).toEqual([dbPath]) + expect(cache.providers.opencode.files[dbPath].turns).toHaveLength(3) + }) +}) diff --git a/tests/providers/opencode.test.ts b/tests/providers/opencode.test.ts index 7d64ca9..720b967 100644 --- a/tests/providers/opencode.test.ts +++ b/tests/providers/opencode.test.ts @@ -103,10 +103,16 @@ function insertPart(db: TestDb, id: string, messageId: string, sessionId: string .run(id, messageId, sessionId, JSON.stringify(data)) } -async function collectCalls(provider: ReturnType, dbPath: string, sessionId: string, seenKeys?: Set): Promise { - const source = { path: `${dbPath}:${sessionId}`, project: 'myproject', provider: 'opencode' } +async function collectCalls( + provider: ReturnType, + dbPath: string, + sessionIdOrSeenKeys?: string | Set, + seenKeys?: Set, +): Promise { + const source = { path: dbPath, project: 'opencode', provider: 'opencode' } + const keys = sessionIdOrSeenKeys instanceof Set ? sessionIdOrSeenKeys : seenKeys const calls: ParsedProviderCall[] = [] - for await (const call of provider.createSessionParser(source, seenKeys ?? new Set()).parse()) { + for await (const call of provider.createSessionParser(source, keys ?? new Set()).parse()) { calls.push(call) } return calls @@ -167,7 +173,7 @@ skipUnlessSqlite('opencode provider - tool display names', () => { }) skipUnlessSqlite('opencode provider - session discovery', () => { - it('discovers sessions with correct path format', async () => { + it('discovers databases with real filesystem paths', async () => { const dbPath = createTestDb(tmpDir) withTestDb(dbPath, (db) => { insertSession(db, 'sess-1') @@ -178,11 +184,11 @@ skipUnlessSqlite('opencode provider - session discovery', () => { 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`) + expect(sessions[0]!.project).toBe('opencode') + expect(sessions[0]!.path).toBe(dbPath) }) - it('excludes archived sessions', async () => { + it('discovers the database even when it only contains archived sessions', async () => { const dbPath = createTestDb(tmpDir) withTestDb(dbPath, (db) => { insertSession(db, 'sess-archived', { archived: 1700000001000 }) @@ -190,10 +196,11 @@ skipUnlessSqlite('opencode provider - session discovery', () => { const provider = createOpenCodeProvider(tmpDir) const sessions = await provider.discoverSessions() - expect(sessions).toHaveLength(0) + expect(sessions).toHaveLength(1) + expect(sessions[0]!.path).toBe(dbPath) }) - it('excludes child sessions', async () => { + it('discovers the database even when it only contains child sessions', async () => { const dbPath = createTestDb(tmpDir) withTestDb(dbPath, (db) => { insertSession(db, 'sess-child', { parentId: 'parent-id' }) @@ -201,7 +208,8 @@ skipUnlessSqlite('opencode provider - session discovery', () => { const provider = createOpenCodeProvider(tmpDir) const sessions = await provider.discoverSessions() - expect(sessions).toHaveLength(0) + expect(sessions).toHaveLength(1) + expect(sessions[0]!.path).toBe(dbPath) }) it('returns empty for non-existent path', async () => { @@ -210,11 +218,12 @@ skipUnlessSqlite('opencode provider - session discovery', () => { expect(sessions).toEqual([]) }) - it('returns empty for empty database', async () => { - createTestDb(tmpDir) + it('discovers empty databases so stale cache entries can be reconciled', async () => { + const dbPath = createTestDb(tmpDir) const provider = createOpenCodeProvider(tmpDir) const sessions = await provider.discoverSessions() - expect(sessions).toEqual([]) + expect(sessions).toHaveLength(1) + expect(sessions[0]!.path).toBe(dbPath) }) it('discovers sessions across multiple channel databases', async () => { @@ -245,8 +254,8 @@ skipUnlessSqlite('opencode provider - session discovery', () => { 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.stringContaining('opencode.db'), + expect.stringContaining('opencode-dev.db'), ]), ) expect(sessions.every(s => s.provider === 'opencode')).toBe(true) @@ -265,18 +274,24 @@ skipUnlessSqlite('opencode provider - session discovery', () => { expect(sessions).toHaveLength(1) }) - it('sanitizes title when directory is empty', async () => { + it('uses session project metadata while parsing when directory is empty', async () => { const dbPath = createTestDb(tmpDir) withTestDb(dbPath, (db) => { insertSession(db, 'sess-1', { directory: '', title: 'My Session Title' }) + 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 sessions = await provider.discoverSessions() - expect(sessions[0]!.project).toBe('My Session Title') + const calls = await collectCalls(provider, dbPath, 'sess-1') + expect(calls[0]!.project).toBe('My Session Title') }) - it('discovers multiple sessions in one database', async () => { + it('discovers one source for multiple sessions in one database', async () => { const dbPath = createTestDb(tmpDir) withTestDb(dbPath, (db) => { insertSession(db, 'sess-1', { directory: '/home/user/project-a', title: 'A' }) @@ -285,11 +300,91 @@ skipUnlessSqlite('opencode provider - session discovery', () => { const provider = createOpenCodeProvider(tmpDir) const sessions = await provider.discoverSessions() - expect(sessions).toHaveLength(2) + expect(sessions).toHaveLength(1) + expect(sessions[0]!.path).toBe(dbPath) }) }) skipUnlessSqlite('opencode provider - session parsing', () => { + it('parses all root sessions from one database source', 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' }) + + insertMessage(db, 'msg-a1', 'sess-1', 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-b1', 'sess-2', 1700000002000, { + 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) + + expect(calls).toHaveLength(2) + expect(calls.map(call => call.sessionId).sort()).toEqual(['sess-1', 'sess-2']) + expect(calls.map(call => call.project).sort()).toEqual([ + 'home-user-project-a', + 'home-user-project-b', + ]) + }) + + it('does not parse archived root sessions from a database source', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-archived', { archived: 1700000001000 }) + insertMessage(db, 'msg-archived', 'sess-archived', 1700000001000, { + role: 'assistant', + modelID: 'claude-opus-4-6', + cost: 0.01, + tokens: { input: 10, output: 20, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath) + + expect(calls).toHaveLength(0) + }) + + it('still accepts legacy compound database/session source paths', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + insertSession(db, 'sess-2') + + insertMessage(db, 'msg-1', 'sess-1', 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-2', 'sess-2', 1700000002000, { + role: 'assistant', + modelID: 'claude-opus-4-6', + cost: 0.02, + tokens: { input: 30, output: 40, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + }) + + const provider = createOpenCodeProvider(tmpDir) + const source = { path: `${dbPath}:sess-2`, project: 'legacy', provider: 'opencode' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + + expect(calls).toHaveLength(1) + expect(calls[0]!.sessionId).toBe('sess-2') + expect(calls[0]!.deduplicationKey).toBe('opencode:sess-2:msg-2') + }) + it('parses assistant messages with all fields', async () => { const dbPath = createTestDb(tmpDir) withTestDb(dbPath, (db) => {