From 45c4d6ecbb21c071304ff94abfad9aab01a20630 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 9 Oct 2026 21:44:16 -0700 Subject: [PATCH] fix(native-chat): preserve ZCode store selection and transcript order --- .../transcript-opencode-database.test.ts | 51 ++++ .../transcript-opencode-database.ts | 40 ++- .../transcript-opencode-fingerprint-window.ts | 17 ++ .../transcript-opencode-sqlite-query.ts | 83 ++++-- .../transcript-opencode-subscribe.test.ts | 18 ++ .../transcript-opencode-subscribe.ts | 58 ++-- .../native-chat/transcript-opencode.test.ts | 16 ++ src/main/native-chat/transcript-opencode.ts | 37 +-- src/main/native-chat/transcript-zcode.test.ts | 254 ++++++++++++++++++ .../methods/native-chat-rpc-page-bounds.ts | 1 + src/shared/native-chat-agent-support.ts | 8 +- .../native-chat-reasoning-order.test.ts | 15 ++ .../native-chat-transcript-projection.ts | 10 + src/shared/native-chat-types.ts | 2 + .../e2e/native-chat-zcode-transcript.spec.ts | 88 ++++++ 15 files changed, 593 insertions(+), 105 deletions(-) create mode 100644 src/main/native-chat/transcript-opencode-fingerprint-window.ts create mode 100644 src/main/native-chat/transcript-zcode.test.ts create mode 100644 tests/e2e/native-chat-zcode-transcript.spec.ts diff --git a/src/main/native-chat/transcript-opencode-database.test.ts b/src/main/native-chat/transcript-opencode-database.test.ts index daaeca3b685..165d3ac20e4 100644 --- a/src/main/native-chat/transcript-opencode-database.test.ts +++ b/src/main/native-chat/transcript-opencode-database.test.ts @@ -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) => { diff --git a/src/main/native-chat/transcript-opencode-database.ts b/src/main/native-chat/transcript-opencode-database.ts index a99de75a081..2a9ad947b14 100644 --- a/src/main/native-chat/transcript-opencode-database.ts +++ b/src/main/native-chat/transcript-opencode-database.ts @@ -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() diff --git a/src/main/native-chat/transcript-opencode-fingerprint-window.ts b/src/main/native-chat/transcript-opencode-fingerprint-window.ts new file mode 100644 index 00000000000..baa60be503c --- /dev/null +++ b/src/main/native-chat/transcript-opencode-fingerprint-window.ts @@ -0,0 +1,17 @@ +export function pruneOpenCodeFingerprintCache( + fingerprints: Map, + 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) + } + } +} diff --git a/src/main/native-chat/transcript-opencode-sqlite-query.ts b/src/main/native-chat/transcript-opencode-sqlite-query.ts index 9583c9cc210..99b1c0d1577 100644 --- a/src/main/native-chat/transcript-opencode-sqlite-query.ts +++ b/src/main/native-chat/transcript-opencode-sqlite-query.ts @@ -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( 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( 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 } : {}) + } }) } } diff --git a/src/main/native-chat/transcript-opencode-subscribe.test.ts b/src/main/native-chat/transcript-opencode-subscribe.test.ts index cce4b243cf8..caa7e574a08 100644 --- a/src/main/native-chat/transcript-opencode-subscribe.test.ts +++ b/src/main/native-chat/transcript-opencode-subscribe.test.ts @@ -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() diff --git a/src/main/native-chat/transcript-opencode-subscribe.ts b/src/main/native-chat/transcript-opencode-subscribe.ts index c1997f3ed55..637218d04ee 100644 --- a/src/main/native-chat/transcript-opencode-subscribe.ts +++ b/src/main/native-chat/transcript-opencode-subscribe.ts @@ -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, 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>[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>, + page: Awaited>, shrinking = false ): Promise { 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) diff --git a/src/main/native-chat/transcript-opencode.test.ts b/src/main/native-chat/transcript-opencode.test.ts index 6cf17a8cb75..7b49dcac3f2 100644 --- a/src/main/native-chat/transcript-opencode.test.ts +++ b/src/main/native-chat/transcript-opencode.test.ts @@ -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 () => { diff --git a/src/main/native-chat/transcript-opencode.ts b/src/main/native-chat/transcript-opencode.ts index eada2eca985..93f14b3bbc6 100644 --- a/src/main/native-chat/transcript-opencode.ts +++ b/src/main/native-chat/transcript-opencode.ts @@ -54,40 +54,6 @@ export const openCodeTranscriptDefaultDeps: Required = { 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 - readSignal: (dbPath: string, sessionId: string) => Promise - readPage: ( - page: Parameters>[0] - ) => Promise -} { - 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 } diff --git a/src/main/native-chat/transcript-zcode.test.ts b/src/main/native-chat/transcript-zcode.test.ts new file mode 100644 index 00000000000..31fcc45b5b7 --- /dev/null +++ b/src/main/native-chat/transcript-zcode.test.ts @@ -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[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']) +}) diff --git a/src/main/runtime/rpc/methods/native-chat-rpc-page-bounds.ts b/src/main/runtime/rpc/methods/native-chat-rpc-page-bounds.ts index 1ebdf1056dc..9aa659da668 100644 --- a/src/main/runtime/rpc/methods/native-chat-rpc-page-bounds.ts +++ b/src/main/runtime/rpc/methods/native-chat-rpc-page-bounds.ts @@ -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 } : {}) diff --git a/src/shared/native-chat-agent-support.ts b/src/shared/native-chat-agent-support.ts index 081086bae92..26127d797e3 100644 --- a/src/shared/native-chat-agent-support.ts +++ b/src/shared/native-chat-agent-support.ts @@ -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') { diff --git a/src/shared/native-chat-reasoning-order.test.ts b/src/shared/native-chat-reasoning-order.test.ts index ea3fd56305b..d5b7ae84ea8 100644 --- a/src/shared/native-chat-reasoning-order.test.ts +++ b/src/shared/native-chat-reasoning-order.test.ts @@ -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' + ]) +}) diff --git a/src/shared/native-chat-transcript-projection.ts b/src/shared/native-chat-transcript-projection.ts index 8833d6c8304..70d6283341c 100644 --- a/src/shared/native-chat-transcript-projection.ts +++ b/src/shared/native-chat-transcript-projection.ts @@ -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) } diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index 46955890ac5..28e9f86f9f3 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -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. */ diff --git a/tests/e2e/native-chat-zcode-transcript.spec.ts b/tests/e2e/native-chat-zcode-transcript.spec.ts new file mode 100644 index 00000000000..f9510d51446 --- /dev/null +++ b/tests/e2e/native-chat-zcode-transcript.spec.ts @@ -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() + } +})