mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
fix(native-chat): preserve ZCode store selection and transcript order
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { join } from 'node:path'
|
||||
import { homedir } from 'node:os'
|
||||
import type { OpenCodeTranscriptSignal } from './transcript-opencode-sqlite-query'
|
||||
import { WslTranscriptFsError } from './wsl-transcript-fs-error'
|
||||
|
||||
@@ -14,6 +15,7 @@ const mocks = vi.hoisted(() => ({
|
||||
),
|
||||
prepare: vi.fn(async () => []),
|
||||
configure: vi.fn(),
|
||||
access: vi.fn(async () => true),
|
||||
wslPath: vi.fn((_path: string): { distro: string; linuxPath: string } | null => null),
|
||||
readSignal: vi.fn(
|
||||
async (
|
||||
@@ -23,6 +25,7 @@ const mocks = vi.hoisted(() => ({
|
||||
)
|
||||
}))
|
||||
vi.mock('../ai-vault/cached-session-list', () => ({ getAiVaultWslHomeDirs: mocks.homes }))
|
||||
vi.mock('./wsl-transcript-fs-access', () => ({ wslGatedAccess: mocks.access }))
|
||||
vi.mock('../opencode-usage/opencode-database-discovery', () => ({
|
||||
listOpenCodeDatabases: mocks.native,
|
||||
listOpenCodeDatabasesInDirectory: mocks.directory,
|
||||
@@ -50,6 +53,54 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('OpenCode transcript owning-host database discovery', () => {
|
||||
it('reports a missing ZCode install as unavailable without a SQLite refusal', async () => {
|
||||
mocks.access.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
|
||||
await expect(
|
||||
openCodeTranscriptDefaultDeps.resolveDbPath('z-session', undefined, 'zcode')
|
||||
).resolves.toBeNull()
|
||||
expect(mocks.readSignal).not.toHaveBeenCalled()
|
||||
})
|
||||
it('opens a native ZCode session before an unresolved WSL lookup', async () => {
|
||||
vi.useFakeTimers()
|
||||
mocks.homes.mockImplementation(() => new Promise(() => {}))
|
||||
mocks.readSignal.mockResolvedValue({
|
||||
messageCount: 1,
|
||||
partCount: 1,
|
||||
maxMessageRowId: 1,
|
||||
maxPartTimeUpdated: 1
|
||||
})
|
||||
const settled = vi.fn()
|
||||
void openCodeTranscriptDefaultDeps
|
||||
.resolveDbPath('z-session', undefined, 'zcode')
|
||||
.then(settled, settled)
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
expect(settled).toHaveBeenCalledWith(join(homedir(), '.zcode', 'cli', 'db', 'db.sqlite'))
|
||||
expect(mocks.homes).not.toHaveBeenCalled()
|
||||
expect(mocks.native).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('finds a WSL ZCode session without probing OpenCode databases', async () => {
|
||||
const home = 'wsl-home'
|
||||
const dbPath = join(home, '.zcode', 'cli', 'db', 'db.sqlite')
|
||||
mocks.homes.mockResolvedValue([home])
|
||||
mocks.readSignal.mockResolvedValueOnce(null).mockResolvedValueOnce({
|
||||
messageCount: 1,
|
||||
partCount: 1,
|
||||
maxMessageRowId: 1,
|
||||
maxPartTimeUpdated: 1
|
||||
})
|
||||
await expect(
|
||||
openCodeTranscriptDefaultDeps.resolveDbPath('z-session', undefined, 'zcode')
|
||||
).resolves.toBe(dbPath)
|
||||
expect(mocks.prepare).toHaveBeenCalledWith([home])
|
||||
expect(mocks.readSignal).toHaveBeenLastCalledWith(
|
||||
{ dbPath, sessionId: 'z-session', agent: 'zcode' },
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
expect(mocks.native).not.toHaveBeenCalled()
|
||||
expect(mocks.directory).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['homes', 'directory', 'prepare'] as const)(
|
||||
'opens a matching native database without waiting for WSL %s',
|
||||
async (stage) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { homedir } from 'node:os'
|
||||
import { wslGatedAccess } from './wsl-transcript-fs-access'
|
||||
import { join } from 'node:path'
|
||||
import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
|
||||
import { getAiVaultWslHomeDirs } from '../ai-vault/cached-session-list'
|
||||
@@ -14,9 +15,7 @@ import {
|
||||
listOpenCodeDatabasesInDirectory
|
||||
} from '../opencode-usage/opencode-database-discovery'
|
||||
|
||||
// ZCode's CLI keeps its OpenCode-v1-shaped history in its own database, so a
|
||||
// zcode transcript must never be claimed by an unrelated OpenCode install (the
|
||||
// same separation the AI Vault scanner draws in session-scanner-zcode-sources).
|
||||
// ZCode sessions belong only to its own store.
|
||||
function zcodeTranscriptDatabasePaths(wslHomeDirs: readonly string[]): string[] {
|
||||
return [homedir(), ...wslHomeDirs].map((home) => join(home, '.zcode', 'cli', 'db', 'db.sqlite'))
|
||||
}
|
||||
@@ -50,10 +49,19 @@ export async function discoverOpenCodeTranscriptDatabase(
|
||||
}
|
||||
boundedSignal.throwIfAborted()
|
||||
probed.add(dbPath)
|
||||
if (!sessionId) {
|
||||
return dbPath
|
||||
}
|
||||
try {
|
||||
if (
|
||||
agent === 'zcode' &&
|
||||
!(await waitForPromiseWithSignal(
|
||||
wslGatedAccess(dbPath, 'exact', boundedSignal),
|
||||
boundedSignal
|
||||
))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
if (!sessionId) {
|
||||
return dbPath
|
||||
}
|
||||
if (
|
||||
await waitForPromiseWithSignal(
|
||||
readOpenCodeTranscriptSignalViaWorker(
|
||||
@@ -67,24 +75,32 @@ export async function discoverOpenCodeTranscriptDatabase(
|
||||
}
|
||||
} catch (error) {
|
||||
boundedSignal.throwIfAborted()
|
||||
if (
|
||||
agent === 'zcode' &&
|
||||
error instanceof Error &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
refusals.push(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
try {
|
||||
// A zcode transcript only ever lives in a ZCode database: probe those first
|
||||
// and never fall through to OpenCode installs that cannot hold the session.
|
||||
if (agent === 'zcode') {
|
||||
const homes = await waitForPromiseWithSignal(getAiVaultWslHomeDirs(), boundedSignal)
|
||||
const native = await findSession(zcodeTranscriptDatabasePaths([]))
|
||||
if (native) {
|
||||
return native
|
||||
}
|
||||
const readerRoots = homes
|
||||
if (readerRoots.length > 0) {
|
||||
const homes = (await waitForPromiseWithSignal(getAiVaultWslHomeDirs(), boundedSignal)).slice(
|
||||
0,
|
||||
32
|
||||
)
|
||||
if (homes.length > 0) {
|
||||
const readers = await waitForPromiseWithSignal(
|
||||
prepareOpenCodeWslReaders(readerRoots),
|
||||
prepareOpenCodeWslReaders(homes),
|
||||
boundedSignal
|
||||
)
|
||||
boundedSignal.throwIfAborted()
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export function pruneOpenCodeFingerprintCache(
|
||||
fingerprints: Map<number, string>,
|
||||
cap: number,
|
||||
agent?: 'zcode'
|
||||
): void {
|
||||
if (fingerprints.size <= cap) {
|
||||
return
|
||||
}
|
||||
const keys = [...fingerprints.keys()]
|
||||
const newest = agent === 'zcode' ? keys.toReversed() : keys.sort((a, b) => b - a)
|
||||
const keep = new Set(newest.slice(0, cap))
|
||||
for (const rowid of fingerprints.keys()) {
|
||||
if (!keep.has(rowid)) {
|
||||
fingerprints.delete(rowid)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { openOpenCodeDatabaseReadonly } from '../ai-vault/session-scanner-opencode-sqlite-open'
|
||||
import { zcodeVisibleMessageFilter } from '../ai-vault/session-scanner-zcode-visibility'
|
||||
import { zcodeTranscriptOrder } from '../ai-vault/session-scanner-zcode-order'
|
||||
import {
|
||||
readOpenCode2TranscriptPage,
|
||||
readOpenCode2TranscriptSignal
|
||||
@@ -16,11 +18,6 @@ import {
|
||||
} from './transcript-opencode-part-blocks'
|
||||
// Cursors are opaque provider order: SQLite rowid in v1, session sequence in v2.
|
||||
|
||||
// ZCode stores hidden bookkeeping transcripts in the same message table; the
|
||||
// same predicate the AI Vault scan applies keeps them out of chat renders.
|
||||
const ZCODE_HIDDEN_MESSAGE_FILTER =
|
||||
"AND COALESCE(json_extract(data, '$.semantics.transcriptVisibility'), 'visible') != 'hidden'"
|
||||
|
||||
export type OpenCodeTranscriptItem = {
|
||||
rowid: number
|
||||
fingerprint: string
|
||||
@@ -88,31 +85,41 @@ export function readOpenCodeTranscriptSignal(
|
||||
return null
|
||||
}
|
||||
// Aggregates without GROUP BY always yield exactly one row, so [0] is it.
|
||||
const [messageRow] = rowsOf<{ message_count: number; max_message_rowid: number }>(
|
||||
const visibility = zcodeVisibleMessageFilter(agent ?? 'opencode', 'data')
|
||||
const [messageRow] = rowsOf<{
|
||||
message_count: number
|
||||
max_message_rowid: number
|
||||
max_message_time_updated: number
|
||||
}>(
|
||||
db.prepare(
|
||||
`SELECT COUNT(*) AS message_count, COALESCE(MAX(rowid), 0) AS max_message_rowid FROM message WHERE session_id = ? ${
|
||||
agent === 'zcode' ? ZCODE_HIDDEN_MESSAGE_FILTER : ''
|
||||
}`
|
||||
`SELECT COUNT(*) AS message_count, COALESCE(MAX(rowid), 0) AS max_message_rowid,
|
||||
COALESCE(MAX(time_updated), 0) AS max_message_time_updated
|
||||
FROM message WHERE session_id = ? ${visibility}`
|
||||
),
|
||||
sessionId
|
||||
)
|
||||
const [partRow] = rowsOf<{ part_count: number; max_part_time_updated: number }>(
|
||||
db.prepare(
|
||||
`SELECT COUNT(*) AS part_count, COALESCE(MAX(time_updated), 0) AS max_part_time_updated
|
||||
FROM part WHERE session_id = ? AND message_id IN (
|
||||
SELECT id FROM message WHERE session_id = ? ${
|
||||
agent === 'zcode' ? ZCODE_HIDDEN_MESSAGE_FILTER : ''
|
||||
}
|
||||
FROM part WHERE session_id = ? ${
|
||||
agent === 'zcode'
|
||||
? `AND message_id IN (
|
||||
SELECT id FROM message WHERE session_id = ? ${visibility}
|
||||
)`
|
||||
: ''
|
||||
}`
|
||||
),
|
||||
sessionId,
|
||||
sessionId
|
||||
...(agent === 'zcode' ? [sessionId] : [])
|
||||
)
|
||||
return {
|
||||
messageCount: messageRow?.message_count ?? 0,
|
||||
partCount: partRow?.part_count ?? 0,
|
||||
maxMessageRowId: messageRow?.max_message_rowid ?? 0,
|
||||
maxPartTimeUpdated: partRow?.max_part_time_updated ?? 0
|
||||
maxPartTimeUpdated: Math.max(
|
||||
partRow?.max_part_time_updated ?? 0,
|
||||
agent === 'zcode' ? (messageRow?.max_message_time_updated ?? 0) : 0
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
db.close()
|
||||
@@ -136,16 +143,33 @@ export function readOpenCodeTranscriptPage(args: {
|
||||
return null
|
||||
}
|
||||
const limit = openCodeTranscriptPageLimit(args.limit)
|
||||
// Rank only inside the query; wire cursors retain the stable storage row identity.
|
||||
const ranked = args.agent === 'zcode'
|
||||
const orderColumn = ranked ? 'ordered.transcript_order' : 'm.rowid'
|
||||
if (
|
||||
ranked &&
|
||||
args.beforeMessageRowId !== undefined &&
|
||||
!db
|
||||
.prepare('SELECT 1 FROM message WHERE session_id = ? AND rowid = ?')
|
||||
.get(args.sessionId, args.beforeMessageRowId)
|
||||
) {
|
||||
throw new Error('Transcript pagination cursor was removed; reload the conversation')
|
||||
}
|
||||
const ordered = ranked
|
||||
? `WITH ordered AS (
|
||||
SELECT m.rowid AS storage_rowid, ROW_NUMBER() OVER (
|
||||
ORDER BY ${zcodeTranscriptOrder(db, 'zcode', 'message', 'm', 'ASC')}
|
||||
) AS transcript_order FROM message m WHERE session_id = ?
|
||||
)`
|
||||
: ''
|
||||
// The upper bound is always bound: batching advances the cursor mid-page,
|
||||
// so the statement cannot vary with `beforeMessageRowId`'s presence.
|
||||
// MAX_SAFE_INTEGER is the "from the newest row" sentinel.
|
||||
const select = db.prepare(
|
||||
`SELECT rowid AS message_rowid, id, time_created, time_updated, CASE WHEN length(CAST(data AS BLOB)) <= ${OPENCODE_TRANSCRIPT_MAX_ROW_BYTES} THEN data ELSE NULL END AS data
|
||||
FROM message
|
||||
WHERE session_id = ? AND rowid < ? ${
|
||||
args.agent === 'zcode' ? ZCODE_HIDDEN_MESSAGE_FILTER : ''
|
||||
}
|
||||
ORDER BY rowid DESC
|
||||
`${ordered} SELECT m.rowid AS message_rowid, ${orderColumn} AS transcript_order, m.id, m.time_created, m.time_updated, CASE WHEN length(CAST(m.data AS BLOB)) <= ${OPENCODE_TRANSCRIPT_MAX_ROW_BYTES} THEN m.data ELSE NULL END AS data
|
||||
FROM message m ${ranked ? 'JOIN ordered ON ordered.storage_rowid = m.rowid' : ''}
|
||||
WHERE m.session_id = ? AND ${orderColumn} < ${ranked ? 'COALESCE((SELECT transcript_order FROM ordered WHERE storage_rowid = ?), 9007199254740991)' : '?'} ${zcodeVisibleMessageFilter(args.agent ?? 'opencode')}
|
||||
ORDER BY ${orderColumn} DESC
|
||||
LIMIT ?`
|
||||
)
|
||||
const collected: OpenCodeTranscriptItem[] = []
|
||||
@@ -158,6 +182,7 @@ export function readOpenCodeTranscriptPage(args: {
|
||||
const rows = rowsWithinBudget<MessageRow>(
|
||||
select,
|
||||
rawBudget,
|
||||
...(ranked ? [args.sessionId] : []),
|
||||
args.sessionId,
|
||||
cursor ?? Number.MAX_SAFE_INTEGER,
|
||||
limit + 1
|
||||
@@ -173,7 +198,7 @@ export function readOpenCodeTranscriptPage(args: {
|
||||
hasMore = rows.length > limit
|
||||
const selected = hasMore ? rows.slice(0, limit) : rows
|
||||
cursor = selected.at(-1)!.message_rowid
|
||||
const mapped = mapMessageRows(db, args.sessionId, selected, rawBudget)
|
||||
const mapped = mapMessageRows(db, args.sessionId, selected, rawBudget, args.agent)
|
||||
pageBytes += Buffer.byteLength(JSON.stringify(mapped))
|
||||
if (pageBytes > 16 * 1024 * 1024) {
|
||||
throw new Error('OpenCode transcript page exceeds its byte limit')
|
||||
@@ -206,6 +231,7 @@ export function readOpenCodeTranscriptPage(args: {
|
||||
|
||||
type MessageRow = {
|
||||
message_rowid: number
|
||||
transcript_order: number
|
||||
id: string
|
||||
time_created: number
|
||||
time_updated: number
|
||||
@@ -222,7 +248,8 @@ function mapMessageRows(
|
||||
db: SyncDatabase,
|
||||
sessionId: string,
|
||||
rows: MessageRow[],
|
||||
rawBudget: { bytes: number }
|
||||
rawBudget: { bytes: number },
|
||||
agent?: 'zcode'
|
||||
): OpenCodeTranscriptItem[] {
|
||||
if (rows.length === 0) {
|
||||
return []
|
||||
@@ -234,9 +261,9 @@ function mapMessageRows(
|
||||
const placeholders = batch.map(() => '?').join(', ')
|
||||
const partRows = rowsWithinBudget<PartRow>(
|
||||
db.prepare(
|
||||
`SELECT message_id, time_updated, CASE WHEN length(CAST(data AS BLOB)) <= ${OPENCODE_TRANSCRIPT_MAX_ROW_BYTES} THEN data ELSE NULL END AS data FROM part
|
||||
`SELECT message_id, time_updated, CASE WHEN length(CAST(data AS BLOB)) <= ${OPENCODE_TRANSCRIPT_MAX_ROW_BYTES} THEN data ELSE NULL END AS data FROM part p
|
||||
WHERE session_id = ? AND message_id IN (${placeholders})
|
||||
ORDER BY rowid LIMIT 10001`
|
||||
ORDER BY ${agent === 'zcode' ? zcodeTranscriptOrder(db, agent, 'part', 'p', 'ASC') : 'rowid'} LIMIT 10001`
|
||||
),
|
||||
rawBudget,
|
||||
sessionId,
|
||||
@@ -273,7 +300,11 @@ function mapMessageRows(
|
||||
items.push({
|
||||
rowid: row.message_rowid,
|
||||
fingerprint: `${row.time_updated}:${partList.length}:${maxPartTimeUpdated(partList)}`,
|
||||
message: { ...message, transcriptOffset: row.message_rowid }
|
||||
message: {
|
||||
...message,
|
||||
transcriptOffset: row.message_rowid,
|
||||
...(agent === 'zcode' ? { transcriptOrder: row.transcript_order } : {})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,24 @@ import {
|
||||
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()
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { pruneOpenCodeFingerprintCache } from './transcript-opencode-fingerprint-window'
|
||||
export { pruneOpenCodeFingerprintCache as pruneOpenCodeFingerprintCacheForTest } from './transcript-opencode-fingerprint-window'
|
||||
import { errorMessage } from '../ai-vault/session-scanner-values'
|
||||
import {
|
||||
DESKTOP_READ_WINDOW,
|
||||
@@ -6,7 +8,7 @@ import {
|
||||
type SubscribeNativeChatTranscriptArgs
|
||||
} from './transcript-watch-contract'
|
||||
import type { OpenCodeTranscriptItem } from './transcript-opencode-sqlite-query'
|
||||
import { bindOpenCodeTranscriptDeps, type OpenCodeTranscriptDeps } from './transcript-opencode'
|
||||
import { openCodeTranscriptDefaultDeps, type OpenCodeTranscriptDeps } from './transcript-opencode'
|
||||
import {
|
||||
openCodeTranscriptPageLimit,
|
||||
OPENCODE_TRANSCRIPT_MAX_WINDOW
|
||||
@@ -14,30 +16,21 @@ import {
|
||||
|
||||
const OPENCODE_POLL_MS = 1_000
|
||||
|
||||
function pruneOpenCodeFingerprintCache(fingerprints: Map<number, string>, cap: number): void {
|
||||
if (fingerprints.size <= cap) {
|
||||
return
|
||||
}
|
||||
const keep = new Set([...fingerprints.keys()].sort((a, b) => b - a).slice(0, cap))
|
||||
for (const rowid of fingerprints.keys()) {
|
||||
if (!keep.has(rowid)) {
|
||||
fingerprints.delete(rowid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const pruneOpenCodeFingerprintCacheForTest = pruneOpenCodeFingerprintCache
|
||||
|
||||
export function subscribeOpenCodeNativeChatTranscript(
|
||||
args: SubscribeNativeChatTranscriptArgs,
|
||||
setupSignal?: AbortSignal,
|
||||
deps: OpenCodeTranscriptDeps = {},
|
||||
// When 'zcode': discovery probes ZCode's database, reads drop hidden rows.
|
||||
agent?: 'zcode'
|
||||
): NativeChatTranscriptSubscription {
|
||||
setupSignal?.throwIfAborted()
|
||||
const controller = new AbortController()
|
||||
const bound = bindOpenCodeTranscriptDeps(deps, controller.signal, agent)
|
||||
const resolveDbPath = deps.resolveDbPath ?? openCodeTranscriptDefaultDeps.resolveDbPath
|
||||
const readSignal = deps.readSignal ?? openCodeTranscriptDefaultDeps.readSignal
|
||||
const readPage = (page: Parameters<NonNullable<OpenCodeTranscriptDeps['readPage']>>[0]) =>
|
||||
(deps.readPage ?? openCodeTranscriptDefaultDeps.readPage)(
|
||||
{ ...page, ...(agent ? { agent } : {}) },
|
||||
controller.signal
|
||||
)
|
||||
const pollMs = args.resolvePollIntervalMs ?? OPENCODE_POLL_MS
|
||||
const initialLimit = openCodeTranscriptPageLimit(
|
||||
args.initialLimit && args.initialLimit > 0 ? args.initialLimit : DESKTOP_READ_WINDOW
|
||||
@@ -107,7 +100,7 @@ export function subscribeOpenCodeNativeChatTranscript(
|
||||
return
|
||||
}
|
||||
try {
|
||||
dbPath ??= await bound.resolveDbPath()
|
||||
dbPath ??= await resolveDbPath(args.sessionId, controller.signal, agent)
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
@@ -116,7 +109,7 @@ export function subscribeOpenCodeNativeChatTranscript(
|
||||
scheduleTick()
|
||||
return
|
||||
}
|
||||
const signal = await bound.readSignal(dbPath, args.sessionId)
|
||||
const signal = await readSignal(dbPath, args.sessionId, controller.signal, agent)
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
@@ -131,7 +124,7 @@ export function subscribeOpenCodeNativeChatTranscript(
|
||||
scheduleTick()
|
||||
return
|
||||
}
|
||||
const page = await bound.readPage({
|
||||
const page = await readPage({
|
||||
dbPath,
|
||||
sessionId: args.sessionId,
|
||||
limit: firstSnapshot
|
||||
@@ -165,6 +158,17 @@ export function subscribeOpenCodeNativeChatTranscript(
|
||||
scheduleTick()
|
||||
return
|
||||
}
|
||||
// Imported rows can precede the frontier regardless of their storage rowid.
|
||||
if (agent === 'zcode') {
|
||||
if (
|
||||
await replaceWithBridgedWindow(dbPath, page, signal.messageCount < lastCounts.messages)
|
||||
) {
|
||||
lastSignal = fingerprint
|
||||
lastCounts = { messages: signal.messageCount, parts: signal.partCount }
|
||||
}
|
||||
scheduleTick()
|
||||
return
|
||||
}
|
||||
let changed = page.items.some(
|
||||
(item) =>
|
||||
item.rowid <= lastEmittedRowId && fingerprints.get(item.rowid) !== item.fingerprint
|
||||
@@ -183,7 +187,7 @@ export function subscribeOpenCodeNativeChatTranscript(
|
||||
const displaced = [...fingerprints.keys()].filter((rowid) => rowid < oldest)
|
||||
if (displaced.length > 0) {
|
||||
// Verify the displaced fringe: cap eviction and a real deletion look identical in the tail.
|
||||
const older = await bound.readPage({
|
||||
const older = await readPage({
|
||||
dbPath,
|
||||
sessionId: args.sessionId,
|
||||
limit: displaced.length,
|
||||
@@ -246,7 +250,7 @@ export function subscribeOpenCodeNativeChatTranscript(
|
||||
lastEmittedRowId = item.rowid
|
||||
}
|
||||
}
|
||||
pruneOpenCodeFingerprintCache(fingerprints, OPENCODE_TRANSCRIPT_MAX_WINDOW)
|
||||
pruneOpenCodeFingerprintCache(fingerprints, OPENCODE_TRANSCRIPT_MAX_WINDOW, agent)
|
||||
windowLimit = Math.max(windowLimit, fingerprints.size)
|
||||
}
|
||||
|
||||
@@ -257,14 +261,14 @@ export function subscribeOpenCodeNativeChatTranscript(
|
||||
// overlap, so the gap retries instead of dropping rows.
|
||||
async function replaceWithBridgedWindow(
|
||||
db: string,
|
||||
page: Awaited<ReturnType<typeof bound.readPage>>,
|
||||
page: Awaited<ReturnType<typeof readPage>>,
|
||||
shrinking = false
|
||||
): Promise<boolean> {
|
||||
let limit = Math.max(windowLimit, page?.items.length ?? 0)
|
||||
for (;;) {
|
||||
const replacement =
|
||||
page ??
|
||||
(await bound.readPage({
|
||||
(await readPage({
|
||||
dbPath: db,
|
||||
sessionId: args.sessionId,
|
||||
limit
|
||||
@@ -283,7 +287,11 @@ export function subscribeOpenCodeNativeChatTranscript(
|
||||
emitSafely(() => args.onReplace?.([], hasMore, before), 'replace')
|
||||
return true
|
||||
}
|
||||
if (shrinking || oldest <= lastEmittedRowId || !replacement.hasMore) {
|
||||
const overlaps =
|
||||
agent === 'zcode'
|
||||
? replacement.items.some((item) => fingerprints.has(item.rowid))
|
||||
: oldest <= lastEmittedRowId
|
||||
if (shrinking || overlaps || !replacement.hasMore) {
|
||||
fingerprints.clear()
|
||||
lastEmittedRowId = 0
|
||||
rememberItems(replacement.items)
|
||||
|
||||
@@ -66,6 +66,22 @@ describe('readOpenCodeNativeChatTranscriptFull', () => {
|
||||
expect(result).toEqual({ error: 'Transcript unavailable', notFound: true })
|
||||
})
|
||||
|
||||
it('reads the selected ZCode store on every full-read page', async () => {
|
||||
const resolveDbPath = vi.fn(async () => '/fake/zcode.db')
|
||||
const readPage = vi.fn(async () => ({ items: [], hasMore: false, beforeMessageRowId: null }))
|
||||
await readOpenCodeNativeChatTranscriptFull(
|
||||
'z-session',
|
||||
{ resolveDbPath, readPage },
|
||||
undefined,
|
||||
'zcode'
|
||||
)
|
||||
expect(resolveDbPath).toHaveBeenCalledWith('z-session', undefined, 'zcode')
|
||||
expect(readPage).toHaveBeenCalledWith(
|
||||
{ dbPath: '/fake/zcode.db', sessionId: 'z-session', limit: 500, agent: 'zcode' },
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('returns a retryable error when DB discovery throws', async () => {
|
||||
const result = await readOpenCodeNativeChatTranscriptFull('ses-1', {
|
||||
resolveDbPath: async () => {
|
||||
|
||||
@@ -54,40 +54,6 @@ export const openCodeTranscriptDefaultDeps: Required<OpenCodeTranscriptDeps> = {
|
||||
readPage: (args, signal) => readOpenCodeTranscriptPageViaWorker(args, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a subscribe's read legs against its own abort signal, with the agent
|
||||
* tag (when any) already applied to every leg. Kept here so the poll loop stays
|
||||
* free of default-vs-override plumbing.
|
||||
*/
|
||||
export function bindOpenCodeTranscriptDeps(
|
||||
deps: OpenCodeTranscriptDeps,
|
||||
signal: AbortSignal,
|
||||
agent?: 'zcode'
|
||||
): {
|
||||
resolveDbPath: () => Promise<string | null>
|
||||
readSignal: (dbPath: string, sessionId: string) => Promise<OpenCodeTranscriptSignal | null>
|
||||
readPage: (
|
||||
page: Parameters<NonNullable<OpenCodeTranscriptDeps['readPage']>>[0]
|
||||
) => Promise<OpenCodeTranscriptPage | null>
|
||||
} {
|
||||
return {
|
||||
resolveDbPath: () =>
|
||||
(deps.resolveDbPath ?? openCodeTranscriptDefaultDeps.resolveDbPath)(undefined, signal, agent),
|
||||
readSignal: (dbPath, sessionId) =>
|
||||
(deps.readSignal ?? openCodeTranscriptDefaultDeps.readSignal)(
|
||||
dbPath,
|
||||
sessionId,
|
||||
signal,
|
||||
agent
|
||||
),
|
||||
readPage: (page) =>
|
||||
(deps.readPage ?? openCodeTranscriptDefaultDeps.readPage)(
|
||||
{ ...page, ...(agent ? { agent } : {}) },
|
||||
signal
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export type OpenCodeTailResult =
|
||||
| { messages: NativeChatMessage[]; hasMore: boolean; beforeOffset: number }
|
||||
| { error: string; notFound?: true }
|
||||
@@ -144,7 +110,8 @@ export async function readOpenCodeNativeChatTranscriptFull(
|
||||
try {
|
||||
const dbPath = await (deps.resolveDbPath ?? openCodeTranscriptDefaultDeps.resolveDbPath)(
|
||||
sessionId,
|
||||
signal
|
||||
signal,
|
||||
agent
|
||||
)
|
||||
if (!dbPath) {
|
||||
return { error: 'Transcript unavailable', notFound: true }
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, 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 {
|
||||
readOpenCodeNativeChatTranscriptFull,
|
||||
readOpenCodeNativeChatTranscriptTail
|
||||
} from './transcript-opencode'
|
||||
import { subscribeOpenCodeNativeChatTranscript } from './transcript-opencode-subscribe'
|
||||
import { handleOpenCodeSqliteRequest } from '../ai-vault/session-scanner-opencode-sqlite-dispatch'
|
||||
|
||||
const cleanups: (() => void)[] = []
|
||||
afterEach(() => {
|
||||
for (const cleanup of cleanups.splice(0).toReversed()) {
|
||||
cleanup()
|
||||
}
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function fixture(sequenced = true) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-zcode-transcript-'))
|
||||
const dbPath = join(root, 'db.sqlite')
|
||||
const db = new Database(dbPath)
|
||||
cleanups.push(() => {
|
||||
db.close()
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
db.exec(`
|
||||
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${sequenced ? ', sequence INTEGER' : ''});
|
||||
CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT, data TEXT, time_created INTEGER, time_updated INTEGER${sequenced ? ', sequence INTEGER' : ''});
|
||||
INSERT INTO session VALUES ('session');
|
||||
`)
|
||||
const insert = (id: string, sequence: number | null, visibility?: string) => {
|
||||
const values = [
|
||||
id,
|
||||
'session',
|
||||
JSON.stringify({ role: 'user', semantics: { transcriptVisibility: visibility } }),
|
||||
1,
|
||||
1
|
||||
]
|
||||
db.prepare(`INSERT INTO message VALUES (?, ?, ?, ?, ?${sequenced ? ', ?' : ''})`).run(
|
||||
...values,
|
||||
...(sequenced ? [sequence] : [])
|
||||
)
|
||||
db.prepare(`INSERT INTO part VALUES (?, ?, ?, ?, 1, 1${sequenced ? ', ?' : ''})`).run(
|
||||
id,
|
||||
id,
|
||||
'session',
|
||||
JSON.stringify({ type: 'text', text: id }),
|
||||
...(sequenced ? [0] : [])
|
||||
)
|
||||
}
|
||||
const deps = {
|
||||
resolveDbPath: vi.fn(async () => dbPath),
|
||||
readSignal: async (path: string, sessionId: string, _signal?: AbortSignal, agent?: 'zcode') =>
|
||||
readOpenCodeTranscriptSignal(path, sessionId, agent),
|
||||
readPage: async (args: Parameters<typeof readOpenCodeTranscriptPage>[0]) =>
|
||||
readOpenCodeTranscriptPage(args)
|
||||
}
|
||||
return { db, dbPath, insert, deps }
|
||||
}
|
||||
|
||||
it.each([true, false])(
|
||||
'filters hidden messages through full, tail and worker reads (sequence=%s)',
|
||||
async (sequenced) => {
|
||||
const f = fixture(sequenced)
|
||||
f.insert('legacy-visible', 0)
|
||||
f.insert('hidden', 1, 'hidden')
|
||||
f.insert('visible', 2, 'visible')
|
||||
expect(readOpenCodeTranscriptSignal(f.dbPath, 'session', 'zcode')).toMatchObject({
|
||||
messageCount: 2,
|
||||
partCount: 2
|
||||
})
|
||||
expect(readOpenCodeTranscriptSignal(f.dbPath, 'session')).toMatchObject({
|
||||
messageCount: 3,
|
||||
partCount: 3
|
||||
})
|
||||
const full = await readOpenCodeNativeChatTranscriptFull('session', f.deps, undefined, 'zcode')
|
||||
expect(full).toMatchObject({ messages: [{ id: 'legacy-visible' }, { id: 'visible' }] })
|
||||
const tail = await readOpenCodeNativeChatTranscriptTail(
|
||||
{ sessionId: 'session', limit: 1, agent: 'zcode' },
|
||||
f.deps
|
||||
)
|
||||
expect(tail).toMatchObject({ messages: [{ id: 'visible' }], hasMore: true })
|
||||
const response = await handleOpenCodeSqliteRequest({
|
||||
id: 1,
|
||||
kind: 'native-page',
|
||||
dbPath: f.dbPath,
|
||||
sessionId: 'session',
|
||||
limit: 10,
|
||||
agent: 'zcode'
|
||||
})
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
value: { items: [{ message: { id: 'legacy-visible' } }, { message: { id: 'visible' } }] }
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('paginates imported messages and parts by their recorded sequence', async () => {
|
||||
const f = fixture()
|
||||
f.insert('last', 2)
|
||||
f.insert('first', 0)
|
||||
f.insert('hidden', 1, 'hidden')
|
||||
f.insert('second', 1)
|
||||
f.insert('legacy-null', null)
|
||||
f.db.prepare('UPDATE part SET sequence = 1 WHERE id = ?').run('second')
|
||||
f.db
|
||||
.prepare('INSERT INTO part VALUES (?, ?, ?, ?, 1, 1, 0)')
|
||||
.run(
|
||||
'earlier-part',
|
||||
'second',
|
||||
'session',
|
||||
JSON.stringify({ type: 'text', text: 'earlier part' })
|
||||
)
|
||||
const full = await readOpenCodeNativeChatTranscriptFull('session', f.deps, undefined, 'zcode')
|
||||
expect(full).toMatchObject({
|
||||
messages: [
|
||||
{ id: 'first' },
|
||||
{ id: 'second', blocks: [{ text: 'earlier part' }, { text: 'second' }] },
|
||||
{ id: 'last' },
|
||||
{ id: 'legacy-null' }
|
||||
]
|
||||
})
|
||||
const collected: string[] = []
|
||||
let cursor: number | undefined
|
||||
for (let index = 0; index < 5; index++) {
|
||||
const page = readOpenCodeTranscriptPage({
|
||||
dbPath: f.dbPath,
|
||||
sessionId: 'session',
|
||||
limit: 1,
|
||||
agent: 'zcode',
|
||||
beforeMessageRowId: cursor
|
||||
})
|
||||
collected.unshift(...(page?.items.map((item) => item.message.id) ?? []))
|
||||
if (!page?.hasMore) {
|
||||
break
|
||||
}
|
||||
cursor = page.beforeMessageRowId ?? undefined
|
||||
}
|
||||
expect(collected).toEqual(['first', 'second', 'last', 'legacy-null'])
|
||||
})
|
||||
|
||||
it('reconciles visibility changes, ordered imports and deletions in a live chat', async () => {
|
||||
vi.useFakeTimers()
|
||||
const f = fixture()
|
||||
f.insert('last', 2)
|
||||
f.insert('first', 0)
|
||||
f.insert('hidden', 1, 'hidden')
|
||||
let displayed: NativeChatMessage[] = []
|
||||
const subscription = subscribeOpenCodeNativeChatTranscript(
|
||||
{
|
||||
agent: 'zcode',
|
||||
sessionId: 'session',
|
||||
resolvePollIntervalMs: 5,
|
||||
onInitialSnapshot: (messages) => {
|
||||
displayed = messages
|
||||
},
|
||||
onReplace: (messages) => {
|
||||
displayed = messages
|
||||
},
|
||||
onAppend: (messages) => {
|
||||
displayed.push(...messages)
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
f.deps,
|
||||
'zcode'
|
||||
)
|
||||
cleanups.push(subscription.unsubscribe)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(displayed.map((message) => message.id)).toEqual(['first', 'last'])
|
||||
f.db
|
||||
.prepare('UPDATE message SET data = ?, time_updated = 2 WHERE id = ?')
|
||||
.run(JSON.stringify({ role: 'user' }), 'hidden')
|
||||
await vi.advanceTimersByTimeAsync(5)
|
||||
expect(displayed.map((message) => message.id)).toEqual(['first', 'hidden', 'last'])
|
||||
f.insert('middle', 1)
|
||||
await vi.advanceTimersByTimeAsync(5)
|
||||
expect(displayed.map((message) => message.id)).toEqual(['first', 'hidden', 'middle', 'last'])
|
||||
f.db.prepare('DELETE FROM message WHERE id = ?').run('first')
|
||||
f.db.prepare('DELETE FROM part WHERE message_id = ?').run('first')
|
||||
await vi.advanceTimersByTimeAsync(5)
|
||||
expect(displayed.map((message) => message.id)).toEqual(['hidden', 'middle', 'last'])
|
||||
})
|
||||
|
||||
it('keeps a paging cursor stable when earlier messages are removed or imported', () => {
|
||||
const f = fixture()
|
||||
f.insert('last', 3)
|
||||
f.insert('first', 0)
|
||||
f.insert('middle', 2)
|
||||
const newest = readOpenCodeTranscriptPage({
|
||||
dbPath: f.dbPath,
|
||||
sessionId: 'session',
|
||||
limit: 1,
|
||||
agent: 'zcode'
|
||||
})
|
||||
f.db.prepare('DELETE FROM message WHERE id = ?').run('first')
|
||||
f.db.prepare('DELETE FROM part WHERE message_id = ?').run('first')
|
||||
f.insert('imported', 1)
|
||||
const older = readOpenCodeTranscriptPage({
|
||||
dbPath: f.dbPath,
|
||||
sessionId: 'session',
|
||||
limit: 10,
|
||||
agent: 'zcode',
|
||||
beforeMessageRowId: newest?.beforeMessageRowId ?? undefined
|
||||
})
|
||||
expect(older?.items.map((item) => item.message.id)).toEqual(['imported', 'middle'])
|
||||
})
|
||||
|
||||
it('observes a balanced visibility swap even when part counts and times stay unchanged', async () => {
|
||||
vi.useFakeTimers()
|
||||
const f = fixture()
|
||||
f.insert('first', 0)
|
||||
f.insert('hidden', 1, 'hidden')
|
||||
f.insert('last', 2)
|
||||
let displayed: NativeChatMessage[] = []
|
||||
const subscription = subscribeOpenCodeNativeChatTranscript(
|
||||
{
|
||||
agent: 'zcode',
|
||||
sessionId: 'session',
|
||||
resolvePollIntervalMs: 5,
|
||||
onInitialSnapshot: (messages) => {
|
||||
displayed = messages
|
||||
},
|
||||
onReplace: (messages) => {
|
||||
displayed = messages
|
||||
},
|
||||
onAppend: (messages) => {
|
||||
displayed.push(...messages)
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
f.deps,
|
||||
'zcode'
|
||||
)
|
||||
cleanups.push(subscription.unsubscribe)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
f.db
|
||||
.prepare('UPDATE message SET data = ?, time_updated = 2 WHERE id = ?')
|
||||
.run(JSON.stringify({ role: 'user', semantics: { transcriptVisibility: 'hidden' } }), 'first')
|
||||
f.db
|
||||
.prepare('UPDATE message SET data = ?, time_updated = 2 WHERE id = ?')
|
||||
.run(JSON.stringify({ role: 'user' }), 'hidden')
|
||||
await vi.advanceTimersByTimeAsync(5)
|
||||
expect(displayed.map((message) => message.id)).toEqual(['hidden', 'last'])
|
||||
})
|
||||
@@ -15,6 +15,7 @@ function oversizedRow(message: NativeChatMessage): NativeChatMessage {
|
||||
blocks: [{ type: 'text', text, presentation }],
|
||||
timestamp: message.timestamp,
|
||||
source: message.source,
|
||||
...(message.transcriptOrder !== undefined ? { transcriptOrder: message.transcriptOrder } : {}),
|
||||
...(message.transcriptOffset !== undefined
|
||||
? { transcriptOffset: message.transcriptOffset }
|
||||
: {})
|
||||
|
||||
@@ -46,13 +46,7 @@ export function resolveNativeChatTranscriptAgent(
|
||||
if (agent === 'claude' || agent === 'openclaude') {
|
||||
return 'claude'
|
||||
}
|
||||
if (agent === 'opencode' || agent === 'opencode2') {
|
||||
return 'opencode'
|
||||
}
|
||||
// Why: ZCode's CLI stores an OpenCode-v1-shaped history in its own SQLite DB
|
||||
// (~/.zcode/cli/db/db.sqlite), so its transcripts read through the same
|
||||
// OpenCode SQLite reader as OpenCode 1.x — the agent identity stays zcode.
|
||||
if (agent === 'zcode') {
|
||||
if (agent === 'opencode' || agent === 'opencode2' || agent === 'zcode') {
|
||||
return 'opencode'
|
||||
}
|
||||
if (agent === 'codex' || agent === 'grok' || agent === 'omp') {
|
||||
|
||||
@@ -44,3 +44,18 @@ it('keeps timestamp and journal authority ahead of a derived reasoning key', ()
|
||||
laterReasoning.timestamp = 0
|
||||
expect(compareNativeChatTranscriptMessages(answer, laterReasoning)).toBeLessThan(0)
|
||||
})
|
||||
|
||||
it('preserves a provider sequence ahead of timestamps and keeps split reasoning beside its answer', () => {
|
||||
const messages = [
|
||||
row('pending-send', 'user', 50),
|
||||
{ ...row('a', 'assistant', 0), transcriptOrder: 2 },
|
||||
{ ...row('z', 'assistant', 100), transcriptOrder: 1 },
|
||||
{ ...row('z:reasoning', 'reasoning', 100), transcriptOrder: 1 }
|
||||
]
|
||||
expect(projectNativeChatTranscriptMessages(messages).map((message) => message.id)).toEqual([
|
||||
'z:reasoning',
|
||||
'z',
|
||||
'a',
|
||||
'pending-send'
|
||||
])
|
||||
})
|
||||
|
||||
@@ -54,6 +54,16 @@ export function compareNativeChatTranscriptMessages(
|
||||
if (a.journalPosition || b.journalPosition) {
|
||||
return a.journalPosition ? -1 : 1
|
||||
}
|
||||
if (
|
||||
a.transcriptOrder !== undefined &&
|
||||
b.transcriptOrder !== undefined &&
|
||||
a.transcriptOrder !== b.transcriptOrder
|
||||
) {
|
||||
return a.transcriptOrder - b.transcriptOrder
|
||||
}
|
||||
if ((a.transcriptOrder === undefined) !== (b.transcriptOrder === undefined)) {
|
||||
return a.transcriptOrder === undefined ? 1 : -1
|
||||
}
|
||||
return compareNativeChatMessagesByTime(a, b)
|
||||
}
|
||||
|
||||
|
||||
@@ -219,6 +219,8 @@ export type NativeChatMessage = AgentJournalProducerLinkage & {
|
||||
source: NativeChatSource
|
||||
/** Optional provider row cursor; split projections share it for whole-row paging. */
|
||||
transcriptOffset?: number
|
||||
/** Display order when the provider's sequence differs from its stable paging cursor. */
|
||||
transcriptOrder?: number
|
||||
/** Model id that produced an assistant response, as the provider API names it. */
|
||||
model?: string
|
||||
/** The agent's provider that served `model`, where the agent records one. */
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import { waitForActivePaneHookDescriptor, waitForActiveTerminalManager } from './helpers/terminal'
|
||||
import {
|
||||
enableNativeChatSetting,
|
||||
toggleTerminalTabToChatView
|
||||
} from './helpers/native-chat-transcript-fixture'
|
||||
|
||||
test('ZCode native chat reads ordered visible history and follows new messages', async ({
|
||||
orcaPage,
|
||||
electronApp
|
||||
}, testInfo) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
const home = await electronApp.evaluate(({ app }) => app.getPath('home'))
|
||||
const dbPath = path.join(home, '.zcode', 'cli', 'db', 'db.sqlite')
|
||||
mkdirSync(path.dirname(dbPath), { recursive: true })
|
||||
const db = new DatabaseSync(dbPath)
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE TABLE session (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, time_created INTEGER, time_updated INTEGER, data TEXT, sequence INTEGER);
|
||||
CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT, time_created INTEGER, time_updated INTEGER, data TEXT, sequence INTEGER);
|
||||
INSERT INTO session VALUES ('zcode-proof');
|
||||
`)
|
||||
const insert = (id: string, role: string, text: string, sequence: number, hidden = false) => {
|
||||
db.prepare('INSERT INTO message VALUES (?, ?, 1, 1, ?, ?)').run(
|
||||
id,
|
||||
'zcode-proof',
|
||||
JSON.stringify({
|
||||
role,
|
||||
semantics: { transcriptVisibility: hidden ? 'hidden' : 'visible' }
|
||||
}),
|
||||
sequence
|
||||
)
|
||||
db.prepare('INSERT INTO part VALUES (?, ?, ?, 1, 1, ?, 0)').run(
|
||||
id,
|
||||
id,
|
||||
'zcode-proof',
|
||||
JSON.stringify({ type: 'text', text })
|
||||
)
|
||||
}
|
||||
insert('answer', 'assistant', 'The stored conversation now opens in native chat.', 2)
|
||||
insert('question', 'user', 'Show my saved ZCode conversation.', 0)
|
||||
insert('hidden', 'user', 'HIDDEN BOOKKEEPING MUST NOT RENDER', 1, true)
|
||||
const descriptor = await waitForActivePaneHookDescriptor(orcaPage)
|
||||
const [tabId] = descriptor.paneKey.split(':')
|
||||
await enableNativeChatSetting(orcaPage)
|
||||
await orcaPage.evaluate(({ paneKey, worktreeId }) => {
|
||||
window.__store
|
||||
?.getState()
|
||||
.setAgentStatus(
|
||||
paneKey,
|
||||
{ state: 'waiting', prompt: '', agentType: 'zcode' },
|
||||
'ZCode',
|
||||
undefined,
|
||||
{ worktreeId },
|
||||
{ providerSession: { key: 'session_id', id: 'zcode-proof' } }
|
||||
)
|
||||
}, descriptor)
|
||||
await orcaPage.screenshot({ path: testInfo.outputPath('01-terminal-before-chat.png') })
|
||||
await toggleTerminalTabToChatView(orcaPage, { tabId, worktreeId: descriptor.worktreeId })
|
||||
const chat = orcaPage.locator('[data-native-chat-root="true"]')
|
||||
await expect(chat).toBeVisible({ timeout: 15_000 })
|
||||
await expect(chat.getByText('Show my saved ZCode conversation.')).toBeVisible({
|
||||
timeout: 30_000
|
||||
})
|
||||
await expect(chat.getByText('The stored conversation now opens in native chat.')).toBeVisible()
|
||||
await expect(chat.getByText('HIDDEN BOOKKEEPING MUST NOT RENDER')).toHaveCount(0)
|
||||
const transcriptText = await chat.innerText()
|
||||
expect(transcriptText.indexOf('Show my saved ZCode conversation.')).toBeLessThan(
|
||||
transcriptText.indexOf('The stored conversation now opens in native chat.')
|
||||
)
|
||||
await orcaPage.screenshot({ path: testInfo.outputPath('02-ordered-history.png') })
|
||||
insert('followup', 'assistant', 'Live updates also reach this conversation.', 3)
|
||||
await expect(chat.getByText('Live updates also reach this conversation.')).toBeVisible({
|
||||
timeout: 15_000
|
||||
})
|
||||
await orcaPage.screenshot({ path: testInfo.outputPath('03-live-update.png') })
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user