Files
orca/src/main/ai-vault/session-scanner-opencode-sqlite.test.ts
T
Jinwoo-H e0281999db refactor(ai-vault-search): consolidate the review fixes into one owner per rule
Second pass over the session-search delta. Keeps the staged-write, streaming
capture, consent-gate and SQL-side filtering designs; removes the layers that
had accumulated around them.

Store and schema (schema 10): row visibility is two SQL views instead of four
hand-written predicates; publish nulls messages.batch_id and drops the batch row,
so the messages view is `batch_id IS NULL` and a recycled batch id can no longer
hide published rows; `published` column and its index removed; WAL checkpoint
guard taken off every read path and sampled on the write loop only;
maintenance class split into three plain functions; cwd_key indexed and the
scope filter switched to a range seek with an EXPLAIN plan assertion.

Service and capture: one write shape (streamingCapture flag and the dead
array branch deleted); configure applies policy to the store once; one
shutdown path (dispose removed) with an ownership guard so a late close
cannot unregister a replacement service's sink; unverifiable sources are
returned with a caveat instead of filtered like deletions; a transient write
failure no longer pins the indexing badge at error; refresh lane reuses
stableInFlightKey; message channel handles concurrent checkpoints.

Query: relevance sort now groups by session before the candidate limit (the
must-fix was only applied to newest); one operator parser shared by panel and
backend with the apostrophe bug fixed; OR within a key, AND across keys; one
documented case rule; received enums tolerate unknown values; result type
derived from the schema; projection applied on the desktop IPC path; tokenizer
contract pinned against fts5vocab.

Remote and CLI: SSH and local ids rejected at the RPC boundary for all four
methods; method-name regex sniffing removed from both transports; CommandSpec
gained booleanFlags/repeatableFlags so args.ts carries no command vocabulary;
settings update no longer blocks or fails on scanner reconfiguration; relay
owner keeps its lease through caller cancellation and answers index-status on
an unreadable policy; one SESSION_SEARCH_METHODS record feeds every caller.

Renderer: coverage polling stops once the index settles and re-arms on focus;
search results feed the coverage store instead of re-fetching; the `updating`
state and header line are deleted in favour of the list's own loading state;
local-only notice only when a remote host is in scope; status-bar segment is
read-only and opens settings; ownerKey folded into the args key.

Providers: OpenCode capture resets (not clears) the parse deadline; capture
and preview reads degrade to a scan issue instead of dropping the session;
a consumer write failure is not counted as a worker death; response union
discriminated on `kind`; worker host split out of the client.

Every behavioural fix carries a test that fails when the fix is reverted.
2026-09-07 22:12:56 -04:00

490 lines
15 KiB
TypeScript

