mirror of
https://github.com/stablyai/orca.git
synced 2026-10-13 16:02:49 +00:00
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:
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user