diff --git a/src/main/opencode-usage/opencode-usage-provider.ts b/src/main/opencode-usage/opencode-usage-provider.ts index 4a1d1191b61..0fbf9ef7814 100644 --- a/src/main/opencode-usage/opencode-usage-provider.ts +++ b/src/main/opencode-usage/opencode-usage-provider.ts @@ -6,8 +6,8 @@ import type { OpenCodeUsageSession } from './types' -// Why: v3 includes cache-read tokens in totals; older caches undercount usage. -export const OPENCODE_USAGE_SCHEMA_VERSION = 3 +// Why: v4 reads OpenCode 2's `session_v2` table; v3 caches miss every v2 session. +export const OPENCODE_USAGE_SCHEMA_VERSION = 4 export const openCodeUsageProvider = { id: 'opencode', diff --git a/src/main/opencode-usage/opencode-usage-row-queries.ts b/src/main/opencode-usage/opencode-usage-row-queries.ts index db8e84010b2..8d44a20d363 100644 --- a/src/main/opencode-usage/opencode-usage-row-queries.ts +++ b/src/main/opencode-usage/opencode-usage-row-queries.ts @@ -30,14 +30,92 @@ type OpenCodeSessionUsageRow = { tokens_cache_write: number } -function getProjectJoin(db: Database.Database): string { - return tableExists(db, 'project') && columnExists(db, 'session', 'project_id') - ? 'LEFT JOIN project p ON p.id = s.project_id' - : 'LEFT JOIN (SELECT NULL AS id, NULL AS worktree) p ON 1 = 0' +// Why: OpenCode 2 copies every v1 `session` row into `session_v2` and then only +// writes there, so a migrated opencode.db holds both tables and the same session +// id in each. Reading `session` alone loses every OpenCode 2 session (#15841); +// reading both unfiltered would double-count the migrated ones. Newest first, +// which only breaks ties — the fuller row wins, see `buildSessionTableSelect`. +const SESSION_TABLES_BY_PRIORITY = ['session_v2', 'session'] as const + +// Columns the usage scan reads off a session row, with the SQL literal to +// substitute when a schema generation lacks the column. +const SESSION_SOURCE_COLUMNS: Record = { + project_id: 'NULL', + directory: 'NULL', + title: 'NULL', + model: 'NULL', + time_created: '0', + time_updated: 'NULL', + cost: '0', + tokens_input: '0', + tokens_output: '0', + tokens_reasoning: '0', + tokens_cache_read: '0', + tokens_cache_write: '0' } -function getSessionModelSelect(db: Database.Database): string { - return columnExists(db, 'session', 'model') ? 's.model AS session_model' : 'NULL AS session_model' +const SESSION_TOKEN_COLUMNS = [ + 'tokens_input', + 'tokens_output', + 'tokens_reasoning', + 'tokens_cache_read', + 'tokens_cache_write' +] as const + +const SESSION_TOKEN_TOTAL = SESSION_TOKEN_COLUMNS.map((name) => `s.${name}`).join(' + ') + +/** The same total against one raw session table, which may be missing columns. */ +function sessionTableTokenTotal(db: Database.Database, table: string, alias: string): string { + return SESSION_TOKEN_COLUMNS.map((name) => + columnExists(db, table, name) ? `${alias}.${name}` : '0' + ).join(' + ') +} + +function listSessionTables(db: Database.Database): string[] { + return SESSION_TABLES_BY_PRIORITY.filter( + (table) => tableExists(db, table) && columnExists(db, table, 'id') + ) +} + +function buildSessionTableSelect( + db: Database.Database, + tables: readonly string[], + index: number +): string { + const table = tables[index] ?? '' + const columns = Object.entries(SESSION_SOURCE_COLUMNS).map( + ([name, fallback]) => `${columnExists(db, table, name) ? `t.${name}` : fallback} AS ${name}` + ) + // Why the fuller row rather than the newer one: `session_v2` is not reliably a + // superset. Upstream's importer recomputes v2 totals from decoded messages, so + // a session whose messages fail to decode lands below its frozen legacy row; a + // v2 table without the token columns at all scores 0 and would otherwise erase + // the legacy row's usage entirely. Ties go to the higher-priority table, so a + // faithful copy still resolves to `session_v2`. + const total = sessionTableTokenTotal(db, table, 't') + const exclusions = tables + .map((other, otherIndex) => { + if (otherIndex === index) { + return null + } + const beats = otherIndex < index ? '>=' : '>' + return `NOT EXISTS (SELECT 1 FROM ${other} o WHERE o.id = t.id AND ${sessionTableTokenTotal(db, other, 'o')} ${beats} ${total})` + }) + .filter((clause) => clause !== null) + .join(' AND ') + return `SELECT t.id, ${columns.join(', ')} FROM ${table} t${exclusions ? ` WHERE ${exclusions}` : ''}` +} + +/** A single deduplicated session relation spanning every session table generation. */ +function buildSessionSource(db: Database.Database, tables: readonly string[]): string { + const selects = tables.map((_table, index) => buildSessionTableSelect(db, tables, index)) + return `(${selects.join(' UNION ALL ')})` +} + +function getProjectJoin(db: Database.Database): string { + return tableExists(db, 'project') + ? 'LEFT JOIN project p ON p.id = s.project_id' + : 'LEFT JOIN (SELECT NULL AS id, NULL AS worktree) p ON 1 = 0' } function getAssistantSessionMessageCount(db: Database.Database): number { @@ -54,57 +132,39 @@ function getAssistantSessionMessageCount(db: Database.Database): number { return row?.count ?? 0 } -function canReadSessionUsageRows(db: Database.Database): boolean { - if (!tableExists(db, 'session')) { - return false - } - return ['cost', 'tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read'].every( - (columnName) => columnExists(db, 'session', columnName) +// `some`, not `every`: a table missing the token columns scores 0 in the source's +// tie-break, so it can never outrank — or erase — a sibling that carries them. +function hasSessionUsageColumns(db: Database.Database, tables: readonly string[]): boolean { + return tables.some((table) => + ['cost', 'tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read'].every( + (columnName) => columnExists(db, table, columnName) + ) ) } -function getSessionCacheWriteSelect(db: Database.Database): string { - return columnExists(db, 'session', 'tokens_cache_write') ? 's.tokens_cache_write' : '0' -} - -function getSessionTokenTotalExpression(db: Database.Database): string { - const cacheWrite = columnExists(db, 'session', 'tokens_cache_write') - ? ' + tokens_cache_write' - : '' - return `tokens_input + tokens_output + tokens_reasoning + tokens_cache_read${cacheWrite}` -} - -function getSessionUsageRowCount(db: Database.Database): number { - if (!canReadSessionUsageRows(db)) { - return 0 - } +function getSessionUsageRowCount(db: Database.Database, sessionSource: string): number { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SQLite aggregate rows are validated by the typed count field below. const row = db .prepare( `SELECT COUNT(*) AS count - FROM session - WHERE ${getSessionTokenTotalExpression(db)} > 0` + FROM ${sessionSource} s + WHERE ${SESSION_TOKEN_TOTAL} > 0` ) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SQLite aggregate rows are validated by the typed count field below. .get() as { count?: number } | undefined return row?.count ?? 0 } -function selectSessionUsageRows(db: Database.Database): OpenCodeUsageRow[] { - const projectJoin = getProjectJoin(db) - const sessionModelSelect = getSessionModelSelect(db) - const cacheWriteSelect = getSessionCacheWriteSelect(db) - const tokenTotalExpression = getSessionTokenTotalExpression(db) +function selectSessionUsageRows(db: Database.Database, sessionSource: string): OpenCodeUsageRow[] { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SELECT aliases match OpenCodeSessionUsageRow across supported schemas. const rows = db .prepare( `SELECT s.id, s.id AS session_id, s.time_created, s.time_updated, - s.directory, s.title, p.worktree, ${sessionModelSelect}, + s.directory, s.title, p.worktree, s.model AS session_model, s.cost, s.tokens_input, s.tokens_output, s.tokens_reasoning, s.tokens_cache_read, - ${cacheWriteSelect} AS tokens_cache_write - FROM session s - ${projectJoin} - WHERE ${tokenTotalExpression.replaceAll('tokens_', 's.tokens_')} > 0 + s.tokens_cache_write + FROM ${sessionSource} s + ${getProjectJoin(db)} + WHERE ${SESSION_TOKEN_TOTAL} > 0 ORDER BY s.time_created, s.id` ) .all() as OpenCodeSessionUsageRow[] @@ -140,29 +200,31 @@ function selectSessionUsageRows(db: Database.Database): OpenCodeUsageRow[] { } export function selectUsageRows(db: Database.Database): OpenCodeUsageRow[] { - if (!tableExists(db, 'session')) { + const sessionTables = listSessionTables(db) + if (sessionTables.length === 0) { return [] } + const sessionSource = buildSessionSource(db, sessionTables) // Why: newer OpenCode DBs maintain session-level token/cost totals. Reading // one aggregate row per session is faster than parsing every message blob. - if (getSessionUsageRowCount(db) > 0) { - return selectSessionUsageRows(db) + if (hasSessionUsageColumns(db, sessionTables) && getSessionUsageRowCount(db, sessionSource) > 0) { + return selectSessionUsageRows(db, sessionSource) } const projectJoin = getProjectJoin(db) - const sessionModelSelect = getSessionModelSelect(db) if (getAssistantSessionMessageCount(db) > 0) { const assistantPredicate = columnExists(db, 'session_message', 'type') ? "sm.type = 'assistant'" : "json_extract(sm.data, '$.tokens.input') IS NOT NULL" + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SELECT aliases match OpenCodeUsageRow across supported schemas. return db .prepare( `SELECT sm.id, sm.session_id, sm.time_created, sm.time_updated, sm.data, - s.directory, s.title, p.worktree, ${sessionModelSelect} + s.directory, s.title, p.worktree, s.model AS session_model FROM session_message sm - JOIN session s ON s.id = sm.session_id + JOIN ${sessionSource} s ON s.id = sm.session_id ${projectJoin} WHERE ${assistantPredicate} ORDER BY sm.time_created, sm.id` @@ -174,12 +236,13 @@ export function selectUsageRows(db: Database.Database): OpenCodeUsageRow[] { return [] } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SELECT aliases match OpenCodeUsageRow across supported schemas. return db .prepare( `SELECT m.id, m.session_id, m.time_created, m.time_updated, m.data, - s.directory, s.title, p.worktree, ${sessionModelSelect} + s.directory, s.title, p.worktree, s.model AS session_model FROM message m - JOIN session s ON s.id = m.session_id + JOIN ${sessionSource} s ON s.id = m.session_id ${projectJoin} WHERE json_extract(m.data, '$.role') = 'assistant' ORDER BY m.time_created, m.id` diff --git a/src/main/opencode-usage/opencode-usage-sqlite-fixture.ts b/src/main/opencode-usage/opencode-usage-sqlite-fixture.ts new file mode 100644 index 00000000000..3263ad964bf --- /dev/null +++ b/src/main/opencode-usage/opencode-usage-sqlite-fixture.ts @@ -0,0 +1,196 @@ +import { mkdirSync } from 'node:fs' +import { dirname } from 'node:path' +import SyncDatabase from '../sqlite/sync-database' + +// The OpenCode schemas as the app itself creates them, written out in full +// rather than trimmed to the columns a reader names: every read probes for its +// columns, so a trimmed fixture would pass a probe the real database fails. +// Foreign keys are dropped so a generation can be built without its siblings. + +const PROJECT_TABLE = ` + CREATE TABLE project ( + id TEXT PRIMARY KEY, worktree TEXT NOT NULL, vcs TEXT, name TEXT, icon_url TEXT, + icon_color TEXT, time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, + time_initialized INTEGER, sandboxes TEXT NOT NULL, commands TEXT, icon_url_override TEXT + ); +` + +// OpenCode 1.17.x — and still present, frozen, after an OpenCode 2 migration. +const LEGACY_SESSION_TABLE = ` + 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, share_url TEXT, + summary_additions INTEGER, summary_deletions INTEGER, summary_files INTEGER, + summary_diffs TEXT, revert TEXT, permission TEXT, + time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, time_compacting INTEGER, + time_archived INTEGER, workspace_id TEXT, path TEXT, agent TEXT, model TEXT, + cost REAL DEFAULT 0 NOT NULL, tokens_input INTEGER DEFAULT 0 NOT NULL, + tokens_output INTEGER DEFAULT 0 NOT NULL, tokens_reasoning INTEGER DEFAULT 0 NOT NULL, + tokens_cache_read INTEGER DEFAULT 0 NOT NULL, tokens_cache_write INTEGER DEFAULT 0 NOT NULL, + metadata TEXT + ); + CREATE TABLE message ( + id TEXT PRIMARY KEY, session_id TEXT NOT NULL, time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, data TEXT NOT NULL + ); + CREATE TABLE part ( + id TEXT PRIMARY KEY, message_id TEXT NOT NULL, session_id TEXT NOT NULL, + time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, data TEXT NOT NULL + ); +` + +// OpenCode 2.0.x — session rows move to `session_v2`, messages to `session_message`. +const V2_SESSION_TABLE = ` + CREATE TABLE session_v2 ( + id TEXT PRIMARY KEY, project_id TEXT NOT NULL, workspace_id TEXT, parent_id TEXT, + fork_session_id TEXT, fork_boundary TEXT, slug TEXT NOT NULL, directory TEXT NOT NULL, + path TEXT, title TEXT, version TEXT NOT NULL, share_url TEXT, + summary_additions INTEGER, summary_deletions INTEGER, summary_files INTEGER, + summary_diffs TEXT, metadata TEXT, + cost REAL DEFAULT 0 NOT NULL, tokens_input INTEGER DEFAULT 0 NOT NULL, + tokens_output INTEGER DEFAULT 0 NOT NULL, tokens_reasoning INTEGER DEFAULT 0 NOT NULL, + tokens_cache_read INTEGER DEFAULT 0 NOT NULL, tokens_cache_write INTEGER DEFAULT 0 NOT NULL, + revert TEXT, permission TEXT, agent TEXT, model TEXT, + time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, time_compacting INTEGER, + time_archived INTEGER, time_suspended INTEGER, resume_attempts INTEGER DEFAULT 0 NOT NULL, + time_idle INTEGER, time_viewed INTEGER, idle_outcome TEXT + ); +` + +// A `session_v2` that never grew the token/cost columns. Reachable on databases +// whose v2 lineage predates them, and the shape that must not erase a legacy row. +const V2_SESSION_TABLE_WITHOUT_TOKENS = ` + CREATE TABLE session_v2 ( + id TEXT PRIMARY KEY, project_id TEXT NOT NULL, workspace_id TEXT, parent_id TEXT, + fork_session_id TEXT, fork_boundary TEXT, slug TEXT NOT NULL, directory TEXT NOT NULL, + path TEXT, title TEXT, version TEXT NOT NULL, share_url TEXT, metadata TEXT, + revert TEXT, permission TEXT, agent TEXT, model TEXT, + time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, time_archived INTEGER + ); +` + +const SESSION_MESSAGE_TABLE = ` + CREATE TABLE session_message ( + id TEXT PRIMARY KEY, session_id TEXT NOT NULL, type TEXT NOT NULL, seq INTEGER NOT NULL, + time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, data TEXT NOT NULL + ); +` + +export const OPENCODE_USAGE_FIXTURE_EPOCH_MS = 1_740_000_000_000 + +export type OpenCodeUsageFixtureSession = { + id: string + directory: string + title?: string + model?: string + cost?: number + tokensInput?: number + tokensOutput?: number + tokensReasoning?: number + tokensCacheRead?: number + tokensCacheWrite?: number + timeCreated?: number + timeUpdated?: number +} + +/** + * Which session tables the database carries. + * + * - `v1`: OpenCode 1 only. + * - `migrated`: what an OpenCode 2 upgrade leaves behind — both tables, with the + * pre-upgrade sessions copied into `session_v2` and `session` frozen. + * - `v2-only`: `session_v2` without the legacy table at all. + */ +export type OpenCodeUsageFixtureGeneration = 'v1' | 'migrated' | 'v2-only' + +export type OpenCodeUsageFixtureSpec = { + generation: OpenCodeUsageFixtureGeneration + /** Rows in the legacy `session` table; ignored for `v2-only`. */ + legacySessions?: readonly OpenCodeUsageFixtureSession[] + /** Rows in `session_v2`; ignored for `v1`. */ + v2Sessions?: readonly OpenCodeUsageFixtureSession[] + /** Build `session_v2` without the token/cost columns; ignored for `v1`. */ + v2WithoutTokenColumns?: boolean + /** `project.worktree`, the repo root OpenCode recorded for the project. */ + worktree?: string +} + +const SESSION_IDENTITY_COLUMNS = + 'id, project_id, slug, directory, title, version, time_created, time_updated, agent, model' + +const SESSION_USAGE_COLUMNS = + 'cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write' + +function insertSession( + db: SyncDatabase, + table: 'session' | 'session_v2', + session: OpenCodeUsageFixtureSession, + withUsageColumns = true +): void { + const created = session.timeCreated ?? OPENCODE_USAGE_FIXTURE_EPOCH_MS + const identity = [ + session.id, + session.directory, + session.title ?? 'OpenCode session', + created, + session.timeUpdated ?? created + 60_000, + session.model ?? '{"providerID":"anthropic","modelID":"claude-sonnet-4-5"}' + ] + const usage = withUsageColumns + ? [ + session.cost ?? 0, + session.tokensInput ?? 0, + session.tokensOutput ?? 0, + session.tokensReasoning ?? 0, + session.tokensCacheRead ?? 0, + session.tokensCacheWrite ?? 0 + ] + : [] + const columns = withUsageColumns + ? `${SESSION_IDENTITY_COLUMNS}, ${SESSION_USAGE_COLUMNS}` + : SESSION_IDENTITY_COLUMNS + db.prepare( + `INSERT INTO ${table} (${columns}) + VALUES (?, 'proj-1', 'slug-1', ?, ?, '1.0.0', ?, ?, 'build', ?${', ?'.repeat(usage.length)})` + ).run(...identity, ...usage) +} + +/** + * Create an OpenCode usage database at `dbPath` for one schema generation. + * @param dbPath - Where to create the database; parent directories are created. + * @param spec - The generation to build and the session rows to write. + */ +export function writeOpenCodeUsageDatabase(dbPath: string, spec: OpenCodeUsageFixtureSpec): void { + mkdirSync(dirname(dbPath), { recursive: true }) + const db = new SyncDatabase(dbPath) + try { + db.exec(PROJECT_TABLE) + if (spec.generation !== 'v2-only') { + db.exec(LEGACY_SESSION_TABLE) + } + if (spec.generation !== 'v1') { + db.exec(spec.v2WithoutTokenColumns ? V2_SESSION_TABLE_WITHOUT_TOKENS : V2_SESSION_TABLE) + db.exec(SESSION_MESSAGE_TABLE) + } + db.prepare( + `INSERT INTO project (id, worktree, name, time_created, time_updated, sandboxes) + VALUES ('proj-1', ?, 'proj', ?, ?, '[]')` + ).run( + spec.worktree ?? '/workspace/repo', + OPENCODE_USAGE_FIXTURE_EPOCH_MS, + OPENCODE_USAGE_FIXTURE_EPOCH_MS + ) + if (spec.generation !== 'v2-only') { + for (const session of spec.legacySessions ?? []) { + insertSession(db, 'session', session) + } + } + if (spec.generation !== 'v1') { + for (const session of spec.v2Sessions ?? []) { + insertSession(db, 'session_v2', session, !spec.v2WithoutTokenColumns) + } + } + } finally { + db.close() + } +} diff --git a/src/main/opencode-usage/opencode2-session-usage.test.ts b/src/main/opencode-usage/opencode2-session-usage.test.ts new file mode 100644 index 00000000000..d2ccaf8b48a --- /dev/null +++ b/src/main/opencode-usage/opencode2-session-usage.test.ts @@ -0,0 +1,284 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import Database from '../sqlite/sync-database' +import { createUsageWorktreeResolver } from '../usage/usage-worktree-resolver' +import { parseOpenCodeUsageRow } from './opencode-usage-row-parsing' +import { selectUsageRows } from './opencode-usage-row-queries' +import { + OPENCODE_USAGE_FIXTURE_EPOCH_MS, + writeOpenCodeUsageDatabase, + type OpenCodeUsageFixtureSpec +} from './opencode-usage-sqlite-fixture' +import { parseOpenCodeUsageDatabase } from './scanner' + +// Why: OpenCode 2 copies v1 `session` rows into `session_v2` and then writes +// only there. A reader that knows just `session` reports zero usage for every +// OpenCode 2 session, and a reader that unions both double-counts the migrated +// ones (#15841). These tests pin both halves against the real v2 schema. + +const WORKTREE = '/workspace/repo' + +let tempDirs: string[] = [] + +afterEach(() => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }) + } + tempDirs = [] +}) + +function createFixture(spec: OpenCodeUsageFixtureSpec): string { + const dir = mkdtempSync(join(tmpdir(), 'orca-opencode2-usage-')) + tempDirs.push(dir) + const path = join(dir, 'opencode.db') + writeOpenCodeUsageDatabase(path, spec) + return path +} + +function readEvents(dbPath: string) { + const db = new Database(dbPath, { readonly: true, fileMustExist: true }) + try { + return selectUsageRows(db).flatMap((row) => parseOpenCodeUsageRow(row) ?? []) + } finally { + db.close() + } +} + +async function worktreeResolver() { + return createUsageWorktreeResolver([ + { + repoId: 'repo-1', + worktreeId: 'repo-1::/workspace/repo', + path: WORKTREE, + displayName: 'Repo' + } + ]) +} + +describe('OpenCode 2 session_v2 usage', () => { + it('reads sessions that exist only in session_v2', () => { + const path = createFixture({ + generation: 'v2-only', + v2Sessions: [ + { + id: 'ses_v2_1', + directory: WORKTREE, + cost: 0.5, + tokensInput: 100, + tokensOutput: 20, + tokensReasoning: 5, + tokensCacheRead: 900, + tokensCacheWrite: 300 + } + ] + }) + + expect(readEvents(path)).toEqual([ + expect.objectContaining({ + sessionId: 'ses_v2_1', + cwd: WORKTREE, + model: 'anthropic/claude-sonnet-4-5', + estimatedCostUsd: 0.5, + inputTokens: 100, + outputTokens: 20, + reasoningOutputTokens: 5, + cachedInputTokens: 900, + totalTokens: 1325 + }) + ]) + }) + + it('includes cache read and cache write tokens in the session total', () => { + const path = createFixture({ + generation: 'v2-only', + v2Sessions: [ + { + id: 'ses_cache', + directory: WORKTREE, + tokensInput: 10, + tokensOutput: 1, + tokensCacheRead: 5_000, + tokensCacheWrite: 2_000 + } + ] + }) + + const [event] = readEvents(path) + expect(event?.cachedInputTokens).toBe(5_000) + // Cache writes are not billed as input, so they only reach the total. + expect(event?.totalTokens).toBe(7_011) + }) + + it('counts a migrated session once, from session_v2', () => { + const shared = { + id: 'ses_shared', + directory: WORKTREE, + tokensInput: 100, + tokensCacheRead: 40 + } + const path = createFixture({ + generation: 'migrated', + // The frozen pre-upgrade copy, still carrying the smaller snapshot totals. + legacySessions: [shared], + v2Sessions: [ + { ...shared, tokensInput: 180, tokensCacheRead: 60 }, + { id: 'ses_v2_only', directory: WORKTREE, tokensInput: 7 } + ] + }) + + const events = readEvents(path) + expect(events.map((event) => event.sessionId)).toEqual(['ses_shared', 'ses_v2_only']) + expect(events[0]?.inputTokens).toBe(180) + expect(events[0]?.cachedInputTokens).toBe(60) + }) + + it('keeps the legacy totals when session_v2 lacks the token columns', () => { + const path = createFixture({ + generation: 'migrated', + v2WithoutTokenColumns: true, + legacySessions: [{ id: 'ses_shared', directory: WORKTREE, tokensInput: 1000, cost: 5 }], + v2Sessions: [{ id: 'ses_shared', directory: WORKTREE }] + }) + + const events = readEvents(path) + expect(events.map((event) => event.sessionId)).toEqual(['ses_shared']) + expect(events[0]?.inputTokens).toBe(1000) + expect(events[0]?.estimatedCostUsd).toBe(5) + }) + + it('keeps the legacy totals when the migration recomputed session_v2 lower', () => { + const path = createFixture({ + generation: 'migrated', + // Upstream recomputes v2 totals from decoded messages; undecodable ones + // are dropped, landing the v2 row below its frozen legacy copy. + legacySessions: [{ id: 'ses_shared', directory: WORKTREE, tokensInput: 900 }], + v2Sessions: [{ id: 'ses_shared', directory: WORKTREE, tokensInput: 120 }] + }) + + const events = readEvents(path) + expect(events.map((event) => event.sessionId)).toEqual(['ses_shared']) + expect(events[0]?.inputTokens).toBe(900) + }) + + it('resolves an identical migrated copy to session_v2', () => { + const shared = { id: 'ses_shared', directory: WORKTREE, tokensInput: 64 } + const path = createFixture({ + generation: 'migrated', + legacySessions: [{ ...shared, model: '{"providerID":"anthropic","modelID":"legacy"}' }], + v2Sessions: [{ ...shared, model: '{"providerID":"anthropic","modelID":"v2"}' }] + }) + + const events = readEvents(path) + expect(events.map((event) => event.sessionId)).toEqual(['ses_shared']) + expect(events[0]?.inputTokens).toBe(64) + // Equal totals: the tie goes to the newer table, so its row supplies metadata. + expect(events[0]?.model).toContain('v2') + }) + + it('keeps legacy sessions that never migrated', () => { + const path = createFixture({ + generation: 'migrated', + legacySessions: [{ id: 'ses_legacy_only', directory: WORKTREE, tokensInput: 42 }], + v2Sessions: [{ id: 'ses_v2_1', directory: WORKTREE, tokensInput: 9 }] + }) + + expect( + readEvents(path) + .map((event) => event.sessionId) + .sort() + ).toEqual(['ses_legacy_only', 'ses_v2_1']) + }) + + it('still reads an OpenCode 1 database unchanged', () => { + const path = createFixture({ + generation: 'v1', + legacySessions: [ + { + id: 'ses_v1', + directory: WORKTREE, + cost: 1.25, + tokensInput: 11, + tokensOutput: 3, + tokensCacheRead: 7, + tokensCacheWrite: 2 + } + ] + }) + + expect(readEvents(path)).toEqual([ + expect.objectContaining({ + sessionId: 'ses_v1', + estimatedCostUsd: 1.25, + inputTokens: 11, + outputTokens: 3, + cachedInputTokens: 7, + totalTokens: 23 + }) + ]) + }) + + it('reads assistant session_message rows when session_v2 carries no totals', () => { + const path = createFixture({ + generation: 'v2-only', + v2Sessions: [{ id: 'ses_msg', directory: WORKTREE }] + }) + const db = new Database(path) + db.prepare( + `INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) + VALUES ('msg-1', 'ses_msg', 'assistant', 1, ?, ?, ?)` + ).run( + OPENCODE_USAGE_FIXTURE_EPOCH_MS, + OPENCODE_USAGE_FIXTURE_EPOCH_MS, + JSON.stringify({ + tokens: { input: 60, output: 4, reasoning: 0, cache: { read: 12, write: 3 } }, + cost: 0.02, + time: { created: OPENCODE_USAGE_FIXTURE_EPOCH_MS } + }) + ) + db.close() + + expect(readEvents(path)).toEqual([ + expect.objectContaining({ sessionId: 'ses_msg', inputTokens: 60, cachedInputTokens: 12 }) + ]) + }) + + it('attributes session_v2 usage to the worktree that ran it', async () => { + const path = createFixture({ + generation: 'v2-only', + v2Sessions: [ + { id: 'ses_in_worktree', directory: WORKTREE, tokensInput: 30 }, + { id: 'ses_elsewhere', directory: '/elsewhere/project', tokensInput: 40 } + ] + }) + + const parsed = await parseOpenCodeUsageDatabase(path, await worktreeResolver()) + const byId = new Map(parsed.sessions.map((session) => [session.sessionId, session])) + expect(byId.get('ses_in_worktree')).toMatchObject({ + primaryWorktreeId: 'repo-1::/workspace/repo', + primaryRepoId: 'repo-1', + primaryProjectLabel: 'Repo', + totalInputTokens: 30 + }) + expect(byId.get('ses_elsewhere')).toMatchObject({ + primaryWorktreeId: null, + primaryRepoId: null, + totalInputTokens: 40 + }) + }) + + it('falls back to the project worktree when the session has no directory', async () => { + const path = createFixture({ + generation: 'v2-only', + worktree: WORKTREE, + v2Sessions: [{ id: 'ses_no_dir', directory: '', tokensInput: 15 }] + }) + + const parsed = await parseOpenCodeUsageDatabase(path, await worktreeResolver()) + expect(parsed.sessions[0]).toMatchObject({ + sessionId: 'ses_no_dir', + primaryWorktreeId: 'repo-1::/workspace/repo' + }) + }) +})