From ce1f77891716de930f771d3d027a97f22da88672 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 16 Sep 2026 11:58:56 -0400 Subject: [PATCH] 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. --- .../session-scanner-opencode-sqlite-capture.ts | 7 ++++++- .../session-scanner-opencode-sqlite-fixture.ts | 13 +++++++++++-- .../session-scanner-opencode-sqlite.test.ts | 15 +++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-capture.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-capture.ts index fe2c1867bd3..9be9b734e2f 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-capture.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-capture.ts @@ -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) { diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-fixture.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-fixture.ts index b23734584df..2bb259d4e64 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-fixture.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-fixture.ts @@ -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 } diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite.test.ts index f7e042d663b..c9854e525c5 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite.test.ts @@ -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)