diff --git a/.github/workflows/zcode-transcript-contract.yml b/.github/workflows/zcode-transcript-contract.yml new file mode 100644 index 000000000000..bb2b86cec484 --- /dev/null +++ b/.github/workflows/zcode-transcript-contract.yml @@ -0,0 +1,40 @@ +name: ZCode transcript contract +on: + pull_request: + paths: + - 'src/main/ai-vault/session-scanner-zcode*' + - 'src/main/ai-vault/session-scanner-opencode*' + - 'src/main/ai-vault/opencode-wsl-runtime*' + - 'src/main/native-chat/transcript-opencode*' + - 'src/main/native-chat/transcript-zcode*' + - 'src/shared/native-chat-types.ts' + - 'src/shared/native-chat-transcript-projection*' + - '.github/workflows/zcode-transcript-contract.yml' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: zcode-transcript-contract-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +jobs: + transcript-readers: + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, macos-15, windows-2022] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: config/.bun-version + - name: Verify SQLite ordering, legacy readers, and host routing + run: pnpm test src/main/native-chat/transcript-zcode.test.ts src/main/native-chat/transcript-opencode.test.ts src/main/native-chat/transcript-opencode-sqlite-query.test.ts src/main/native-chat/transcript-opencode2-sqlite.test.ts src/main/native-chat/transcript-opencode-semantic-fidelity.test.ts src/main/native-chat/transcript-opencode-read-bounds.test.ts src/main/native-chat/transcript-opencode-subscribe.test.ts src/main/ai-vault/session-scanner-zcode.test.ts src/main/ai-vault/session-scanner-zcode-sources.test.ts src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts src/main/ai-vault/session-scanner-opencode-wsl-routing.test.ts src/shared/native-chat-transcript-projection.test.ts src/renderer/src/components/native-chat/structured-agent-session-transcript-order.test.ts diff --git a/config/scripts/vitest-sqlite-database-runtime-files.mjs b/config/scripts/vitest-sqlite-database-runtime-files.mjs index 0d2c20aeb30c..2777ad707ef5 100644 --- a/config/scripts/vitest-sqlite-database-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-database-runtime-files.mjs @@ -18,6 +18,7 @@ export const SQLITE_DATABASE_RUNTIME_INCLUDE = [ 'src/main/native-chat/transcript-opencode-semantic-fidelity.test.ts', 'src/main/native-chat/transcript-opencode-sqlite-query.test.ts', 'src/main/native-chat/transcript-opencode-subscribe.test.ts', + 'src/main/native-chat/transcript-zcode.test.ts', 'src/main/native-chat/transcript-opencode2-sqlite.test.ts', 'src/main/opencode-usage/cache-read-reproduction.test.ts', 'src/main/opencode-usage/opencode2-session-usage.test.ts', diff --git a/src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts b/src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts index 8ba01bf46176..923baf824d41 100644 --- a/src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts +++ b/src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts @@ -263,7 +263,7 @@ describe('WSL SQLite runtime preparation', () => { mocks.run.mockResolvedValueOnce(success('')) await prepare([home]) await vi.waitFor(async () => - expect((await prepare([home]))[0]?.error).toContain('No OpenCode database') + expect((await prepare([home]))[0]?.error).toContain('No OpenCode or ZCode database') ) expect(mocks.run).toHaveBeenCalledOnce() expect(mocks.download).not.toHaveBeenCalled() @@ -274,16 +274,19 @@ describe('WSL SQLite runtime preparation', () => { expect(mocks.running).toHaveBeenCalledWith(expect.any(Array), { requireConfirmed: true }) }) - it.skipIf(process.platform === 'win32')( - 'keeps the discovery default visible when guest overrides point elsewhere', - async () => { + it.skipIf(process.platform === 'win32').each(['opencode', 'zcode'] as const)( + 'prepares a %s-only distro despite unrelated OpenCode overrides', + async (agent) => { await prepared() const script = mocks.run.mock.calls[0]?.[0].script const directory = await mkdtemp(join(tmpdir(), 'orca-wsl-presence-')) try { - const data = join(directory, '.local', 'share', 'opencode') + const data = + agent === 'zcode' + ? join(directory, '.zcode', 'cli', 'db') + : join(directory, '.local', 'share', 'opencode') await mkdir(data, { recursive: true }) - await writeFile(join(data, 'opencode.db'), '') + await writeFile(join(data, agent === 'zcode' ? 'db.sqlite' : 'opencode.db'), '') const result = await runProcess({ program: '/bin/sh', args: ['-c', script], diff --git a/src/main/ai-vault/opencode-wsl-runtime-preparation.ts b/src/main/ai-vault/opencode-wsl-runtime-preparation.ts index 1f242772acd3..c107a3082483 100644 --- a/src/main/ai-vault/opencode-wsl-runtime-preparation.ts +++ b/src/main/ai-vault/opencode-wsl-runtime-preparation.ts @@ -113,6 +113,7 @@ async function prepare(distro: string): Promise { const hasDatabase = await run({ script: [ 'data="${XDG_DATA_HOME:-$HOME/.local/share}/opencode"', + 'if [ -f "$HOME/.zcode/cli/db/db.sqlite" ]; then printf present; exit 0; fi', // WSL discovery still enumerates the default data root independently of guest overrides. 'for db in "$HOME/.local/share/opencode"/opencode.db "$HOME/.local/share/opencode"/opencode-*.db; do if [ -f "$db" ]; then printf present; exit 0; fi; done', 'case "${OPENCODE_DB-}" in', @@ -125,7 +126,7 @@ async function prepare(distro: string): Promise { loginPath: 'none' }) if (hasDatabase !== 'present') { - return { distro, error: 'No OpenCode database is present in this WSL distro.' } + return { distro, error: 'No OpenCode or ZCode database is present in this WSL distro.' } } const readerPath = await run({ program: 'wslpath', diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-dispatch.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-dispatch.ts index d9bc0b3a27c6..d4e66dd48b02 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-dispatch.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-dispatch.ts @@ -25,7 +25,7 @@ export async function handleOpenCodeSqliteRequest( if (request.kind === 'native-page' || request.kind === 'native-signal') { const value = request.kind === 'native-signal' - ? readOpenCodeTranscriptSignal(request.dbPath, request.sessionId) + ? readOpenCodeTranscriptSignal(request.dbPath, request.sessionId, request.agent) : readOpenCodeTranscriptPage({ ...request, limit: request.limit ?? 50 }) return { id: request.id, ok: true, value } } diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts index afee7778e3db..aea241b250f6 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts @@ -50,6 +50,8 @@ export type OpenCodeNativeChatReadRequest = { sessionId: string limit?: number beforeMessageRowId?: number + /** When 'zcode', reads apply ZCode's hidden-transcript filter. */ + agent?: 'zcode' } export type OpenCodeNativeChatReadValue = OpenCodeTranscriptPage | OpenCodeTranscriptSignal | null diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts index e89a6361b1e2..7e6f5eb31e0d 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts @@ -265,6 +265,7 @@ export async function readOpenCodeTranscriptPageViaWorker( sessionId: string limit: number beforeMessageRowId?: number + agent?: 'zcode' }, signal?: AbortSignal ) { @@ -289,6 +290,7 @@ export async function readOpenCodeTranscriptSignalViaWorker( args: { dbPath: string sessionId: string + agent?: 'zcode' }, signal?: AbortSignal ) { diff --git a/src/main/native-chat/transcript-opencode-database.test.ts b/src/main/native-chat/transcript-opencode-database.test.ts index daaeca3b6856..165d3ac20e46 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 92b97e7c9b09..2a9ad947b141 100644 --- a/src/main/native-chat/transcript-opencode-database.ts +++ b/src/main/native-chat/transcript-opencode-database.ts @@ -1,3 +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' @@ -13,9 +15,15 @@ import { listOpenCodeDatabasesInDirectory } from '../opencode-usage/opencode-database-discovery' +// 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')) +} + export async function discoverOpenCodeTranscriptDatabase( sessionId?: string, - signal?: AbortSignal + signal?: AbortSignal, + agent?: 'zcode' ): Promise { signal?.throwIfAborted() const deadline = new AbortController() @@ -41,13 +49,25 @@ 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({ dbPath, sessionId }, boundedSignal), + readOpenCodeTranscriptSignalViaWorker( + { dbPath, sessionId, ...(agent ? { agent } : {}) }, + boundedSignal + ), boundedSignal ) ) { @@ -55,12 +75,46 @@ 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 { + if (agent === 'zcode') { + const native = await findSession(zcodeTranscriptDatabasePaths([])) + if (native) { + return native + } + const homes = (await waitForPromiseWithSignal(getAiVaultWslHomeDirs(), boundedSignal)).slice( + 0, + 32 + ) + if (homes.length > 0) { + const readers = await waitForPromiseWithSignal( + prepareOpenCodeWslReaders(homes), + boundedSignal + ) + boundedSignal.throwIfAborted() + configureOpenCodeWslReaders(readers) + } + const wsl = await findSession(zcodeTranscriptDatabasePaths(homes)) + if (wsl) { + return wsl + } + if (refusals[0]) { + throw refusals[0] + } + return null + } const primary = await waitForPromiseWithSignal( listOpenCodeDatabases(onRefusal, undefined, boundedSignal), boundedSignal 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 000000000000..baa60be503cf --- /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-semantic-fidelity.test.ts b/src/main/native-chat/transcript-opencode-semantic-fidelity.test.ts index aa30a4b743a5..94d21df4b450 100644 --- a/src/main/native-chat/transcript-opencode-semantic-fidelity.test.ts +++ b/src/main/native-chat/transcript-opencode-semantic-fidelity.test.ts @@ -177,12 +177,14 @@ describe.each(['v1', 'v2'] as const)('%s semantic pagination', (version) => { }) it('reconstructs a full read beyond its 500-message window without losing a split row', async () => { - const { path, insert, prefix } = fixture(version) + const { db, path, insert, prefix } = fixture(version) const expected: string[] = [] + db.exec('BEGIN') for (let index = 0; index < 260; index++) { insert(String(index), mixed()) expected.push(`${prefix}${index}:reasoning`, `${prefix}${index}`) } + db.exec('COMMIT') const result = await readOpenCodeNativeChatTranscriptFull('session', { resolveDbPath: async () => path, readPage: async (args) => readOpenCodeTranscriptPage(args) diff --git a/src/main/native-chat/transcript-opencode-sqlite-query.ts b/src/main/native-chat/transcript-opencode-sqlite-query.ts index d245020b619a..99b1c0d1577b 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 @@ -70,7 +72,8 @@ function rowsWithinBudget( export function readOpenCodeTranscriptSignal( dbPath: string, - sessionId: string + sessionId: string, + agent?: 'zcode' ): OpenCodeTranscriptSignal | null { const db = openOpenCodeDatabaseReadonly(dbPath) try { @@ -82,23 +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 = ?' + `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 = ?' + `SELECT COUNT(*) AS part_count, COALESCE(MAX(time_updated), 0) AS max_part_time_updated + 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() @@ -110,6 +131,7 @@ export function readOpenCodeTranscriptPage(args: { sessionId: string limit: number beforeMessageRowId?: number + agent?: 'zcode' }): OpenCodeTranscriptPage | null { const db = openOpenCodeDatabaseReadonly(args.dbPath) try { @@ -121,14 +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 < ? - 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[] = [] @@ -141,6 +182,7 @@ export function readOpenCodeTranscriptPage(args: { const rows = rowsWithinBudget( select, rawBudget, + ...(ranked ? [args.sessionId] : []), args.sessionId, cursor ?? Number.MAX_SAFE_INTEGER, limit + 1 @@ -156,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') @@ -189,6 +231,7 @@ export function readOpenCodeTranscriptPage(args: { type MessageRow = { message_rowid: number + transcript_order: number id: string time_created: number time_updated: number @@ -205,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 [] @@ -217,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, @@ -256,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 cce4b243cf8a..caa7e574a088 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 b5faaf884ca3..637218d04ee4 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, @@ -14,40 +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 = {} + deps: OpenCodeTranscriptDeps = {}, + agent?: 'zcode' ): NativeChatTranscriptSubscription { setupSignal?.throwIfAborted() const controller = new AbortController() - const resolveDbPath = () => - (deps.resolveDbPath ?? openCodeTranscriptDefaultDeps.resolveDbPath)( - args.sessionId, - controller.signal - ) - const readSignal = (dbPath: string, sessionId: string) => - (deps.readSignal ?? openCodeTranscriptDefaultDeps.readSignal)( - dbPath, - sessionId, + 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 readPage = (page: Parameters>[0]) => - (deps.readPage ?? openCodeTranscriptDefaultDeps.readPage)(page, controller.signal) const pollMs = args.resolvePollIntervalMs ?? OPENCODE_POLL_MS const initialLimit = openCodeTranscriptPageLimit( args.initialLimit && args.initialLimit > 0 ? args.initialLimit : DESKTOP_READ_WINDOW @@ -117,7 +100,7 @@ export function subscribeOpenCodeNativeChatTranscript( return } try { - dbPath ??= await resolveDbPath() + dbPath ??= await resolveDbPath(args.sessionId, controller.signal, agent) if (closed) { return } @@ -126,7 +109,7 @@ export function subscribeOpenCodeNativeChatTranscript( scheduleTick() return } - const signal = await readSignal(dbPath, args.sessionId) + const signal = await readSignal(dbPath, args.sessionId, controller.signal, agent) if (closed) { return } @@ -175,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 @@ -256,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) } @@ -293,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 6cf17a8cb75d..7b49dcac3f26 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 497635de87e1..93f14b3bbc63 100644 --- a/src/main/native-chat/transcript-opencode.ts +++ b/src/main/native-chat/transcript-opencode.ts @@ -20,11 +20,16 @@ import { openCodeTranscriptPageLimit } from '../../shared/opencode-transcript-pa // OpenCode SQLite reads use the same bounded worker as AI Vault. export type OpenCodeTranscriptDeps = { - resolveDbPath?: (sessionId?: string, signal?: AbortSignal) => Promise + resolveDbPath?: ( + sessionId?: string, + signal?: AbortSignal, + agent?: 'zcode' + ) => Promise readSignal?: ( dbPath: string, sessionId: string, - signal?: AbortSignal + signal?: AbortSignal, + agent?: 'zcode' ) => Promise readPage?: ( args: { @@ -32,6 +37,7 @@ export type OpenCodeTranscriptDeps = { sessionId: string limit: number beforeMessageRowId?: number + agent?: 'zcode' }, signal?: AbortSignal ) => Promise @@ -43,8 +49,8 @@ export function resolveOpenCodeTranscriptDbPath(sessionId?: string): Promise = { resolveDbPath: discoverOpenCodeTranscriptDatabase, - readSignal: (dbPath, sessionId, signal) => - readOpenCodeTranscriptSignalViaWorker({ dbPath, sessionId }, signal), + readSignal: (dbPath, sessionId, signal, agent) => + readOpenCodeTranscriptSignalViaWorker({ dbPath, sessionId, agent }, signal), readPage: (args, signal) => readOpenCodeTranscriptPageViaWorker(args, signal) } @@ -53,7 +59,7 @@ export type OpenCodeTailResult = | { error: string; notFound?: true } export async function readOpenCodeNativeChatTranscriptTail( - args: { sessionId: string; limit: number; beforeOffset?: number }, + args: { sessionId: string; limit: number; beforeOffset?: number; agent?: 'zcode' }, deps: OpenCodeTranscriptDeps = {}, signal?: AbortSignal ): Promise { @@ -61,7 +67,8 @@ export async function readOpenCodeNativeChatTranscriptTail( try { const dbPath = await (deps.resolveDbPath ?? openCodeTranscriptDefaultDeps.resolveDbPath)( args.sessionId, - signal + signal, + args.agent ) if (!dbPath) { return { error: 'Transcript unavailable', notFound: true } @@ -71,7 +78,8 @@ export async function readOpenCodeNativeChatTranscriptTail( dbPath, sessionId: args.sessionId, limit, - ...(args.beforeOffset !== undefined ? { beforeMessageRowId: args.beforeOffset } : {}) + ...(args.beforeOffset !== undefined ? { beforeMessageRowId: args.beforeOffset } : {}), + ...(args.agent ? { agent: args.agent } : {}) }, signal ) @@ -91,7 +99,8 @@ export async function readOpenCodeNativeChatTranscriptTail( export async function readOpenCodeNativeChatTranscriptFull( sessionId: string, deps: OpenCodeTranscriptDeps = {}, - signal?: AbortSignal + signal?: AbortSignal, + agent?: 'zcode' ): Promise { // Pages arrive newest-window first; reverse windows, preserving message order. const pages: NativeChatMessage[][] = [] @@ -101,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 } @@ -113,7 +123,8 @@ export async function readOpenCodeNativeChatTranscriptFull( dbPath, sessionId, limit: 500, - ...(cursor !== undefined ? { beforeMessageRowId: cursor } : {}) + ...(cursor !== undefined ? { beforeMessageRowId: cursor } : {}), + ...(agent ? { agent } : {}) }, signal ) diff --git a/src/main/native-chat/transcript-read-cache.ts b/src/main/native-chat/transcript-read-cache.ts index 7233f001de48..25072906d17b 100644 --- a/src/main/native-chat/transcript-read-cache.ts +++ b/src/main/native-chat/transcript-read-cache.ts @@ -107,7 +107,12 @@ export async function readNativeChatTranscriptCached( transcriptPath?: string ): Promise { if (resolveNativeChatTranscriptAgent(agent) === 'opencode') { - return readOpenCodeNativeChatTranscriptFull(sessionId) + return readOpenCodeNativeChatTranscriptFull( + sessionId, + {}, + undefined, + agent === 'zcode' ? 'zcode' : undefined + ) } const epoch = cacheEpoch let filePath: string | null diff --git a/src/main/native-chat/transcript-reader.ts b/src/main/native-chat/transcript-reader.ts index 6cad15a7757f..b90b8681b445 100644 --- a/src/main/native-chat/transcript-reader.ts +++ b/src/main/native-chat/transcript-reader.ts @@ -45,7 +45,12 @@ export async function readNativeChatTranscript( options: ReadTranscriptOptions = {} ): Promise { if (resolveNativeChatTranscriptAgent(agent) === 'opencode') { - return readOpenCodeNativeChatTranscriptFull(sessionId) + return readOpenCodeNativeChatTranscriptFull( + sessionId, + {}, + undefined, + agent === 'zcode' ? 'zcode' : undefined + ) } let filePath: string | null try { diff --git a/src/main/native-chat/transcript-tail-reader.ts b/src/main/native-chat/transcript-tail-reader.ts index fb44470047bf..fce4b630e4e8 100644 --- a/src/main/native-chat/transcript-tail-reader.ts +++ b/src/main/native-chat/transcript-tail-reader.ts @@ -246,7 +246,11 @@ export async function readNativeChatTranscriptTail( | { error: string; notFound?: true } > { if (resolveNativeChatTranscriptAgent(args.agent) === 'opencode') { - return readOpenCodeNativeChatTranscriptTail(args, {}, signal) + return readOpenCodeNativeChatTranscriptTail( + { ...args, agent: args.agent === 'zcode' ? 'zcode' : undefined }, + {}, + signal + ) } const decode = nativeChatLineDecoderForAgent(args.agent) const decodeLifecycle = nativeChatTurnLifecycleDecoderForAgent(args.agent) diff --git a/src/main/native-chat/transcript-watch.ts b/src/main/native-chat/transcript-watch.ts index a5090b4c8008..69bb21e8c714 100644 --- a/src/main/native-chat/transcript-watch.ts +++ b/src/main/native-chat/transcript-watch.ts @@ -267,7 +267,12 @@ export async function subscribeNativeChatTranscript( ): Promise { setupSignal?.throwIfAborted() if (resolveNativeChatTranscriptAgent(args.agent) === 'opencode') { - return subscribeOpenCodeNativeChatTranscript(args, setupSignal) + return subscribeOpenCodeNativeChatTranscript( + args, + setupSignal, + {}, + args.agent === 'zcode' ? 'zcode' : undefined + ) } const decode = nativeChatLineDecoderForAgent(args.agent) if (!decode) { 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 000000000000..31fcc45b5b75 --- /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 1ebdf1056dc1..9aa659da6683 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/renderer/src/components/automations/use-automations-page-setup-state.ts b/src/renderer/src/components/automations/use-automations-page-setup-state.ts index ae0939085fe3..5ada1d683d28 100644 --- a/src/renderer/src/components/automations/use-automations-page-setup-state.ts +++ b/src/renderer/src/components/automations/use-automations-page-setup-state.ts @@ -150,8 +150,7 @@ export function useAutomationsPageSetupState({ automationYamlHooksByRepoKey, getAutomationHooksCacheKey, repos, - setAutomationYamlHooksByRepoKey, - settings + setAutomationYamlHooksByRepoKey ] ) const getDraftSetupDecisionDefaultSignature = useCallback( diff --git a/src/renderer/src/components/settings/NativeChatSupportedAgents.test.tsx b/src/renderer/src/components/settings/NativeChatSupportedAgents.test.tsx index e087d0be7a37..860ce62e7aa5 100644 --- a/src/renderer/src/components/settings/NativeChatSupportedAgents.test.tsx +++ b/src/renderer/src/components/settings/NativeChatSupportedAgents.test.tsx @@ -19,7 +19,8 @@ const EXPECTED_SUPPORTED_AGENTS = [ 'grok', 'omp', 'opencode', - 'opencode2' + 'opencode2', + 'zcode' ] as const satisfies readonly TuiAgent[] const SUPPORTED_AGENTS_LABEL_KEY = 'auto.components.settings.NativeChatSupportedAgents.label' diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit-fixtures.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit-fixtures.ts new file mode 100644 index 000000000000..303445980ab1 --- /dev/null +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit-fixtures.ts @@ -0,0 +1,36 @@ +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../shared/project-group-types' + +export function makeProjectGroup(): ProjectGroup { + return { + id: 'group-1', + name: 'Platform', + parentPath: '/repo/platform', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } +} + +export function makeFolderWorkspace(overrides: Partial = {}): FolderWorkspace { + return { + id: 'folder-workspace-1', + projectGroupId: 'group-1', + name: 'hi', + folderPath: '/repo/platform/hi', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit-host-platform.test.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit-host-platform.test.ts index f1fcb5e6f008..95a01c86f40b 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit-host-platform.test.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit-host-platform.test.ts @@ -1,8 +1,7 @@ // @vitest-environment happy-dom import { afterEach, describe, expect, it, vi } from 'vitest' -import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' -import type { ProjectGroup } from '../../../../shared/project-group-types' +import { makeProjectGroup, makeFolderWorkspace } from './folder-workspace-composer-submit-fixtures' const mocks = vi.hoisted(() => ({ activateAndRevealFolderWorkspace: vi.fn() })) @@ -14,39 +13,6 @@ vi.mock('@/lib/worktree-activation', async (importOriginal) => { import { useAppStore } from '@/store' import { submitFolderWorkspaceCreate } from './folder-workspace-composer-submit' -function makeProjectGroup(): ProjectGroup { - return { - id: 'group-1', - name: 'Platform', - parentPath: '/repo/platform', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } -} - -function makeFolderWorkspace(): FolderWorkspace { - return { - id: 'folder-workspace-1', - projectGroupId: 'group-1', - name: 'hi', - folderPath: '/repo/platform/hi', - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 1, - createdAt: 1, - updatedAt: 1 - } -} - // Why: a folder group on an Orca server runs its agents there, so the server's OS decides quoting. describe('submitFolderWorkspaceCreate on an Orca server', () => { afterEach(() => { diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts index 85d5f2020be0..0964e076f87a 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts @@ -1,8 +1,7 @@ // @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' -import type { ProjectGroup } from '../../../../shared/project-group-types' +import { makeProjectGroup, makeFolderWorkspace } from './folder-workspace-composer-submit-fixtures' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import type * as NewWorkspaceModule from '@/lib/new-workspace' @@ -30,40 +29,6 @@ import { submitFolderWorkspaceCreate } from './folder-workspace-composer-submit' -function makeProjectGroup(): ProjectGroup { - return { - id: 'group-1', - name: 'Platform', - parentPath: '/repo/platform', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } -} - -function makeFolderWorkspace(overrides: Partial = {}): FolderWorkspace { - return { - id: 'folder-workspace-1', - projectGroupId: 'group-1', - name: 'hi', - folderPath: '/repo/platform/hi', - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 1, - createdAt: 1, - updatedAt: 1, - ...overrides - } -} - describe('submitFolderWorkspaceCreate', () => { it.each(['347', '002'])('keeps manual folder name %s with its linked source', async (name) => { const linkedWorkItem = { diff --git a/src/shared/native-chat-agent-support.test.ts b/src/shared/native-chat-agent-support.test.ts index 23749bb0dfbf..3da69dd80a0d 100644 --- a/src/shared/native-chat-agent-support.test.ts +++ b/src/shared/native-chat-agent-support.test.ts @@ -15,6 +15,9 @@ describe('resolveNativeChatTranscriptAgent', () => { it('passes codex, grok and omp through and rejects everything else', () => { expect(resolveNativeChatTranscriptAgent('opencode')).toBe('opencode') expect(resolveNativeChatTranscriptAgent('opencode2')).toBe('opencode') + // ZCode keeps an OpenCode-v1-shaped history in its own SQLite DB, so its + // transcripts read through the OpenCode SQLite reader. + expect(resolveNativeChatTranscriptAgent('zcode')).toBe('opencode') expect(resolveNativeChatTranscriptAgent('codex')).toBe('codex') expect(resolveNativeChatTranscriptAgent('grok')).toBe('grok') expect(resolveNativeChatTranscriptAgent('omp')).toBe('omp') @@ -28,6 +31,7 @@ describe('isNativeChatSupportedAgent', () => { it('recognizes the parseable agents and rejects unknown / nullish input', () => { expect(isNativeChatSupportedAgent('opencode')).toBe(true) expect(isNativeChatSupportedAgent('opencode2')).toBe(true) + expect(isNativeChatSupportedAgent('zcode')).toBe(true) expect(isNativeChatSupportedAgent('claude')).toBe(true) expect(isNativeChatSupportedAgent('openclaude')).toBe(true) expect(isNativeChatSupportedAgent('omp')).toBe(true) @@ -43,6 +47,7 @@ describe('nativeChatRequiresLocalTranscript', () => { // native chat has to find their file on a disk this process can read. expect(nativeChatRequiresLocalTranscript('opencode')).toBe(true) expect(nativeChatRequiresLocalTranscript('opencode2')).toBe(true) + expect(nativeChatRequiresLocalTranscript('zcode')).toBe(true) expect(nativeChatRequiresLocalTranscript('grok')).toBe(true) expect(nativeChatRequiresLocalTranscript('omp')).toBe(true) expect(nativeChatRequiresLocalTranscript('claude')).toBe(false) diff --git a/src/shared/native-chat-agent-support.ts b/src/shared/native-chat-agent-support.ts index e2bb9c40cacc..26127d797e34 100644 --- a/src/shared/native-chat-agent-support.ts +++ b/src/shared/native-chat-agent-support.ts @@ -11,7 +11,8 @@ export const NATIVE_CHAT_SUPPORTED_AGENT_LIST: readonly TuiAgent[] = [ 'grok', 'omp', 'opencode', - 'opencode2' + 'opencode2', + 'zcode' ] export const NATIVE_CHAT_SUPPORTED_AGENTS: ReadonlySet = new Set( @@ -45,7 +46,7 @@ export function resolveNativeChatTranscriptAgent( if (agent === 'claude' || agent === 'openclaude') { return 'claude' } - if (agent === 'opencode' || agent === 'opencode2') { + 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 ea3fd56305b4..d5b7ae84ea8c 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 8833d6c83040..70d6283341c4 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 46955890ac55..28e9f86f9f3d 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/helpers/orca-app.ts b/tests/e2e/helpers/orca-app.ts index e1d34d722293..edda9b620893 100644 --- a/tests/e2e/helpers/orca-app.ts +++ b/tests/e2e/helpers/orca-app.ts @@ -340,7 +340,7 @@ export const test = base.extend({ if (!store) { return false } - await store.getState().fetchRepos() + await store.getState().fetchRepos({ runtimeEnvironmentId: null }) const repo = store.getState().repos.find((candidate) => candidate.id === repoId) if (!repo) { return false 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 000000000000..f96e18066542 --- /dev/null +++ b/tests/e2e/native-chat-zcode-transcript.spec.ts @@ -0,0 +1,105 @@ +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 { readHookEndpoint } from './helpers/agent-hook-endpoint' +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) + const endpoint = await readHookEndpoint(electronApp) + const response = await fetch(`http://127.0.0.1:${endpoint.port}/hook/zcode`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': endpoint.token + }, + body: JSON.stringify({ + ...descriptor, + tabId, + env: endpoint.env, + version: endpoint.version, + payload: { + hook_event_name: 'SessionStart', + source: 'resume', + session_id: 'zcode-proof' + } + }) + }) + expect(response.status).toBe(204) + await expect + .poll(() => + orcaPage.evaluate( + (paneKey) => window.__store?.getState().agentStatusByPaneKey[paneKey]?.agentType, + descriptor.paneKey + ) + ) + .toBe('zcode') + 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() + } +})