mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 18:47:40 +00:00
Support real OpenCode sessions in native Chat (#24647)
* 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 * Update native chat settings contract for both OpenCode agents * fix(native-chat): reconcile bounded OpenCode transcript reads * fix(native-chat): dispatch OpenCode questions safely * fix(native-chat): keep native discovery and transcript windows current * feat(accounts): link standalone GLM Coding Plans (#24618) * feat(accounts): link standalone GLM Coding Plans Adapt the reviewed GLM accounts contribution to current main, retain Antigravity behavior, guard late credential results, expose storage protection, and redact quota errors. Co-authored-by: Luchong <lu740528977@gmail.com> * fix(accounts): retain GLM credential results during quota refresh * fix(accounts): make GLM credential editing desktop-only * fix(accounts): mirror the host GLM site in paired clients * fix(accounts): report unknown GLM host details and split web settings tests Apply the independently reviewed Accounts correction from697284a without the v2 adapter commits. Preserve the saved-key store and serialized write behavior. * fix(zcode): ship required GLM account translation entries * chore: record GLM reconciliation hook validation * chore: validate installed GLM commit hooks * test: complete GLM account fixtures and web API inventory --------- Co-authored-by: Luchong <lu740528977@gmail.com> * fix(native-chat): route transcript requests through shared SQLite worker * fix(ci): prevent concurrent pnpm refresh during mobile typechecks (#24776) * fix(ci): run mobile typechecks without concurrent dependency refresh * test(ci): check effective Linux E2E package list * test(ci): preserve the mobile production compiler barrier --------- Co-authored-by: Orca Integration Recovery <orca-validation@invalid.example> * test(terminal): restore the live fish fixture prerequisites (#24947) A restored pane waits for the initial status replay before subscribing to PTY output. This fixture never settled that replay, so fish printed its mode-2031 arm before the renderer connected. Its PTY API also omitted the reset-input listener required by the serializer, aborting attachment. Settle and dispose the existing startup-snapshot registration and provide the same reset-listener mock used by the other PTY tests. The real fish child-stdin assertions and timeouts remain unchanged. No production change. * fix(shortcuts): defer TUI editing chords in terminal-first mode (#24640) Restack the original focused change onto current main, preserving every owned source and test blob and the merged CI contract and journal cleanup fixes. Original-commit:f707cde14afix(shortcuts): defer TUI editing chords in terminal-first mode Original-commit:0c6348e49edocs(shortcuts): describe deferred preview terminal chords Original-commit:be62c1b6c6Align worktree history shortcut metadata with terminal conflict policy Restacked-from:be62c1b6c6Restacked-onto:f7b1f9d8be* Register supervised Qoder China and Qwen Code (#24616) * Add Qoder session history and search with real CLI coverage * Allow the real Qoder marker file to end with a newline * Keep Qoder tool output out of history previews and search * Keep Qoder search pages readable by older clients * Verify persisted Qoder history after a real generated and resumed task * Negotiate Qoder filters before searching an older execution host * Combine search client imports for the CI plugin gate * Keep the relay search oracle aligned with legacy agent filtering * Register supervised Qoder China and Qwen lifecycle integration * Cover Qoder China mobile assets and mixed-host resume gates * Verify Qoder provider tags against the older released wire parser * Verify China and Qwen keep independent Windows hook scripts * Verify Qoder registrations against the installed older Windows release * test(qoder): align search capability contracts and pin old-host fencing * fix(qoder): rank exact picker identities and command aliases first * test(qoder): preserve the regional CLI shared icon expectation Keep the full bundled-asset and no-remote-image checks, with an explicit shared-logo basename for Qoder China. The map also works with older catalog type unions. * fix(qoder): align China catalog entry with fallback order --------- Co-authored-by: Orca Integration Recovery <orca-validation@invalid.example> * Use the measured pnpm lookup policy automatically in hosted root CI (#24951) * Select lookup automatically for the measured hosted root-install profile * Record hosted automatic-mode cold cache publication proof * fix(native-chat): keep OpenCode history usable at read limits Continue past failed database probes while preserving discovery cancellation. Verify rows displaced by a capped tail before deciding whether to replace history. Represent oversized v1/v2 rows with the existing omission text and stable cursors. * Continue Antigravity IDE and 2.0 history in new CLI conversations (#24692) * feat(antigravity): bridge IDE history into new CLI conversations * fix(antigravity): preserve fresh-launch model and environment for IDE references * fix(antigravity): forward IDE history opt-in through desktop IPC * fix(antigravity): rebuild remote IDE reference startup on its host * fix(antigravity): register IDE continuation action labels * fix(antigravity): confine IDE references and bound metadata reads * fix(antigravity): localize IDE continuation badges * Preserve scanner service cache assertions and refresh Antigravity opening metadata * Preserve Antigravity opening joins and target folder runtime authority * fix(opencode): retry timed-out SSH plugin updates (#24666) Preserve bounded retry behavior and the current-main status-envelope fields. Original-PR: #24124 Reviewed-source:103144f9c5Co-authored-by: Justas Brazauskas <brazauskasjustas@gmail.com> * fix(opencode): keep Go credentials private and resolve backend keys (#24615) Preserve the complete credential storage, migration, IPC, Settings and rate-limit refresh change alongside standalone GLM plans, current-main database diagnostics and the reviewed unknown-backend environment correction. Keep native discovery cancellation third and selected environment fourth. Original-topic-commit:7903f1cddbOriginal-topic-commit:588117b5cfOriginal-topic-commit:dedd4f8c86Original-topic-commit:6645dae104Original-topic-commit: a25b80c02c1af7830b0e6a65e72d965b3ad98276 Restacked-from: a25b80c02c1af7830b0e6a65e72d965b3ad98276 Restacked-onto:b032867021Co-authored-by: kespineira <kespineira@users.noreply.github.com> Co-authored-by: kevimux <kevimux@users.noreply.github.com> Reported-by: pullfrog Reviewed-full-source: 849fe093073f4c1606bd65d79a0c725d955d0d1f Native-helper-source:80dbe23237Reviewed-full-current-source: 36acb57d44adb3d378c0289c8c15f7da0fda214c --------- Co-authored-by: mrcha033 <mrcha033@users.noreply.github.com> Co-authored-by: xodmd45-ctrl <xodmd45-ctrl@users.noreply.github.com> Co-authored-by: Luchong <lu740528977@gmail.com> Co-authored-by: Orca Integration Recovery <orca-validation@invalid.example> Co-authored-by: Justas Brazauskas <brazauskasjustas@gmail.com>
This commit is contained in:
co-authored by
Justas Brazauskas
kespineira
kevimux
mrcha033
xodmd45-ctrl
Luchong
Orca Integration Recovery
parent
4a5f00f46f
commit
a2896f5470
@@ -0,0 +1,543 @@
|
||||
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'] }
|
||||
])
|
||||
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) }
|
||||
})
|
||||
expect(
|
||||
readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'ses-1', limit: 1 })?.items[0]?.message
|
||||
.blocks
|
||||
).toEqual([
|
||||
{ type: 'text', text: 'see this' },
|
||||
{ type: 'text', text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'] }
|
||||
])
|
||||
})
|
||||
|
||||
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()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user