From 07f777a084e78faa8cfe61eca24c0f77b759bd52 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 00:33:12 -0700 Subject: [PATCH] fix(ai-vault): honor ZCode transcript sequence order --- ...session-scanner-opencode-sqlite-capture.ts | 8 +- .../session-scanner-opencode-sqlite.ts | 19 +-- .../ai-vault/session-scanner-zcode-order.ts | 18 +++ .../ai-vault/session-scanner-zcode.test.ts | 116 ++++++++++++++++++ 4 files changed, 151 insertions(+), 10 deletions(-) create mode 100644 src/main/ai-vault/session-scanner-zcode-order.ts diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-capture.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-capture.ts index ef15b368ef34..818a40ed874f 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-capture.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-capture.ts @@ -11,6 +11,7 @@ import { canReadOpenCodeMessageParts } from './session-scanner-opencode-sqlite-s import type { TranscriptMessage, TranscriptMessageRole } from './session-transcript-consumers' import { boundedText, toolCallText } from './session-transcript-message-content' import { zcodeVisibleMessageFilter } from './session-scanner-zcode-visibility' +import { zcodeTranscriptOrder } from './session-scanner-zcode-order' import type SyncDatabase from '../sqlite/sync-database' // Why: the session list needs the newest few messages, and the search index @@ -120,7 +121,7 @@ function captureRole(role: string | null): TranscriptMessageRole | null { return role === 'user' || role === 'assistant' ? role : null } -function buildCaptureQuery(agent: 'opencode' | 'zcode'): string { +function buildCaptureQuery(db: SyncDatabase, agent: 'opencode' | 'zcode'): string { // Message order, then part order within a message: the same key the preview // read uses, run forwards and without the newest-N window. return `SELECT m.id AS message_id, @@ -134,7 +135,8 @@ function buildCaptureQuery(agent: 'opencode' | 'zcode'): string { ${zcodeVisibleMessageFilter(agent)} AND json_extract(m.data, '$.role') IN ('user','assistant') AND json_extract(p.data, '$.type') IN ${OPENCODE_CAPTURE_PART_TYPES} - ORDER BY m.time_created ASC, m.id ASC, p.time_created ASC, p.rowid ASC + ORDER BY ${zcodeTranscriptOrder(db, agent, 'message', 'm', 'ASC')}, + ${zcodeTranscriptOrder(db, agent, 'part', 'p', 'ASC')} LIMIT ?` } @@ -164,7 +166,7 @@ export function readOpenCodeSessionMessages( ) } const rows = db - .prepare(buildCaptureQuery(agent)) + .prepare(buildCaptureQuery(db, agent)) .all(sessionId, OPENCODE_CAPTURE_RECORD_LIMIT + 1) if (rows.length > OPENCODE_CAPTURE_RECORD_LIMIT) { throw new Error( diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite.ts b/src/main/ai-vault/session-scanner-opencode-sqlite.ts index 4e3d8bc74b83..3e2052b2aabc 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite.ts @@ -17,6 +17,7 @@ import { } from './session-scanner-opencode-sqlite-schema' import { normalizeTitleText } from './session-scanner-values' import { zcodeVisibleMessageFilter } from './session-scanner-zcode-visibility' +import { zcodeTranscriptOrder } from './session-scanner-zcode-order' import type SyncDatabase from '../sqlite/sync-database' import { columnExists, tableExists } from '../opencode-usage/schema-helpers' @@ -162,11 +163,11 @@ function readFirstUserPromptFromOpenCodeDb( ${zcodeVisibleMessageFilter(agent)} AND json_extract(m.data, '$.role') = 'user' AND json_extract(fp.data, '$.type') = 'text' - ORDER BY m.time_created ASC, m.id ASC + ORDER BY ${zcodeTranscriptOrder(db, agent, 'message', 'm', 'ASC')} LIMIT 1 ) AND json_extract(p.data, '$.type') = 'text' - ORDER BY p.time_created ASC, p.rowid ASC + ORDER BY ${zcodeTranscriptOrder(db, agent, 'part', 'p', 'ASC')} LIMIT ${FIRST_USER_PROMPT_PART_LIMIT}` ) .all(sessionId) @@ -198,15 +199,19 @@ function buildPreviewQuery(db: SyncDatabase, agent: 'opencode' | 'zcode'): strin p.time_created, json_extract(m.data, '$.summary.title') AS summary_title, json_extract(m.data, '$.summary.body') AS summary_body - FROM (SELECT id, data FROM message - WHERE session_id = ? - ${zcodeVisibleMessageFilter(agent, 'data')} - ORDER BY time_created DESC, id DESC + FROM (SELECT ${agent === 'zcode' ? 'm.rowid AS rowid, m.*' : 'm.id, m.data'} FROM message m + WHERE m.session_id = ? + ${zcodeVisibleMessageFilter(agent)} + ORDER BY ${zcodeTranscriptOrder(db, agent, 'message', 'm', 'DESC')} LIMIT ${OPENCODE_SQLITE_PREVIEW_MESSAGE_WINDOW}) m JOIN part p ON p.message_id = m.id WHERE json_extract(m.data, '$.role') IN ('user','assistant') AND json_extract(p.data, '$.type') = 'text' - ORDER BY p.time_created DESC + ORDER BY ${ + agent === 'zcode' + ? `${zcodeTranscriptOrder(db, agent, 'message', 'm', 'DESC')}, ${zcodeTranscriptOrder(db, agent, 'part', 'p', 'DESC')}` + : 'p.time_created DESC' + } LIMIT ?` } diff --git a/src/main/ai-vault/session-scanner-zcode-order.ts b/src/main/ai-vault/session-scanner-zcode-order.ts new file mode 100644 index 000000000000..958f73ff7ca6 --- /dev/null +++ b/src/main/ai-vault/session-scanner-zcode-order.ts @@ -0,0 +1,18 @@ +import { columnExists } from '../opencode-usage/schema-helpers' +import type SyncDatabase from '../sqlite/sync-database' + +export function zcodeTranscriptOrder( + db: SyncDatabase, + agent: 'opencode' | 'zcode', + table: 'message' | 'part', + alias: 'm' | 'p', + direction: 'ASC' | 'DESC' +): string { + const sequenced = agent === 'zcode' && columnExists(db, table, 'sequence') + const sequence = sequenced + ? `${alias}.sequence IS NULL ${direction}, ${alias}.sequence ${direction}, ` + : '' + // ZCode orders imported/forked transcripts by sequence, with legacy NULL rows last. + const tieBreaker = table === 'message' ? (sequenced ? 'rowid' : 'id') : sequenced ? 'id' : 'rowid' + return `${sequence}${alias}.time_created ${direction}, ${alias}.${tieBreaker} ${direction}` +} diff --git a/src/main/ai-vault/session-scanner-zcode.test.ts b/src/main/ai-vault/session-scanner-zcode.test.ts index 4783b2134506..1acade5af179 100644 --- a/src/main/ai-vault/session-scanner-zcode.test.ts +++ b/src/main/ai-vault/session-scanner-zcode.test.ts @@ -57,6 +57,122 @@ afterEach(() => { }) describe('ZCode AI Vault SQLite worker', () => { + it.each(['message', 'part', 'both'] as const)( + 'reads partially migrated %s sequence columns and keeps legacy NULL rows last', + async (tables) => { + const directory = mkdtempSync(join(tmpdir(), 'orca-zcode-legacy-sequence-')) + directories.push(directory) + const dbPath = join(directory, 'db.sqlite') + writeOpenCodeSqliteDatabase(dbPath, [ + { + id: 'legacy-session', + turns: [ + { role: 'user', parts: ['first', 'second', 'legacy part'] }, + { role: 'user', parts: ['legacy message'] } + ] + } + ]) + const db = new SyncDatabase(dbPath) + try { + if (tables !== 'part') { + db.exec(`ALTER TABLE message ADD COLUMN sequence INTEGER; + UPDATE message SET sequence = 0 WHERE rowid = 1; + UPDATE message SET time_created = 1 WHERE rowid = 2;`) + } + if (tables !== 'message') { + db.exec(`ALTER TABLE part ADD COLUMN sequence INTEGER; + UPDATE part SET sequence = rowid WHERE rowid <= 2; + UPDATE part SET time_created = 1 WHERE rowid = 3;`) + } + } finally { + db.close() + } + const captured = await handleOpenCodeSqliteRequest({ + id: 8, + kind: 'capture', + agent: 'zcode', + dbPath, + sessionId: 'legacy-session', + platform: 'win32' + }) + expect(captured.ok).toBe(true) + if (!captured.ok) { + return + } + expect(readMessageTexts(captured.value)).toEqual([ + { text: 'first\nsecond\nlegacy part' }, + { text: 'legacy message' } + ]) + } + ) + + it('uses stored message and part sequence for first prompt, preview, and search', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-zcode-sequence-')) + directories.push(directory) + const dbPath = join(directory, 'db.sqlite') + writeOpenCodeSqliteDatabase(dbPath, [ + { + id: 'ordered-session', + turns: [ + { role: 'user', parts: ['first prompt start', 'first prompt end'] }, + { role: 'assistant', parts: ['first response'] }, + { role: 'user', parts: ['second prompt'] }, + { role: 'assistant', parts: ['second response start', 'second response end'] } + ] + } + ]) + const db = new SyncDatabase(dbPath) + try { + db.exec(`ALTER TABLE message ADD COLUMN sequence INTEGER; + ALTER TABLE part ADD COLUMN sequence INTEGER; + UPDATE message SET sequence = rowid, time_created = time_created - rowid * 120000; + UPDATE part SET sequence = rowid, time_created = time_created - rowid * 120000;`) + } finally { + db.close() + } + const parsed = await handleOpenCodeSqliteRequest({ + id: 6, + kind: 'parse', + agent: 'zcode', + dbPath, + sessionId: 'ordered-session', + platform: 'darwin', + fullFirstUserPrompt: true + }) + expect(parsed).toMatchObject({ + ok: true, + value: { + firstUserPrompt: 'first prompt start\nfirst prompt end', + previewMessagesTruncated: true, + previewMessages: [ + { text: 'first prompt end' }, + { text: 'first response' }, + { text: 'second prompt' }, + { text: 'second response start' }, + { text: 'second response end' } + ] + } + }) + const captured = await handleOpenCodeSqliteRequest({ + id: 7, + kind: 'capture', + agent: 'zcode', + dbPath, + sessionId: 'ordered-session', + platform: 'darwin' + }) + expect(captured.ok).toBe(true) + if (!captured.ok) { + return + } + expect(readMessageTexts(captured.value)).toEqual([ + { text: 'first prompt start\nfirst prompt end' }, + { text: 'first response' }, + { text: 'second prompt' }, + { text: 'second response start\nsecond response end' } + ]) + }) + it('lists, parses, and captures ZCode sessions without labelling them OpenCode', async () => { const directory = mkdtempSync(join(tmpdir(), 'orca-zcode-ai-vault-')) directories.push(directory)