fix(opencode-usage): merge a migrated session's two rows per column (#22550)

A session that lived through OpenCode 2's V1 import has a row in both
`session` and `session_v2`, and neither is complete. #22391 resolved the
pair by ranking whole rows on one number — total token count, ties to
`session_v2` — which let that number decide everything else on the row.

Three consequences, each reproduced against that PR's own fixtures:

- A recorded cost could be zeroed. `session_v2` wins on tokens while
  carrying `cost = 0`, and row parsing maps a zero cost to `null`, so a
  legacy row's $12.50 disappeared. Cost is re-derived by the same lossy
  reduce as the tokens, but only the tokens were guarded.
- The token comparison decided metadata. A legacy row with more tokens
  supplied a stale pre-migration directory, and a legacy row with a NULL
  model erased the model `session_v2` had — 23 of 234 shared ids on a
  real migrated database have a model only on the v2 side.
- Winner-takes-all is per row, so a legacy row holding the input tokens
  and a v2 row holding the cache reads reported one of them as zero.

Metadata now comes from the generation OpenCode still writes, with older
generations filling only its NULLs; usage columns take a per-column MAX.
Both rows aggregate the same assistant messages, and the import can only
drop messages, never invent them, so each column's MAX is a tighter lower
bound on the truth than either row and can never exceed it.

The relation stays exactly one row per id — the highest-priority
generation holding it — every column stays `columnExists`-guarded with a
SQL fallback, and a database with a single session table builds the same
SQL it did before.

Cache schema version 4 -> 5 so existing caches rescan.
This commit is contained in:
Neil
2026-09-23 17:39:26 -07:00
committed by GitHub
parent 800d33e5c9
commit d4386763d5
4 changed files with 279 additions and 41 deletions
@@ -268,6 +268,21 @@ describe('OpenCode 2 session_v2 usage', () => {
})
})
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',
@@ -282,3 +297,163 @@ describe('OpenCode 2 session_v2 usage', () => {
})
})
})
// 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: 1000,
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: 11,
outputTokens: 3,
cachedInputTokens: 7,
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()
}
})
})