fix(ai-vault): fail an OpenCode capture it cannot read the message parts of

`readOpenCodeSessionMessages` returned an empty list when the message-part
schema probe failed. The sink-aware reader treats that as a complete read,
so the consumer committed nothing and marked the source `current`: the
session stayed out of the index with nothing on its row to say why and no
retry. The part limit a few lines below already throws for exactly this
reason, so the two now agree.

The preview path is unchanged and still degrades to no messages, which is
what a list read should do.

Also throws from the fixture's `appendOpenCodeSqliteTurn` when the session
id names no row, instead of falling back to the fixture epoch and appending
orphan messages a test would then assert over.
This commit is contained in:
Jinwoo-H
2026-09-16 11:58:56 -04:00
parent ed24c77ade
commit ce1f778917
3 changed files with 32 additions and 3 deletions
@@ -173,7 +173,12 @@ export function readOpenCodeSessionMessages(
sessionId: string
): TranscriptMessage[] {
if (!canReadOpenCodeMessageParts(db)) {
return []
// Thrown for the same reason the part limit below throws: an empty capture
// returned here is committed under a complete-read cursor, so the session
// stays out of search with nothing on its row to say why and no retry.
throw new Error(
`OpenCode session ${sessionId} uses an unreadable message-part schema; its transcript was not read.`
)
}
const rows = db.prepare(buildCaptureQuery()).all(sessionId, OPENCODE_CAPTURE_PART_LIMIT + 1)
if (rows.length > OPENCODE_CAPTURE_PART_LIMIT) {
@@ -210,8 +210,17 @@ export function appendOpenCodeSqliteTurn(
}
}
// Throws rather than falling back to the epoch: a mistyped id would otherwise
// append orphan rows and update nothing, leaving a test asserting over a
// transcript that no session owns.
function currentUpdatedMs(db: SyncDatabase, sessionId: string): number {
const row = db.prepare('SELECT time_updated FROM session WHERE id = ?').get(sessionId)
const updated = row === undefined ? undefined : Object.values(row)[0]
return typeof updated === 'number' ? updated : OPENCODE_FIXTURE_EPOCH_MS
if (row === undefined) {
throw new Error(`OpenCode fixture has no session ${sessionId} to append to`)
}
const updated = Object.values(row)[0]
if (typeof updated !== 'number') {
throw new Error(`OpenCode fixture session ${sessionId} has no numeric time_updated`)
}
return updated
}
@@ -6,6 +6,7 @@ import Database from '../sqlite/sync-database'
import { buildOpenCodeSqliteCandidatePath } from './session-scanner-opencode-sqlite-paths'
import { listOpenCodeSqliteSessions } from './session-scanner-opencode-sqlite-discovery'
import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite'
import { captureOpenCodeSqliteSession } from './session-scanner-opencode-sqlite-capture'
import { withFullFirstUserPromptCapture } from './session-scanner-first-user-prompt-capture'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
@@ -479,6 +480,20 @@ describe('parseOpenCodeSqliteSession', () => {
expect(session!.previewMessages).toEqual([])
})
// The preview may degrade to nothing, but the search index may not: an empty
// capture is committed under a complete-read cursor, so the session would stay
// unsearchable with nothing on its row to say why and no retry.
it('refuses to capture a transcript it cannot read the message parts of', async () => {
const { db, path } = createTempDb()
applyMinimalOpenCodeSchema(db)
db.prepare(`INSERT INTO session VALUES ('ses_minimal', 1777634000000, 1777634001000)`).run()
db.close()
await expect(
captureOpenCodeSqliteSession({ dbPath: path, sessionId: 'ses_minimal', platform: 'darwin' })
).rejects.toThrow(/unreadable message-part schema/)
})
it('extracts model from older modelID schema', async () => {
const { db, path } = createTempDb()
applyOpenCodeSchema(db)