fix(opencode-usage): read OpenCode 2 session_v2 token totals (#22391)

* fix(opencode-usage): read OpenCode 2 session_v2 token totals

OpenCode 2 copies every v1 `session` row into a new `session_v2` table and
then writes only there, so every OpenCode 2 session was invisible to the
usage scanner, which only knew `session`. Reading both tables unfiltered
would double-count the migrated rows, so the scan now builds one
deduplicated session relation where `session_v2` owns any id it shares with
the legacy table, and joins the message-level fallbacks against it too.

Bumps the usage cache schema version so existing caches rescan.

Measured against the real local opencode.db (OpenCode v2.0.12, 234 legacy
`session` rows / 252 `session_v2` rows, 18 of them v2-only):

  migrated db   before: 206 sessions, 367,419,745 tokens, $79.4704
                after:  219 sessions, 374,305,364 tokens, $79.4817
  fresh v2 db   before:   0 sessions,           0 tokens, $0
  (legacy table after:  219 sessions, 374,305,364 tokens, $79.4817
   emptied)

Fixes #15841

* fix(opencode-usage): resolve a migrated session to its fuller row

Review found two ways the fixed `session_v2`-wins precedence loses usage.

A `session_v2` without the token columns scores 0 through the source's
fallback literals, but still excluded the legacy row for every shared id, so
a token-bearing legacy `session` paired with a token-less `session_v2`
returned nothing at all — worse than before this branch. And upstream's
importer recomputes v2 totals from decoded messages, so a session whose
messages fail to decode lands *below* its frozen legacy copy, which the
"legacy is frozen, v2 is newer" rationale did not account for.

Both collapse into one rule: per id, the row with the greater token total
wins, and a tie goes to the higher-priority table. A faithful migration is a
tie, so it still resolves to `session_v2`; a v2 row that lost data no longer
erases the legacy record. This also makes `hasSessionUsageColumns`'s `some`
correct rather than merely lenient — a table without the columns can never
outrank a sibling that has them.

Measured on the real local opencode.db: zero shared ids have a legacy row
that beats its `session_v2` counterpart, so the rule is a no-op on healthy
data and only engages on the degraded shapes above.

Both new tests fail on the previous commit and pass here.
This commit is contained in:
Neil
2026-09-22 21:40:49 -07:00
committed by GitHub
parent 98a6a5325c
commit ee1c522070
4 changed files with 593 additions and 50 deletions
@@ -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'
})
})
})