mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 10:37:35 +00:00
* Use bounded OpenCode context for vault session continuation OpenCode database and synthetic row paths are not text transcripts. Use the vault preview or captured pane context, preserving actual transcript paths containing a hash and supporting both OpenCode lanes and Windows paths. Adapted the intent of #11859 and extended it to actual installed v2 vault rows. Co-authored-by: mrcha033 <mrcha033@users.noreply.github.com> * Read real OpenCode sessions in terminal-backed native Chat Reuse the bounded AI Vault SQLite worker for v1 and v2 session pages and live updates. Keep terminal input as the real execution path and pace OpenCode Stop through its two-Escape interrupt. Co-authored-by: xodmd45-ctrl <xodmd45-ctrl@users.noreply.github.com> * fix(opencode): publish approval cards for permission requests * Send OpenCode native approval through its Enter selector * Resolve mobile Chat readability for folder workspaces * Bound OpenCode part batches and preserve v2 image attachments * Prefer live migrated OpenCode sessions over legacy copies * Consolidate mobile Chat eligibility test imports * Consolidate OpenCode SQLite protocol type imports * fix(native-chat): preserve OpenCode reasoning and patch parts Separate genuine reasoning from answer blocks in both native SQLite schemas and retain recorded patches as completed patch tools. Keep each database row together at page boundaries so the existing raw-row cursors cannot drop half of a mixed row. Adapted from @akhan157's OpenCode native history work in #13287 at bb661d10d716764fb472d824cd434678875b1947; retains the current bounded reader and account discovery instead of restoring the older capture and cursor implementation. Verified against genuine private installed 2.0.16 and official 1.18.30 CLI ingestion. Co-authored-by: Adnan Khan <adnank11427@gmail.com> * fix(native-chat): keep split OpenCode rows intact on desktop and mobile Preserve the native reader's bounded OpenCode row groups in paired reads and snapshot/replacement frames so a second presentation-count slice cannot drop reasoning while advancing the database cursor. Sort derived reasoning before its answer under the same provider timestamp while retaining journal order. These two boundaries were reproduced with genuine installed 2.0.16 and official 1.18.30 sessions in a hidden desktop renderer and the current mobile view over an actual authenticated encrypted pairing. Completes the semantic presentation from @akhan157's #13287 without importing its older clipping or cursor implementation. Co-authored-by: Adnan Khan <adnank11427@gmail.com> * fix(native-chat): keep reasoning and answers together in live windows * Bound OpenCode transcript RPC pages and present omission notices * Bound OpenCode transcript RPC pages and present omission notices * Bound OpenCode transcript RPC pages and present omission notices * Update native worker oversized-history notice contract --------- Co-authored-by: mrcha033 <mrcha033@users.noreply.github.com> Co-authored-by: xodmd45-ctrl <xodmd45-ctrl@users.noreply.github.com> Co-authored-by: nwparker <nwparker@users.noreply.github.com>
552 lines
19 KiB
TypeScript
552 lines
19 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(() => {
|
|
// Why: Windows keeps the file locked while the handle is open, which would
|
|
// make rmSync below fail with EPERM.
|
|
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 }
|
|
}
|
|
|
|
// The query module only touches these columns; the full production schema is
|
|
// exercised by the scanner's own suite.
|
|
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()
|
|
// Why: OpenCode 2 stores sessions in session_v2 — the v1 reader must fail
|
|
// soft like session-scanner-opencode2-sqlite-list.ts, not throw
|
|
// "no such table: session".
|
|
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()
|
|
// The probe row is the oldest extra — the newest message must survive.
|
|
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)
|
|
// Rows 1..6 alternate step-start (no blocks) / renderable — mirror of the
|
|
// forward page's batching test, walking newest-to-oldest this time.
|
|
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 })
|
|
// Two renderable messages even though they span three raw rows.
|
|
expect(page!.items.map((item) => item.message.id)).toEqual(['msg-4', 'msg-6'])
|
|
expect(page!.hasMore).toBe(true)
|
|
// Cursor names the oldest RAW scanned row (msg-3, consumed but dropped),
|
|
// so the probe row msg-2 comes back on the next page — nothing is skipped.
|
|
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)
|
|
// The raw cursor still names the oldest scanned row — a later renderable
|
|
// append is picked up instead of being swallowed by the sparse prefix.
|
|
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-then-dense shape (reviewer repro): batch 1 yields one renderable
|
|
// past a step-start row, batch 2 is dense and overshoots the budget.
|
|
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
|
|
}
|
|
// Every renderable message returns exactly once — msg-2 must not fall
|
|
// through the gap between the trimmed batch and the cursor. Pages arrive
|
|
// newest-first (the walk starts at the tail), so chronological order
|
|
// only holds inside each page.
|
|
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)
|
|
// Batch 1 collects one renderable past a step-start; batch 2 hits the
|
|
// table end (rows.length <= limit → probe says no more) while still
|
|
// overshooting — the trim itself must keep the walk alive.
|
|
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 })
|
|
// The overshoot trimmed msg-1 — that IS more history below the cursor.
|
|
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)
|
|
// A file:// URL written on the READING machine's platform decodes back to
|
|
// a local path; a foreign-platform one falls back to the opaque ref.
|
|
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 })
|
|
// Blocks follow part-row order; the PDF (non-image) renders nothing.
|
|
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' },
|
|
// A running tool captured no output yet — call only, no result block.
|
|
{ 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' } })
|
|
|
|
const signal = readOpenCodeTranscriptSignal(path, 'ses-1')
|
|
expect(signal).toEqual({
|
|
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()
|
|
}
|
|
})
|