mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 10:37:35 +00:00
* [comment audit] Tighten watcher lifetime and file-write constraints [skip ci] * [comment audit] Clarify IPC routing and notification comments [skip ci] * [comment audit] Shorten watcher, preflight and PTY comments [skip ci] * [comment audit] Clarify PTY delivery and restore ownership [skip ci] * [comment audit] Tighten PTY environment and flow-control rationale [skip ci] * [comment audit] Clarify IPC spawn and stable-pane guarantees [skip ci] * [comment audit] Tighten PTY provider and filesystem ownership rationale [skip ci] * [comment audit] Clarify clone ownership and remote runtime recovery [skip ci] * [comment audit] Condense upload, settings and SSH lifecycle comments [skip ci] * [comment audit] Shorten shutdown, preview and worktree watcher rationale [skip ci] * [comment audit] Clarify worktree identity, remote ownership and copy limits [skip ci] * [comment audit] Shorten worktree catalog, removal and WSL routing rationale [skip ci] * [comment audit] Tighten Linear credential, pagination and export comments [skip ci] * [comment audit] Condense managed-server activation, snapshot and launch rationale [skip ci] * [comment audit] Trim runtime installation and managed-server lifecycle comments [skip ci] * [comment audit] Simplify managed tunnel, memory and menu comments [skip ci] * [comment audit] Condense integration, model catalog and attachment rationale [skip ci] * [comment audit] Shorten journal persistence, replay and queue rationale [skip ci] * [comment audit] Condense journal ordering and timeline lifecycle comments [skip ci] * [comment audit] Trim timeline and subscriber delivery explanations [skip ci] * [comment audit] Shorten Stop, child-exit and recovery contracts [skip ci] * [comment audit] Simplify handover and host recovery comments [skip ci] * [comment audit] Trim queue delivery and provider startup commentary [skip ci] * [comment audit] Clarify restart and rewind safety comments [skip ci] * [comment audit] Trim status, Stop and completion commentary [skip ci] * [comment audit] Simplify transcript lookup and visual containment comments [skip ci] * [comment audit] Trim transcript reader, watcher and WSL transport commentary [skip ci] --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
534 lines
18 KiB
TypeScript
534 lines
18 KiB
TypeScript
import { mkdtempSync, rmSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { pathToFileURL } from 'node:url'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { AGENT_SESSION_HOST_STATUS_COPY } from '../../shared/agent-session-host-status-rows'
|
|
import Database from '../sqlite/sync-database'
|
|
import {
|
|
readOpenCodeTranscriptPage,
|
|
readOpenCodeTranscriptSignal
|
|
} from './transcript-opencode-sqlite-query'
|
|
|
|
let tempDirs: string[] = []
|
|
let openDbs: Database.Database[] = []
|
|
|
|
afterEach(() => {
|
|
// Close before removal: Windows locks open databases.
|
|
for (const db of openDbs) {
|
|
db.close()
|
|
}
|
|
openDbs = []
|
|
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-native-chat-'))
|
|
tempDirs.push(dir)
|
|
const path = join(dir, 'opencode.db')
|
|
const db = new Database(path)
|
|
openDbs.push(db)
|
|
return { db, path }
|
|
}
|
|
|
|
function applySchema(db: Database.Database): void {
|
|
db.exec(`
|
|
CREATE TABLE session (id TEXT PRIMARY KEY);
|
|
CREATE TABLE message (
|
|
id TEXT PRIMARY KEY,
|
|
session_id TEXT NOT NULL,
|
|
time_created INTEGER NOT NULL,
|
|
time_updated INTEGER NOT NULL,
|
|
data TEXT NOT NULL
|
|
);
|
|
CREATE TABLE part (
|
|
id TEXT PRIMARY KEY,
|
|
message_id TEXT NOT NULL,
|
|
session_id TEXT NOT NULL,
|
|
time_updated INTEGER NOT NULL,
|
|
data TEXT NOT NULL
|
|
);
|
|
INSERT INTO session (id) VALUES ('ses-1'), ('ses-2');
|
|
`)
|
|
}
|
|
|
|
function insertMessage(
|
|
db: Database.Database,
|
|
args: { id: string; sessionId?: string; time: number; role?: string }
|
|
): void {
|
|
db.prepare(
|
|
'INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)'
|
|
).run(
|
|
args.id,
|
|
args.sessionId ?? 'ses-1',
|
|
args.time,
|
|
args.time,
|
|
JSON.stringify({ role: args.role ?? 'user' })
|
|
)
|
|
}
|
|
|
|
function insertPart(
|
|
db: Database.Database,
|
|
args: { id: string; messageId: string; sessionId?: string; time: number; data: unknown }
|
|
): void {
|
|
db.prepare(
|
|
'INSERT INTO part (id, message_id, session_id, time_updated, data) VALUES (?, ?, ?, ?, ?)'
|
|
).run(args.id, args.messageId, args.sessionId ?? 'ses-1', args.time, JSON.stringify(args.data))
|
|
}
|
|
|
|
function messageRowid(db: Database.Database, id: string): number {
|
|
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: node:sqlite rows are unknown; the SELECT projects exactly one `r` integer column.
|
|
return (db.prepare('SELECT rowid AS r FROM message WHERE id = ?').get(id) as { r: number }).r
|
|
}
|
|
|
|
describe('readOpenCodeTranscriptPage', () => {
|
|
it.each(['message', 'part'] as const)(
|
|
'omits an oversized %s without losing identity or pagination',
|
|
(table) => {
|
|
const { db, path } = createTempDb()
|
|
applySchema(db)
|
|
for (let index = 1; index <= 3; index++) {
|
|
insertMessage(db, { id: `msg-${index}`, time: index })
|
|
insertPart(db, {
|
|
id: `prt-${index}`,
|
|
messageId: `msg-${index}`,
|
|
time: index,
|
|
data: { type: 'text', text: `message ${index}` }
|
|
})
|
|
}
|
|
db.prepare(`UPDATE ${table} SET data = ? WHERE id = ?`).run(
|
|
JSON.stringify({
|
|
role: 'user',
|
|
type: 'file',
|
|
mime: 'image/png',
|
|
url: 'x'.repeat(2 * 1024 * 1024)
|
|
}),
|
|
table === 'message' ? 'msg-2' : 'prt-2'
|
|
)
|
|
const args = { dbPath: path, sessionId: 'ses-1', limit: 2 }
|
|
const page = readOpenCodeTranscriptPage(args)
|
|
expect(page?.items.map((item) => item.message.id)).toEqual(['msg-2', 'msg-3'])
|
|
expect(page?.items[0]?.message.blocks).toEqual([
|
|
{
|
|
type: 'text',
|
|
text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'],
|
|
presentation: 'history-item-too-large'
|
|
}
|
|
])
|
|
expect(page?.items[0]?.rowid).toBe(messageRowid(db, 'msg-2'))
|
|
expect(readOpenCodeTranscriptPage(args)?.items[0]?.fingerprint).toBe(
|
|
page?.items[0]?.fingerprint
|
|
)
|
|
db.prepare(`UPDATE ${table} SET time_updated = 10 WHERE id = ?`).run(
|
|
table === 'message' ? 'msg-2' : 'prt-2'
|
|
)
|
|
expect(readOpenCodeTranscriptPage(args)?.items[0]?.fingerprint).not.toBe(
|
|
page?.items[0]?.fingerprint
|
|
)
|
|
expect(
|
|
readOpenCodeTranscriptPage({
|
|
...args,
|
|
beforeMessageRowId: page?.beforeMessageRowId ?? undefined
|
|
})?.items.map((item) => item.message.id)
|
|
).toEqual(['msg-1'])
|
|
}
|
|
)
|
|
|
|
it('preserves ordinary text beside an oversized UTF-8 attachment part', () => {
|
|
const { db, path } = createTempDb()
|
|
applySchema(db)
|
|
insertMessage(db, { id: 'msg', time: 1 })
|
|
insertPart(db, {
|
|
id: 'text',
|
|
messageId: 'msg',
|
|
time: 1,
|
|
data: { type: 'text', text: 'see this' }
|
|
})
|
|
insertPart(db, {
|
|
id: 'large',
|
|
messageId: 'msg',
|
|
time: 1,
|
|
data: { type: 'file', mime: 'image/png', url: '😀'.repeat(600_000) }
|
|
})
|
|
const page = readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'ses-1', limit: 1 })
|
|
expect(page?.items.map((item) => item.message)).toMatchObject([
|
|
{ id: 'msg', role: 'user', blocks: [{ type: 'text', text: 'see this' }] },
|
|
{
|
|
id: 'msg:omission',
|
|
role: 'system',
|
|
blocks: [{ type: 'text', presentation: 'history-item-too-large' }]
|
|
}
|
|
])
|
|
expect(page?.items[0]?.rowid).toBe(page?.items[1]?.rowid)
|
|
expect(page?.items[0]?.message.transcriptOffset).toBe(page?.items[1]?.message.transcriptOffset)
|
|
})
|
|
|
|
it('returns null when the session row does not exist', () => {
|
|
const { db, path } = createTempDb()
|
|
applySchema(db)
|
|
expect(readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'missing', limit: 10 })).toBeNull()
|
|
})
|
|
|
|
it('treats a session_v2-only (OpenCode 2) DB as not-found instead of throwing', () => {
|
|
const { db, path } = createTempDb()
|
|
// A v2-only store must fail softly in the v1 reader.
|
|
db.exec('CREATE TABLE session_v2 (id TEXT PRIMARY KEY)')
|
|
expect(readOpenCodeTranscriptSignal(path, 'ses-1')).toBeNull()
|
|
expect(readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'ses-1', limit: 10 })).toBeNull()
|
|
})
|
|
|
|
it('returns the NEWEST limit messages when more history exists', () => {
|
|
const { db, path } = createTempDb()
|
|
applySchema(db)
|
|
for (let index = 1; index <= 10; index++) {
|
|
insertMessage(db, { id: `msg-${index}`, time: index })
|
|
insertPart(db, {
|
|
id: `prt-${index}`,
|
|
messageId: `msg-${index}`,
|
|
time: index,
|
|
data: { type: 'text', text: `message ${index}` }
|
|
})
|
|
}
|
|
const page = readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'ses-1', limit: 3 })
|
|
expect(page).not.toBeNull()
|
|
expect(page!.items.map((item) => item.message.id)).toEqual(['msg-8', 'msg-9', 'msg-10'])
|
|
expect(page!.hasMore).toBe(true)
|
|
expect(page!.beforeMessageRowId).toBe(page!.items[0]!.rowid)
|
|
})
|
|
|
|
it('pages strictly older messages from the raw-row cursor', () => {
|
|
const { db, path } = createTempDb()
|
|
applySchema(db)
|
|
for (let index = 1; index <= 10; index++) {
|
|
insertMessage(db, { id: `msg-${index}`, time: index })
|
|
insertPart(db, {
|
|
id: `prt-${index}`,
|
|
messageId: `msg-${index}`,
|
|
time: index,
|
|
data: { type: 'text', text: `message ${index}` }
|
|
})
|
|
}
|
|
const first = readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'ses-1', limit: 3 })
|
|
const second = readOpenCodeTranscriptPage({
|
|
dbPath: path,
|
|
sessionId: 'ses-1',
|
|
limit: 3,
|
|
beforeMessageRowId: first!.beforeMessageRowId!
|
|
})
|
|
expect(second!.items.map((item) => item.message.id)).toEqual(['msg-5', 'msg-6', 'msg-7'])
|
|
})
|
|
|
|
it('counts the limit in renderable messages, batching past non-renderable rows', () => {
|
|
const { db, path } = createTempDb()
|
|
applySchema(db)
|
|
for (let index = 1; index <= 6; index++) {
|
|
insertMessage(db, { id: `msg-${index}`, time: index })
|
|
insertPart(db, {
|
|
id: `prt-${index}`,
|
|
messageId: `msg-${index}`,
|
|
time: index,
|
|
data: index % 2 === 0 ? { type: 'text', text: `message ${index}` } : { type: 'step-start' }
|
|
})
|
|
}
|
|
const page = readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'ses-1', limit: 2 })
|
|
expect(page!.items.map((item) => item.message.id)).toEqual(['msg-4', 'msg-6'])
|
|
expect(page!.hasMore).toBe(true)
|
|
// Cursor tracks the oldest raw row, including filtered rows, so the probe survives for the next page.
|
|
expect(page!.beforeMessageRowId).toBe(messageRowid(db, 'msg-3'))
|
|
const next = readOpenCodeTranscriptPage({
|
|
dbPath: path,
|
|
sessionId: 'ses-1',
|
|
limit: 2,
|
|
beforeMessageRowId: page!.beforeMessageRowId!
|
|
})
|
|
expect(next!.items.map((item) => item.message.id)).toEqual(['msg-2'])
|
|
expect(next!.hasMore).toBe(false)
|
|
})
|
|
|
|
it('walks a fully non-renderable history to its oldest raw row without dead-ending', () => {
|
|
const { db, path } = createTempDb()
|
|
applySchema(db)
|
|
for (let index = 1; index <= 4; index++) {
|
|
insertMessage(db, { id: `msg-${index}`, time: index })
|
|
insertPart(db, {
|
|
id: `prt-${index}`,
|
|
messageId: `msg-${index}`,
|
|
time: index,
|
|
data: { type: 'step-start' }
|
|
})
|
|
}
|
|
const page = readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'ses-1', limit: 2 })
|
|
expect(page!.items).toEqual([])
|
|
expect(page!.hasMore).toBe(false)
|
|
expect(page!.beforeMessageRowId).toBe(messageRowid(db, 'msg-1'))
|
|
})
|
|
|
|
it('re-reads messages trimmed by an overshoot batch instead of skipping them', () => {
|
|
const { db, path } = createTempDb()
|
|
applySchema(db)
|
|
// Sparse first batch then dense second batch must trim without skipping overflow.
|
|
for (const index of [1, 2, 3, 5]) {
|
|
insertMessage(db, { id: `msg-${index}`, time: index })
|
|
insertPart(db, {
|
|
id: `prt-${index}`,
|
|
messageId: `msg-${index}`,
|
|
time: index,
|
|
data: { type: 'text', text: `message ${index}` }
|
|
})
|
|
}
|
|
insertMessage(db, { id: 'msg-4', time: 4 })
|
|
insertPart(db, {
|
|
id: 'prt-4',
|
|
messageId: 'msg-4',
|
|
time: 4,
|
|
data: { type: 'step-start' }
|
|
})
|
|
const seen: string[] = []
|
|
let cursor: number | null = null
|
|
for (;;) {
|
|
const page = readOpenCodeTranscriptPage({
|
|
dbPath: path,
|
|
sessionId: 'ses-1',
|
|
limit: 2,
|
|
...(cursor !== null ? { beforeMessageRowId: cursor } : {})
|
|
})
|
|
seen.push(...page!.items.map((item) => item.message.id))
|
|
if (!page!.hasMore) {
|
|
break
|
|
}
|
|
cursor = page!.beforeMessageRowId
|
|
}
|
|
// Pages arrive newest-first; preserve order within each page and include every message once.
|
|
expect(seen).toEqual(['msg-3', 'msg-5', 'msg-1', 'msg-2'])
|
|
})
|
|
|
|
it('keeps hasMore true when a trim happens on the final short batch', () => {
|
|
const { db, path } = createTempDb()
|
|
applySchema(db)
|
|
// A final batch can overshoot despite reaching table end; trimming must retain hasMore.
|
|
for (const index of [1, 2, 4]) {
|
|
insertMessage(db, { id: `msg-${index}`, time: index })
|
|
insertPart(db, {
|
|
id: `prt-${index}`,
|
|
messageId: `msg-${index}`,
|
|
time: index,
|
|
data: { type: 'text', text: `message ${index}` }
|
|
})
|
|
}
|
|
insertMessage(db, { id: 'msg-3', time: 3 })
|
|
insertPart(db, {
|
|
id: 'prt-3',
|
|
messageId: 'msg-3',
|
|
time: 3,
|
|
data: { type: 'step-start' }
|
|
})
|
|
const first = readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'ses-1', limit: 2 })
|
|
expect(first!.hasMore).toBe(true)
|
|
const second = readOpenCodeTranscriptPage({
|
|
dbPath: path,
|
|
sessionId: 'ses-1',
|
|
limit: 2,
|
|
beforeMessageRowId: first!.beforeMessageRowId!
|
|
})
|
|
expect(second!.items.map((item) => item.message.id)).toEqual(['msg-1'])
|
|
expect(second!.hasMore).toBe(false)
|
|
})
|
|
|
|
it('maps image file parts to image refs and skips non-image files', () => {
|
|
const { db, path } = createTempDb()
|
|
applySchema(db)
|
|
// Only native-platform file URLs decode to local paths; foreign paths stay opaque.
|
|
const localPath = process.platform === 'win32' ? 'C:\\Users\\u\\pic.jpg' : '/home/user/pic.jpg'
|
|
const fileUrl = pathToFileURL(localPath).href
|
|
insertMessage(db, { id: 'msg-1', time: 1, role: 'user' })
|
|
insertPart(db, {
|
|
id: 'prt-data',
|
|
messageId: 'msg-1',
|
|
time: 1,
|
|
data: {
|
|
type: 'file',
|
|
mime: 'image/png',
|
|
url: 'data:image/png;base64,AAA',
|
|
filename: 'paste.png'
|
|
}
|
|
})
|
|
insertPart(db, {
|
|
id: 'prt-file-url',
|
|
messageId: 'msg-1',
|
|
time: 1,
|
|
data: { type: 'file', mime: 'image/jpeg', url: fileUrl }
|
|
})
|
|
insertPart(db, {
|
|
id: 'prt-plain-path',
|
|
messageId: 'msg-1',
|
|
time: 1,
|
|
data: { type: 'file', mime: 'image/webp', url: '/tmp/opencode/paste.webp' }
|
|
})
|
|
insertPart(db, {
|
|
id: 'prt-pdf',
|
|
messageId: 'msg-1',
|
|
time: 1,
|
|
data: { type: 'file', mime: 'application/pdf', url: 'file:///tmp/doc.pdf' }
|
|
})
|
|
insertPart(db, {
|
|
id: 'prt-text',
|
|
messageId: 'msg-1',
|
|
time: 1,
|
|
data: { type: 'text', text: 'prompt body' }
|
|
})
|
|
|
|
const page = readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'ses-1', limit: 10 })
|
|
expect(page!.items[0]!.message.blocks).toEqual([
|
|
{ type: 'image-ref', url: 'data:image/png;base64,AAA', alt: 'paste.png' },
|
|
{ type: 'image-ref', path: localPath },
|
|
{ type: 'image-ref', path: '/tmp/opencode/paste.webp' },
|
|
{ type: 'text', text: 'prompt body' }
|
|
])
|
|
})
|
|
|
|
it('filters synthetic text parts and maps tool parts to call/result blocks', () => {
|
|
const { db, path } = createTempDb()
|
|
applySchema(db)
|
|
insertMessage(db, { id: 'msg-1', time: 1, role: 'user' })
|
|
insertPart(db, {
|
|
id: 'prt-syn',
|
|
messageId: 'msg-1',
|
|
time: 1,
|
|
data: { type: 'text', text: 'injected context', synthetic: true }
|
|
})
|
|
insertPart(db, {
|
|
id: 'prt-text',
|
|
messageId: 'msg-1',
|
|
time: 1,
|
|
data: { type: 'text', text: 'real prompt' }
|
|
})
|
|
insertMessage(db, { id: 'msg-2', time: 2, role: 'assistant' })
|
|
insertPart(db, {
|
|
id: 'prt-tool-done',
|
|
messageId: 'msg-2',
|
|
time: 2,
|
|
data: {
|
|
type: 'tool',
|
|
tool: 'bash',
|
|
callID: 'call-1',
|
|
state: { status: 'completed', input: { command: 'ls' }, output: 'file list' }
|
|
}
|
|
})
|
|
insertPart(db, {
|
|
id: 'prt-tool-pending',
|
|
messageId: 'msg-2',
|
|
time: 2,
|
|
data: { type: 'tool', tool: 'read', callID: 'call-2', state: { status: 'running' } }
|
|
})
|
|
|
|
const page = readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'ses-1', limit: 10 })
|
|
const user = page!.items.find((item) => item.message.id === 'msg-1')!
|
|
expect(user.message.blocks).toEqual([{ type: 'text', text: 'real prompt' }])
|
|
const assistant = page!.items.find((item) => item.message.id === 'msg-2')!
|
|
expect(assistant.message.blocks).toEqual([
|
|
{
|
|
type: 'tool-call',
|
|
callId: 'call-1',
|
|
name: 'bash',
|
|
state: 'completed',
|
|
input: { command: 'ls' }
|
|
},
|
|
{ type: 'tool-result', callId: 'call-1', output: 'file list' },
|
|
{ type: 'tool-call', callId: 'call-2', name: 'read', state: 'running', input: undefined }
|
|
])
|
|
})
|
|
|
|
it('surfaces a tool error string as the result output', () => {
|
|
const { db, path } = createTempDb()
|
|
applySchema(db)
|
|
insertMessage(db, { id: 'msg-1', time: 1, role: 'assistant' })
|
|
insertPart(db, {
|
|
id: 'prt-tool-error',
|
|
messageId: 'msg-1',
|
|
time: 1,
|
|
data: {
|
|
type: 'tool',
|
|
tool: 'bash',
|
|
callID: 'call-1',
|
|
state: { status: 'error', input: { command: 'ls' }, error: 'exit 1: boom' }
|
|
}
|
|
})
|
|
|
|
const page = readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'ses-1', limit: 10 })
|
|
expect(page!.items[0]!.message.blocks).toEqual([
|
|
{
|
|
type: 'tool-call',
|
|
callId: 'call-1',
|
|
name: 'bash',
|
|
state: 'failed',
|
|
input: { command: 'ls' }
|
|
},
|
|
{ type: 'tool-result', callId: 'call-1', output: 'exit 1: boom', isError: true }
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('readOpenCodeTranscriptSignal', () => {
|
|
it('aggregates counts and maxes per session', () => {
|
|
const { db, path } = createTempDb()
|
|
applySchema(db)
|
|
insertMessage(db, { id: 'msg-1', time: 1 })
|
|
insertMessage(db, { id: 'msg-2', time: 2 })
|
|
insertMessage(db, { id: 'other-1', time: 3, sessionId: 'ses-2' })
|
|
insertPart(db, { id: 'prt-1', messageId: 'msg-1', time: 10, data: { type: 'text', text: 'a' } })
|
|
insertPart(db, { id: 'prt-2', messageId: 'msg-2', time: 20, data: { type: 'text', text: 'b' } })
|
|
|
|
db.exec(
|
|
"ALTER TABLE session ADD COLUMN directory TEXT; UPDATE session SET directory = '/folder'"
|
|
)
|
|
const signal = readOpenCodeTranscriptSignal(path, 'ses-1')
|
|
expect(signal).toEqual({
|
|
cwd: '/folder',
|
|
messageCount: 2,
|
|
partCount: 2,
|
|
maxMessageRowId: 2,
|
|
maxPartTimeUpdated: 20
|
|
})
|
|
})
|
|
|
|
it('returns null when the session row does not exist', () => {
|
|
const { db, path } = createTempDb()
|
|
applySchema(db)
|
|
expect(readOpenCodeTranscriptSignal(path, 'missing')).toBeNull()
|
|
})
|
|
})
|
|
|
|
it('stops before fetching a third part batch when earlier batches exhaust the page byte budget', () => {
|
|
const { db, path } = createTempDb()
|
|
applySchema(db)
|
|
const text = 'x'.repeat(100_000)
|
|
db.exec('BEGIN')
|
|
{
|
|
for (let i = 0; i < 300; i++) {
|
|
insertMessage(db, { id: `budget-${i}`, time: i })
|
|
insertPart(db, {
|
|
id: `part-${i}`,
|
|
messageId: `budget-${i}`,
|
|
time: i,
|
|
data: { type: 'text', text }
|
|
})
|
|
}
|
|
}
|
|
db.exec('COMMIT')
|
|
const prepared = vi.spyOn(Database.prototype, 'prepare')
|
|
try {
|
|
expect(() =>
|
|
readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'ses-1', limit: 300 })
|
|
).toThrow('read limit')
|
|
const partQueries = prepared.mock.calls.filter(
|
|
([sql]) => sql.includes('FROM part') && sql.includes('message_id IN')
|
|
)
|
|
expect(partQueries).toHaveLength(2)
|
|
} finally {
|
|
prepared.mockRestore()
|
|
}
|
|
})
|