Files
orca/src/main/opencode-usage/store.test.ts
T
JinjingandOrca 4908a03671 fix: dedupe fork-copied usage history across Claude, Codex, and OpenCode scanners (#8023)
* fix: dedupe fork-copied usage history across Claude, Codex, and OpenCode scanners

Coding-agent CLIs copy transcript/rollout history into new files on
resume/fork, and the usage scanners deduped per-file only (or not at
all), so copied history was re-counted once per descendant file
(issue #8006: 38.6B tokens / $72,981 reported vs ~2.3B real).

- claude-usage: cross-file turn ownership keyed on message.id:requestId;
  per-file ownedDedupeKeys persisted; deterministic sorted-path claim
  order; schema v3 -> v4 so inflated caches rebuild.
- codex-usage: cross-file token_count event ownership keyed on the raw
  record identity (sessionId + timestamp + token tuples); fixes both the
  copied-prefix re-count and the total-only branch that re-counted the
  entire cumulative session per descendant rollout; legacy
  .orca-session-copies skip-bytes bridge unchanged; schema v3 -> v4.
- opencode-usage: each sessionId is counted from exactly one database;
  the canonical opencode.db claims ahead of stale sibling copies
  (opencode-backup.db etc.) so backups no longer double totals, while
  backup-only sessions are still counted; schema v1 -> v2.

Regression tests cover fork-copied files counted once (including the
Codex total-only variant), duplicated OpenCode databases, and dedupe
stability across cached incremental rescans.

Co-authored-by: Orca <help@stably.ai>

* Fix cross-tool usage double-counting for fork/resume-copied history

- Widen Claude dedupe keys with message-id and uuid fallbacks so forks
  missing requestId still dedupe correctly, and drop Codex's sessionId
  from event keys since fork/resume rewrites session_meta.id while
  copying identical token_count records.
- Track hasDeferredClaims per cached file/database across Claude, Codex,
  and OpenCode scanners so that when an owning file is deleted, only
  files that deferred a claim need reparsing to reclaim those turns
  instead of rescanning the entire corpus.
- Let OpenCode's live opencode.db reclaim sessions from a stale backup
  claim once it reappears, avoiding a frozen stale snapshot.
- Bump schema versions to invalidate caches built with the old,
  narrower ownership keys (#8006, #8013 follow-up).

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-09 22:36:53 -07:00

314 lines
8.3 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type {
OpenCodeUsageDailyAggregate,
OpenCodeUsagePersistedState,
OpenCodeUsageSession
} from './types'
const { getPathMock } = vi.hoisted(() => ({
getPathMock: vi.fn(() => '/tmp/orca-test-userdata')
}))
vi.mock('electron', () => ({
app: {
getPath: getPathMock
}
}))
import { OpenCodeUsageStore, normalizePersistedState } from './store'
function getDefaultState(): OpenCodeUsagePersistedState {
return {
schemaVersion: 2,
worktreeFingerprint: null,
processedDatabases: [],
sessions: [],
dailyAggregates: [],
scanState: {
enabled: false,
lastScanStartedAt: null,
lastScanCompletedAt: null,
lastScanError: null
}
}
}
function createStoreWithState(state: Partial<OpenCodeUsagePersistedState>): OpenCodeUsageStore {
const store = new OpenCodeUsageStore({
getRepos: () => [],
getWorktreeMeta: () => undefined
} as never)
;(store as unknown as { state: OpenCodeUsagePersistedState }).state = {
...getDefaultState(),
...state
}
return store
}
function makeSession(overrides: Partial<OpenCodeUsageSession> = {}): OpenCodeUsageSession {
const worktreeId = overrides.primaryWorktreeId ?? 'repo-1::/workspace/repo'
const repoId = overrides.primaryRepoId ?? 'repo-1'
const projectLabel = overrides.primaryProjectLabel ?? 'Repo'
const model = overrides.primaryModel ?? 'anthropic/claude-sonnet-4-5'
return {
sessionId: 'session-1',
firstTimestamp: '2026-04-09T10:00:00.000Z',
lastTimestamp: '2026-04-09T10:10:00.000Z',
primaryModel: model,
hasMixedModels: false,
primaryProjectLabel: projectLabel,
hasMixedLocations: false,
primaryWorktreeId: worktreeId,
primaryRepoId: repoId,
eventCount: 1,
totalInputTokens: 1000,
totalCachedInputTokens: 400,
totalOutputTokens: 250,
totalReasoningOutputTokens: 100,
totalTokens: 1350,
estimatedCostUsd: 0.05,
locationBreakdown: [
{
locationKey: worktreeId ? `worktree:${worktreeId}` : 'cwd:/outside/repo',
projectLabel,
repoId,
worktreeId,
eventCount: 1,
inputTokens: 1000,
cachedInputTokens: 400,
outputTokens: 250,
reasoningOutputTokens: 100,
totalTokens: 1350,
estimatedCostUsd: 0.05
}
],
modelBreakdown: [
{
modelKey: model ?? 'unknown',
modelLabel: model ?? 'Unknown model',
eventCount: 1,
inputTokens: 1000,
cachedInputTokens: 400,
outputTokens: 250,
reasoningOutputTokens: 100,
totalTokens: 1350,
estimatedCostUsd: 0.05
}
],
locationModelBreakdown: [
{
locationKey: worktreeId ? `worktree:${worktreeId}` : 'cwd:/outside/repo',
modelKey: model ?? 'unknown',
modelLabel: model ?? 'Unknown model',
repoId,
worktreeId,
eventCount: 1,
inputTokens: 1000,
cachedInputTokens: 400,
outputTokens: 250,
reasoningOutputTokens: 100,
totalTokens: 1350,
estimatedCostUsd: 0.05
}
],
...overrides
}
}
function makeDaily(
overrides: Partial<OpenCodeUsageDailyAggregate> = {}
): OpenCodeUsageDailyAggregate {
const worktreeId = overrides.worktreeId ?? 'repo-1::/workspace/repo'
return {
day: '2026-04-09',
model: 'anthropic/claude-sonnet-4-5',
projectKey: worktreeId ? `worktree:${worktreeId}` : 'cwd:/outside/repo',
projectLabel: worktreeId ? 'Repo' : 'outside/repo',
repoId: worktreeId ? 'repo-1' : null,
worktreeId,
eventCount: 1,
inputTokens: 1000,
cachedInputTokens: 400,
outputTokens: 250,
reasoningOutputTokens: 100,
totalTokens: 1350,
estimatedCostUsd: 0.05,
...overrides
}
}
describe('OpenCodeUsageStore', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-10T12:00:00.000-04:00'))
})
afterEach(() => {
vi.useRealTimers()
})
it('reports no data for Orca scope when only non-Orca OpenCode usage exists', async () => {
const store = createStoreWithState({
sessions: [
makeSession({
primaryProjectLabel: 'outside/repo',
primaryWorktreeId: null,
primaryRepoId: null
})
],
dailyAggregates: [
makeDaily({
projectKey: 'cwd:/outside/repo',
projectLabel: 'outside/repo',
repoId: null,
worktreeId: null
})
]
})
const summary = await store.getSummary('orca', '30d')
expect(summary.hasAnyOpenCodeData).toBe(false)
expect(summary.sessions).toBe(0)
expect(summary.events).toBe(0)
})
it('uses recorded OpenCode costs and token totals without model pricing inference', async () => {
const store = createStoreWithState({
sessions: [
makeSession({ sessionId: 'session-1' }),
makeSession({
sessionId: 'session-2',
primaryModel: 'openai/gpt-5.5',
totalTokens: 2000,
estimatedCostUsd: null,
modelBreakdown: [
{
modelKey: 'openai/gpt-5.5',
modelLabel: 'openai/gpt-5.5',
eventCount: 1,
inputTokens: 1500,
cachedInputTokens: 200,
outputTokens: 500,
reasoningOutputTokens: 0,
totalTokens: 2000,
estimatedCostUsd: null
}
]
})
],
dailyAggregates: [
makeDaily(),
makeDaily({
model: 'openai/gpt-5.5',
eventCount: 2,
inputTokens: 1500,
cachedInputTokens: 200,
outputTokens: 500,
reasoningOutputTokens: 0,
totalTokens: 2000,
estimatedCostUsd: null
})
]
})
const summary = await store.getSummary('orca', '30d')
const daily = await store.getDaily('orca', '30d')
const breakdown = await store.getBreakdown('orca', '30d', 'model')
expect(summary).toMatchObject({
sessions: 2,
events: 3,
inputTokens: 2500,
cachedInputTokens: 600,
outputTokens: 750,
reasoningOutputTokens: 100,
totalTokens: 3350,
estimatedCostUsd: 0.05,
topModel: 'openai/gpt-5.5',
topProject: 'Repo',
hasAnyOpenCodeData: true
})
expect(daily).toEqual([
{
day: '2026-04-09',
inputTokens: 2500,
cachedInputTokens: 600,
outputTokens: 750,
reasoningOutputTokens: 100,
totalTokens: 3350
}
])
expect(breakdown.find((row) => row.key === 'openai/gpt-5.5')).toMatchObject({
sessions: 1,
estimatedCostUsd: null
})
})
it('returns recent sessions with OpenCode event and token fields', async () => {
const store = createStoreWithState({
sessions: [makeSession()],
dailyAggregates: [makeDaily()]
})
const sessions = await store.getRecentSessions('orca', '30d', 5)
expect(sessions).toEqual([
{
sessionId: 'session-1',
lastActiveAt: '2026-04-09T10:10:00.000Z',
durationMinutes: 10,
projectLabel: 'Repo',
model: 'anthropic/claude-sonnet-4-5',
events: 1,
inputTokens: 1000,
cachedInputTokens: 400,
outputTokens: 250,
reasoningOutputTokens: 100,
totalTokens: 1350
}
])
})
it('normalizes persisted OpenCode state by schema version', () => {
expect(
normalizePersistedState({
...getDefaultState(),
schemaVersion: 0,
processedDatabases: [
{
path: '/tmp/opencode.db',
mtimeMs: 1,
size: 2,
sessions: [makeSession()],
dailyAggregates: [makeDaily()],
ownedSessionIds: ['session-1'],
hasDeferredClaims: false
}
],
sessions: [makeSession()],
dailyAggregates: [makeDaily()]
})
).toEqual(getDefaultState())
expect(
normalizePersistedState({
...getDefaultState(),
processedDatabases: [
{
path: '/tmp/opencode.db',
mtimeMs: 1,
size: 2,
sessions: [],
dailyAggregates: [],
ownedSessionIds: [],
hasDeferredClaims: false
}
]
}).processedDatabases
).toHaveLength(1)
})
})