mirror of
https://github.com/stablyai/orca.git
synced 2026-10-13 00:02:54 +00:00
Normalize OpenCode cache reads and writes consistently across usage views, persistence and telemetry. Continues #20187. Co-authored-by: griffinmartin <griffinmartin@users.noreply.github.com>
461 lines
14 KiB
TypeScript
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()
|
|
}
|
|
})
|
|
})
|