import { describe, expect, it } from 'vitest'
import { spawnSync } from 'node:child_process'
import { join } from 'node:path'
// bun:sqlite is only resolvable under the Bun runtime, while the unit suite
// runs on node (vitest). Mirror the agy modules' approach: the production
// module lazy-imports bun:sqlite, and these tests drive the real module +
// a real sqlite db inside a `bun` subprocess, asserting on its JSON output.
const RUNNER = /* ts */ `
import { mkdtempSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { Database } from 'bun:sqlite'
import {
getOpencodeDbPath,
listLocalOpencodeSessionSummaries,
listLocalOpencodeSessionsWithMessagesByIds
} from './opencodeSessions'
function createDb(root) {
const db = new Database(join(root, 'opencode.db'))
db.exec('CREATE TABLE session (id TEXT PRIMARY KEY, title TEXT, directory TEXT, time_updated REAL, time_archived REAL); CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, data TEXT, time_created REAL); CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, data TEXT, time_created REAL);')
return db
}
function insertMessages(dbPath, messages, parts) {
const db = new Database(dbPath)
try {
for (const m of messages) {
db.query('INSERT INTO message (id, session_id, data, time_created) VALUES (?, ?, ?, ?)')
.run(m.id, m.sessionId, JSON.stringify({ role: m.role }), m.timeCreated)
}
let i = 0
for (const p of parts) {
i += 1
const data = Object.assign(
{ type: p.type },
p.text === undefined ? {} : { text: p.text },
p.extra ?? {}
)
db.query('INSERT INTO part (id, message_id, data, time_created) VALUES (?, ?, ?, ?)')
.run('part-' + i, p.messageId, JSON.stringify(data), i)
}
} finally {
db.close()
}
}
const scenario = process.env.SCENARIO
if (scenario === 'sessions') {
const root = mkdtempSync(join(tmpdir(), 'opencode-sessions-'))
process.env.OPENCODE_HOME = root
const dbPath = join(root, 'opencode.db')
createDb(root)
const db = new Database(dbPath)
db.query('INSERT INTO session (id, title, directory, time_updated) VALUES (?, ?, ?, ?)').run('session-1', 'Normal session', '/tmp/project', 2000)
db.query('INSERT INTO session (id, title, directory, time_updated, time_archived) VALUES (?, ?, ?, ?, ?)').run('session-2', 'Archived session', '/tmp/project', 3000, 4000)
db.query('INSERT INTO session (id, title, directory, time_updated) VALUES (?, ?, ?, ?)').run('session-3', 'Malformed', '/tmp/project', 1000)
db.close()
insertMessages(dbPath, [
{ id: 'm1-u', sessionId: 'session-1', role: 'user', timeCreated: 1100 },
{ id: 'm1-a', sessionId: 'session-1', role: 'assistant', timeCreated: 1200 },
{ id: 'm2-u', sessionId: 'session-2', role: 'user', timeCreated: 1400 }
], [
{ messageId: 'm1-a', type: 'tool' },
{ messageId: 'm1-u', type: 'text', text: 'hello world' },
{ messageId: 'm1-a', type: 'text', text: 'hi there', extra: { time: { end: 1250 } } },
{ messageId: 'm2-u', type: 'text', text: 'internal' }
])
{
const db = new Database(dbPath)
try {
// malformed message-data JSON row must be skipped silently
db.query('INSERT INTO message (id, session_id, data, time_created) VALUES (?, ?, ?, ?)')
.run('m3-u', 'session-3', '{invalid json', 1300)
db.query('INSERT INTO part (id, message_id, data, time_created) VALUES (?, ?, ?, ?)')
.run('part-m3', 'm3-u', JSON.stringify({ type: 'text', text: 'unreachable' }), 1)
} finally {
db.close()
}
}
const summaries = await listLocalOpencodeSessionSummaries()
const sessions = await listLocalOpencodeSessionsWithMessagesByIds(new Set(['session-1', 'session-2']))
console.log(JSON.stringify({ summaries, sessions }))
} else if (scenario === 'truncate') {
const root = mkdtempSync(join(tmpdir(), 'opencode-truncate-'))
process.env.OPENCODE_HOME = root
const dbPath = join(root, 'opencode.db')
createDb(root)
const db = new Database(dbPath)
db.query('INSERT INTO session (id, title, directory, time_updated) VALUES (?, ?, ?, ?)').run('session-long', 'Long', '/tmp/project', 5000)
db.close()
insertMessages(dbPath, [
{ id: 'ml-u', sessionId: 'session-long', role: 'user', timeCreated: 5100 }
], [
{ messageId: 'ml-u', type: 'text', text: 'x'.repeat(200) }
])
const summaries = await listLocalOpencodeSessionSummaries()
console.log(JSON.stringify({ summaries }))
} else if (scenario === 'streaming') {
const root = mkdtempSync(join(tmpdir(), 'opencode-streaming-'))
process.env.OPENCODE_HOME = root
const dbPath = join(root, 'opencode.db')
createDb(root)
const db = new Database(dbPath)
db.query('INSERT INTO session (id, title, directory, time_updated) VALUES (?, ?, ?, ?)').run('session-stream', 'Streaming', '/tmp/project', 6000)
db.close()
insertMessages(dbPath, [
{ id: 'ms-u', sessionId: 'session-stream', role: 'user', timeCreated: 6100 },
{ id: 'ms-a', sessionId: 'session-stream', role: 'assistant', timeCreated: 6200 }
], [
{ messageId: 'ms-u', type: 'text', text: 'question' },
{ messageId: 'ms-a', type: 'text', text: 'finished answer', extra: { time: { end: 6300 } } },
{ messageId: 'ms-a', type: 'text', text: 'partial answer being generated' }
])
const sessions = await listLocalOpencodeSessionsWithMessagesByIds(new Set(['session-stream']))
console.log(JSON.stringify({ sessions }))
} else if (scenario === 'queryfail') {
const root = mkdtempSync(join(tmpdir(), 'opencode-queryfail-'))
process.env.OPENCODE_HOME = root
const dbPath = join(root, 'opencode.db')
createDb(root)
const db = new Database(dbPath)
db.query('INSERT INTO session (id, title, directory, time_updated) VALUES (?, ?, ?, ?)').run('session-qf', 'QueryFail', '/tmp/project', 7000)
insertMessages(dbPath, [
{ id: 'mq-u', sessionId: 'session-qf', role: 'user', timeCreated: 7100 },
{ id: 'mq-a', sessionId: 'session-qf', role: 'assistant', timeCreated: 7200 }
], [
{ messageId: 'mq-u', type: 'text', text: 'question' },
{ messageId: 'mq-a', type: 'text', text: 'answer', extra: { time: { end: 7300 } } }
])
// Simulate a transient SQLite failure reading parts of later messages.
db.exec('DROP TABLE part')
db.close()
const out = await listLocalOpencodeSessionsWithMessagesByIds(new Set(['session-qf']))
console.log(JSON.stringify({ sessions: out }))
} else if (scenario === 'failclosed') {
const missingRoot = mkdtempSync(join(tmpdir(), 'opencode-missing-'))
process.env.OPENCODE_HOME = missingRoot
const noDb = {
path: getOpencodeDbPath(),
summaries: await listLocalOpencodeSessionSummaries(),
byIds: await listLocalOpencodeSessionsWithMessagesByIds(new Set(['any']))
}
const emptyRoot = mkdtempSync(join(tmpdir(), 'opencode-empty-'))
process.env.OPENCODE_HOME = emptyRoot
writeFileSync(join(emptyRoot, 'opencode.db'), '')
const emptyFile = {
summaries: await listLocalOpencodeSessionSummaries(),
byIds: await listLocalOpencodeSessionsWithMessagesByIds(new Set(['any']))
}
const partialRoot = mkdtempSync(join(tmpdir(), 'opencode-partial-'))
process.env.OPENCODE_HOME = partialRoot
const partial = new Database(join(partialRoot, 'opencode.db'))
partial.exec('CREATE TABLE session (id TEXT PRIMARY KEY);')
partial.close()
const missingTables = {
summaries: await listLocalOpencodeSessionSummaries(),
byIds: await listLocalOpencodeSessionsWithMessagesByIds(new Set(['any']))
}
console.log(JSON.stringify({ noDb, emptyFile, missingTables }))
}
`
function runScenario(name: string): Record {
const result = spawnSync('bun', ['-e', RUNNER], {
cwd: import.meta.dirname,
env: { ...process.env, SCENARIO: name },
encoding: 'utf-8'
})
if (result.status !== 0) throw new Error(`bun scenario '${name}' failed:\n${result.stderr}`)
const line = result.stdout.trim().split('\n').filter(Boolean).pop()
return JSON.parse(line ?? '{}')
}
describe('local opencode sessions', () => {
it('lists summaries with lastUserMessage rules and converts message envelopes', () => {
const output = runScenario('sessions') as any
expect(output.summaries.map((summary: any) => summary.id)).toEqual(['session-1', 'session-3'])
expect(output.summaries[0]).toMatchObject({
id: 'session-1',
title: 'Normal session',
cwd: '/tmp/project',
modifiedAt: 2_000_000,
lastUserMessage: 'hello world'
})
// tool part skipped; malformed-JSON user row yields null lastUserMessage
expect(output.summaries[1]).toMatchObject({ id: 'session-3', lastUserMessage: null })
expect(output.sessions.map((session: any) => session.id)).toEqual(['session-2', 'session-1'])
const archived = output.sessions.find((session: any) => session.id === 'session-2')
// archived sessions are loadable by ids; their '<'-prefixed user text
// is excluded from lastUserMessage but still converts to an envelope
expect(archived?.messages).toHaveLength(1)
expect(archived?.messages[0]).toMatchObject({ content: { role: 'user' } })
expect(archived?.lastUserMessage ?? null).toBe(null)
const normal = output.sessions.find((session: any) => session.id === 'session-1')
expect(normal?.messages).toHaveLength(2)
expect(normal.messages[0]).toMatchObject({
content: { role: 'user', content: { type: 'text', text: 'hello world' }, meta: { sentFrom: 'cli' } }
})
expect(normal.messages[1]).toMatchObject({
content: {
role: 'agent',
content: { type: 'codex', data: { type: 'message', message: 'hi there' } },
meta: { sentFrom: 'cli' }
}
})
expect(typeof normal.messages[0].localId).toBe('string')
})
it('truncates long last user messages to 140 chars', () => {
const output = runScenario('truncate') as any
const lastUserMessage = output.summaries[0]?.lastUserMessage as string | undefined
expect(lastUserMessage).toHaveLength(140)
expect(lastUserMessage?.endsWith('…')).toBe(true)
})
it('skips unfinished assistant text parts that are still streaming', () => {
const output = runScenario('streaming') as any
const messages = output.sessions[0]?.messages ?? []
expect(messages).toHaveLength(2)
expect(messages[0]).toMatchObject({ content: { role: 'user', content: { type: 'text', text: 'question' } } })
expect(messages[1]).toMatchObject({
content: {
role: 'agent',
content: { type: 'codex', data: { type: 'message', message: 'finished answer' } },
meta: { sentFrom: 'cli' }
}
})
})
it('never surfaces a holey partial transcript when parts are unreadable', () => {
const output = runScenario('queryfail') as any
// Missing part table short-circuits the read entirely — the caller
// sees an empty result, never a transcript with a hole in the middle.
expect(output.sessions).toEqual([])
})
it('fails closed when the db or required tables are missing', () => {
const output = runScenario('failclosed') as any
expect(output.noDb.path.endsWith(join('opencode.db'))).toBe(true)
expect(output.noDb.summaries).toEqual([])
expect(output.noDb.byIds).toEqual([])
expect(output.emptyFile.summaries).toEqual([])
expect(output.emptyFile.byIds).toEqual([])
expect(output.missingTables.summaries).toEqual([])
expect(output.missingTables.byIds).toEqual([])
})
it('rejects instead of returning a partial transcript when a middle part query fails', async () => {
const { listLocalOpencodeSessionsWithMessagesByIds } = await import('./opencodeSessions')
let partReads = 0
const stmt = (rows: unknown[]) => ({ all: () => rows, get: () => rows[0] })
const fakeDb = {
query(sql: string) {
if (/FROM part WHERE/.test(sql)) {
partReads += 1
if (partReads === 2) {
return { all: () => { throw new Error('injected part read failure') }, get: () => undefined }
}
}
if (/sqlite_master/.test(sql)) return stmt([{ name: 'x' }])
if (/FROM session WHERE id/.test(sql)) {
return stmt([{ id: 's1', title: 'T', directory: '/tmp', time_updated: 7000 }])
}
if (/FROM message WHERE session_id/.test(sql)) {
return stmt([
{ id: 'm1', data: JSON.stringify({ role: 'user' }), time_created: 7100 },
{ id: 'm2', data: JSON.stringify({ role: 'assistant' }), time_created: 7200 }
])
}
return stmt([])
},
close: () => {}
}
await expect(listLocalOpencodeSessionsWithMessagesByIds(new Set(['s1']), async () => fakeDb))
.rejects.toThrow('injected part read failure')
})
})