Files
orca/src/main/native-chat/transcript-opencode-subscribe.test.ts
T

345 lines
12 KiB
TypeScript

import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import Database from '../sqlite/sync-database'
import type { NativeChatMessage } from '../../shared/native-chat-types'
import {
readOpenCodeTranscriptPage,
readOpenCodeTranscriptSignal
} from './transcript-opencode-sqlite-query'
import { subscribeOpenCodeNativeChatTranscript } from './transcript-opencode-subscribe'
const fixtures: { db: Database.Database; root: string; stop: () => void }[] = []
it.each([undefined, 'zcode'] as const)(
'discovers the subscribed session in its own store (%s)',
async (agent) => {
const resolveDbPath = vi.fn(async () => null)
const subscription = subscribeOpenCodeNativeChatTranscript(
{ agent: agent ?? 'opencode', sessionId: 'second-database-session', onAppend: vi.fn() },
undefined,
{ resolveDbPath },
agent
)
subscription.unsubscribe()
expect(resolveDbPath).toHaveBeenCalledWith(
'second-database-session',
expect.any(AbortSignal),
agent
)
}
)
afterEach(() => {
for (const { db, root, stop } of fixtures.splice(0)) {
stop()
db.close()
rmSync(root, { recursive: true, force: true })
}
vi.useRealTimers()
})
function watchFixture(
version: 'v1' | 'v2',
hiddenFirst = false,
messageCount = 300,
initialLimit = 300
) {
vi.useFakeTimers()
const root = mkdtempSync(join(tmpdir(), 'orca-opencode-watch-'))
const path = join(root, 'opencode.db')
const db = new Database(path)
db.exec(
version === 'v1'
? `
CREATE TABLE session (id TEXT PRIMARY KEY);
CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, data TEXT, time_created INTEGER, time_updated INTEGER);
CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT, data TEXT, time_updated INTEGER);
INSERT INTO session VALUES ('session');
`
: `
CREATE TABLE session_v2 (id TEXT PRIMARY KEY);
CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT, type TEXT, seq INTEGER, data TEXT, time_created INTEGER, time_updated INTEGER);
INSERT INTO session_v2 VALUES ('session');
`
)
const insert = (index: number, text = `message ${index}`) => {
if (version === 'v1') {
db.prepare('INSERT INTO message VALUES (?, ?, ?, 1, 1)').run(
String(index),
'session',
'{"role":"user"}'
)
db.prepare('INSERT INTO part VALUES (?, ?, ?, ?, 1)').run(
String(index),
String(index),
'session',
JSON.stringify({ type: 'text', text })
)
} else {
db.prepare('INSERT INTO session_message VALUES (?, ?, ?, ?, ?, 1, 1)').run(
String(index),
'session',
'user',
index,
JSON.stringify({ text })
)
}
}
const update = (index: number, text: string, time = 2) => {
db.prepare(
version === 'v1'
? 'UPDATE part SET data = ?, time_updated = ? WHERE message_id = ?'
: 'UPDATE session_message SET data = ?, time_updated = ? WHERE id = ?'
).run(JSON.stringify(version === 'v1' ? { type: 'text', text } : { text }), time, String(index))
}
const remove = (index: number) => {
if (version === 'v1') {
db.prepare('DELETE FROM part WHERE message_id = ?').run(String(index))
db.prepare('DELETE FROM message WHERE id = ?').run(String(index))
} else {
db.prepare('DELETE FROM session_message WHERE id = ?').run(String(index))
}
}
const batch = (mutate: () => void) => {
db.exec('BEGIN')
try {
mutate()
db.exec('COMMIT')
} catch (error) {
db.exec('ROLLBACK')
throw error
}
}
batch(() => {
for (let index = 1; index <= messageCount; index++) {
insert(index, hiddenFirst && index === 1 ? '' : undefined)
}
})
let displayed: NativeChatMessage[] = []
const onReplace = vi.fn((messages: NativeChatMessage[]) => {
displayed = messages
})
const onAppend = vi.fn((messages: NativeChatMessage[]) => {
displayed.push(...messages)
})
const readPage = vi.fn(async (args: Parameters<typeof readOpenCodeTranscriptPage>[0]) =>
readOpenCodeTranscriptPage(args)
)
const subscription = subscribeOpenCodeNativeChatTranscript(
{
agent: 'opencode',
sessionId: 'session',
initialLimit,
resolvePollIntervalMs: 5,
onInitialSnapshot: (messages) => {
displayed = messages
},
onAppend,
onReplace
},
undefined,
{
resolveDbPath: async () => path,
readSignal: async (dbPath, sessionId) => readOpenCodeTranscriptSignal(dbPath, sessionId),
readPage
}
)
fixtures.push({ db, root, stop: subscription.unsubscribe })
return {
db,
insert,
update,
remove,
batch,
displayed: () => displayed,
onReplace,
onAppend,
readPage
}
}
describe.each(['v1', 'v2'] as const)('OpenCode %s watch reconciliation', (version) => {
it('removes the oldest displayed row when an append keeps the database count unchanged', async () => {
const f = watchFixture(version, false, 1000)
await vi.advanceTimersByTimeAsync(0)
expect(f.displayed()).toHaveLength(300)
f.batch(() => {
f.remove(701)
f.insert(1001)
})
await vi.advanceTimersByTimeAsync(10)
expect(f.displayed()).toHaveLength(300)
expect(f.displayed()[0]?.blocks).toEqual([{ type: 'text', text: 'message 702' }])
expect(f.displayed().at(-1)?.blocks).toEqual([{ type: 'text', text: 'message 1001' }])
expect(f.onReplace).toHaveBeenCalledOnce()
expect(f.readPage.mock.calls.every(([args]) => args.limit <= 2400)).toBe(true)
})
it('replaces an edit older than the newest 100 displayed messages', async () => {
const f = watchFixture(version)
await vi.advanceTimersByTimeAsync(0)
expect(f.displayed()).toHaveLength(300)
f.update(1, 'edited oldest')
await vi.advanceTimersByTimeAsync(10)
expect(f.displayed()[0]?.blocks).toEqual([{ type: 'text', text: 'edited oldest' }])
expect(f.onReplace).toHaveBeenCalledOnce()
})
it('includes an older row that becomes renderable after the emitted frontier', async () => {
const f = watchFixture(version, true)
await vi.advanceTimersByTimeAsync(0)
expect(f.displayed()).toHaveLength(299)
f.update(1, 'newly renderable')
await vi.advanceTimersByTimeAsync(10)
expect(f.displayed()[0]?.blocks).toEqual([{ type: 'text', text: 'newly renderable' }])
expect(f.displayed()).toHaveLength(300)
})
it.each([1, 300])('removes a deleted row at %s from the displayed window', async (index) => {
const f = watchFixture(version)
await vi.advanceTimersByTimeAsync(0)
f.remove(index)
await vi.advanceTimersByTimeAsync(10)
expect(f.displayed()).toHaveLength(299)
expect(
f
.displayed()
.some((message) => message.id === (version === 'v1' ? String(index) : `opencode:${index}`))
).toBe(false)
expect(f.onReplace).toHaveBeenCalledOnce()
})
it('replaces an empty session and permits a reused provider cursor to append', async () => {
const f = watchFixture(version)
await vi.advanceTimersByTimeAsync(0)
f.batch(() => {
for (let index = 1; index <= 300; index++) {
f.remove(index)
}
})
await vi.advanceTimersByTimeAsync(10)
expect(f.displayed()).toEqual([])
f.insert(1, 'after revert')
await vi.advanceTimersByTimeAsync(10)
expect(f.displayed().map((message) => message.blocks)).toEqual([
[{ type: 'text', text: 'after revert' }]
])
})
it('removes a row whose last renderable content is removed', async () => {
const f = watchFixture(version)
await vi.advanceTimersByTimeAsync(0)
f.update(1, '')
await vi.advanceTimersByTimeAsync(10)
expect(f.displayed()).toHaveLength(299)
expect(f.displayed()[0]?.blocks).toEqual([{ type: 'text', text: 'message 2' }])
})
it('continues checking previously appended history and bounds every read', async () => {
const f = watchFixture(version)
await vi.advanceTimersByTimeAsync(0)
f.batch(() => {
for (let index = 301; index <= 450; index++) {
f.insert(index)
}
})
await vi.advanceTimersByTimeAsync(10)
f.update(1, 'edited after append')
await vi.advanceTimersByTimeAsync(10)
expect(f.displayed()[0]?.blocks).toEqual([{ type: 'text', text: 'edited after append' }])
expect(f.readPage.mock.calls.every(([args]) => args.limit <= 2400)).toBe(true)
const reads = f.readPage.mock.calls.length
await vi.advanceTimersByTimeAsync(20)
expect(f.readPage).toHaveBeenCalledTimes(reads)
})
it('holds the frontier and retries when a burst cannot bridge within the read cap', async () => {
const f = watchFixture(version)
await vi.advanceTimersByTimeAsync(0)
f.batch(() => {
for (let index = 301; index <= 2800; index++) {
f.insert(index)
}
})
await vi.advanceTimersByTimeAsync(10)
expect(f.displayed()).toHaveLength(300)
expect(f.onReplace).not.toHaveBeenCalled()
const reads = f.readPage.mock.calls.length
await vi.advanceTimersByTimeAsync(10)
expect(f.readPage.mock.calls.length).toBeGreaterThan(reads)
expect(f.readPage.mock.calls.every(([args]) => args.limit <= 2400)).toBe(true)
f.batch(() => {
for (let index = 301; index <= 2800; index++) {
f.remove(index)
}
f.insert(301, 'after discarded burst')
})
await vi.advanceTimersByTimeAsync(10)
expect(f.displayed()).toHaveLength(301)
expect(f.displayed().at(-1)?.blocks).toEqual([{ type: 'text', text: 'after discarded burst' }])
})
it('keeps appending after the remembered window reaches 2400 rows', async () => {
const f = watchFixture(version)
await vi.advanceTimersByTimeAsync(0)
for (let start = 301; start <= 2400; start += 300) {
f.batch(() => {
for (let index = start; index < start + 300; index++) {
f.insert(index)
}
})
await vi.advanceTimersByTimeAsync(10)
}
f.onReplace.mockClear()
f.onAppend.mockClear()
for (let index = 2401; index <= 2403; index++) {
f.insert(index)
await vi.advanceTimersByTimeAsync(10)
}
expect(f.onReplace).not.toHaveBeenCalled()
expect(f.onAppend.mock.calls.map(([messages]) => messages.length)).toEqual([1, 1, 1])
expect(f.displayed()).toHaveLength(2403)
expect(f.readPage.mock.calls.every(([args]) => args.limit <= 2400)).toBe(true)
})
it('keeps appending after a bridged burst fills the cap', async () => {
const f = watchFixture(version)
await vi.advanceTimersByTimeAsync(0)
f.batch(() => {
for (let index = 301; index <= 2600; index++) {
f.insert(index)
}
})
await vi.advanceTimersByTimeAsync(10)
expect(f.onReplace).toHaveBeenCalledOnce()
expect(f.displayed()).toHaveLength(2400)
f.onReplace.mockClear()
f.insert(2601)
await vi.advanceTimersByTimeAsync(10)
expect(f.onReplace).not.toHaveBeenCalled()
expect(f.onAppend).toHaveBeenCalledOnce()
expect(f.displayed().at(-1)?.blocks).toEqual([{ type: 'text', text: 'message 2601' }])
})
it.each(['delete', 'hide', 'edit'] as const)(
'detects a first-tail-row %s outside the capped page during an append',
async (operation) => {
const f = watchFixture(version, false, 2500, 2400)
await vi.advanceTimersByTimeAsync(0)
f.batch(() => {
if (operation === 'delete') {
f.remove(101)
} else {
f.update(101, operation === 'hide' ? '' : 'edited first tail row')
}
f.insert(2501)
f.insert(2502)
})
await vi.advanceTimersByTimeAsync(10)
expect(f.onReplace).toHaveBeenCalledOnce()
expect(f.onAppend).not.toHaveBeenCalled()
expect(f.readPage.mock.calls.every(([args]) => args.limit <= 2400)).toBe(true)
}
)
})