mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-20 20:15:54 +00:00
fix(opencode): keep pre-upgrade history on OpenCode 2 databases (#1543)
* fix(opencode): keep pre-upgrade history on OpenCode 2 databases and price v2 turns like 1.x OpenCode 2 copies 1.x sessions into session_v2/session_message on first start, but the copy drops compaction summary and task-tool turns, skips messages it cannot decode, and can stop partway. A session present in both generations now reads its frozen legacy turns first and adds only the v2 turns legacy does not hold (migrated rows keep their ids, so dedup skips the copies). v2 turns also used a provider/model id that failed pricing for ids such as opencode/claude-haiku-4-5; they now use the bare id 1.x stored. Reported by a user. * Changelog: OpenCode 2 unpriced ids showed as $0
This commit is contained in:
parent
5dd0061f8d
commit
09f5856bcf
5 changed files with 190 additions and 65 deletions
|
|
@ -3,6 +3,7 @@
|
|||
## Unreleased
|
||||
|
||||
### Fixed
|
||||
- **OpenCode 2.x: upgraded history is no longer lost and v2 turns price like 1.x ones.** OpenCode 2 copies 1.x sessions into new tables on first start, but the copy drops compaction summary and task-tool turns, skips messages it cannot decode, and can stop partway. CodeBurn read only the copy for any migrated session, so on one real upgraded database more than half the sessions and three quarters of the all-time OpenCode cost disappeared. A session present in both generations now reads its legacy turns first and adds only the turns written after the upgrade, so pre-upgrade figures match 1.x exactly. v2 turns also carried a `provider/model` id that failed pricing for ids like `opencode/claude-haiku-4-5` or `github-copilot/gpt-5` so they showed as $0 in reports; they now use the bare id 1.x used. OpenCode sessions re-parse once. Users on CodeBurn 0.9.24 or older see no OpenCode 2 usage at all and need 0.9.25 or later. Reported by a user.
|
||||
- **An expired Claude Code login is recovered from the Keychain, and `codeburn quota` and the desktop app say so when it cannot be.** Both providers read the OAuth credential from `~/.claude/.credentials.json` and fall back to the macOS Keychain, which is where Claude Code keeps it on a normal Mac. After a 401 the retry re-read the file whatever the credential had come from, so a Keychain user got `Temporarily unavailable.` until Claude Code was run again, and the desktop Plans card kept showing the last percentage it had fetched. A 401 now re-reads the store the credential came from, which picks up a login Claude Code has since renewed. When nothing newer is there and the credential's `expiresAt` has passed, the row reports a terminal failure with a line telling you to run Claude Code once. The desktop app still reads the Keychain only on Check now or a forced refresh, and a background poll keeps that reconnect prompt instead of falling back to Check now. A 401 on a credential still within its life stays transient with its backoff. (#1516, thanks @ozymandiashh)
|
||||
|
||||
## 0.9.25 - 2026-09-21
|
||||
|
|
|
|||
|
|
@ -30,16 +30,24 @@ Precedence when no `dataDir` argument is passed (the production path):
|
|||
SQLite (older builds) or file-based JSON (OpenCode 1.1+, under `storage/`).
|
||||
|
||||
OpenCode 2.x (mainline since 2.0.3, issue #1293) writes a second SQLite
|
||||
generation: `session_v2` + `session_message` (whose FK points at
|
||||
`session_v2(id)`; messages are tagged by a `type` column, ordered by `seq`,
|
||||
payload JSON in `data`). The legacy `session`/`message`/`part` tables freeze at
|
||||
upgrade — a post-upgrade session has rows in `session_message` and zero new
|
||||
rows in `message`. The parser branches per database on `sqlite_master`: when
|
||||
the v2 tables exist they win and the legacy tables are ignored entirely (the
|
||||
generations are never joined); otherwise the legacy path runs unchanged.
|
||||
Session-level cost/token rollups and the `parent_id` child-session walk exist
|
||||
in both generations, so discovery, parsing and dedup behave the same either
|
||||
way.
|
||||
generation into the same `opencode.db`: `session_v2` + `session_message`
|
||||
(messages tagged by a `type` column, ordered by `seq`, payload JSON in `data`;
|
||||
assistant rows carry `model: {id, providerID}`, `cost` and `tokens` inline).
|
||||
The desktop app (Electron) runs the same CLI as a background service, so CLI
|
||||
and GUI sessions land in the same file. On first start 2.x copies 1.x sessions
|
||||
into the new tables under their original session and message ids, but the
|
||||
legacy `session`/`message`/`part` tables stay and freeze. The copy is lossy:
|
||||
it drops compaction summary turns and task-tool turns, skips any message it
|
||||
fails to decode, and can stop partway, leaving `session_v2` rows with no
|
||||
`session_message` rows.
|
||||
|
||||
So on a database with both generations the parser reads a session's legacy
|
||||
turns first and then adds the `session_message` turns legacy does not have
|
||||
(dedup by message id). Pre-upgrade history counts exactly as it did on 1.x and
|
||||
post-upgrade turns are added. Legacy sessions missing from `session_v2` are
|
||||
discovered from the legacy table. A 2.x-only database reads `session_message`
|
||||
alone; a 1.x database runs the legacy path unchanged. v2 model ids are used
|
||||
bare (as 1.x stored `modelID`) so a turn prices the same from either table.
|
||||
|
||||
## Caching
|
||||
|
||||
|
|
|
|||
|
|
@ -65,7 +65,8 @@ type V2MessageRow = {
|
|||
* primary generation (v2 when present), but on an upgraded DB the frozen legacy
|
||||
* rows are NOT dead: any legacy session whose id never made it into `session_v2`
|
||||
* is real history and is unioned in (see `discoverSqliteSessions` and the
|
||||
* per-session resolution in `parse`). v2 wins for any id present in both.
|
||||
* per-session resolution in `parse`). For an id present in both, `parse` reads
|
||||
* legacy first and adds only the v2 turns legacy does not hold.
|
||||
*/
|
||||
function detectGeneration(db: SqliteDatabase): 'v2' | 'legacy' | null {
|
||||
try {
|
||||
|
|
@ -129,7 +130,10 @@ function v2RowsToLegacyShape(rows: V2MessageRow[]): { messages: MessageRow[]; pa
|
|||
const id = typeof ref['id'] === 'string' ? ref['id'] : ''
|
||||
const providerID = typeof ref['providerID'] === 'string' ? ref['providerID'] : ''
|
||||
if (providerID) data.providerID = providerID
|
||||
if (id && providerID) data.modelID = `${providerID}/${id}`
|
||||
// Bare id, as 1.x stored `modelID`: the provider prefix breaks pricing for
|
||||
// ids like `opencode/claude-haiku-4-5` or `github-copilot/gpt-5`, and a
|
||||
// migrated turn must price the same whichever table it is read from.
|
||||
if (id) data.modelID = id
|
||||
}
|
||||
if (typeof payload['cost'] === 'number') data.cost = payload['cost']
|
||||
const tokens = payload['tokens']
|
||||
|
|
@ -282,33 +286,23 @@ export function createSqliteSessionParser(
|
|||
// legacy — reading it from session_message would find nothing and drop
|
||||
// its history. v2-only and legacy-only DBs resolve to `generation` as
|
||||
// before.
|
||||
const legacyOk = generation === 'legacy' || validateSchemaDetailed(db).ok
|
||||
const gen: 'v2' | 'legacy' =
|
||||
generation === 'v2' && !sessionInV2(db, sessionId) && validateSchemaDetailed(db).ok
|
||||
generation === 'v2' && !sessionInV2(db, sessionId) && legacyOk
|
||||
? 'legacy'
|
||||
: generation
|
||||
|
||||
let messages: MessageRow[]
|
||||
let partsByMsg: Map<string, PartData[]>
|
||||
let messages: MessageRow[] = []
|
||||
const partsByMsg = new Map<string, PartData[]>()
|
||||
|
||||
if (gen === 'v2') {
|
||||
const rows = db.query<V2MessageRow>(
|
||||
`WITH RECURSIVE session_tree(id) AS (
|
||||
SELECT id FROM session_v2 WHERE id = ?
|
||||
UNION
|
||||
SELECT child.id
|
||||
FROM session_v2 child
|
||||
JOIN session_tree parent ON child.parent_id = parent.id
|
||||
)
|
||||
SELECT session_id, id, type, seq, time_created, CAST(data AS BLOB) AS data
|
||||
FROM session_message
|
||||
WHERE session_id IN (SELECT id FROM session_tree)
|
||||
ORDER BY time_created ASC, session_id ASC, seq ASC`,
|
||||
[sessionId],
|
||||
)
|
||||
const normalized = v2RowsToLegacyShape(rows)
|
||||
messages = normalized.messages
|
||||
partsByMsg = normalized.partsByMsg
|
||||
} else {
|
||||
// A migrated session keeps its full pre-upgrade history in the frozen
|
||||
// legacy tables, while session_message holds only what OpenCode's
|
||||
// migration carried over (it drops compaction summaries, task-tool
|
||||
// turns and any message it fails to decode, and can stop partway) plus
|
||||
// turns added after the upgrade. Legacy is read first so pre-upgrade
|
||||
// turns count exactly as on 1.x; migrated rows keep their legacy
|
||||
// message ids, so the seenKeys dedup below skips the v2 copies.
|
||||
if (legacyOk) {
|
||||
messages = db.query<MessageRow>(
|
||||
`WITH RECURSIVE session_tree(id) AS (
|
||||
SELECT id FROM session WHERE id = ?
|
||||
|
|
@ -339,7 +333,6 @@ export function createSqliteSessionParser(
|
|||
[sessionId],
|
||||
)
|
||||
|
||||
partsByMsg = new Map<string, PartData[]>()
|
||||
for (const part of parts) {
|
||||
try {
|
||||
const parsed = JSON.parse(blobToText(part.data)) as PartData
|
||||
|
|
@ -352,6 +345,28 @@ export function createSqliteSessionParser(
|
|||
}
|
||||
}
|
||||
|
||||
if (gen === 'v2') {
|
||||
const rows = db.query<V2MessageRow>(
|
||||
`WITH RECURSIVE session_tree(id) AS (
|
||||
SELECT id FROM session_v2 WHERE id = ?
|
||||
UNION
|
||||
SELECT child.id
|
||||
FROM session_v2 child
|
||||
JOIN session_tree parent ON child.parent_id = parent.id
|
||||
)
|
||||
SELECT session_id, id, type, seq, time_created, CAST(data AS BLOB) AS data
|
||||
FROM session_message
|
||||
WHERE session_id IN (SELECT id FROM session_tree)
|
||||
ORDER BY time_created ASC, session_id ASC, seq ASC`,
|
||||
[sessionId],
|
||||
)
|
||||
const normalized = v2RowsToLegacyShape(rows)
|
||||
messages.push(...normalized.messages)
|
||||
for (const [id, parts] of normalized.partsByMsg) {
|
||||
if (!partsByMsg.has(id)) partsByMsg.set(id, parts)
|
||||
}
|
||||
}
|
||||
|
||||
const currentUserMessageBySession = new Map<string, string>()
|
||||
let yieldCount = 0
|
||||
let parseFailCount = 0
|
||||
|
|
@ -500,7 +515,7 @@ export async function discoverSqliteSessions(
|
|||
|
||||
// On an upgraded DB, also surface legacy top-level sessions that never
|
||||
// migrated into session_v2 — their frozen history is otherwise dropped
|
||||
// (#1293). Sessions present in both are excluded here so v2 wins.
|
||||
// (#1293). Sessions present in both are excluded here so each is listed once.
|
||||
if (generation === 'v2' && validateSchemaDetailed(db).ok) {
|
||||
rows.push(...db.query<SessionRow>(
|
||||
`SELECT id, CAST(directory AS BLOB) AS directory, CAST(title AS BLOB) AS title, time_created
|
||||
|
|
|
|||
|
|
@ -479,7 +479,11 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
|
|||
// parsed call as `route`: `openrouter` and `amazon-bedrock`. Cached calls hold
|
||||
// neither, so they must re-parse. v2 also invalidates the OpenRouter-only
|
||||
// fingerprint used by pre-merge builds of #1486.
|
||||
opencode: 'session-model-v1-archived-subtree-v1-billing-routes-v2',
|
||||
// v2-legacy-union-v1: a session present in both OpenCode 2.x `session_v2` and
|
||||
// the frozen 1.x tables now reads its legacy turns too. Cached parses of an
|
||||
// upgraded database hold only what the 2.x migration carried over, and v2
|
||||
// turns now carry the bare model id 1.x used instead of `provider/model`.
|
||||
opencode: 'session-model-v1-archived-subtree-v1-billing-routes-v2-v2-legacy-union-v1',
|
||||
quickdesk: 'emf-sqlite-v2-est-cost',
|
||||
// session-lineage-capture-v1: SessionLineage (CB-1, slice 1) is now carried
|
||||
// on the cached file for every kimicode wire. Child evidence is the
|
||||
|
|
@ -492,7 +496,7 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
|
|||
kimicode: 'wire-usage-v1-est-cost-session-lineage-capture-v1',
|
||||
// archived-subtree-v1: KiloCode shares the SQLite parser and the same schema.
|
||||
// billing-routes-v2: its warm cache must move with both shared route fields.
|
||||
'kilo-code': 'worktree-project-grouping-v1-session-model-v1-archived-subtree-v1-billing-routes-v2',
|
||||
'kilo-code': 'worktree-project-grouping-v1-session-model-v1-archived-subtree-v1-billing-routes-v2-v2-legacy-union-v1',
|
||||
'roo-code': 'worktree-project-grouping-v1',
|
||||
// billing-cost-v1: Warp's own billing record (total_provider_cost_in_cents,
|
||||
// total_charged_usage, credits_spent) now rides on each call as
|
||||
|
|
|
|||
|
|
@ -1190,7 +1190,7 @@ skipUnlessSqlite('opencode provider - v2 generation (session_v2 + session_messag
|
|||
const calls = await collectCalls(provider, dbPath, 'ses_v2_1')
|
||||
expect(calls).toHaveLength(1)
|
||||
const call = calls[0]!
|
||||
expect(call.model).toBe('opencode/glm-5.3-flash')
|
||||
expect(call.model).toBe('glm-5.3-flash')
|
||||
expect(call.inputTokens).toBe(21410)
|
||||
expect(call.outputTokens).toBe(2175)
|
||||
expect(call.cacheReadInputTokens).toBe(112128)
|
||||
|
|
@ -1200,6 +1200,89 @@ skipUnlessSqlite('opencode provider - v2 generation (session_v2 + session_messag
|
|||
expect(call.costUSD).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('reads a real OpenCode 2.0.16 session (tool calls, reasoning, cache reads)', async () => {
|
||||
// Rows written by `opencode run` 2.0.16 against the free opencode/big-pickle
|
||||
// model; paths redacted, tool output trimmed.
|
||||
const dbPath = createV2TestDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertV2Session(db, 'ses_f2cbda7f0ffevXtdfk20qxO7K9', { directory: '/home/user/proj', title: 'toolcall' })
|
||||
insertV2Message(db, 'msg_0d3425814001k3LGlAKci24D6g', 'ses_f2cbda7f0ffevXtdfk20qxO7K9', 'user', 5, 1790250735642, {
|
||||
time: { created: 1790250735642 }, text: '"Read the file notes.txt and tell me its contents in one line."',
|
||||
})
|
||||
insertV2Message(db, 'msg_0d342581b001xOcuI5GMMHjSQR', 'ses_f2cbda7f0ffevXtdfk20qxO7K9', 'assistant', 6, 1790250735647, {
|
||||
agent: 'build',
|
||||
model: { id: 'big-pickle', providerID: 'opencode', variant: 'default' },
|
||||
content: [
|
||||
{ type: 'reasoning', text: 'The user wants me to read notes.txt and tell them its contents in one line. Let me find and read the file.' },
|
||||
{ type: 'text', text: '\n\n' },
|
||||
{ type: 'tool', name: 'glob', state: { status: 'completed', input: { pattern: '**/notes.txt' } } },
|
||||
],
|
||||
cost: 0,
|
||||
tokens: { input: 6323, output: 72, reasoning: 0, cache: { read: 256, write: 0 } },
|
||||
})
|
||||
insertV2Message(db, 'msg_0d342716c001bPgSpFdp38REPt', 'ses_f2cbda7f0ffevXtdfk20qxO7K9', 'assistant', 17, 1790250742127, {
|
||||
agent: 'build',
|
||||
model: { id: 'big-pickle', providerID: 'opencode', variant: 'default' },
|
||||
content: [{ type: 'tool', name: 'read', state: { status: 'completed', input: { path: '/home/user/proj/notes.txt' } } }],
|
||||
cost: 0,
|
||||
tokens: { input: 318, output: 100, reasoning: 0, cache: { read: 6400, write: 0 } },
|
||||
})
|
||||
insertV2Message(db, 'msg_0d3427704001UBCn6YD3m0MlCQ', 'ses_f2cbda7f0ffevXtdfk20qxO7K9', 'assistant', 24, 1790250743559, {
|
||||
agent: 'build',
|
||||
model: { id: 'big-pickle', providerID: 'opencode', variant: 'default' },
|
||||
content: [{ type: 'text', text: 'notes.txt contains: "hello world".' }],
|
||||
cost: 0,
|
||||
tokens: { input: 243, output: 10, reasoning: 0, cache: { read: 6656, write: 0 } },
|
||||
})
|
||||
insertV2Message(db, 'msg_0d3427a34001SSfPFcwFekJ10S', 'ses_f2cbda7f0ffevXtdfk20qxO7K9', 'idle', 29, 1790250744372, {
|
||||
time: { created: 1790250744372 },
|
||||
})
|
||||
})
|
||||
|
||||
const provider = createOpenCodeProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toEqual([{ path: `${dbPath}:ses_f2cbda7f0ffevXtdfk20qxO7K9`, project: 'home-user-proj', provider: 'opencode' }])
|
||||
|
||||
const calls = await collectCalls(provider, dbPath, 'ses_f2cbda7f0ffevXtdfk20qxO7K9')
|
||||
expect(calls.map((c) => c.tools)).toEqual([['Glob'], ['Read'], []])
|
||||
expect(calls.every((c) => c.model === 'big-pickle' && c.costUSD === 0)).toBe(true)
|
||||
expect(calls[0]!.timestamp).toBe('2026-09-24T11:52:15.647Z')
|
||||
// Matches OpenCode's own session_v2 rollup for this session.
|
||||
const sum = (f: (c: ParsedProviderCall) => number) => calls.reduce((t, c) => t + f(c), 0)
|
||||
expect(sum((c) => c.inputTokens)).toBe(6884)
|
||||
expect(sum((c) => c.outputTokens)).toBe(182)
|
||||
expect(sum((c) => c.cacheReadInputTokens)).toBe(13312)
|
||||
})
|
||||
|
||||
it('prices a v2 turn the same as the identical 1.x turn', async () => {
|
||||
const tokens = { input: 12_000, output: 800, reasoning: 0, cache: { read: 4_000, write: 0 } }
|
||||
const v2Path = createV2TestDb(tmpDir)
|
||||
withTestDb(v2Path, (db) => {
|
||||
insertV2Session(db, 'ses_v2')
|
||||
insertV2Message(db, 'msg_v2', 'ses_v2', 'assistant', 1, 1700000000000, {
|
||||
model: { id: 'claude-haiku-4-5', providerID: 'opencode' },
|
||||
content: [{ type: 'text', text: 'hi' }],
|
||||
cost: 0,
|
||||
tokens,
|
||||
})
|
||||
})
|
||||
const [v2Call] = await collectCalls(createOpenCodeProvider(tmpDir), v2Path, 'ses_v2')
|
||||
|
||||
const legacyDir = join(tmpDir, 'legacy')
|
||||
const legacyPath = createTestDb(legacyDir)
|
||||
withTestDb(legacyPath, (db) => {
|
||||
insertSession(db, 'ses_v1')
|
||||
insertMessage(db, 'msg_v1', 'ses_v1', 1700000000000, {
|
||||
role: 'assistant', modelID: 'claude-haiku-4-5', providerID: 'opencode', cost: 0, tokens,
|
||||
})
|
||||
})
|
||||
const [legacyCall] = await collectCalls(createOpenCodeProvider(legacyDir), legacyPath, 'ses_v1')
|
||||
|
||||
expect(legacyCall!.costUSD).toBeGreaterThan(0)
|
||||
expect(v2Call!.costUSD).toBe(legacyCall!.costUSD)
|
||||
expect(v2Call!.model).toBe(legacyCall!.model)
|
||||
})
|
||||
|
||||
it('preserves the OpenRouter provider field in v2 messages', async () => {
|
||||
const dbPath = createV2TestDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
|
|
@ -1215,7 +1298,7 @@ skipUnlessSqlite('opencode provider - v2 generation (session_v2 + session_messag
|
|||
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'ses_v2_openrouter')
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toMatchObject({
|
||||
model: 'openrouter/cohere/north-mini-code:free',
|
||||
model: 'cohere/north-mini-code:free',
|
||||
route: 'openrouter',
|
||||
costUSD: 0,
|
||||
})
|
||||
|
|
@ -1249,10 +1332,10 @@ skipUnlessSqlite('opencode provider - v2 generation (session_v2 + session_messag
|
|||
const provider = createOpenCodeProvider(tmpDir)
|
||||
const calls = await collectCalls(provider, dbPath, 'ses_v2_1')
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[0]!.model).toBe('opencode/glm-5.3-flash')
|
||||
expect(calls[0]!.model).toBe('glm-5.3-flash')
|
||||
expect(calls[0]!.inputTokens).toBe(18100)
|
||||
expect(calls[0]!.costUSD).toBeGreaterThan(0)
|
||||
expect(calls[1]!.model).toBe('openai/gpt-4o')
|
||||
expect(calls[1]!.model).toBe('gpt-4o')
|
||||
})
|
||||
|
||||
it('walks v2 child sessions through parent_id', async () => {
|
||||
|
|
@ -1295,7 +1378,7 @@ skipUnlessSqlite('opencode provider - v2 generation (session_v2 + session_messag
|
|||
expect(calls[0]!.inputTokens).toBe(11)
|
||||
})
|
||||
|
||||
it('unions un-migrated legacy sessions on an upgraded DB, v2 winning on collision (#1293)', async () => {
|
||||
it('unions legacy and v2 turns on an upgraded DB, counting each turn once (#1293)', async () => {
|
||||
const dbPath = createV2TestDb(tmpDir, { withLegacy: true })
|
||||
withTestDb(dbPath, (db) => {
|
||||
// Live v2 session.
|
||||
|
|
@ -1306,8 +1389,7 @@ skipUnlessSqlite('opencode provider - v2 generation (session_v2 + session_messag
|
|||
tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
|
||||
// Three frozen pre-upgrade sessions that never migrated into session_v2 —
|
||||
// these were silently dropped before the union fix.
|
||||
// Three frozen pre-upgrade sessions that never migrated into session_v2.
|
||||
for (const [i, id] of ['ses_frozen_1', 'ses_frozen_2', 'ses_frozen_3'].entries()) {
|
||||
insertSession(db, id, { title: `frozen ${i + 1}` })
|
||||
insertMessage(db, `lmsg_${id}`, id, 1690000000000 + i, {
|
||||
|
|
@ -1316,35 +1398,53 @@ skipUnlessSqlite('opencode provider - v2 generation (session_v2 + session_messag
|
|||
})
|
||||
}
|
||||
|
||||
// A session present in BOTH generations: v2 must win, and it must be
|
||||
// counted once (not double-listed by discovery).
|
||||
insertV2Session(db, 'ses_both')
|
||||
insertV2Message(db, 'msg_both_v2', 'ses_both', 'assistant', 1, 1700000000500, {
|
||||
model: { id: 'glm-5.3-flash', providerID: 'opencode' },
|
||||
content: [{ type: 'text', text: 'v2 wins' }],
|
||||
tokens: { input: 500, output: 10, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
insertSession(db, 'ses_both', { title: 'stale legacy copy' })
|
||||
// A migrated session continued on 2.x. OpenCode's migration copies each
|
||||
// assistant turn under its legacy id, drops the compaction summary turn,
|
||||
// and the post-upgrade turn exists only in session_message.
|
||||
insertSession(db, 'ses_both', { title: 'migrated' })
|
||||
insertMessage(db, 'lmsg_both', 'ses_both', 1690000000500, {
|
||||
role: 'assistant', modelID: 'opencode/old',
|
||||
role: 'assistant', modelID: 'glm-5.3-flash', providerID: 'opencode',
|
||||
tokens: { input: 999, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
insertMessage(db, 'lmsg_summary', 'ses_both', 1690000000600, {
|
||||
role: 'assistant', modelID: 'glm-5.3-flash', providerID: 'opencode',
|
||||
tokens: { input: 40, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
insertV2Session(db, 'ses_both')
|
||||
insertV2Message(db, 'lmsg_both', 'ses_both', 'assistant', 0, 1690000000500, {
|
||||
model: { id: 'glm-5.3-flash', providerID: 'opencode' },
|
||||
content: [{ type: 'text', text: 'migrated copy' }],
|
||||
tokens: { input: 999, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
insertV2Message(db, 'msg_both_new', 'ses_both', 'assistant', 5, 1700000000500, {
|
||||
model: { id: 'glm-5.3-flash', providerID: 'opencode' },
|
||||
content: [{ type: 'text', text: 'after the upgrade' }],
|
||||
tokens: { input: 500, output: 10, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
|
||||
// Migration stopped before this session: its session_v2 row exists but
|
||||
// no session_message rows were written.
|
||||
insertSession(db, 'ses_stuck', { title: 'stuck' })
|
||||
insertMessage(db, 'lmsg_stuck', 'ses_stuck', 1690000000700, {
|
||||
role: 'assistant', modelID: 'glm-5.3-flash', providerID: 'opencode',
|
||||
tokens: { input: 70, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
insertV2Session(db, 'ses_stuck')
|
||||
})
|
||||
|
||||
const provider = createOpenCodeProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
// v2 sessions plus the three un-migrated legacy sessions; ses_both listed once.
|
||||
expect(sessions.map((s) => s.path).sort()).toEqual(
|
||||
[
|
||||
`${dbPath}:ses_both`,
|
||||
`${dbPath}:ses_frozen_1`,
|
||||
`${dbPath}:ses_frozen_2`,
|
||||
`${dbPath}:ses_frozen_3`,
|
||||
`${dbPath}:ses_stuck`,
|
||||
`${dbPath}:ses_v2_live`,
|
||||
].sort(),
|
||||
)
|
||||
|
||||
// Aggregate across every discovered session, as a real scan would.
|
||||
const seen = new Set<string>()
|
||||
let totalInput = 0
|
||||
let totalCalls = 0
|
||||
|
|
@ -1355,16 +1455,13 @@ skipUnlessSqlite('opencode provider - v2 generation (session_v2 + session_messag
|
|||
totalCalls++
|
||||
}
|
||||
}
|
||||
// 100 (v2) + 500 (v2 wins on ses_both) + 10 + 20 + 30 (legacy) = 660; the
|
||||
// stale legacy 999 for ses_both is never counted. Before the fix the three
|
||||
// frozen sessions were dropped: total was 600 over 2 calls.
|
||||
expect(totalCalls).toBe(5)
|
||||
expect(totalInput).toBe(660)
|
||||
// 100 + (10 + 20 + 30) + (999 + 40 + 500) + 70
|
||||
expect(totalCalls).toBe(8)
|
||||
expect(totalInput).toBe(1769)
|
||||
|
||||
// ses_both resolves to v2, not the stale legacy row.
|
||||
const bothCalls = await collectCalls(provider, dbPath, 'ses_both')
|
||||
expect(bothCalls).toHaveLength(1)
|
||||
expect(bothCalls[0]!.inputTokens).toBe(500)
|
||||
expect(bothCalls.map((c) => c.inputTokens)).toEqual([999, 40, 500])
|
||||
expect(bothCalls.map((c) => c.model)).toEqual(['glm-5.3-flash', 'glm-5.3-flash', 'glm-5.3-flash'])
|
||||
})
|
||||
|
||||
it('keeps fallback model prefixes normalized without accepting malformed routes', async () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue