Files
orca/src/main/opencode-usage/opencode2-session-usage.test.ts
T
Neilandgriffinmartin f1463683ee fix(usage): include OpenCode cached input in totals
Normalize OpenCode cache reads and writes consistently across usage views, persistence and telemetry.

Continues #20187.

Co-authored-by: griffinmartin <griffinmartin@users.noreply.github.com>
2026-10-02 20:41:18 -07:00

461 lines
14 KiB
TypeScript

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: 1300,
outputTokens: 20,
reasoningOutputTokens: 5,
cachedInputTokens: 1200,
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(7_000)
expect(event?.inputTokens).toBe(7_010)
// Both cache buckets contribute to input; total counts them once.
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(240)
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: 20,
outputTokens: 3,
cachedInputTokens: 9,
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: 75, cachedInputTokens: 15 })
])
})
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('keeps a session that only session_v2 has when a legacy twin is absent', () => {
const path = createFixture({
generation: 'migrated',
legacySessions: [{ id: 'ses_shared', directory: WORKTREE, tokensInput: 5 }],
v2Sessions: [
{ id: 'ses_shared', directory: WORKTREE, tokensInput: 5 },
{ id: 'ses_v2_only', directory: WORKTREE, cost: 3.25, tokensInput: 70 }
]
})
const events = readEvents(path)
const v2Only = events.find((event) => event.sessionId === 'ses_v2_only')
expect(v2Only).toMatchObject({ inputTokens: 70, estimatedCostUsd: 3.25, cwd: WORKTREE })
})
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'
})
})
})
// Why: a migrated session has two rows for one session, and neither is complete.
// `session_v2` is the row OpenCode still writes, so it says what the session is;
// each usage column is a lossy re-derivation, so each column takes the larger of
// the two. Ranking whole rows by one number let that number decide cost,
// directory and model too.
describe('OpenCode 2 migrated session column merge', () => {
const SHARED = 'ses_shared'
it('keeps a recorded cost the recomputed session_v2 row lost', () => {
const path = createFixture({
generation: 'migrated',
legacySessions: [{ id: SHARED, directory: WORKTREE, tokensInput: 100, cost: 12.5 }],
v2Sessions: [{ id: SHARED, directory: WORKTREE, tokensInput: 200, cost: 0 }]
})
const events = readEvents(path)
expect(events).toHaveLength(1)
expect(events[0]?.inputTokens).toBe(200)
expect(events[0]?.estimatedCostUsd).toBe(12.5)
})
it('attributes usage to the directory session_v2 records now', () => {
const path = createFixture({
generation: 'migrated',
legacySessions: [
{ id: SHARED, directory: '/old/pre-migration-path', title: 'Old title', tokensInput: 900 }
],
v2Sessions: [
{ id: SHARED, directory: '/new/current-path', title: 'New title', tokensInput: 120 }
]
})
const events = readEvents(path)
expect(events).toHaveLength(1)
// The legacy row still wins the token column; it must not drag metadata with it.
expect(events[0]?.inputTokens).toBe(900)
expect(events[0]?.cwd).toBe('/new/current-path')
})
it('keeps the model session_v2 derived when the legacy row has none', () => {
const path = createFixture({
generation: 'migrated',
// The import fills session_v2.model from the last user message when the v1
// row had none: 23 of 234 shared ids on a real migrated database.
legacySessions: [{ id: SHARED, directory: WORKTREE, model: null, tokensInput: 900 }],
v2Sessions: [
{
id: SHARED,
directory: WORKTREE,
model: '{"providerID":"anthropic","modelID":"claude-opus-4-1"}',
tokensInput: 120
}
]
})
const events = readEvents(path)
expect(events).toHaveLength(1)
expect(events[0]?.inputTokens).toBe(900)
expect(events[0]?.model).toBe('anthropic/claude-opus-4-1')
})
it('takes each usage column from whichever generation recorded more', () => {
const path = createFixture({
generation: 'migrated',
legacySessions: [
{ id: SHARED, directory: WORKTREE, tokensInput: 1000, tokensCacheRead: 0, cost: 1 }
],
v2Sessions: [
{ id: SHARED, directory: WORKTREE, tokensInput: 0, tokensCacheRead: 1200, cost: 2 }
]
})
const events = readEvents(path)
expect(events).toHaveLength(1)
expect(events[0]).toMatchObject({
inputTokens: 2200,
cachedInputTokens: 1200,
estimatedCostUsd: 2,
totalTokens: 2200
})
})
it('resolves a faithful migrated copy exactly as the v2 row alone', () => {
const session = {
id: SHARED,
directory: WORKTREE,
cost: 0.75,
tokensInput: 100,
tokensOutput: 20,
tokensReasoning: 5,
tokensCacheRead: 900,
tokensCacheWrite: 300
}
const migrated = readEvents(
createFixture({
generation: 'migrated',
legacySessions: [session],
v2Sessions: [session]
})
)
expect(migrated).toEqual(
readEvents(createFixture({ generation: 'v2-only', v2Sessions: [session] }))
)
})
it('leaves an OpenCode 1-only database untouched by the merge', () => {
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',
cwd: WORKTREE,
model: 'anthropic/claude-sonnet-4-5',
estimatedCostUsd: 1.25,
inputTokens: 20,
outputTokens: 3,
cachedInputTokens: 9,
totalTokens: 23
})
])
})
it('emits exactly one row per session id across both generations', () => {
const path = createFixture({
generation: 'migrated',
legacySessions: [
{ id: SHARED, directory: WORKTREE, tokensInput: 900 },
{ id: 'ses_legacy_only', directory: WORKTREE, tokensInput: 42 }
],
v2Sessions: [
{ id: SHARED, directory: WORKTREE, tokensInput: 120 },
{ id: 'ses_v2_only', directory: WORKTREE, tokensInput: 7 }
]
})
const db = new Database(path, { readonly: true, fileMustExist: true })
try {
const ids = selectUsageRows(db).map((row) => row.id)
expect(ids).toHaveLength(3)
expect(new Set(ids).size).toBe(3)
} finally {
db.close()
}
})
})