import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import Database from '../sqlite/sync-database'
import {
applyMinimalOpenCodeSqliteSchema,
applyOpenCodeSqliteSchema,
insertOpenCodeMessage,
insertOpenCodePart,
insertOpenCodeSession
} from './session-scanner-opencode-sqlite-fixtures'
import { buildOpenCodeSqliteCandidatePath } from './session-scanner-opencode-sqlite-paths'
import { listOpenCodeSqliteSessions } from './session-scanner-opencode-sqlite-discovery'
import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite'
import { withFullFirstUserPromptCapture } from './session-scanner-first-user-prompt-capture'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
let tempDirs: string[] = []
afterEach(() => {
for (const dir of tempDirs) {
rmSync(dir, { recursive: true, force: true })
}
tempDirs = []
})
function createTempDb(): { db: Database.Database; path: string } {
const dir = mkdtempSync(join(tmpdir(), 'orca-opencode-sqlite-'))
tempDirs.push(dir)
const path = join(dir, 'opencode.db')
return { db: new Database(path), path }
}
describe('listOpenCodeSqliteSessions', () => {
it('returns candidates sorted by time_updated desc via the synthesized mtimeMs', async () => {
const { db, path } = createTempDb()
applyOpenCodeSqliteSchema(db)
insertOpenCodeSession(db, {
id: 'ses_old',
title: 'Old',
timeCreated: 1_777_634_000_000,
timeUpdated: 1_777_634_001_000
})
insertOpenCodeSession(db, {
id: 'ses_new',
title: 'New',
timeCreated: 1_777_634_002_000,
timeUpdated: 1_777_634_003_000
})
db.close()
const issues: AiVaultScanIssue[] = []
const candidates = await listOpenCodeSqliteSessions({
dbPaths: [path],
limit: 10,
issues
})
expect(issues).toEqual([])
expect(candidates).toHaveLength(2)
expect(candidates[0].agent).toBe('opencode')
expect(candidates[0].file.mtimeMs).toBe(1_777_634_003_000)
expect(candidates[0].file.path).toBe(buildOpenCodeSqliteCandidatePath(path, 'ses_new'))
expect(candidates[1].file.path).toBe(buildOpenCodeSqliteCandidatePath(path, 'ses_old'))
})
it('dedups matching session ids across databases and keeps the newest row', async () => {
const { db: oldDb, path: oldPath } = createTempDb()
applyOpenCodeSqliteSchema(oldDb)
insertOpenCodeSession(oldDb, {
id: 'ses_duplicate',
title: 'Old duplicate',
timeCreated: 1_777_634_000_000,
timeUpdated: 1_777_634_001_000
})
oldDb.close()
const { db: newDb, path: newPath } = createTempDb()
applyOpenCodeSqliteSchema(newDb)
insertOpenCodeSession(newDb, {
id: 'ses_duplicate',
title: 'New duplicate',
timeCreated: 1_777_634_002_000,
timeUpdated: 1_777_634_003_000
})
newDb.close()
const candidates = await listOpenCodeSqliteSessions({
dbPaths: [oldPath, newPath],
limit: 10,
issues: []
})
expect(candidates).toHaveLength(1)
expect(candidates[0].file.path).toBe(buildOpenCodeSqliteCandidatePath(newPath, 'ses_duplicate'))
})
it('excludes archived and child sessions', async () => {
const { db, path } = createTempDb()
applyOpenCodeSqliteSchema(db)
insertOpenCodeSession(db, {
id: 'ses_normal',
timeCreated: 1_777_634_000_000,
timeUpdated: 1_777_634_001_000
})
insertOpenCodeSession(db, {
id: 'ses_archived',
timeCreated: 1_777_634_000_000,
timeUpdated: 1_777_634_002_000,
timeArchived: 1_777_634_002_500
})
insertOpenCodeSession(db, {
id: 'ses_child',
timeCreated: 1_777_634_000_000,
timeUpdated: 1_777_634_003_000,
parentId: 'ses_normal'
})
db.close()
const candidates = await listOpenCodeSqliteSessions({
dbPaths: [path],
limit: 10,
issues: []
})
expect(candidates.map((c) => c.file.path)).toEqual([
buildOpenCodeSqliteCandidatePath(path, 'ses_normal')
])
})
it('returns [] when the session table is missing (legacy install)', async () => {
const { db, path } = createTempDb()
db.exec('CREATE TABLE other (id TEXT)')
db.close()
const candidates = await listOpenCodeSqliteSessions({
dbPaths: [path],
limit: 10,
issues: []
})
expect(candidates).toEqual([])
})
it('records an issue when the DB file does not exist', async () => {
const issues: AiVaultScanIssue[] = []
const candidates = await listOpenCodeSqliteSessions({
dbPaths: ['/nonexistent/opencode.db'],
limit: 10,
issues
})
expect(candidates).toEqual([])
expect(issues).toHaveLength(1)
expect(issues[0].agent).toBe('opencode')
expect(issues[0].path).toBe('/nonexistent/opencode.db')
})
it('lists sessions from a minimal readable session table', async () => {
const { db, path } = createTempDb()
applyMinimalOpenCodeSqliteSchema(db)
db.prepare(`INSERT INTO session VALUES ('ses_minimal', 1777634000000, 1777634001000)`).run()
db.close()
const issues: AiVaultScanIssue[] = []
const candidates = await listOpenCodeSqliteSessions({
dbPaths: [path],
limit: 10,
issues
})
expect(issues).toEqual([])
expect(candidates).toHaveLength(1)
expect(candidates[0].file.path).toBe(buildOpenCodeSqliteCandidatePath(path, 'ses_minimal'))
})
})
describe('parseOpenCodeSqliteSession', () => {
it('builds an AiVaultSession with title, cwd, model, tokens, and resume command', async () => {
const { db, path } = createTempDb()
applyOpenCodeSqliteSchema(db)
insertOpenCodeSession(db, {
id: 'ses_1',
title: 'OpenCode title',
directory: '/tmp/opencode',
timeCreated: 1_777_634_000_000,
timeUpdated: 1_777_634_001_000,
tokensInput: 100,
tokensOutput: 40,
tokensReasoning: 10,
tokensCacheRead: 5,
cost: 0.01
})
insertOpenCodeMessage(db, {
id: 'msg_1',
sessionId: 'ses_1',
role: 'user',
timeCreated: 1_777_634_000_500,
summaryTitle: 'OpenCode title'
})
insertOpenCodePart(db, {
id: 'prt_1',
messageId: 'msg_1',
sessionId: 'ses_1',
timeCreated: 1_777_634_000_500,
text: 'Plan the work'
})
insertOpenCodeMessage(db, {
id: 'msg_2',
sessionId: 'ses_1',
role: 'assistant',
timeCreated: 1_777_634_000_900
})
insertOpenCodePart(db, {
id: 'prt_2',
messageId: 'msg_2',
sessionId: 'ses_1',
timeCreated: 1_777_634_001_000,
text: 'Done'
})
db.close()
const session = await parseOpenCodeSqliteSession({
dbPath: path,
sessionId: 'ses_1',
platform: 'darwin'
})
expect(session).not.toBeNull()
expect(session!.agent).toBe('opencode')
expect(session!.sessionId).toBe('ses_1')
expect(session!.filePath).toBe(path)
expect(session!.title).toBe('OpenCode title')
expect(session!.cwd).toBe('/tmp/opencode')
expect(session!.model).toBe('glm-5.2')
expect(session!.totalTokens).toBe(150)
expect(session!.messageCount).toBe(2)
expect(session!.createdAt).toBe(new Date(1_777_634_000_000).toISOString())
expect(session!.updatedAt).toBe(new Date(1_777_634_001_000).toISOString())
expect(session!.resumeCommand).toBe("cd '/tmp/opencode' && opencode --session 'ses_1'")
expect(session!.previewMessages).toHaveLength(2)
expect(session!.previewMessages[0].text).toBe('Plan the work')
expect(session!.previewMessages[0].role).toBe('user')
expect(session!.previewMessages[1].text).toBe('Done')
expect(session!.previewMessages[1].role).toBe('assistant')
})
it('falls back to summary.body for title when session.title is empty', async () => {
const { db, path } = createTempDb()
applyOpenCodeSqliteSchema(db)
insertOpenCodeSession(db, {
id: 'ses_2',
title: '',
timeCreated: 1_777_634_000_000,
timeUpdated: 1_777_634_001_000
})
insertOpenCodeMessage(db, {
id: 'msg_1',
sessionId: 'ses_2',
role: 'user',
timeCreated: 1_777_634_000_500,
summaryBody: 'fallback title from summary'
})
insertOpenCodePart(db, {
id: 'prt_1',
messageId: 'msg_1',
sessionId: 'ses_2',
timeCreated: 1_777_634_000_500,
text: 'hello'
})
db.close()
const session = await parseOpenCodeSqliteSession({
dbPath: path,
sessionId: 'ses_2',
platform: 'darwin'
})
expect(session).not.toBeNull()
expect(session!.title).toBe('fallback title from summary')
})
it('returns null when the session id is not found', async () => {
const { db, path } = createTempDb()
applyOpenCodeSqliteSchema(db)
insertOpenCodeSession(db, {
id: 'ses_real',
timeCreated: 1_777_634_000_000,
timeUpdated: 1_777_634_001_000
})
db.close()
const session = await parseOpenCodeSqliteSession({
dbPath: path,
sessionId: 'ses_missing',
platform: 'darwin'
})
expect(session).toBeNull()
})
it('returns null when the DB has no session table', async () => {
const { db, path } = createTempDb()
db.exec('CREATE TABLE other (id TEXT)')
db.close()
const session = await parseOpenCodeSqliteSession({
dbPath: path,
sessionId: 'ses_1',
platform: 'darwin'
})
expect(session).toBeNull()
})
it('parses a minimal readable session table without optional columns or messages', async () => {
const { db, path } = createTempDb()
applyMinimalOpenCodeSqliteSchema(db)
db.prepare(`INSERT INTO session VALUES ('ses_minimal', 1777634000000, 1777634001000)`).run()
db.close()
const session = await parseOpenCodeSqliteSession({
dbPath: path,
sessionId: 'ses_minimal',
platform: 'darwin'
})
expect(session).not.toBeNull()
expect(session!.sessionId).toBe('ses_minimal')
expect(session!.filePath).toBe(path)
expect(session!.title).toBe('OpenCode ses_mini')
expect(session!.cwd).toBeNull()
expect(session!.model).toBeNull()
expect(session!.messageCount).toBe(0)
expect(session!.totalTokens).toBe(0)
expect(session!.previewMessages).toEqual([])
})
it('extracts model from older modelID schema', async () => {
const { db, path } = createTempDb()
applyOpenCodeSqliteSchema(db)
insertOpenCodeSession(db, {
id: 'ses_3',
timeCreated: 1_777_634_000_000,
timeUpdated: 1_777_634_001_000,
model: JSON.stringify({ modelID: 'claude-sonnet-4-5' })
})
db.close()
const session = await parseOpenCodeSqliteSession({
dbPath: path,
sessionId: 'ses_3',
platform: 'darwin'
})
expect(session).not.toBeNull()
expect(session!.model).toBe('claude-sonnet-4-5')
})
it('captures every text part of the earliest user message and no later turn', async () => {
const { db, path } = createTempDb()
applyOpenCodeSqliteSchema(db)
insertOpenCodeSession(db, {
id: 'ses_fp',
timeCreated: 1_777_634_000_000,
timeUpdated: 1_777_634_900_000
})
insertOpenCodeMessage(db, {
id: 'msg_1',
sessionId: 'ses_fp',
role: 'user',
timeCreated: 1_777_634_000_000
})
insertOpenCodePart(db, {
id: 'part_1a',
messageId: 'msg_1',
sessionId: 'ses_fp',
timeCreated: 10,
text: 'first ask line one'
})
insertOpenCodePart(db, {
id: 'part_1b',
messageId: 'msg_1',
sessionId: 'ses_fp',
timeCreated: 20,
text: 'first ask line two'
})
// Non-text parts of the same message must not leak into the copied prompt.
insertOpenCodePart(db, {
id: 'part_1c',
messageId: 'msg_1',
sessionId: 'ses_fp',
timeCreated: 30,
type: 'tool',
text: 'tool output blob'
})
insertOpenCodeMessage(db, {
id: 'msg_2',
sessionId: 'ses_fp',
role: 'user',
timeCreated: 1_777_634_500_000
})
insertOpenCodePart(db, {
id: 'part_2a',
messageId: 'msg_2',
sessionId: 'ses_fp',
timeCreated: 40,
text: 'a later ask'
})
db.close()
const session = await withFullFirstUserPromptCapture(() =>
parseOpenCodeSqliteSession({ dbPath: path, sessionId: 'ses_fp', platform: 'darwin' })
)
expect(session!.firstUserPrompt).toBe('first ask line one\nfirst ask line two')
})
it('skips an earliest user message that has no text parts', async () => {
const { db, path } = createTempDb()
applyOpenCodeSqliteSchema(db)
insertOpenCodeSession(db, {
id: 'ses_fp2',
timeCreated: 1_777_634_000_000,
timeUpdated: 1_777_634_900_000
})
insertOpenCodeMessage(db, {
id: 'msg_1',
sessionId: 'ses_fp2',
role: 'user',
timeCreated: 1_777_634_000_000
})
insertOpenCodePart(db, {
id: 'part_1a',
messageId: 'msg_1',
sessionId: 'ses_fp2',
timeCreated: 10,
type: 'tool',
text: 'tool only'
})
insertOpenCodeMessage(db, {
id: 'msg_2',
sessionId: 'ses_fp2',
role: 'user',
timeCreated: 1_777_634_500_000
})
insertOpenCodePart(db, {
id: 'part_2a',
messageId: 'msg_2',
sessionId: 'ses_fp2',
timeCreated: 40,
text: 'the real typed ask'
})
db.close()
const session = await withFullFirstUserPromptCapture(() =>
parseOpenCodeSqliteSession({ dbPath: path, sessionId: 'ses_fp2', platform: 'darwin' })
)
expect(session!.firstUserPrompt).toBe('the real typed ask')
})
it('still returns the session when a corrupt part blob breaks the preview read', async () => {
const { db, path } = createTempDb()
applyOpenCodeSqliteSchema(db)
insertOpenCodeSession(db, {
id: 'ses_badblob',
title: 'Ballast planning',
timeCreated: 1_777_634_000_000,
timeUpdated: 1_777_634_900_000
})
insertOpenCodeMessage(db, {
id: 'msg_1',
sessionId: 'ses_badblob',
role: 'user',
timeCreated: 1_777_634_000_000
})
insertOpenCodePart(db, {
id: 'part_ok',
messageId: 'msg_1',
sessionId: 'ses_badblob',
timeCreated: 10,
text: 'readable turn'
})
// Truncated JSON, so the preview query's json_extract raises rather than
// returning NULL. One corrupt row must not cost the session its listing.
db.prepare(
`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data)
VALUES ('part_bad', 'msg_1', 'ses_badblob', 20, 20, '{"type":')`
).run()
db.close()
const session = await parseOpenCodeSqliteSession({
dbPath: path,
sessionId: 'ses_badblob',
platform: 'darwin'
})
expect(session).not.toBeNull()
expect(session!.sessionId).toBe('ses_badblob')
expect(session!.title).toBe('Ballast planning')
// The preview degrades to empty rather than taking the session with it.
expect(session!.previewMessages).toEqual([])
})
})