diff --git a/mobile/src/session/mobile-native-chat-eligibility.test.ts b/mobile/src/session/mobile-native-chat-eligibility.test.ts index 829af1c3d8c..91ee3a518c7 100644 --- a/mobile/src/session/mobile-native-chat-eligibility.test.ts +++ b/mobile/src/session/mobile-native-chat-eligibility.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' import { canShowMobileNativeChat, + isMobileFolderNativeChatReadable, isMobileNativeChatTranscriptReadable, resolveMobileNativeChat } from './mobile-native-chat-eligibility' @@ -80,6 +81,22 @@ describe('resolveMobileNativeChat', () => { }) }) + it.each(['opencode', 'opencode2'])('admits %s only on the execution host', (agent) => { + const tab = { + type: 'terminal', + launchAgent: agent, + agentStatus: status({ + providerSession: { key: 'session_id', id: 'real-session' } + }) + } + expect(resolveMobileNativeChat(tab, true)).toEqual({ + agent, + sessionId: 'real-session', + transcriptPath: null + }) + expect(resolveMobileNativeChat(tab, false)).toBeNull() + }) + it('returns null for unsupported agents', () => { expect(resolveMobileNativeChat({ type: 'terminal', launchAgent: 'gemini' })).toBeNull() }) @@ -166,3 +183,17 @@ describe('resolveMobileNativeChat', () => { expect(canShowMobileNativeChat(null)).toBe(false) }) }) + +it('resolves folder readability from the serving host catalog and rejects Model-A SSH', () => { + const read = (connectionId: unknown) => + isMobileFolderNativeChatReadable( + { + folderWorkspaces: [{ id: 'one', connectionId }] + }, + 'folder:one' + ) + expect(read(null)).toBe(true) + expect(read('ssh:box')).toBe(false) + expect(isMobileFolderNativeChatReadable({ folderWorkspaces: [] }, 'folder:one')).toBe(false) + expect(isMobileFolderNativeChatReadable(null, 'folder:one')).toBe(false) +}) diff --git a/mobile/src/session/mobile-native-chat-eligibility.ts b/mobile/src/session/mobile-native-chat-eligibility.ts index c04f5ec72dc..1ad104f5c2d 100644 --- a/mobile/src/session/mobile-native-chat-eligibility.ts +++ b/mobile/src/session/mobile-native-chat-eligibility.ts @@ -96,3 +96,26 @@ export function resolveMobileNativeChatFileSessionId( } return null } + +export function isMobileFolderNativeChatReadable(value: unknown, worktreeId: string): boolean { + if ( + !value || + typeof value !== 'object' || + !('folderWorkspaces' in value) || + !Array.isArray(value.folderWorkspaces) + ) { + return false + } + const id = worktreeId.slice('folder:'.length) + const workspace = value.folderWorkspaces.find( + (row: unknown) => row !== null && typeof row === 'object' && 'id' in row && row.id === id + ) + if (!workspace || typeof workspace !== 'object') { + return false + } + const connectionId = 'connectionId' in workspace ? workspace.connectionId : null + return ( + connectionId === null || + (typeof connectionId === 'string' && isMobileNativeChatTranscriptReadable(connectionId)) + ) +} diff --git a/mobile/src/session/mobile-native-chat-permission.test.ts b/mobile/src/session/mobile-native-chat-permission.test.ts index 4744c394756..3062e35c238 100644 --- a/mobile/src/session/mobile-native-chat-permission.test.ts +++ b/mobile/src/session/mobile-native-chat-permission.test.ts @@ -142,3 +142,10 @@ describe('parseApprovalFromStatus', () => { expect(parseApprovalFromStatus(JSON.stringify({ approval: {} }))).toBeNull() }) }) + +for (const agent of ['opencode', 'opencode2']) { + it(`${agent} approves the captured default selector with Enter`, () => { + const card = parseApprovalFromStatus(JSON.stringify({ approval: { tool: 'shell' } }), agent) + expect(card?.options[0].send).toBe('\r') + }) +} diff --git a/mobile/src/session/mobile-native-chat-permission.ts b/mobile/src/session/mobile-native-chat-permission.ts index 72650b2dd22..220599d73bd 100644 --- a/mobile/src/session/mobile-native-chat-permission.ts +++ b/mobile/src/session/mobile-native-chat-permission.ts @@ -1,3 +1,4 @@ +import { nativeChatApprovalAcceptKey } from '../../../src/shared/native-chat-agent-support' import type { AgentJournalApprovalMatchedAskRule, AgentJournalApprovalSubject @@ -37,7 +38,8 @@ const ESCAPE = String.fromCharCode(27) * detectAgentPermission still takes precedence when it can read the real numbered * options from the prompt text. */ export function parseApprovalFromStatus( - interactivePrompt: string | undefined | null + interactivePrompt: string | undefined | null, + agent?: string ): MobileChatPermission | null { if (!interactivePrompt) { return null @@ -64,7 +66,7 @@ export function parseApprovalFromStatus( title: `Allow ${tool}?`, detail: typeof summary === 'string' && summary.length > 0 ? summary : undefined, options: [ - { label: 'Allow', send: '1' }, + { label: 'Allow', send: nativeChatApprovalAcceptKey(agent) }, { label: 'Deny', send: ESCAPE } ] } diff --git a/mobile/src/session/use-mobile-native-chat-answer-send.test.ts b/mobile/src/session/use-mobile-native-chat-answer-send.test.ts index 394c5555e2f..634d42f6945 100644 --- a/mobile/src/session/use-mobile-native-chat-answer-send.test.ts +++ b/mobile/src/session/use-mobile-native-chat-answer-send.test.ts @@ -108,50 +108,58 @@ describe('useMobileNativeChatAnswerSend', () => { }) } - it('single-select: sends the picked option NUMBER (not the label), no trailing Enter', async () => { - const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) - await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + it.each(['claude', 'opencode', 'opencode2'] as const)( + '%s single-select: sends the picked option number without a trailing Enter', + async (agent) => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture exercises only the scripted sendRequest port. + await mount({ sendRequest } as unknown as RpcClient, vi.fn(), agent) - // Spaces is option 2 — the STA-1860 case where label text committed Tabs. - await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true) - expect(sendRequest).toHaveBeenCalledTimes(1) - expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '2', enter: false }) - }) - - it('multi-select: toggles each option number, steps to Submit, confirms — paced apart', async () => { - const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) - await mount({ sendRequest } as unknown as RpcClient, vi.fn()) - - const prompt: AskPrompt = { - questions: [ - { - question: 'Pick fruits', - multiSelect: true, - options: [{ label: 'Apple' }, { label: 'Banana' }, { label: 'Cherry' }] - } - ] + // Spaces is option 2 — the STA-1860 case where label text committed Tabs. + await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true) + expect(sendRequest).toHaveBeenCalledTimes(1) + expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '2', enter: false }) } - let result: Promise | undefined - await act(async () => { - result = answerSend?.answerAsk(prompt, [{ indices: [0, 2] }]) - }) - expect(sendRequest).toHaveBeenCalledTimes(1) - expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '1', enter: false }) + ) - await act(async () => { - await vi.advanceTimersByTimeAsync(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS) - }) - expect(sendRequest.mock.calls[1]?.[1]).toMatchObject({ text: '3', enter: false }) - await act(async () => { - await vi.advanceTimersByTimeAsync(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS) - }) - expect(sendRequest.mock.calls[2]?.[1]).toMatchObject({ text: '\x1b[C', enter: false }) - await act(async () => { - await vi.advanceTimersByTimeAsync(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS) - }) - await expect(result).resolves.toBe(true) - expect(sendRequest.mock.calls[3]?.[1]).toMatchObject({ text: '\r', enter: false }) - }) + it.each(['claude', 'opencode', 'opencode2'] as const)( + '%s multi-select: toggles option numbers and confirms in paced steps', + async (agent) => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture exercises only the scripted sendRequest port. + await mount({ sendRequest } as unknown as RpcClient, vi.fn(), agent) + + const prompt: AskPrompt = { + questions: [ + { + question: 'Pick fruits', + multiSelect: true, + options: [{ label: 'Apple' }, { label: 'Banana' }, { label: 'Cherry' }] + } + ] + } + let result: Promise | undefined + await act(async () => { + result = answerSend?.answerAsk(prompt, [{ indices: [0, 2] }]) + }) + expect(sendRequest).toHaveBeenCalledTimes(1) + expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '1', enter: false }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS) + }) + expect(sendRequest.mock.calls[1]?.[1]).toMatchObject({ text: '3', enter: false }) + await act(async () => { + await vi.advanceTimersByTimeAsync(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS) + }) + expect(sendRequest.mock.calls[2]?.[1]).toMatchObject({ text: '\x1b[C', enter: false }) + await act(async () => { + await vi.advanceTimersByTimeAsync(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS) + }) + await expect(result).resolves.toBe(true) + expect(sendRequest.mock.calls[3]?.[1]).toMatchObject({ text: '\r', enter: false }) + } + ) it('multi-question: option numbers auto-advance, one final submit Enter', async () => { const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) diff --git a/mobile/src/session/use-mobile-native-chat-cancel-ask.test.ts b/mobile/src/session/use-mobile-native-chat-cancel-ask.test.ts new file mode 100644 index 00000000000..0220e09c665 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-cancel-ask.test.ts @@ -0,0 +1,76 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' +import { useMobileNativeChatCancelAsk } from './use-mobile-native-chat-cancel-ask' + +let renderer: ReactTestRenderer | undefined +afterEach(() => act(() => renderer?.unmount())) + +function fixture() { + const sendRequest = vi.fn().mockResolvedValue({ + id: 'send', + ok: true, + result: { send: { accepted: true } } + }) + const client: RpcClient = { + sendRequest, + subscribe: () => () => {}, + updateTerminalSubscriptionViewport: () => {}, + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => 1, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} + } + const cancelPending = vi.fn() + const onSendError = vi.fn() + let cancelAsk: (() => Promise) | undefined + function Probe(): null { + cancelAsk = useMobileNativeChatCancelAsk({ + client, + enabled: true, + handleRef: { current: 'terminal' }, + deviceTokenRef: { current: 'device' }, + cancelPending, + onSendError + }) + return null + } + act(() => { + renderer = create(createElement(Probe)) + }) + return { + sendRequest, + cancelPending, + onSendError, + cancel: () => { + if (!cancelAsk) { + throw new Error('Cancel hook did not mount') + } + return cancelAsk() + } + } +} + +describe('mobile question rejection', () => { + it('cancels pending answer writes then sends exactly one Escape without submitting', async () => { + const f = fixture() + await expect(f.cancel()).resolves.toBe(true) + expect(f.cancelPending).toHaveBeenCalledOnce() + const sends = f.sendRequest.mock.calls.filter(([method]) => method === 'terminal.send') + expect(sends).toHaveLength(1) + expect(sends[0]?.[1]).toMatchObject({ terminal: 'terminal', text: '\x1b', enter: false }) + expect(f.onSendError).not.toHaveBeenCalled() + }) + + it('reports ambiguous rejection delivery without retrying into a changed selector', async () => { + const f = fixture() + f.sendRequest.mockRejectedValue(markRpcDeliveryUnknown(new Error('connection lost'))) + await expect(f.cancel()).resolves.toBe(false) + expect(f.sendRequest).toHaveBeenCalledOnce() + expect(f.onSendError).toHaveBeenCalledWith('Cancel unconfirmed — check chat before retrying') + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-prompts.test.ts b/mobile/src/session/use-mobile-native-chat-prompts.test.ts index 905b92155e6..a82d16ade68 100644 --- a/mobile/src/session/use-mobile-native-chat-prompts.test.ts +++ b/mobile/src/session/use-mobile-native-chat-prompts.test.ts @@ -39,6 +39,39 @@ function permissionFor(status: Partial | null): unknown { } describe('useMobileNativeChatPrompts approval-envelope state gate', () => { + it.each(['opencode', 'opencode2'])( + 'keeps the %s approval event authoritative over numbered assistant prose', + (agentType) => { + expect( + permissionFor({ + state: 'waiting', + agentType, + interactivePrompt: APPROVAL, + lastAssistantMessage: 'Allow this Bash command?\n1. Yes\n2. No' + }) + ).toMatchObject({ + title: 'Allow Bash?', + options: [ + { label: 'Allow', send: '\r' }, + { label: 'Deny', send: '\x1b' } + ] + }) + } + ) + + it.each(['opencode', 'opencode2'])( + 'does not mistake %s question text for an approval', + (agentType) => { + const prompts = promptsFor({ + state: 'waiting', + agentType, + interactivePrompt: ASK, + lastAssistantMessage: 'Allow this Bash command?\n1. Yes\n2. No' + }) + expect(prompts.permission).toBeNull() + expect(prompts.ask?.questions[0]?.question).toBe('Which path?') + } + ) it('renders no approval card while the agent is working', () => { expect(permissionFor({ state: 'working', interactivePrompt: APPROVAL })).toBeNull() }) diff --git a/mobile/src/session/use-mobile-native-chat-prompts.ts b/mobile/src/session/use-mobile-native-chat-prompts.ts index 35acecbe844..58a17e77f99 100644 --- a/mobile/src/session/use-mobile-native-chat-prompts.ts +++ b/mobile/src/session/use-mobile-native-chat-prompts.ts @@ -2,6 +2,7 @@ import { useMemo } from 'react' import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' import { parseAskFromStatus, resolveNativeChatAsk } from '../../../src/shared/native-chat-ask' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { resolveNativeChatTranscriptAgent } from '../../../src/shared/native-chat-agent-support' import { detectAgentPermission, parseApprovalFromStatus } from './mobile-native-chat-permission' import { parseAgentQuestion } from './mobile-native-chat-question' @@ -23,17 +24,20 @@ export function useMobileNativeChatPrompts(args: { }): MobileNativeChatPrompts { const { enabled, status, messages, transcriptLoading } = args const blocked = status?.state === 'waiting' || status?.state === 'blocked' + const openCode = resolveNativeChatTranscriptAgent(status?.agentType) === 'opencode' // Both permission paths sit inside the paused gate: an approval envelope can // outlive its answer (the host keeps it sticky), so only a waiting/blocked // agent may surface it — never a working or done one (STA-3144). const permission = blocked && status - ? (detectAgentPermission({ - state: status.state, - lastAssistantMessage: status.lastAssistantMessage, - toolName: status.toolName, - toolInput: status.toolInput - }) ?? parseApprovalFromStatus(status.interactivePrompt)) + ? openCode + ? parseApprovalFromStatus(status.interactivePrompt, status.agentType) + : (detectAgentPermission({ + state: status.state, + lastAssistantMessage: status.lastAssistantMessage, + toolName: status.toolName, + toolInput: status.toolInput + }) ?? parseApprovalFromStatus(status.interactivePrompt, status.agentType)) : null const question = blocked && status && !permission ? parseAgentQuestion(status.lastAssistantMessage ?? '') : null diff --git a/mobile/src/session/use-mobile-native-chat-readability.ts b/mobile/src/session/use-mobile-native-chat-readability.ts index 167e70f832f..c8c67f62119 100644 --- a/mobile/src/session/use-mobile-native-chat-readability.ts +++ b/mobile/src/session/use-mobile-native-chat-readability.ts @@ -5,7 +5,11 @@ import { type MobileRuntimeRepoSummary } from './mobile-session-read-operations' import { isFloatingWorkspaceWorktreeId } from './floating-workspace' -import { isMobileNativeChatTranscriptReadable } from './mobile-native-chat-eligibility' +import { resumeFolderWorkspaceListRead } from '../agent-history/mobile-agent-history-operations' +import { + isMobileFolderNativeChatReadable, + isMobileNativeChatTranscriptReadable +} from './mobile-native-chat-eligibility' import { getRepoIdFromMobileWorktreeId } from './mobile-session-route-helpers' type ReadabilityState = { client: RpcClient | null; worktreeId: string; readable: boolean } @@ -30,6 +34,29 @@ export function useMobileNativeChatReadability( setState({ client, worktreeId, readable: false }) return } + if (worktreeId.startsWith('folder:')) { + void resumeFolderWorkspaceListRead + .request(client) + .then((response) => { + if (!active) { + return + } + const result = resumeFolderWorkspaceListRead.interpret(response) + setState({ + client, + worktreeId, + readable: result.accepted && isMobileFolderNativeChatReadable(result.value, worktreeId) + }) + }) + .catch(() => { + if (active) { + setState({ client, worktreeId, readable: false }) + } + }) + return () => { + active = false + } + } void nativeChatRepoListRead .request(client) .then((response) => { diff --git a/src/main/ai-vault/session-scanner-opencode-native-page-limit.test.ts b/src/main/ai-vault/session-scanner-opencode-native-page-limit.test.ts new file mode 100644 index 00000000000..56664bf9abf --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-native-page-limit.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest' + +const readNativeChat = vi.hoisted(() => + vi.fn(async () => ({ items: [], hasMore: false, beforeMessageRowId: null })) +) +vi.mock('./session-scanner-opencode-sqlite-worker-client', () => ({ + OpenCodeSqliteWorkerClient: class { + readNativeChat = readNativeChat + } +})) +vi.mock('./session-scanner-opencode-wsl-client', () => ({ + openCodeWslPath: (path: string) => + path.startsWith('wsl:') ? { distro: 'Ubuntu', linuxPath: '/home/test/opencode.db' } : null, + openCodeWslClient: async () => ({ readNativeChat }), + mapOpenCodeWslSession: (value: unknown) => value +})) +import { readOpenCodeTranscriptPageViaWorker } from './session-scanner-opencode-sqlite-worker-spawn' + +describe.each(['native.db', 'wsl:opencode.db'])('native-page protocol bounds for %s', (dbPath) => { + it.each([ + [10.9, 10], + [0, 1], + [-1, 1], + [0.1, 1], + [2401, 2400], + [Number.MAX_VALUE, 2400], + [Number.MAX_SAFE_INTEGER + 1, 2400], + [Number.NaN, 300], + [Infinity, 300], + [-Infinity, 300] + ])( + 'normalizes %s to a finite safe page limit of %s before transport', + async (limit, expected) => { + await readOpenCodeTranscriptPageViaWorker({ dbPath, sessionId: 'session', limit }) + expect(readNativeChat).toHaveBeenLastCalledWith( + { + kind: 'native-page', + dbPath: dbPath.startsWith('wsl:') ? '/home/test/opencode.db' : dbPath, + sessionId: 'session', + limit: expected + }, + undefined + ) + } + ) +}) diff --git a/src/main/ai-vault/session-scanner-opencode-native-worker.test.ts b/src/main/ai-vault/session-scanner-opencode-native-worker.test.ts new file mode 100644 index 00000000000..9e8511967f2 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-native-worker.test.ts @@ -0,0 +1,153 @@ +import { build } from 'esbuild' +import { copyFileSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Worker } from 'node:worker_threads' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import SyncDatabase from '../sqlite/sync-database' +import { writeOpenCodeSqliteDatabase } from './session-scanner-opencode-sqlite-fixture' +import { OpenCodeSqliteWorkerClient } from './session-scanner-opencode-sqlite-worker-client' +import { resolveOpenCodeSqliteWorkerEntryPath } from './session-scanner-opencode-sqlite-worker-spawn' +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import { AGENT_SESSION_HOST_STATUS_COPY } from '../../shared/agent-session-host-status-rows' + +const directory = mkdtempSync(join(tmpdir(), 'orca-opencode-native-worker-')) +const entry = resolveOpenCodeSqliteWorkerEntryPath(directory) +const v1Path = join(directory, 'v1', 'opencode.db') +const v2Path = join(directory, 'v2.db') + +beforeAll(async () => { + await build({ + entryPoints: ['src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry.ts'], + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node22', + external: ['electron'], + outfile: entry, + logLevel: 'silent' + }) + writeOpenCodeSqliteDatabase(v1Path, [ + { + id: 'session', + turns: ['first', 'second', 'third'].map((text) => ({ role: 'user', parts: [text] })) + } + ]) + const db = new SyncDatabase(v2Path) + try { + db.exec(`CREATE TABLE session_v2 (id TEXT PRIMARY KEY); + CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT, type TEXT, + seq INTEGER, data TEXT, time_created INTEGER, time_updated INTEGER); + INSERT INTO session_v2 VALUES ('session');`) + const insert = db.prepare('INSERT INTO session_message VALUES (?, ?, ?, ?, ?, 1, 1)') + for (const [index, text] of ['first', 'second', 'third'].entries()) { + insert.run(String(index), 'session', 'user', index + 1, JSON.stringify({ text })) + } + } finally { + db.close() + } +}) + +afterAll(() => { + rmSync(directory, { recursive: true, force: true }) +}) + +describe('OpenCode reads through the production shared worker', () => { + it.each(['v1-message', 'v1-part', 'v2-message'] as const)( + 'degrades %s oversize content through the actual worker and retains the next page', + async (kind) => { + const dbPath = join(directory, `${kind}.db`) + const v2 = kind === 'v2-message' + copyFileSync(v2 ? v2Path : v1Path, dbPath) + const db = new SyncDatabase(dbPath) + const table = v2 ? 'session_message' : kind === 'v1-part' ? 'part' : 'message' + db.prepare(`UPDATE ${table} SET data = ?, time_updated = 2 WHERE rowid = 2`).run( + JSON.stringify({ text: '😀'.repeat(600_000) }) + ) + db.close() + const client = new OpenCodeSqliteWorkerClient({ workerFactory: () => new Worker(entry) }) + try { + const args = { dbPath, sessionId: 'session', kind: 'native-page' as const, limit: 2 } + const page = await client.readNativeChat(args) + if (!page || !('items' in page)) { + throw new Error('Expected the bounded native page') + } + expect(page.items.map((item) => item.rowid)).toEqual([2, 3]) + expect(page.items[0]?.message.blocks).toEqual([ + { type: 'text', text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'] } + ]) + const repeat = await client.readNativeChat(args) + expect(repeat).toEqual(page) + const older = await client.readNativeChat({ + ...args, + beforeMessageRowId: page.beforeMessageRowId ?? undefined + }) + expect(older).toMatchObject({ items: [{ rowid: 1 }], hasMore: false }) + } finally { + client.dispose() + } + } + ) + + it.each([v1Path, v2Path])( + 'reads native signals and paginated history from %s', + async (dbPath) => { + const start = vi.fn(() => new Worker(entry)) + const client = new OpenCodeSqliteWorkerClient({ workerFactory: start }) + const args = { dbPath, sessionId: 'session' } + try { + await expect( + client.readNativeChat({ ...args, kind: 'native-signal' }) + ).resolves.toMatchObject({ + messageCount: 3, + maxMessageRowId: 3 + }) + const page = await client.readNativeChat({ ...args, kind: 'native-page', limit: 2 }) + if (!page || !('items' in page)) { + throw new Error('Expected a native transcript page') + } + expect(page.items.map((item) => item.message.blocks)).toEqual([ + [{ type: 'text', text: 'second' }], + [{ type: 'text', text: 'third' }] + ]) + expect(page.hasMore).toBe(true) + const older = await client.readNativeChat({ + ...args, + kind: 'native-page', + limit: 2, + beforeMessageRowId: page.beforeMessageRowId ?? undefined + }) + if (!older || !('items' in older)) { + throw new Error('Expected the older transcript page') + } + expect(older.items.map((item) => item.message.blocks)).toEqual([ + [{ type: 'text', text: 'first' }] + ]) + expect(older.hasMore).toBe(false) + expect(start).toHaveBeenCalledOnce() + } finally { + client.dispose() + } + } + ) + + it('keeps list, parse and capture supported in the same worker', async () => { + const start = vi.fn(() => new Worker(entry)) + const client = new OpenCodeSqliteWorkerClient({ workerFactory: start }) + const issues: AiVaultScanIssue[] = [] + const args = { dbPath: v1Path, sessionId: 'session', platform: process.platform } + try { + expect(await client.list({ dbPaths: [v1Path], limit: 5, issues })).toHaveLength(1) + expect(issues).toEqual([]) + expect(await client.parse(args)).toMatchObject({ sessionId: 'session' }) + expect((await client.capture(args)).messages.map((message) => message.text)).toEqual([ + 'first', + 'second', + 'third' + ]) + expect(start).toHaveBeenCalledOnce() + } finally { + client.dispose() + } + }) +}) diff --git a/src/main/ai-vault/session-scanner-opencode-sources.ts b/src/main/ai-vault/session-scanner-opencode-sources.ts index 5f15f17d758..45abf16160a 100644 --- a/src/main/ai-vault/session-scanner-opencode-sources.ts +++ b/src/main/ai-vault/session-scanner-opencode-sources.ts @@ -1,9 +1,11 @@ import { basename, dirname, join } from 'node:path' import type { AiVaultScanIssue } from '../../shared/ai-vault-types' -import { wslGatedReaddir } from '../native-chat/wsl-transcript-fs-access' -import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' +import type { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' import { resolveOpenCodeStorageDirectory } from '../opencode/opencode-data-directory' -import { listOpenCodeDatabases } from '../opencode-usage/opencode-database-discovery' +import { + listOpenCodeDatabases, + listOpenCodeDatabasesInDirectory +} from '../opencode-usage/opencode-database-discovery' import { recordSessionScanIssue } from './session-scan-issues' import { discoverOpenCodeSessions } from './session-scanner-opencode-sqlite-discovery' import { listOpenCode2SqliteSessionsViaWorker } from './session-scanner-opencode-sqlite-worker-spawn' @@ -56,45 +58,26 @@ async function opencodeDbPathsForSource( if (options.opencodeDbPaths) { return sourceIndex === 0 ? options.opencodeDbPaths : [] } + const onRefusal = (path: string, error: WslTranscriptFsError): void => { + recordSessionScanIssue(issues, { agent: 'opencode', path, message: error.message }) + } // Why: custom OpenCode storage roots still keep SQLite DBs in the parent data dir. if (sourceIndex === 0 && options.opencodeStorageDir) { - return listOpenCodeDatabasesInDirectory(dirname(storageDir), issues) + return listOpenCodeDatabasesInDirectory(dirname(storageDir), onRefusal, options.signal) } if (sourceIndex === 0) { - return listOpenCodeDatabases((path, error) => { - recordSessionScanIssue(issues, { agent: 'opencode', path, message: error.message }) - }) + return listOpenCodeDatabases(onRefusal, undefined, options.signal) } const wslHomeDir = wslHomeDirs[sourceIndex - 1] return wslHomeDir - ? listOpenCodeDatabasesInDirectory(join(wslHomeDir, '.local', 'share', 'opencode'), issues) + ? listOpenCodeDatabasesInDirectory( + join(wslHomeDir, '.local', 'share', 'opencode'), + onRefusal, + options.signal + ) : [] } -async function listOpenCodeDatabasesInDirectory( - dataDir: string, - issues: AiVaultScanIssue[] -): Promise { - try { - const entries = await wslGatedReaddir(dataDir, 'scan') - return entries - .filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name)) - .map((entry) => join(dataDir, entry.name)) - .sort() - } catch (error) { - // A stalled WSL data dir still degrades to "no databases", but the gap has - // to be reportable — an empty list otherwise reads as "OpenCode not used". - if (error instanceof WslTranscriptFsError) { - recordSessionScanIssue(issues, { - agent: 'opencode', - path: dataDir, - message: error.message - }) - } - return [] - } -} - async function discoverOpenCode2Sessions( storageDir: string, dbPaths: readonly string[], 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 e483f9482e1..d9bc0b3a27c 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-dispatch.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-dispatch.ts @@ -1,3 +1,7 @@ +import { + readOpenCodeTranscriptPage, + readOpenCodeTranscriptSignal +} from '../native-chat/transcript-opencode-sqlite-query' import type { AiVaultScanIssue } from '../../shared/ai-vault-types' import { captureOpenCodeSqliteSession } from './session-scanner-opencode-sqlite-capture' import { listOpenCodeSqliteSessions } from './session-scanner-opencode-sqlite-list' @@ -18,6 +22,13 @@ export async function handleOpenCodeSqliteRequest( request: OpenCodeSqliteWorkerRequest ): Promise { try { + if (request.kind === 'native-page' || request.kind === 'native-signal') { + const value = + request.kind === 'native-signal' + ? readOpenCodeTranscriptSignal(request.dbPath, request.sessionId) + : readOpenCodeTranscriptPage({ ...request, limit: request.limit ?? 50 }) + return { id: request.id, ok: true, value } + } if (request.kind === 'list') { const issues: AiVaultScanIssue[] = [] const candidates = @@ -45,6 +56,9 @@ export async function handleOpenCodeSqliteRequest( }) return { id: request.id, ok: true, value: capture } } + if (request.kind !== 'parse') { + throw new Error('Unsupported OpenCode SQLite request') + } const parse = async () => request.agent === 'opencode2' ? await parseOpenCode2SqliteSession({ diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-open.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-open.ts index 32f807cb1d9..168a7ee6620 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-open.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-open.ts @@ -35,7 +35,7 @@ export function openCodeBusyTimeoutMs(dbPath: string): number { return isWslUncPath(dbPath) ? 0 : OPENCODE_SQLITE_BUSY_TIMEOUT_MS } -function openOpenCodeDatabaseReadonly(dbPath: string): SyncDatabase { +export function openOpenCodeDatabaseReadonly(dbPath: string): SyncDatabase { const db = new SyncDatabase(dbPath, { readonly: true, fileMustExist: true, diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-paths.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-paths.test.ts index db9c60f0b70..c46fcd195e2 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-paths.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-paths.test.ts @@ -16,6 +16,17 @@ describe('splitOpenCodeSqliteCandidate', () => { expect(result).toEqual({ dbPath: '/data/opencode-stable.db', sessionId: 'ses_xyz' }) }) + it('preserves Windows host paths including hashes in directory names', () => { + const dbPath = String.raw`C:\Users\reader\project#one\opencode.db` + expect(splitOpenCodeSqliteCandidate(`${dbPath}#ses_abc`)).toEqual({ + dbPath, + sessionId: 'ses_abc' + }) + expect( + splitOpenCodeSqliteCandidate(String.raw`C:\Users\opencode.db\notes.txt#ses_abc`) + ).toBeNull() + }) + it('rejects a path whose db basename is not opencode*.db', () => { expect(splitOpenCodeSqliteCandidate('/data/random.db#ses_abc')).toBeNull() expect(splitOpenCodeSqliteCandidate('/data/notes.txt#ses_abc')).toBeNull() diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-paths.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-paths.ts index 77c0e464154..cc9d93781df 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-paths.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-paths.ts @@ -1,61 +1,5 @@ -import { basename } from 'node:path' - -// Why: keep the synthetic candidate-path helpers separate from the SQLite -// discovery/parser so both the scanner and the agent-parser dispatcher can -// import them without pulling in the SyncDatabase dependency. - -const OPENCODE_SQLITE_PATH_SEPARATOR = '#' - -/** - * Build a synthetic candidate path that encodes the SQLite DB path and session ID - * as `#`. Used by the discovery layer so SQLite-backed - * sessions flow through the same FileWithMtime pipeline as file-backed ones. - * @param dbPath - Absolute path to the opencode.db file. - * @param sessionId - The OpenCode session ID (primary key in the session table). - * @returns The synthetic candidate path string. - */ -export function buildOpenCodeSqliteCandidatePath(dbPath: string, sessionId: string): string { - return `${dbPath}${OPENCODE_SQLITE_PATH_SEPARATOR}${sessionId}` -} - -/** - * Parse a synthetic candidate path back into its DB path and session ID parts. - * Validates that the DB basename matches `opencode*.db` so real filesystem paths - * that happen to contain `#` are never misrouted to the SQLite parser. - * @param candidatePath - The synthetic path to parse. - * @returns `{ dbPath, sessionId }` if the path is a valid synthetic candidate, `null` otherwise. - */ -export function splitOpenCodeSqliteCandidate( - candidatePath: string, - agent: 'opencode' | 'opencode2' | 'zcode' = 'opencode' -): { dbPath: string; sessionId: string } | null { - const separatorIndex = candidatePath.lastIndexOf(OPENCODE_SQLITE_PATH_SEPARATOR) - if (separatorIndex <= 0 || separatorIndex === candidatePath.length - 1) { - return null - } - const dbPath = candidatePath.slice(0, separatorIndex) - const sessionId = candidatePath.slice(separatorIndex + 1) - if (!dbPath || !sessionId) { - return null - } - // Why: OpenCode DB files are named opencode*.db; reject anything else so we - // never misroute a real filesystem path that happens to contain '#'. - const validName = - agent === 'zcode' - ? basename(dbPath).toLowerCase() === 'db.sqlite' - : /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/i.test(basename(dbPath)) - if (!validName) { - return null - } - return { dbPath, sessionId } -} - -/** - * Type guard: returns `true` if the path is a valid synthetic OpenCode SQLite - * candidate path (i.e. `splitOpenCodeSqliteCandidate` would return non-null). - * @param candidatePath - The path to test. - * @returns `true` if the path is a synthetic SQLite candidate, `false` otherwise. - */ -export function looksLikeOpenCodeSqliteCandidate(candidatePath: string): boolean { - return splitOpenCodeSqliteCandidate(candidatePath) !== null -} +export { + buildOpenCodeSqliteCandidatePath, + splitOpenCodeSqliteCandidate, + looksLikeOpenCodeSqliteCandidate +} from '../../shared/opencode-sqlite-session-path' diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-process-entry.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-process-entry.ts index e53d89eeeab..bcd380bc51e 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-process-entry.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-process-entry.ts @@ -24,6 +24,23 @@ function validRequest(value: unknown): value is OpenCodeSqliteWorkerRequest { ) { return false } + if (value.kind === 'native-page' || value.kind === 'native-signal') { + return ( + 'dbPath' in value && + typeof value.dbPath === 'string' && + 'sessionId' in value && + typeof value.sessionId === 'string' && + (!('limit' in value) || + (typeof value.limit === 'number' && + Number.isSafeInteger(value.limit) && + value.limit > 0 && + value.limit <= 2400)) && + (!('beforeMessageRowId' in value) || + (typeof value.beforeMessageRowId === 'number' && + Number.isSafeInteger(value.beforeMessageRowId) && + value.beforeMessageRowId >= 0)) + ) + } if (value.kind === 'list') { return ( 'dbPaths' in value && diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts index c5f96c030e3..f303de52443 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts @@ -2,6 +2,8 @@ import type { WorkerThreadFactory } from '../lazy-worker-thread-host' import { WorkerThreadRequestQueue } from '../worker-thread-request-queue' import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types' import type { + OpenCodeNativeChatReadRequest, + OpenCodeNativeChatReadValue, OpenCodeSqliteCaptureValue, OpenCodeSqliteListValue, OpenCodeSqliteWorkerRequest, @@ -223,6 +225,15 @@ export class OpenCodeSqliteWorkerClient { } } + async readNativeChat( + args: Omit, + signal?: AbortSignal + ): Promise { + const value = await this.dispatch((id) => ({ ...args, id }), PARSE_TIMEOUT_MS, signal) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Only this build's internal worker dispatch constructs page/signal results; they are not client-supplied paths or frames. + return value as OpenCodeNativeChatReadValue + } + dispose(): void { this.requests.dispose() } 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 9c8e42a5c7f..afee7778e3d 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 @@ -1,3 +1,7 @@ +import type { + OpenCodeTranscriptPage, + OpenCodeTranscriptSignal +} from '../native-chat/transcript-opencode-sqlite-query' import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types' import type { SessionFileCandidate } from './session-scanner-types' import type { TranscriptMessage } from './session-transcript-consumers' @@ -39,10 +43,22 @@ export type OpenCodeSqliteCaptureRequest = { agent?: 'opencode2' | 'zcode' } +export type OpenCodeNativeChatReadRequest = { + id: number + kind: 'native-page' | 'native-signal' + dbPath: string + sessionId: string + limit?: number + beforeMessageRowId?: number +} + +export type OpenCodeNativeChatReadValue = OpenCodeTranscriptPage | OpenCodeTranscriptSignal | null + export type OpenCodeSqliteWorkerRequest = ( | OpenCodeSqliteListRequest | OpenCodeSqliteParseRequest | OpenCodeSqliteCaptureRequest + | OpenCodeNativeChatReadRequest ) & { timeoutMs?: number } // The list leg returns candidates plus the issues it accumulated; the worker 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 ef3285f2001..13b9cd3cd5e 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 @@ -2,6 +2,7 @@ import { existsSync } from 'node:fs' import { Worker } from 'node:worker_threads' import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types' import { throwIfSignalAborted } from '../../shared/abort-signal-reason' +import { openCodeTranscriptPageLimit } from '../../shared/opencode-transcript-page-limit' import type { SessionFileCandidate } from './session-scanner-types' import type { OpenCodeSqliteCaptureValue } from './session-scanner-opencode-sqlite-worker-protocol' import { OpenCodeSqliteWorkerClient } from './session-scanner-opencode-sqlite-worker-client' @@ -238,3 +239,48 @@ async function captureForHost( const capture = await client.capture({ ...args, dbPath: wsl.linuxPath, platform: 'linux' }) return { ...capture, session: mapOpenCodeWslSession(capture.session, args.dbPath) } } + +export async function readOpenCodeTranscriptPageViaWorker( + args: { + dbPath: string + sessionId: string + limit: number + beforeMessageRowId?: number + }, + signal?: AbortSignal +) { + const wsl = openCodeWslPath(args.dbPath) + const client = wsl ? await openCodeWslClient(wsl.distro, args.dbPath, signal) : getSharedClient() + const value = await client.readNativeChat( + { + ...args, + limit: openCodeTranscriptPageLimit(args.limit), + dbPath: wsl?.linuxPath ?? args.dbPath, + kind: 'native-page' + }, + signal + ) + if (value !== null && !('items' in value)) { + throw new Error('Invalid OpenCode transcript page') + } + return value +} + +export async function readOpenCodeTranscriptSignalViaWorker( + args: { + dbPath: string + sessionId: string + }, + signal?: AbortSignal +) { + const wsl = openCodeWslPath(args.dbPath) + const client = wsl ? await openCodeWslClient(wsl.distro, args.dbPath, signal) : getSharedClient() + const value = await client.readNativeChat( + { ...args, dbPath: wsl?.linuxPath ?? args.dbPath, kind: 'native-signal' }, + signal + ) + if (value !== null && !('messageCount' in value)) { + throw new Error('Invalid OpenCode transcript signal') + } + return value +} diff --git a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry.ts b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry.ts index 951f6b9b52f..0bd9f5e745a 100644 --- a/src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry.ts +++ b/src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry.ts @@ -18,7 +18,13 @@ const port = parentPort type ReaderRequest = OpenCodeSqliteWorkerRequest | ForeignSqliteReaderRequest function isOpenCodeRequest(request: ReaderRequest): request is OpenCodeSqliteWorkerRequest { - return request.kind === 'list' || request.kind === 'parse' || request.kind === 'capture' + return ( + request.kind === 'list' || + request.kind === 'parse' || + request.kind === 'capture' || + request.kind === 'native-page' || + request.kind === 'native-signal' + ) } function handle(request: ReaderRequest): Promise { diff --git a/src/main/ipc/native-chat.ts b/src/main/ipc/native-chat.ts index 9e5058282ef..02e2928f3c6 100644 --- a/src/main/ipc/native-chat.ts +++ b/src/main/ipc/native-chat.ts @@ -1,3 +1,4 @@ +import { DESKTOP_READ_WINDOW } from '../native-chat/transcript-watch-contract' import { ipcMain, type IpcMainEvent, type WebContents } from 'electron' import type { AgentType, @@ -31,7 +32,6 @@ export type NativeChatReadSessionArgs = { // Why: render and parse only the recent window so long transcripts do not stall // either the main process or the message list. Pagination raises this limit. -const DESKTOP_READ_WINDOW = 300 async function readSession(args: NativeChatReadSessionArgs): Promise { const { agent, sessionId } = args diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts index cf9ff45805c..ba6a918cc6e 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts @@ -90,7 +90,7 @@ export async function prepareLegacyTranscriptImport(input: { const options = input.options ?? {} const limits = options.limits ?? DEFAULT_JOURNAL_PAYLOAD_LIMITS const transcriptAgent = resolveNativeChatTranscriptAgent(input.agent) - if (!transcriptAgent) { + if (!transcriptAgent || transcriptAgent === 'opencode') { return { ok: false, error: `Unsupported agent for journal import: ${input.agent}` } } const filePath = diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index 0f7d7c7367f..08de49763b4 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -101,7 +101,7 @@ export async function resolveSessionFilePath( ): Promise { signal?.throwIfAborted() const transcriptAgent = resolveNativeChatTranscriptAgent(agent) - if (!transcriptAgent) { + if (!transcriptAgent || transcriptAgent === 'opencode') { return null } // Why: the hook's transcript_path is the exact file the agent is writing, so it @@ -160,6 +160,9 @@ async function resolveSessionFileById( options: ResolveSessionFileOptions, signal?: AbortSignal ): Promise { + if (transcriptAgent === 'opencode') { + return null + } const trimmedId = sessionId.trim() if (!trimmedId) { return null diff --git a/src/main/native-chat/transcript-opencode-database.test.ts b/src/main/native-chat/transcript-opencode-database.test.ts new file mode 100644 index 00000000000..daaeca3b685 --- /dev/null +++ b/src/main/native-chat/transcript-opencode-database.test.ts @@ -0,0 +1,215 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import type { OpenCodeTranscriptSignal } from './transcript-opencode-sqlite-query' +import { WslTranscriptFsError } from './wsl-transcript-fs-error' + +const mocks = vi.hoisted(() => ({ + homes: vi.fn(async (): Promise => []), + native: vi.fn(async (): Promise => []), + directory: vi.fn( + async ( + _path: string, + _onRefusal?: (path: string, error: WslTranscriptFsError) => void + ): Promise => [] + ), + prepare: vi.fn(async () => []), + configure: vi.fn(), + wslPath: vi.fn((_path: string): { distro: string; linuxPath: string } | null => null), + readSignal: vi.fn( + async ( + _args: { dbPath: string; sessionId: string }, + _signal?: AbortSignal + ): Promise => null + ) +})) +vi.mock('../ai-vault/cached-session-list', () => ({ getAiVaultWslHomeDirs: mocks.homes })) +vi.mock('../opencode-usage/opencode-database-discovery', () => ({ + listOpenCodeDatabases: mocks.native, + listOpenCodeDatabasesInDirectory: mocks.directory, + compareOpenCodeClaimPriority: (a: string, b: string) => a.localeCompare(b) +})) +vi.mock('../ai-vault/opencode-wsl-runtime-preparation', () => ({ + prepareOpenCodeWslReaders: mocks.prepare +})) +vi.mock('../ai-vault/session-scanner-opencode-wsl-client', () => ({ + configureOpenCodeWslReaders: mocks.configure, + openCodeWslPath: mocks.wslPath +})) +vi.mock('../ai-vault/session-scanner-opencode-sqlite-worker-spawn', () => ({ + readOpenCodeTranscriptSignalViaWorker: mocks.readSignal, + readOpenCodeTranscriptPageViaWorker: vi.fn() +})) +import { + resolveOpenCodeTranscriptDbPath, + openCodeTranscriptDefaultDeps +} from './transcript-opencode' + +afterEach(() => { + vi.resetAllMocks() + vi.useRealTimers() +}) + +describe('OpenCode transcript owning-host database discovery', () => { + it.each(['homes', 'directory', 'prepare'] as const)( + 'opens a matching native database without waiting for WSL %s', + async (stage) => { + vi.useFakeTimers() + mocks.native.mockResolvedValue(['native.db']) + mocks.homes.mockResolvedValue(['wsl-home']) + mocks[stage].mockImplementation(() => new Promise(() => {})) + mocks.readSignal.mockResolvedValue({ + messageCount: 1, + partCount: 1, + maxMessageRowId: 1, + maxPartTimeUpdated: 1 + }) + const settled = vi.fn() + void resolveOpenCodeTranscriptDbPath('session').then(settled, settled) + await vi.advanceTimersByTimeAsync(50) + expect(settled).toHaveBeenCalledWith('native.db') + expect(mocks.readSignal).toHaveBeenCalledOnce() + expect(mocks.prepare).not.toHaveBeenCalled() + } + ) + + it('finds a WSL-only session using the same running homes and database discovery as Vault', async () => { + const home = 'wsl-home' + const dbPath = join(home, '.local', 'share', 'opencode', 'opencode.db') + mocks.homes.mockResolvedValue([home]) + mocks.directory.mockResolvedValue([dbPath]) + mocks.readSignal.mockResolvedValue(null) + mocks.readSignal.mockImplementation(async () => ({ + messageCount: 1, + partCount: 1, + maxMessageRowId: 1, + maxPartTimeUpdated: 1 + })) + await expect(resolveOpenCodeTranscriptDbPath('session')).resolves.toBe(dbPath) + expect(mocks.directory).toHaveBeenCalledWith( + join(home, '.local', 'share', 'opencode'), + expect.any(Function), + expect.any(AbortSignal) + ) + expect(mocks.prepare).toHaveBeenCalledWith([home]) + expect(mocks.configure).toHaveBeenCalledOnce() + expect(mocks.readSignal).toHaveBeenCalledWith( + { dbPath, sessionId: 'session' }, + expect.any(AbortSignal) + ) + }) + + it('caps candidate probes when a session is missing', async () => { + mocks.native.mockResolvedValue(Array.from({ length: 40 }, (_, index) => `db-${index}`)) + await expect(resolveOpenCodeTranscriptDbPath('missing')).resolves.toBe(null) + expect(mocks.readSignal).toHaveBeenCalledTimes(32) + }) + + it.each(['database is locked', 'file is not a database', 'Preparing the WSL SQLite reader…'])( + 'continues to the next candidate after %s', + async (message) => { + mocks.native.mockResolvedValue(['a.db', 'b.db']) + mocks.readSignal.mockRejectedValueOnce(new Error(message)).mockResolvedValueOnce({ + messageCount: 1, + partCount: 1, + maxMessageRowId: 1, + maxPartTimeUpdated: 1 + }) + await expect(resolveOpenCodeTranscriptDbPath('session')).resolves.toBe('b.db') + expect(mocks.readSignal).toHaveBeenCalledTimes(2) + } + ) + + it('retains the first probe failure when no candidate has the session', async () => { + const refusal = new Error('database is locked') + mocks.native.mockResolvedValue(['a.db', 'b.db']) + mocks.readSignal.mockRejectedValueOnce(refusal).mockResolvedValueOnce(null) + await expect(resolveOpenCodeTranscriptDbPath('session')).rejects.toBe(refusal) + expect(mocks.readSignal).toHaveBeenCalledTimes(2) + }) + + it('continues past an unprepared WSL reader to another owning-host candidate', async () => { + const home = 'wsl-home' + const failedPath = join(home, '.local', 'share', 'opencode', 'opencode-a.db') + const matchingPath = join(home, '.local', 'share', 'opencode', 'opencode-b.db') + mocks.homes.mockResolvedValue([home]) + mocks.directory.mockResolvedValue([failedPath, matchingPath]) + mocks.readSignal + .mockRejectedValueOnce(new Error('Preparing the WSL SQLite reader…')) + .mockResolvedValueOnce({ + messageCount: 1, + partCount: 1, + maxMessageRowId: 1, + maxPartTimeUpdated: 1 + }) + await expect(resolveOpenCodeTranscriptDbPath('session')).resolves.toBe(matchingPath) + expect(mocks.prepare).toHaveBeenCalledOnce() + expect(mocks.configure).toHaveBeenCalledOnce() + expect(mocks.readSignal).toHaveBeenCalledTimes(2) + }) + + it('caps failed probes as well as missing-session probes', async () => { + const error = new Error('file is not a database') + mocks.native.mockResolvedValue(Array.from({ length: 40 }, (_, index) => `db-${index}`)) + mocks.readSignal.mockRejectedValue(error) + await expect(resolveOpenCodeTranscriptDbPath('session')).rejects.toBe(error) + expect(mocks.readSignal).toHaveBeenCalledTimes(32) + }) + + it('rethrows cancellation during a probe without trying the next candidate', async () => { + const controller = new AbortController() + const reason = new Error('caller cancelled the probe') + mocks.native.mockResolvedValue(['a.db', 'b.db']) + mocks.readSignal.mockImplementationOnce(async () => { + controller.abort(reason) + throw new Error('database is locked') + }) + await expect( + openCodeTranscriptDefaultDeps.resolveDbPath('session', controller.signal) + ).rejects.toBe(reason) + expect(mocks.readSignal).toHaveBeenCalledOnce() + }) + + it('prepares an overridden WSL database before its owning-host probe', async () => { + const dbPath = '\\\\wsl.localhost\\Ubuntu\\custom\\opencode.db' + mocks.native.mockResolvedValue([dbPath]) + mocks.wslPath.mockReturnValue({ distro: 'Ubuntu', linuxPath: '/custom/opencode.db' }) + mocks.readSignal.mockImplementation(async () => { + expect(mocks.configure).toHaveBeenCalledOnce() + return { messageCount: 1, partCount: 1, maxMessageRowId: 1, maxPartTimeUpdated: 1 } + }) + await expect(resolveOpenCodeTranscriptDbPath('session')).resolves.toBe(dbPath) + expect(mocks.prepare).toHaveBeenCalledWith([dbPath]) + expect(mocks.readSignal).toHaveBeenCalledOnce() + }) + + it('reports a refused WSL read instead of treating that host as empty', async () => { + mocks.homes.mockResolvedValue(['wsl-home']) + const refusal = new WslTranscriptFsError('unavailable', 'WSL discovery unavailable') + mocks.directory.mockImplementation(async (path, onRefusal) => { + onRefusal?.(path, refusal) + return [] + }) + await expect(resolveOpenCodeTranscriptDbPath('session')).rejects.toBe(refusal) + }) + + it('aborts a slow candidate within the discovery deadline, including its in-flight read', async () => { + vi.useFakeTimers() + mocks.native.mockResolvedValue(['slow.db']) + mocks.readSignal.mockImplementation(() => new Promise(() => {})) + const pending = resolveOpenCodeTranscriptDbPath('session') + const assertion = expect(pending).rejects.toThrow('database discovery exceeded its time limit') + await vi.advanceTimersByTimeAsync(5000) + await assertion + const readSignal = mocks.readSignal.mock.calls[0]?.[1] + expect(readSignal?.aborted).toBe(true) + }) + + it('propagates caller cancellation rather than reporting a missing local transcript', async () => { + const controller = new AbortController() + controller.abort(new Error('cancelled')) + await expect( + openCodeTranscriptDefaultDeps.resolveDbPath('session', controller.signal) + ).rejects.toThrow('cancelled') + expect(mocks.readSignal).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/native-chat/transcript-opencode-database.ts b/src/main/native-chat/transcript-opencode-database.ts new file mode 100644 index 00000000000..92b97e7c9b0 --- /dev/null +++ b/src/main/native-chat/transcript-opencode-database.ts @@ -0,0 +1,109 @@ +import { join } from 'node:path' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import { getAiVaultWslHomeDirs } from '../ai-vault/cached-session-list' +import { prepareOpenCodeWslReaders } from '../ai-vault/opencode-wsl-runtime-preparation' +import { + configureOpenCodeWslReaders, + openCodeWslPath +} from '../ai-vault/session-scanner-opencode-wsl-client' +import { readOpenCodeTranscriptSignalViaWorker } from '../ai-vault/session-scanner-opencode-sqlite-worker-spawn' +import { + compareOpenCodeClaimPriority, + listOpenCodeDatabases, + listOpenCodeDatabasesInDirectory +} from '../opencode-usage/opencode-database-discovery' + +export async function discoverOpenCodeTranscriptDatabase( + sessionId?: string, + signal?: AbortSignal +): Promise { + signal?.throwIfAborted() + const deadline = new AbortController() + const timer = setTimeout( + () => + deadline.abort(new Error('OpenCode transcript database discovery exceeded its time limit')), + 5000 + ) + timer.unref?.() + const boundedSignal = signal ? AbortSignal.any([signal, deadline.signal]) : deadline.signal + const refusals: Error[] = [] + const onRefusal = (_path: string, error: Error): void => { + refusals.push(error) + } + const probed = new Set() + async function findSession(paths: readonly string[]): Promise { + for (const dbPath of [...new Set(paths)].sort(compareOpenCodeClaimPriority)) { + if (probed.size >= 32) { + break + } + if (probed.has(dbPath)) { + continue + } + boundedSignal.throwIfAborted() + probed.add(dbPath) + if (!sessionId) { + return dbPath + } + try { + if ( + await waitForPromiseWithSignal( + readOpenCodeTranscriptSignalViaWorker({ dbPath, sessionId }, boundedSignal), + boundedSignal + ) + ) { + return dbPath + } + } catch (error) { + boundedSignal.throwIfAborted() + refusals.push(error instanceof Error ? error : new Error(String(error))) + } + } + return null + } + try { + const primary = await waitForPromiseWithSignal( + listOpenCodeDatabases(onRefusal, undefined, boundedSignal), + boundedSignal + ) + // Unrelated WSL setup must not hold up a matching native database. + const native = await findSession(primary.filter((path) => !openCodeWslPath(path))) + if (native) { + return native + } + const homes = await waitForPromiseWithSignal(getAiVaultWslHomeDirs(), boundedSignal) + const sources = await waitForPromiseWithSignal( + Promise.all( + homes + .slice(0, 32) + .map((home) => + listOpenCodeDatabasesInDirectory( + join(home, '.local', 'share', 'opencode'), + onRefusal, + boundedSignal + ) + ) + ), + boundedSignal + ) + const primaryWsl = primary.filter((path) => openCodeWslPath(path)) + const readerRoots = [...new Set([...homes, ...primaryWsl])] + if (readerRoots.length > 0) { + const readers = await waitForPromiseWithSignal( + prepareOpenCodeWslReaders(readerRoots), + boundedSignal + ) + boundedSignal.throwIfAborted() + configureOpenCodeWslReaders(readers) + } + const wsl = await findSession([...primaryWsl, ...sources.flat()]) + if (wsl) { + return wsl + } + if (refusals[0]) { + throw refusals[0] + } + return null + } finally { + clearTimeout(timer) + } +} diff --git a/src/main/native-chat/transcript-opencode-part-blocks.ts b/src/main/native-chat/transcript-opencode-part-blocks.ts new file mode 100644 index 00000000000..572851b34ae --- /dev/null +++ b/src/main/native-chat/transcript-opencode-part-blocks.ts @@ -0,0 +1,127 @@ +import { fileURLToPath } from 'node:url' +import type { NativeChatBlock, NativeChatImageRefBlock } from '../../shared/native-chat-types' +import { AGENT_SESSION_HOST_STATUS_COPY } from '../../shared/agent-session-host-status-rows' +import { asRecord, extractString, parseJsonObject } from '../ai-vault/session-scanner-values' +// query module so each stays under the repo's file-size cap. Electron-free: +// runs on the OpenCode SQLite worker thread (#8864). + +export type OpenCodePartRow = { + message_id: string + time_updated: number + data: string | null +} + +export const OPENCODE_TRANSCRIPT_MAX_ROW_BYTES = 2 * 1024 * 1024 + +export function opencodeMessageBlocks(partRows: OpenCodePartRow[]): NativeChatBlock[] { + const blocks: NativeChatBlock[] = [] + for (const partRow of partRows) { + if (partRow.data === null) { + blocks.push({ type: 'text', text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'] }) + continue + } + const part = parseJsonObject(partRow.data) + if (!part) { + continue + } + switch (part.type) { + case 'text': { + if (part.synthetic === true) { + break + } + const text = extractString(part.text) + if (text) { + blocks.push({ type: 'text', text }) + } + break + } + case 'reasoning': { + const text = extractString(part.text) + if (text) { + blocks.push({ type: 'text', text }) + } + break + } + case 'tool': { + blocks.push(...opencodeToolBlocks(part)) + break + } + case 'file': { + const block = opencodeFileBlock(part) + if (block) { + blocks.push(block) + } + break + } + default: + // step-start / snapshot / unknown bookkeeping parts render nothing. + break + } + } + return blocks +} + +function opencodeFileBlock(part: Record): NativeChatImageRefBlock | null { + const mime = extractString(part.mime) + if (!mime?.startsWith('image/')) { + return null + } + const url = extractString(part.url) + if (!url) { + return null + } + const alt = extractString(part.filename) + const withAlt = alt ? { alt } : {} + if (url.startsWith('data:') || /^https?:\/\//.test(url)) { + return { type: 'image-ref', url, ...withAlt } + } + if (url.startsWith('file://')) { + try { + return { type: 'image-ref', path: fileURLToPath(url), ...withAlt } + } catch { + // A malformed file URL still renders as an opaque ref. + return { type: 'image-ref', url, ...withAlt } + } + } + return { type: 'image-ref', path: url, ...withAlt } +} + +function opencodeToolBlocks(part: Record): NativeChatBlock[] { + const name = extractString(part.tool) ?? 'tool' + const state = asRecord(part.state) + const callId = extractString(part.callID) ?? extractString(part.id) + const status = state?.status + const lifecycle = status === 'completed' ? 'completed' : status === 'error' ? 'failed' : 'running' + const blocks: NativeChatBlock[] = [ + { + type: 'tool-call', + name, + ...(callId ? { callId } : {}), + state: lifecycle, + input: state ? state.input : undefined + } + ] + if (!state) { + return blocks + } + const output = state.output + const error = state.error + if (typeof output !== 'string' && error == null) { + // pending / running: the result has not been captured yet. + return blocks + } + blocks.push({ + type: 'tool-result', + ...(callId ? { callId } : {}), + output: + typeof output === 'string' + ? output + : typeof error === 'string' + ? error + : error != null + ? JSON.stringify(error) + : '', + ...(error != null ? { isError: true } : {}) + }) + return blocks +} diff --git a/src/main/native-chat/transcript-opencode-sqlite-query.test.ts b/src/main/native-chat/transcript-opencode-sqlite-query.test.ts new file mode 100644 index 00000000000..d8261365072 --- /dev/null +++ b/src/main/native-chat/transcript-opencode-sqlite-query.test.ts @@ -0,0 +1,543 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AGENT_SESSION_HOST_STATUS_COPY } from '../../shared/agent-session-host-status-rows' +import Database from '../sqlite/sync-database' +import { + readOpenCodeTranscriptPage, + readOpenCodeTranscriptSignal +} from './transcript-opencode-sqlite-query' + +let tempDirs: string[] = [] +let openDbs: Database.Database[] = [] + +afterEach(() => { + // Why: Windows keeps the file locked while the handle is open, which would + // make rmSync below fail with EPERM. + for (const db of openDbs) { + db.close() + } + openDbs = [] + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }) + } + tempDirs = [] +}) + +function createTempDb(): { db: Database.Database; path: string } { + const dir = mkdtempSync(join(tmpdir(), 'orca-opencode-native-chat-')) + tempDirs.push(dir) + const path = join(dir, 'opencode.db') + const db = new Database(path) + openDbs.push(db) + return { db, path } +} + +// The query module only touches these columns; the full production schema is +// exercised by the scanner's own suite. +function applySchema(db: Database.Database): void { + db.exec(` + CREATE TABLE session (id TEXT PRIMARY KEY); + CREATE TABLE message ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + data TEXT NOT NULL + ); + CREATE TABLE part ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL, + session_id TEXT NOT NULL, + time_updated INTEGER NOT NULL, + data TEXT NOT NULL + ); + INSERT INTO session (id) VALUES ('ses-1'), ('ses-2'); + `) +} + +function insertMessage( + db: Database.Database, + args: { id: string; sessionId?: string; time: number; role?: string } +): void { + db.prepare( + 'INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)' + ).run( + args.id, + args.sessionId ?? 'ses-1', + args.time, + args.time, + JSON.stringify({ role: args.role ?? 'user' }) + ) +} + +function insertPart( + db: Database.Database, + args: { id: string; messageId: string; sessionId?: string; time: number; data: unknown } +): void { + db.prepare( + 'INSERT INTO part (id, message_id, session_id, time_updated, data) VALUES (?, ?, ?, ?, ?)' + ).run(args.id, args.messageId, args.sessionId ?? 'ses-1', args.time, JSON.stringify(args.data)) +} + +function messageRowid(db: Database.Database, id: string): number { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: node:sqlite rows are unknown; the SELECT projects exactly one `r` integer column. + return (db.prepare('SELECT rowid AS r FROM message WHERE id = ?').get(id) as { r: number }).r +} + +describe('readOpenCodeTranscriptPage', () => { + it.each(['message', 'part'] as const)( + 'omits an oversized %s without losing identity or pagination', + (table) => { + const { db, path } = createTempDb() + applySchema(db) + for (let index = 1; index <= 3; index++) { + insertMessage(db, { id: `msg-${index}`, time: index }) + insertPart(db, { + id: `prt-${index}`, + messageId: `msg-${index}`, + time: index, + data: { type: 'text', text: `message ${index}` } + }) + } + db.prepare(`UPDATE ${table} SET data = ? WHERE id = ?`).run( + JSON.stringify({ + role: 'user', + type: 'file', + mime: 'image/png', + url: 'x'.repeat(2 * 1024 * 1024) + }), + table === 'message' ? 'msg-2' : 'prt-2' + ) + const args = { dbPath: path, sessionId: 'ses-1', limit: 2 } + const page = readOpenCodeTranscriptPage(args) + expect(page?.items.map((item) => item.message.id)).toEqual(['msg-2', 'msg-3']) + expect(page?.items[0]?.message.blocks).toEqual([ + { type: 'text', text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'] } + ]) + expect(page?.items[0]?.rowid).toBe(messageRowid(db, 'msg-2')) + expect(readOpenCodeTranscriptPage(args)?.items[0]?.fingerprint).toBe( + page?.items[0]?.fingerprint + ) + db.prepare(`UPDATE ${table} SET time_updated = 10 WHERE id = ?`).run( + table === 'message' ? 'msg-2' : 'prt-2' + ) + expect(readOpenCodeTranscriptPage(args)?.items[0]?.fingerprint).not.toBe( + page?.items[0]?.fingerprint + ) + expect( + readOpenCodeTranscriptPage({ + ...args, + beforeMessageRowId: page?.beforeMessageRowId ?? undefined + })?.items.map((item) => item.message.id) + ).toEqual(['msg-1']) + } + ) + + it('preserves ordinary text beside an oversized UTF-8 attachment part', () => { + const { db, path } = createTempDb() + applySchema(db) + insertMessage(db, { id: 'msg', time: 1 }) + insertPart(db, { + id: 'text', + messageId: 'msg', + time: 1, + data: { type: 'text', text: 'see this' } + }) + insertPart(db, { + id: 'large', + messageId: 'msg', + time: 1, + data: { type: 'file', mime: 'image/png', url: '😀'.repeat(600_000) } + }) + expect( + readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'ses-1', limit: 1 })?.items[0]?.message + .blocks + ).toEqual([ + { type: 'text', text: 'see this' }, + { type: 'text', text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'] } + ]) + }) + + it('returns null when the session row does not exist', () => { + const { db, path } = createTempDb() + applySchema(db) + expect(readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'missing', limit: 10 })).toBeNull() + }) + + it('treats a session_v2-only (OpenCode 2) DB as not-found instead of throwing', () => { + const { db, path } = createTempDb() + // Why: OpenCode 2 stores sessions in session_v2 — the v1 reader must fail + // soft like session-scanner-opencode2-sqlite-list.ts, not throw + // "no such table: session". + db.exec('CREATE TABLE session_v2 (id TEXT PRIMARY KEY)') + expect(readOpenCodeTranscriptSignal(path, 'ses-1')).toBeNull() + expect(readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'ses-1', limit: 10 })).toBeNull() + }) + + it('returns the NEWEST limit messages when more history exists', () => { + const { db, path } = createTempDb() + applySchema(db) + for (let index = 1; index <= 10; index++) { + insertMessage(db, { id: `msg-${index}`, time: index }) + insertPart(db, { + id: `prt-${index}`, + messageId: `msg-${index}`, + time: index, + data: { type: 'text', text: `message ${index}` } + }) + } + const page = readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'ses-1', limit: 3 }) + expect(page).not.toBeNull() + // The probe row is the oldest extra — the newest message must survive. + expect(page!.items.map((item) => item.message.id)).toEqual(['msg-8', 'msg-9', 'msg-10']) + expect(page!.hasMore).toBe(true) + expect(page!.beforeMessageRowId).toBe(page!.items[0]!.rowid) + }) + + it('pages strictly older messages from the raw-row cursor', () => { + const { db, path } = createTempDb() + applySchema(db) + for (let index = 1; index <= 10; index++) { + insertMessage(db, { id: `msg-${index}`, time: index }) + insertPart(db, { + id: `prt-${index}`, + messageId: `msg-${index}`, + time: index, + data: { type: 'text', text: `message ${index}` } + }) + } + const first = readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'ses-1', limit: 3 }) + const second = readOpenCodeTranscriptPage({ + dbPath: path, + sessionId: 'ses-1', + limit: 3, + beforeMessageRowId: first!.beforeMessageRowId! + }) + expect(second!.items.map((item) => item.message.id)).toEqual(['msg-5', 'msg-6', 'msg-7']) + }) + + it('counts the limit in renderable messages, batching past non-renderable rows', () => { + const { db, path } = createTempDb() + applySchema(db) + // Rows 1..6 alternate step-start (no blocks) / renderable — mirror of the + // forward page's batching test, walking newest-to-oldest this time. + for (let index = 1; index <= 6; index++) { + insertMessage(db, { id: `msg-${index}`, time: index }) + insertPart(db, { + id: `prt-${index}`, + messageId: `msg-${index}`, + time: index, + data: index % 2 === 0 ? { type: 'text', text: `message ${index}` } : { type: 'step-start' } + }) + } + const page = readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'ses-1', limit: 2 }) + // Two renderable messages even though they span three raw rows. + expect(page!.items.map((item) => item.message.id)).toEqual(['msg-4', 'msg-6']) + expect(page!.hasMore).toBe(true) + // Cursor names the oldest RAW scanned row (msg-3, consumed but dropped), + // so the probe row msg-2 comes back on the next page — nothing is skipped. + expect(page!.beforeMessageRowId).toBe(messageRowid(db, 'msg-3')) + const next = readOpenCodeTranscriptPage({ + dbPath: path, + sessionId: 'ses-1', + limit: 2, + beforeMessageRowId: page!.beforeMessageRowId! + }) + expect(next!.items.map((item) => item.message.id)).toEqual(['msg-2']) + expect(next!.hasMore).toBe(false) + }) + + it('walks a fully non-renderable history to its oldest raw row without dead-ending', () => { + const { db, path } = createTempDb() + applySchema(db) + for (let index = 1; index <= 4; index++) { + insertMessage(db, { id: `msg-${index}`, time: index }) + insertPart(db, { + id: `prt-${index}`, + messageId: `msg-${index}`, + time: index, + data: { type: 'step-start' } + }) + } + const page = readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'ses-1', limit: 2 }) + expect(page!.items).toEqual([]) + expect(page!.hasMore).toBe(false) + // The raw cursor still names the oldest scanned row — a later renderable + // append is picked up instead of being swallowed by the sparse prefix. + expect(page!.beforeMessageRowId).toBe(messageRowid(db, 'msg-1')) + }) + + it('re-reads messages trimmed by an overshoot batch instead of skipping them', () => { + const { db, path } = createTempDb() + applySchema(db) + // Sparse-then-dense shape (reviewer repro): batch 1 yields one renderable + // past a step-start row, batch 2 is dense and overshoots the budget. + for (const index of [1, 2, 3, 5]) { + insertMessage(db, { id: `msg-${index}`, time: index }) + insertPart(db, { + id: `prt-${index}`, + messageId: `msg-${index}`, + time: index, + data: { type: 'text', text: `message ${index}` } + }) + } + insertMessage(db, { id: 'msg-4', time: 4 }) + insertPart(db, { + id: 'prt-4', + messageId: 'msg-4', + time: 4, + data: { type: 'step-start' } + }) + const seen: string[] = [] + let cursor: number | null = null + for (;;) { + const page = readOpenCodeTranscriptPage({ + dbPath: path, + sessionId: 'ses-1', + limit: 2, + ...(cursor !== null ? { beforeMessageRowId: cursor } : {}) + }) + seen.push(...page!.items.map((item) => item.message.id)) + if (!page!.hasMore) { + break + } + cursor = page!.beforeMessageRowId + } + // Every renderable message returns exactly once — msg-2 must not fall + // through the gap between the trimmed batch and the cursor. Pages arrive + // newest-first (the walk starts at the tail), so chronological order + // only holds inside each page. + expect(seen).toEqual(['msg-3', 'msg-5', 'msg-1', 'msg-2']) + }) + + it('keeps hasMore true when a trim happens on the final short batch', () => { + const { db, path } = createTempDb() + applySchema(db) + // Batch 1 collects one renderable past a step-start; batch 2 hits the + // table end (rows.length <= limit → probe says no more) while still + // overshooting — the trim itself must keep the walk alive. + for (const index of [1, 2, 4]) { + insertMessage(db, { id: `msg-${index}`, time: index }) + insertPart(db, { + id: `prt-${index}`, + messageId: `msg-${index}`, + time: index, + data: { type: 'text', text: `message ${index}` } + }) + } + insertMessage(db, { id: 'msg-3', time: 3 }) + insertPart(db, { + id: 'prt-3', + messageId: 'msg-3', + time: 3, + data: { type: 'step-start' } + }) + const first = readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'ses-1', limit: 2 }) + // The overshoot trimmed msg-1 — that IS more history below the cursor. + expect(first!.hasMore).toBe(true) + const second = readOpenCodeTranscriptPage({ + dbPath: path, + sessionId: 'ses-1', + limit: 2, + beforeMessageRowId: first!.beforeMessageRowId! + }) + expect(second!.items.map((item) => item.message.id)).toEqual(['msg-1']) + expect(second!.hasMore).toBe(false) + }) + + it('maps image file parts to image refs and skips non-image files', () => { + const { db, path } = createTempDb() + applySchema(db) + // A file:// URL written on the READING machine's platform decodes back to + // a local path; a foreign-platform one falls back to the opaque ref. + const localPath = process.platform === 'win32' ? 'C:\\Users\\u\\pic.jpg' : '/home/user/pic.jpg' + const fileUrl = pathToFileURL(localPath).href + insertMessage(db, { id: 'msg-1', time: 1, role: 'user' }) + insertPart(db, { + id: 'prt-data', + messageId: 'msg-1', + time: 1, + data: { + type: 'file', + mime: 'image/png', + url: 'data:image/png;base64,AAA', + filename: 'paste.png' + } + }) + insertPart(db, { + id: 'prt-file-url', + messageId: 'msg-1', + time: 1, + data: { type: 'file', mime: 'image/jpeg', url: fileUrl } + }) + insertPart(db, { + id: 'prt-plain-path', + messageId: 'msg-1', + time: 1, + data: { type: 'file', mime: 'image/webp', url: '/tmp/opencode/paste.webp' } + }) + insertPart(db, { + id: 'prt-pdf', + messageId: 'msg-1', + time: 1, + data: { type: 'file', mime: 'application/pdf', url: 'file:///tmp/doc.pdf' } + }) + insertPart(db, { + id: 'prt-text', + messageId: 'msg-1', + time: 1, + data: { type: 'text', text: 'prompt body' } + }) + + const page = readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'ses-1', limit: 10 }) + // Blocks follow part-row order; the PDF (non-image) renders nothing. + expect(page!.items[0]!.message.blocks).toEqual([ + { type: 'image-ref', url: 'data:image/png;base64,AAA', alt: 'paste.png' }, + { type: 'image-ref', path: localPath }, + { type: 'image-ref', path: '/tmp/opencode/paste.webp' }, + { type: 'text', text: 'prompt body' } + ]) + }) + + it('filters synthetic text parts and maps tool parts to call/result blocks', () => { + const { db, path } = createTempDb() + applySchema(db) + insertMessage(db, { id: 'msg-1', time: 1, role: 'user' }) + insertPart(db, { + id: 'prt-syn', + messageId: 'msg-1', + time: 1, + data: { type: 'text', text: 'injected context', synthetic: true } + }) + insertPart(db, { + id: 'prt-text', + messageId: 'msg-1', + time: 1, + data: { type: 'text', text: 'real prompt' } + }) + insertMessage(db, { id: 'msg-2', time: 2, role: 'assistant' }) + insertPart(db, { + id: 'prt-tool-done', + messageId: 'msg-2', + time: 2, + data: { + type: 'tool', + tool: 'bash', + callID: 'call-1', + state: { status: 'completed', input: { command: 'ls' }, output: 'file list' } + } + }) + insertPart(db, { + id: 'prt-tool-pending', + messageId: 'msg-2', + time: 2, + data: { type: 'tool', tool: 'read', callID: 'call-2', state: { status: 'running' } } + }) + + const page = readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'ses-1', limit: 10 }) + const user = page!.items.find((item) => item.message.id === 'msg-1')! + expect(user.message.blocks).toEqual([{ type: 'text', text: 'real prompt' }]) + const assistant = page!.items.find((item) => item.message.id === 'msg-2')! + expect(assistant.message.blocks).toEqual([ + { + type: 'tool-call', + callId: 'call-1', + name: 'bash', + state: 'completed', + input: { command: 'ls' } + }, + { type: 'tool-result', callId: 'call-1', output: 'file list' }, + // A running tool captured no output yet — call only, no result block. + { type: 'tool-call', callId: 'call-2', name: 'read', state: 'running', input: undefined } + ]) + }) + + it('surfaces a tool error string as the result output', () => { + const { db, path } = createTempDb() + applySchema(db) + insertMessage(db, { id: 'msg-1', time: 1, role: 'assistant' }) + insertPart(db, { + id: 'prt-tool-error', + messageId: 'msg-1', + time: 1, + data: { + type: 'tool', + tool: 'bash', + callID: 'call-1', + state: { status: 'error', input: { command: 'ls' }, error: 'exit 1: boom' } + } + }) + + const page = readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'ses-1', limit: 10 }) + expect(page!.items[0]!.message.blocks).toEqual([ + { + type: 'tool-call', + callId: 'call-1', + name: 'bash', + state: 'failed', + input: { command: 'ls' } + }, + { type: 'tool-result', callId: 'call-1', output: 'exit 1: boom', isError: true } + ]) + }) +}) + +describe('readOpenCodeTranscriptSignal', () => { + it('aggregates counts and maxes per session', () => { + const { db, path } = createTempDb() + applySchema(db) + insertMessage(db, { id: 'msg-1', time: 1 }) + insertMessage(db, { id: 'msg-2', time: 2 }) + insertMessage(db, { id: 'other-1', time: 3, sessionId: 'ses-2' }) + insertPart(db, { id: 'prt-1', messageId: 'msg-1', time: 10, data: { type: 'text', text: 'a' } }) + insertPart(db, { id: 'prt-2', messageId: 'msg-2', time: 20, data: { type: 'text', text: 'b' } }) + + const signal = readOpenCodeTranscriptSignal(path, 'ses-1') + expect(signal).toEqual({ + messageCount: 2, + partCount: 2, + maxMessageRowId: 2, + maxPartTimeUpdated: 20 + }) + }) + + it('returns null when the session row does not exist', () => { + const { db, path } = createTempDb() + applySchema(db) + expect(readOpenCodeTranscriptSignal(path, 'missing')).toBeNull() + }) +}) + +it('stops before fetching a third part batch when earlier batches exhaust the page byte budget', () => { + const { db, path } = createTempDb() + applySchema(db) + const text = 'x'.repeat(100_000) + db.exec('BEGIN') + { + for (let i = 0; i < 300; i++) { + insertMessage(db, { id: `budget-${i}`, time: i }) + insertPart(db, { + id: `part-${i}`, + messageId: `budget-${i}`, + time: i, + data: { type: 'text', text } + }) + } + } + db.exec('COMMIT') + const prepared = vi.spyOn(Database.prototype, 'prepare') + try { + expect(() => + readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'ses-1', limit: 300 }) + ).toThrow('read limit') + const partQueries = prepared.mock.calls.filter( + ([sql]) => sql.includes('FROM part') && sql.includes('message_id IN') + ) + expect(partQueries).toHaveLength(2) + } finally { + prepared.mockRestore() + } +}) diff --git a/src/main/native-chat/transcript-opencode-sqlite-query.ts b/src/main/native-chat/transcript-opencode-sqlite-query.ts new file mode 100644 index 00000000000..31729111c2e --- /dev/null +++ b/src/main/native-chat/transcript-opencode-sqlite-query.ts @@ -0,0 +1,268 @@ +import { openOpenCodeDatabaseReadonly } from '../ai-vault/session-scanner-opencode-sqlite-open' +import { + readOpenCode2TranscriptPage, + readOpenCode2TranscriptSignal +} from './transcript-opencode2-sqlite' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { openCodeTranscriptPageLimit } from '../../shared/opencode-transcript-page-limit' +import { extractString, parseJsonObject } from '../ai-vault/session-scanner-values' +import type SyncDatabase from '../sqlite/sync-database' + +type BindValue = SyncDatabase.BindValue +type SqliteStatement = SyncDatabase.Statement +import { + opencodeMessageBlocks, + OPENCODE_TRANSCRIPT_MAX_ROW_BYTES +} from './transcript-opencode-part-blocks' +// Cursors are opaque provider order: SQLite rowid in v1, session sequence in v2. + +export type OpenCodeTranscriptItem = { + rowid: number + fingerprint: string + message: NativeChatMessage +} + +export type OpenCodeTranscriptPage = { + items: OpenCodeTranscriptItem[] + hasMore: boolean + beforeMessageRowId: number | null +} + +export type OpenCodeTranscriptSignal = { + messageCount: number + partCount: number + maxMessageRowId: number + maxPartTimeUpdated: number +} + +const PART_ID_BATCH = 100 + +function sessionExists(db: SyncDatabase, sessionId: string): boolean { + const table = rowsOf<{ name: string }>( + db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'") + ) + return table.length > 0 && db.prepare('SELECT 1 FROM session WHERE id = ?').get(sessionId) != null +} + +function rowsOf(statement: SqliteStatement, ...params: BindValue[]): T[] { + return rowsWithinBudget(statement, { bytes: 0 }, ...params) +} + +function rowsWithinBudget( + statement: SqliteStatement, + budget: { bytes: number }, + ...params: BindValue[] +): T[] { + const rows: ReturnType = [] + for (const row of statement.iterate(...params)) { + budget.bytes += Object.values(row).reduce( + (size, value) => size + (typeof value === 'string' ? Buffer.byteLength(value) : 0), + 0 + ) + if (rows.length >= 10000 || budget.bytes > 16 * 1024 * 1024) { + throw new Error('OpenCode transcript query exceeds its read limit') + } + rows.push(row) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Each caller names the columns projected by its internal SQL query. + return rows as T[] +} + +export function readOpenCodeTranscriptSignal( + dbPath: string, + sessionId: string +): OpenCodeTranscriptSignal | null { + const db = openOpenCodeDatabaseReadonly(dbPath) + try { + const v2 = readOpenCode2TranscriptSignal(db, sessionId) + if (v2) { + return v2 + } + if (!sessionExists(db, sessionId)) { + 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 }>( + db.prepare( + 'SELECT COUNT(*) AS message_count, COALESCE(MAX(rowid), 0) AS max_message_rowid FROM message WHERE session_id = ?' + ), + 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 = ?' + ), + 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 + } + } finally { + db.close() + } +} + +export function readOpenCodeTranscriptPage(args: { + dbPath: string + sessionId: string + limit: number + beforeMessageRowId?: number +}): OpenCodeTranscriptPage | null { + const db = openOpenCodeDatabaseReadonly(args.dbPath) + try { + const v2 = readOpenCode2TranscriptPage(db, args) + if (v2) { + return v2 + } + if (!sessionExists(db, args.sessionId)) { + return null + } + const limit = openCodeTranscriptPageLimit(args.limit) + // 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 + LIMIT ?` + ) + const collected: OpenCodeTranscriptItem[] = [] + let scannedRows = 0 + let pageBytes = 0 + const rawBudget = { bytes: 0 } + let cursor: number | undefined = args.beforeMessageRowId + let hasMore = false + for (;;) { + const rows = rowsWithinBudget( + select, + rawBudget, + args.sessionId, + cursor ?? Number.MAX_SAFE_INTEGER, + limit + 1 + ) + scannedRows += rows.length + if (scannedRows > 10_000) { + throw new Error('OpenCode transcript page exceeds its scan limit') + } + if (rows.length === 0) { + break + } + // The (limit+1)th row is only a hasMore probe — never decoded. + 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) + pageBytes += Buffer.byteLength(JSON.stringify(mapped)) + if (pageBytes > 16 * 1024 * 1024) { + throw new Error('OpenCode transcript page exceeds its byte limit') + } + collected.push(...mapped) + if (!hasMore || collected.length >= limit) { + break + } + } + const overshot = collected.length > limit + const trimmed = overshot ? collected.slice(0, limit) : collected + const items = trimmed.toReversed() + return { + items, + hasMore: hasMore || overshot, + beforeMessageRowId: overshot ? items[0]!.rowid : (cursor ?? null) + } + } finally { + db.close() + } +} + +type MessageRow = { + message_rowid: number + id: string + time_created: number + time_updated: number + data: string | null +} + +type PartRow = { + message_id: string + time_updated: number + data: string | null +} + +function mapMessageRows( + db: SyncDatabase, + sessionId: string, + rows: MessageRow[], + rawBudget: { bytes: number } +): OpenCodeTranscriptItem[] { + if (rows.length === 0) { + return [] + } + const partsByMessage = new Map() + const ids = rows.map((row) => row.id) + for (let start = 0; start < ids.length; start += PART_ID_BATCH) { + const batch = ids.slice(start, start + PART_ID_BATCH) + 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 + WHERE session_id = ? AND message_id IN (${placeholders}) + ORDER BY rowid LIMIT 10001` + ), + rawBudget, + sessionId, + ...batch + ) + if (partRows.length > 10000) { + throw new Error('OpenCode transcript parts exceed their read limit') + } + for (const partRow of partRows) { + const list = partsByMessage.get(partRow.message_id) + if (list) { + list.push(partRow) + } else { + partsByMessage.set(partRow.message_id, [partRow]) + } + } + } + const items: OpenCodeTranscriptItem[] = [] + for (const row of rows) { + const partList = partsByMessage.get(row.id) ?? [] + const blocks = opencodeMessageBlocks( + row.data === null + ? [{ message_id: row.id, time_updated: row.time_updated, data: null }] + : partList + ) + if (blocks.length === 0) { + continue + } + const record = row.data === null ? null : parseJsonObject(row.data) + const role = extractString(record?.role) + items.push({ + rowid: row.message_rowid, + fingerprint: `${row.time_updated}:${partList.length}:${maxPartTimeUpdated(partList)}`, + message: { + id: row.id, + role: role === 'user' ? 'user' : role === 'assistant' ? 'assistant' : 'system', + blocks, + timestamp: Number.isFinite(row.time_created) ? row.time_created : null, + source: 'transcript' + } + }) + } + return items +} + +function maxPartTimeUpdated(partRows: PartRow[]): number { + let max = 0 + for (const partRow of partRows) { + if (partRow.time_updated > max) { + max = partRow.time_updated + } + } + return max +} diff --git a/src/main/native-chat/transcript-opencode-subscribe.test.ts b/src/main/native-chat/transcript-opencode-subscribe.test.ts new file mode 100644 index 00000000000..b8e0fb01e78 --- /dev/null +++ b/src/main/native-chat/transcript-opencode-subscribe.test.ts @@ -0,0 +1,288 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, 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 { subscribeOpenCodeNativeChatTranscript } from './transcript-opencode-subscribe' + +const fixtures: { db: Database.Database; root: string; stop: () => void }[] = [] +afterEach(() => { + for (const { db, root, stop } of fixtures.splice(0)) { + stop() + db.close() + rmSync(root, { recursive: true, force: true }) + } + vi.useRealTimers() +}) + +function watchFixture( + version: 'v1' | 'v2', + hiddenFirst = false, + messageCount = 300, + initialLimit = 300 +) { + vi.useFakeTimers() + const root = mkdtempSync(join(tmpdir(), 'orca-opencode-watch-')) + const path = join(root, 'opencode.db') + const db = new Database(path) + db.exec( + version === 'v1' + ? ` + 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); + CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT, data TEXT, time_updated INTEGER); + INSERT INTO session VALUES ('session'); + ` + : ` + CREATE TABLE session_v2 (id TEXT PRIMARY KEY); + CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT, type TEXT, seq INTEGER, data TEXT, time_created INTEGER, time_updated INTEGER); + INSERT INTO session_v2 VALUES ('session'); + ` + ) + const insert = (index: number, text = `message ${index}`) => { + if (version === 'v1') { + db.prepare('INSERT INTO message VALUES (?, ?, ?, 1, 1)').run( + String(index), + 'session', + '{"role":"user"}' + ) + db.prepare('INSERT INTO part VALUES (?, ?, ?, ?, 1)').run( + String(index), + String(index), + 'session', + JSON.stringify({ type: 'text', text }) + ) + } else { + db.prepare('INSERT INTO session_message VALUES (?, ?, ?, ?, ?, 1, 1)').run( + String(index), + 'session', + 'user', + index, + JSON.stringify({ text }) + ) + } + } + const update = (index: number, text: string, time = 2) => { + db.prepare( + version === 'v1' + ? 'UPDATE part SET data = ?, time_updated = ? WHERE message_id = ?' + : 'UPDATE session_message SET data = ?, time_updated = ? WHERE id = ?' + ).run(JSON.stringify(version === 'v1' ? { type: 'text', text } : { text }), time, String(index)) + } + const remove = (index: number) => { + if (version === 'v1') { + db.prepare('DELETE FROM part WHERE message_id = ?').run(String(index)) + db.prepare('DELETE FROM message WHERE id = ?').run(String(index)) + } else { + db.prepare('DELETE FROM session_message WHERE id = ?').run(String(index)) + } + } + for (let index = 1; index <= messageCount; index++) { + insert(index, hiddenFirst && index === 1 ? '' : undefined) + } + let displayed: NativeChatMessage[] = [] + const onReplace = vi.fn((messages: NativeChatMessage[]) => { + displayed = messages + }) + const onAppend = vi.fn((messages: NativeChatMessage[]) => { + displayed.push(...messages) + }) + const readPage = vi.fn(async (args: Parameters[0]) => + readOpenCodeTranscriptPage(args) + ) + const subscription = subscribeOpenCodeNativeChatTranscript( + { + agent: 'opencode', + sessionId: 'session', + initialLimit, + resolvePollIntervalMs: 5, + onInitialSnapshot: (messages) => { + displayed = messages + }, + onAppend, + onReplace + }, + undefined, + { + resolveDbPath: async () => path, + readSignal: async (dbPath, sessionId) => readOpenCodeTranscriptSignal(dbPath, sessionId), + readPage + } + ) + fixtures.push({ db, root, stop: subscription.unsubscribe }) + return { db, insert, update, remove, displayed: () => displayed, onReplace, onAppend, readPage } +} + +describe.each(['v1', 'v2'] as const)('OpenCode %s watch reconciliation', (version) => { + it('removes the oldest displayed row when an append keeps the database count unchanged', async () => { + const f = watchFixture(version, false, 1000) + await vi.advanceTimersByTimeAsync(0) + expect(f.displayed()).toHaveLength(300) + f.remove(701) + f.insert(1001) + await vi.advanceTimersByTimeAsync(10) + expect(f.displayed()).toHaveLength(300) + expect(f.displayed()[0]?.blocks).toEqual([{ type: 'text', text: 'message 702' }]) + expect(f.displayed().at(-1)?.blocks).toEqual([{ type: 'text', text: 'message 1001' }]) + expect(f.onReplace).toHaveBeenCalledOnce() + expect(f.readPage.mock.calls.every(([args]) => args.limit <= 2400)).toBe(true) + }) + + it('replaces an edit older than the newest 100 displayed messages', async () => { + const f = watchFixture(version) + await vi.advanceTimersByTimeAsync(0) + expect(f.displayed()).toHaveLength(300) + f.update(1, 'edited oldest') + await vi.advanceTimersByTimeAsync(10) + expect(f.displayed()[0]?.blocks).toEqual([{ type: 'text', text: 'edited oldest' }]) + expect(f.onReplace).toHaveBeenCalledOnce() + }) + + it('includes an older row that becomes renderable after the emitted frontier', async () => { + const f = watchFixture(version, true) + await vi.advanceTimersByTimeAsync(0) + expect(f.displayed()).toHaveLength(299) + f.update(1, 'newly renderable') + await vi.advanceTimersByTimeAsync(10) + expect(f.displayed()[0]?.blocks).toEqual([{ type: 'text', text: 'newly renderable' }]) + expect(f.displayed()).toHaveLength(300) + }) + + it.each([1, 300])('removes a deleted row at %s from the displayed window', async (index) => { + const f = watchFixture(version) + await vi.advanceTimersByTimeAsync(0) + f.remove(index) + await vi.advanceTimersByTimeAsync(10) + expect(f.displayed()).toHaveLength(299) + expect( + f + .displayed() + .some((message) => message.id === (version === 'v1' ? String(index) : `opencode:${index}`)) + ).toBe(false) + expect(f.onReplace).toHaveBeenCalledOnce() + }) + + it('replaces an empty session and permits a reused provider cursor to append', async () => { + const f = watchFixture(version) + await vi.advanceTimersByTimeAsync(0) + for (let index = 1; index <= 300; index++) { + f.remove(index) + } + await vi.advanceTimersByTimeAsync(10) + expect(f.displayed()).toEqual([]) + f.insert(1, 'after revert') + await vi.advanceTimersByTimeAsync(10) + expect(f.displayed().map((message) => message.blocks)).toEqual([ + [{ type: 'text', text: 'after revert' }] + ]) + }) + + it('removes a row whose last renderable content is removed', async () => { + const f = watchFixture(version) + await vi.advanceTimersByTimeAsync(0) + f.update(1, '') + await vi.advanceTimersByTimeAsync(10) + expect(f.displayed()).toHaveLength(299) + expect(f.displayed()[0]?.blocks).toEqual([{ type: 'text', text: 'message 2' }]) + }) + + it('continues checking previously appended history and bounds every read', async () => { + const f = watchFixture(version) + await vi.advanceTimersByTimeAsync(0) + for (let index = 301; index <= 450; index++) { + f.insert(index) + } + await vi.advanceTimersByTimeAsync(10) + f.update(1, 'edited after append') + await vi.advanceTimersByTimeAsync(10) + expect(f.displayed()[0]?.blocks).toEqual([{ type: 'text', text: 'edited after append' }]) + expect(f.readPage.mock.calls.every(([args]) => args.limit <= 2400)).toBe(true) + const reads = f.readPage.mock.calls.length + await vi.advanceTimersByTimeAsync(20) + expect(f.readPage).toHaveBeenCalledTimes(reads) + }) + + it('holds the frontier and retries when a burst cannot bridge within the read cap', async () => { + const f = watchFixture(version) + await vi.advanceTimersByTimeAsync(0) + for (let index = 301; index <= 2800; index++) { + f.insert(index) + } + await vi.advanceTimersByTimeAsync(10) + expect(f.displayed()).toHaveLength(300) + expect(f.onReplace).not.toHaveBeenCalled() + const reads = f.readPage.mock.calls.length + await vi.advanceTimersByTimeAsync(10) + expect(f.readPage.mock.calls.length).toBeGreaterThan(reads) + expect(f.readPage.mock.calls.every(([args]) => args.limit <= 2400)).toBe(true) + for (let index = 301; index <= 2800; index++) { + f.remove(index) + } + f.insert(301, 'after discarded burst') + await vi.advanceTimersByTimeAsync(10) + expect(f.displayed()).toHaveLength(301) + expect(f.displayed().at(-1)?.blocks).toEqual([{ type: 'text', text: 'after discarded burst' }]) + }) + + it('keeps appending after the remembered window reaches 2400 rows', async () => { + const f = watchFixture(version) + await vi.advanceTimersByTimeAsync(0) + for (let start = 301; start <= 2400; start += 300) { + for (let index = start; index < start + 300; index++) { + f.insert(index) + } + await vi.advanceTimersByTimeAsync(10) + } + f.onReplace.mockClear() + f.onAppend.mockClear() + for (let index = 2401; index <= 2403; index++) { + f.insert(index) + await vi.advanceTimersByTimeAsync(10) + } + expect(f.onReplace).not.toHaveBeenCalled() + expect(f.onAppend.mock.calls.map(([messages]) => messages.length)).toEqual([1, 1, 1]) + expect(f.displayed()).toHaveLength(2403) + expect(f.readPage.mock.calls.every(([args]) => args.limit <= 2400)).toBe(true) + }) + + it('keeps appending after a bridged burst fills the cap', async () => { + const f = watchFixture(version) + await vi.advanceTimersByTimeAsync(0) + for (let index = 301; index <= 2600; index++) { + f.insert(index) + } + await vi.advanceTimersByTimeAsync(10) + expect(f.onReplace).toHaveBeenCalledOnce() + expect(f.displayed()).toHaveLength(2400) + f.onReplace.mockClear() + f.insert(2601) + await vi.advanceTimersByTimeAsync(10) + expect(f.onReplace).not.toHaveBeenCalled() + expect(f.onAppend).toHaveBeenCalledOnce() + expect(f.displayed().at(-1)?.blocks).toEqual([{ type: 'text', text: 'message 2601' }]) + }) + + it.each(['delete', 'hide', 'edit'] as const)( + 'detects a first-tail-row %s outside the capped page during an append', + async (operation) => { + const f = watchFixture(version, false, 2500, 2400) + await vi.advanceTimersByTimeAsync(0) + if (operation === 'delete') { + f.remove(101) + } else { + f.update(101, operation === 'hide' ? '' : 'edited first tail row') + } + f.insert(2501) + f.insert(2502) + await vi.advanceTimersByTimeAsync(10) + expect(f.onReplace).toHaveBeenCalledOnce() + expect(f.onAppend).not.toHaveBeenCalled() + expect(f.readPage.mock.calls.every(([args]) => args.limit <= 2400)).toBe(true) + } + ) +}) diff --git a/src/main/native-chat/transcript-opencode-subscribe.ts b/src/main/native-chat/transcript-opencode-subscribe.ts new file mode 100644 index 00000000000..b5faaf884ca --- /dev/null +++ b/src/main/native-chat/transcript-opencode-subscribe.ts @@ -0,0 +1,330 @@ +import { errorMessage } from '../ai-vault/session-scanner-values' +import { + DESKTOP_READ_WINDOW, + UNFLUSHED_SETTLE_MS, + type NativeChatTranscriptSubscription, + type SubscribeNativeChatTranscriptArgs +} from './transcript-watch-contract' +import type { OpenCodeTranscriptItem } from './transcript-opencode-sqlite-query' +import { openCodeTranscriptDefaultDeps, type OpenCodeTranscriptDeps } from './transcript-opencode' +import { + openCodeTranscriptPageLimit, + OPENCODE_TRANSCRIPT_MAX_WINDOW +} from '../../shared/opencode-transcript-page-limit' + +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 = {} +): 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, + 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 + ) + + let closed = false + let pollTimer: ReturnType | null = null + let dbPath: string | null = null + let lastSignal: string | null = null + let lastEmittedRowId = 0 + let lastCounts = { messages: 0, parts: 0 } + let windowLimit = initialLimit + const fingerprints = new Map() + let gateErrorEmitted = false + // Whether the subscriber already has a frame (or pending notice) to render. + let settled = false + let settleTimer: ReturnType | null = null + + const onAbort = () => unsubscribe() + setupSignal?.addEventListener('abort', onAbort, { once: true }) + + function stopSettleTimer(): void { + if (settleTimer) { + clearTimeout(settleTimer) + settleTimer = null + } + } + + function settleUnflushed(): void { + settleTimer = null + if (closed || settled || !args.onTranscriptPending) { + return + } + settled = true + emitSafely(() => args.onTranscriptPending?.(), 'pending') + } + + if (args.onTranscriptPending) { + settleTimer = setTimeout(settleUnflushed, UNFLUSHED_SETTLE_MS) + settleTimer.unref?.() + } + // never break the poll loop (an escaping throw would skip scheduleTick and + // leave polling permanently deaf on a void tick()). + function emitSafely(run: () => void, what: string): void { + try { + run() + } catch (err) { + console.warn( + `OpenCode native-chat subscriber (${what}) threw; polling continues. ${errorMessage(err)}` + ) + } + } + + function scheduleTick(): void { + if (closed) { + return + } + pollTimer = setTimeout(() => { + pollTimer = null + void tick() + }, pollMs) + pollTimer.unref?.() + } + + async function tick(): Promise { + if (closed) { + return + } + try { + dbPath ??= await resolveDbPath() + if (closed) { + return + } + if (!dbPath) { + // No DB (yet): keep polling, like the JSONL resolve-poll path (#8401). + scheduleTick() + return + } + const signal = await readSignal(dbPath, args.sessionId) + if (closed) { + return + } + if (!signal) { + // Session row not landed yet — the hook can fire first. + scheduleTick() + return + } + const fingerprint = `${signal.messageCount}:${signal.partCount}:${signal.maxMessageRowId}:${signal.maxPartTimeUpdated}` + const firstSnapshot = lastSignal === null + if (!firstSnapshot && fingerprint === lastSignal) { + scheduleTick() + return + } + const page = await readPage({ + dbPath, + sessionId: args.sessionId, + limit: firstSnapshot + ? initialLimit + : Math.min( + OPENCODE_TRANSCRIPT_MAX_WINDOW, + windowLimit + Math.max(0, signal.messageCount - lastCounts.messages) + ) + }) + if (closed) { + return + } + if (!page) { + // The session vanished between signal and page reads; keep polling. + scheduleTick() + return + } + if (firstSnapshot) { + lastSignal = fingerprint + lastCounts = { messages: signal.messageCount, parts: signal.partCount } + rememberItems(page.items) + settled = true + stopSettleTimer() + const snapshot = page.items.map((item) => item.message) + const snapshotHasMore = page.hasMore + const snapshotBefore = page.beforeMessageRowId ?? 0 + emitSafely( + () => args.onInitialSnapshot?.(snapshot, snapshotHasMore, snapshotBefore), + 'snapshot' + ) + scheduleTick() + return + } + let changed = page.items.some( + (item) => + item.rowid <= lastEmittedRowId && fingerprints.get(item.rowid) !== item.fingerprint + ) + const shrinking = + signal.messageCount < lastCounts.messages || signal.partCount < lastCounts.parts + const present = new Set(page.items.map((item) => item.rowid)) + if ( + !changed && + !shrinking && + page.hasMore && + fingerprints.size === OPENCODE_TRANSCRIPT_MAX_WINDOW && + page.items.length === OPENCODE_TRANSCRIPT_MAX_WINDOW + ) { + const oldest = page.items[0]!.rowid + 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 readPage({ + dbPath, + sessionId: args.sessionId, + limit: displaced.length, + beforeMessageRowId: oldest + }) + if (closed || !older) { + scheduleTick() + return + } + for (const item of older.items) { + present.add(item.rowid) + changed ||= fingerprints.get(item.rowid) !== item.fingerprint + } + } + } + // A balanced delete and append can move a removed row before the new tail. + const removed = [...fingerprints.keys()].some((rowid) => !present.has(rowid)) + if (changed || shrinking || removed) { + if (await replaceWithBridgedWindow(dbPath, page, shrinking)) { + lastSignal = fingerprint + lastCounts = { messages: signal.messageCount, parts: signal.partCount } + } + scheduleTick() + return + } + const appended = page.items.filter((item) => item.rowid > lastEmittedRowId) + if (appended.length > 0 && appended.length === page.items.length && page.hasMore) { + if (await replaceWithBridgedWindow(dbPath, page)) { + lastSignal = fingerprint + lastCounts = { messages: signal.messageCount, parts: signal.partCount } + } + scheduleTick() + return + } + if (appended.length > 0) { + rememberItems(appended) + const appendedMessages = appended.map((item) => item.message) + emitSafely(() => args.onAppend(appendedMessages), 'append') + } + lastSignal = fingerprint + lastCounts = { messages: signal.messageCount, parts: signal.partCount } + scheduleTick() + } catch (err) { + if (!closed && lastSignal === null && !gateErrorEmitted && args.onInitialSnapshot) { + gateErrorEmitted = true + // Its retryable message outranks the empty settle; don't overwrite it. + settled = true + stopSettleTimer() + const failure = errorMessage(err) + emitSafely(() => args.onInitialSnapshot?.([], false, 0, failure), 'error') + } + scheduleTick() + } + } + + function rememberItems(items: OpenCodeTranscriptItem[]): void { + for (const item of items) { + fingerprints.set(item.rowid, item.fingerprint) + if (item.rowid > lastEmittedRowId) { + lastEmittedRowId = item.rowid + } + } + pruneOpenCodeFingerprintCache(fingerprints, OPENCODE_TRANSCRIPT_MAX_WINDOW) + windowLimit = Math.max(windowLimit, fingerprints.size) + } + + // Bounded reads on the worker thread shared with the AI-Vault scanner. + // Widens until the window overlaps the emitted frontier: a poll-gap burst + // bigger than one window must bridge, not skip. Holds everything back (no + // lastSignal/lastEmittedRowId advance) when even the capped window cannot + // overlap, so the gap retries instead of dropping rows. + async function replaceWithBridgedWindow( + db: string, + page: Awaited>, + shrinking = false + ): Promise { + let limit = Math.max(windowLimit, page?.items.length ?? 0) + for (;;) { + const replacement = + page ?? + (await readPage({ + dbPath: db, + sessionId: args.sessionId, + limit + })) + if (closed || !replacement) { + return false + } + const oldest = replacement.items[0]?.rowid + if (oldest === undefined) { + // Empty renderable window (only non-renderable rows moved the + // signal): nothing to bridge — settle like a normal replace. + const hasMore = replacement.hasMore + const before = replacement.beforeMessageRowId ?? 0 + fingerprints.clear() + lastEmittedRowId = 0 + emitSafely(() => args.onReplace?.([], hasMore, before), 'replace') + return true + } + if (shrinking || oldest <= lastEmittedRowId || !replacement.hasMore) { + fingerprints.clear() + lastEmittedRowId = 0 + rememberItems(replacement.items) + const messages = replacement.items.map((item) => item.message) + const hasMore = replacement.hasMore + const before = replacement.beforeMessageRowId ?? 0 + emitSafely(() => args.onReplace?.(messages, hasMore, before), 'replace') + return true + } + if (limit >= OPENCODE_TRANSCRIPT_MAX_WINDOW) { + return false + } + limit = Math.min(limit * 2, OPENCODE_TRANSCRIPT_MAX_WINDOW) + page = null + } + } + + function unsubscribe(): void { + if (closed) { + return + } + closed = true + controller.abort() + setupSignal?.removeEventListener('abort', onAbort) + stopSettleTimer() + if (pollTimer) { + clearTimeout(pollTimer) + pollTimer = null + } + } + + void tick() + return { unsubscribe, watching: true } +} diff --git a/src/main/native-chat/transcript-opencode.test.ts b/src/main/native-chat/transcript-opencode.test.ts new file mode 100644 index 00000000000..6cf17a8cb75 --- /dev/null +++ b/src/main/native-chat/transcript-opencode.test.ts @@ -0,0 +1,230 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { + readOpenCodeNativeChatTranscriptFull, + readOpenCodeNativeChatTranscriptTail, + resolveOpenCodeTranscriptDbPath, + type OpenCodeTranscriptDeps +} from './transcript-opencode' +import { DESKTOP_READ_WINDOW } from './transcript-watch-contract' + +let tempDirs: string[] = [] + +afterEach(() => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }) + } + tempDirs = [] + vi.restoreAllMocks() + vi.unstubAllEnvs() +}) + +describe('readOpenCodeNativeChatTranscriptFull', () => { + const message = (id: string): NativeChatMessage => ({ + id, + role: 'user', + blocks: [{ type: 'text', text: id }], + timestamp: null, + source: 'transcript' + }) + + it('returns the whole session oldest-first across page boundaries', async () => { + // Five messages served in windows of two: pages arrive newest-window-first + // (each oldest-first); the full read must concatenate them globally + // oldest-first like the JSONL full reader, not in page-fetch order. + const ids = ['m1', 'm2', 'm3', 'm4', 'm5'] + const readPage: OpenCodeTranscriptDeps['readPage'] = async (args) => { + const upper = args.beforeMessageRowId ?? 6 + const lower = Math.max(1, upper - 2) + const slice = ids.slice(lower - 1, upper - 1) + return { + items: slice.map((id, index) => ({ + rowid: lower + index, + fingerprint: id, + message: message(id) + })), + hasMore: lower > 1, + beforeMessageRowId: lower > 1 ? lower : null + } + } + const result = await readOpenCodeNativeChatTranscriptFull('ses-1', { + resolveDbPath: async () => '/fake/opencode.db', + readPage + }) + expect(result).toEqual({ messages: ids.map(message) }) + }) + + it('reports notFound when the first page misses', async () => { + const readPage: OpenCodeTranscriptDeps['readPage'] = async () => null + const result = await readOpenCodeNativeChatTranscriptFull('missing', { + resolveDbPath: async () => '/fake/opencode.db', + readPage + }) + expect(result).toEqual({ error: 'Transcript unavailable', notFound: true }) + }) + + it('returns a retryable error when DB discovery throws', async () => { + const result = await readOpenCodeNativeChatTranscriptFull('ses-1', { + resolveDbPath: async () => { + throw new Error('EACCES') + }, + readPage: async () => null + }) + expect(result).toEqual({ error: 'EACCES' }) + }) +}) + +describe('readOpenCodeNativeChatTranscriptTail', () => { + const message = (id: string): NativeChatMessage => ({ + id, + role: 'user', + blocks: [{ type: 'text', text: id }], + timestamp: null, + source: 'transcript' + }) + + it('passes beforeOffset through, floors the limit, and returns the page shape', async () => { + let seen: { limit: number; beforeMessageRowId?: number } | null = null + const result = await readOpenCodeNativeChatTranscriptTail( + { sessionId: 'ses-1', limit: 10.9, beforeOffset: 42 }, + { + resolveDbPath: async () => '/fake/opencode.db', + readPage: async (args) => { + seen = args + return { + items: [{ rowid: 40, fingerprint: 'm1', message: message('m1') }], + hasMore: true, + beforeMessageRowId: 39 + } + } + } + ) + expect(seen).toMatchObject({ + dbPath: '/fake/opencode.db', + sessionId: 'ses-1', + limit: 10, + beforeMessageRowId: 42 + }) + expect(result).toEqual({ messages: [message('m1')], hasMore: true, beforeOffset: 39 }) + }) + + it('defaults a non-positive limit to the desktop window and 0 beforeOffset', async () => { + let seenLimit = 0 + const result = await readOpenCodeNativeChatTranscriptTail( + { sessionId: 'ses-1', limit: 0 }, + { + resolveDbPath: async () => '/fake/opencode.db', + readPage: async (args) => { + seenLimit = args.limit + return { + items: [{ rowid: 7, fingerprint: 'm1', message: message('m1') }], + hasMore: false, + beforeMessageRowId: null + } + } + } + ) + expect(seenLimit).toBe(DESKTOP_READ_WINDOW) + expect(result).toEqual({ messages: [message('m1')], hasMore: false, beforeOffset: 0 }) + }) + + it('reports notFound when the page misses — the session row has not landed', async () => { + const result = await readOpenCodeNativeChatTranscriptTail( + { sessionId: 'ses-1', limit: 10 }, + { + resolveDbPath: async () => '/fake/opencode.db', + readPage: async () => null + } + ) + expect(result).toEqual({ error: 'Transcript unavailable', notFound: true }) + }) + + it('reports notFound when no DB resolves', async () => { + const result = await readOpenCodeNativeChatTranscriptTail( + { sessionId: 'ses-1', limit: 10 }, + { resolveDbPath: async () => null, readPage: async () => null } + ) + expect(result).toEqual({ error: 'Transcript unavailable', notFound: true }) + }) + + it('returns a retryable error without notFound when the page read throws', async () => { + const result = await readOpenCodeNativeChatTranscriptTail( + { sessionId: 'ses-1', limit: 10 }, + { + resolveDbPath: async () => '/fake/opencode.db', + readPage: async () => { + throw new Error('worker timeout') + } + } + ) + expect(result).toEqual({ error: 'worker timeout' }) + }) + + it('returns a retryable error when DB discovery throws', async () => { + // Discovery scans the filesystem and can throw (EACCES/EIO) — the + // value-error contract must not leak that as a rejection. + const result = await readOpenCodeNativeChatTranscriptTail( + { sessionId: 'ses-1', limit: 10 }, + { + resolveDbPath: async () => { + throw new Error('EACCES') + }, + readPage: async () => null + } + ) + expect(result).toEqual({ error: 'EACCES' }) + }) +}) + +describe('resolveOpenCodeTranscriptDbPath', () => { + function createDataHome(): string { + const dataHome = mkdtempSync(join(tmpdir(), 'orca-opencode-discover-')) + tempDirs.push(dataHome) + return dataHome + } + + it('prefers the canonical opencode.db over stale opencode-*.db siblings', async () => { + const dataHome = createDataHome() + const dir = join(dataHome, 'opencode') + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'opencode-backup.db'), '') + writeFileSync(join(dir, 'opencode.db'), '') + vi.stubEnv('XDG_DATA_HOME', dataHome) + await expect(resolveOpenCodeTranscriptDbPath()).resolves.toBe(join(dir, 'opencode.db')) + }) + + it('falls back to a sibling when no canonical DB exists', async () => { + const dataHome = createDataHome() + const dir = join(dataHome, 'opencode') + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'opencode-backup.db'), '') + vi.stubEnv('XDG_DATA_HOME', dataHome) + await expect(resolveOpenCodeTranscriptDbPath()).resolves.toBe(join(dir, 'opencode-backup.db')) + }) + + it('keeps the canonical v1 DB when OpenCode 2 DBs coexist', async () => { + const dataHome = createDataHome() + const dir = join(dataHome, 'opencode') + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'opencode-next.db'), '') + writeFileSync(join(dir, 'opencode-local.db'), '') + writeFileSync(join(dir, 'opencode.db'), '') + vi.stubEnv('XDG_DATA_HOME', dataHome) + await expect(resolveOpenCodeTranscriptDbPath()).resolves.toBe(join(dir, 'opencode.db')) + }) + + it('honors the OPENCODE_DB override and returns null with no DBs', async () => { + const dataHome = createDataHome() + const override = join(dataHome, 'custom.db') + writeFileSync(override, '') + vi.stubEnv('XDG_DATA_HOME', dataHome) + vi.stubEnv('OPENCODE_DB', override) + await expect(resolveOpenCodeTranscriptDbPath()).resolves.toBe(override) + + vi.stubEnv('OPENCODE_DB', '') + await expect(resolveOpenCodeTranscriptDbPath()).resolves.toBe(null) + }) +}) diff --git a/src/main/native-chat/transcript-opencode.ts b/src/main/native-chat/transcript-opencode.ts new file mode 100644 index 00000000000..497635de87e --- /dev/null +++ b/src/main/native-chat/transcript-opencode.ts @@ -0,0 +1,141 @@ +import { + OPENCODE_CAPTURE_RECORD_LIMIT, + OPENCODE_CAPTURE_TEXT_LIMIT +} from '../ai-vault/opencode-transcript-capture-limits' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { errorMessage } from '../ai-vault/session-scanner-values' +import type { ReadTranscriptResult } from './transcript-reader' +import { + readOpenCodeTranscriptPageViaWorker, + readOpenCodeTranscriptSignalViaWorker +} from '../ai-vault/session-scanner-opencode-sqlite-worker-spawn' +import { discoverOpenCodeTranscriptDatabase } from './transcript-opencode-database' +import type { + OpenCodeTranscriptPage, + OpenCodeTranscriptSignal +} from './transcript-opencode-sqlite-query' +import { DESKTOP_READ_WINDOW } from './transcript-watch-contract' +import { openCodeTranscriptPageLimit } from '../../shared/opencode-transcript-page-limit' + +// OpenCode SQLite reads use the same bounded worker as AI Vault. + +export type OpenCodeTranscriptDeps = { + resolveDbPath?: (sessionId?: string, signal?: AbortSignal) => Promise + readSignal?: ( + dbPath: string, + sessionId: string, + signal?: AbortSignal + ) => Promise + readPage?: ( + args: { + dbPath: string + sessionId: string + limit: number + beforeMessageRowId?: number + }, + signal?: AbortSignal + ) => Promise +} + +export function resolveOpenCodeTranscriptDbPath(sessionId?: string): Promise { + return discoverOpenCodeTranscriptDatabase(sessionId) +} + +export const openCodeTranscriptDefaultDeps: Required = { + resolveDbPath: discoverOpenCodeTranscriptDatabase, + readSignal: (dbPath, sessionId, signal) => + readOpenCodeTranscriptSignalViaWorker({ dbPath, sessionId }, signal), + readPage: (args, signal) => readOpenCodeTranscriptPageViaWorker(args, signal) +} + +export type OpenCodeTailResult = + | { messages: NativeChatMessage[]; hasMore: boolean; beforeOffset: number } + | { error: string; notFound?: true } + +export async function readOpenCodeNativeChatTranscriptTail( + args: { sessionId: string; limit: number; beforeOffset?: number }, + deps: OpenCodeTranscriptDeps = {}, + signal?: AbortSignal +): Promise { + const limit = openCodeTranscriptPageLimit(args.limit > 0 ? args.limit : DESKTOP_READ_WINDOW) + try { + const dbPath = await (deps.resolveDbPath ?? openCodeTranscriptDefaultDeps.resolveDbPath)( + args.sessionId, + signal + ) + if (!dbPath) { + return { error: 'Transcript unavailable', notFound: true } + } + const page = await (deps.readPage ?? openCodeTranscriptDefaultDeps.readPage)( + { + dbPath, + sessionId: args.sessionId, + limit, + ...(args.beforeOffset !== undefined ? { beforeMessageRowId: args.beforeOffset } : {}) + }, + signal + ) + if (!page) { + return { error: 'Transcript unavailable', notFound: true } + } + return { + messages: page.items.map((item) => item.message), + hasMore: page.hasMore, + beforeOffset: page.beforeMessageRowId ?? 0 + } + } catch (err) { + return { error: errorMessage(err) } + } +} + +export async function readOpenCodeNativeChatTranscriptFull( + sessionId: string, + deps: OpenCodeTranscriptDeps = {}, + signal?: AbortSignal +): Promise { + // Pages arrive newest-window first; reverse windows, preserving message order. + const pages: NativeChatMessage[][] = [] + let records = 0 + let bytes = 0 + let cursor: number | undefined + try { + const dbPath = await (deps.resolveDbPath ?? openCodeTranscriptDefaultDeps.resolveDbPath)( + sessionId, + signal + ) + if (!dbPath) { + return { error: 'Transcript unavailable', notFound: true } + } + const readPage = deps.readPage ?? openCodeTranscriptDefaultDeps.readPage + for (;;) { + const page = await readPage( + { + dbPath, + sessionId, + limit: 500, + ...(cursor !== undefined ? { beforeMessageRowId: cursor } : {}) + }, + signal + ) + if (!page) { + if (cursor === undefined) { + return { error: 'Transcript unavailable', notFound: true } + } + break + } + pages.push(page.items.map((item) => item.message)) + records += page.items.length + bytes += Buffer.byteLength(JSON.stringify(page.items)) + if (records > OPENCODE_CAPTURE_RECORD_LIMIT || bytes > OPENCODE_CAPTURE_TEXT_LIMIT) { + throw new Error('OpenCode transcript exceeds its full read limit') + } + if (!page.hasMore || page.beforeMessageRowId == null || page.beforeMessageRowId === cursor) { + break + } + cursor = page.beforeMessageRowId + } + } catch (err) { + return { error: errorMessage(err) } + } + return { messages: pages.toReversed().flat() } +} diff --git a/src/main/native-chat/transcript-opencode2-sqlite.test.ts b/src/main/native-chat/transcript-opencode2-sqlite.test.ts new file mode 100644 index 00000000000..333788c82bb --- /dev/null +++ b/src/main/native-chat/transcript-opencode2-sqlite.test.ts @@ -0,0 +1,246 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { AGENT_SESSION_HOST_STATUS_COPY } from '../../shared/agent-session-host-status-rows' +import Database from '../sqlite/sync-database' +import { + readOpenCodeTranscriptPage, + readOpenCodeTranscriptSignal +} from './transcript-opencode-sqlite-query' +import { subscribeOpenCodeNativeChatTranscript } from './transcript-opencode-subscribe' + +const fixtures: { db: Database.Database; root: string }[] = [] +afterEach(() => { + for (const { db, root } of fixtures.splice(0)) { + db.close() + rmSync(root, { recursive: true, force: true }) + } +}) +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'orca-native-opencode2-')) + const path = join(root, 'opencode.db') + const db = new Database(path) + fixtures.push({ db, root }) + db.exec(`CREATE TABLE session_v2 (id TEXT PRIMARY KEY); + CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT, type TEXT, + seq INTEGER, data TEXT, time_created INTEGER, time_updated INTEGER); + INSERT INTO session_v2 VALUES ('session');`) + let sequence = 0 + const insert = (id: string, type: string, data: unknown, time = 1) => + db + .prepare('INSERT INTO session_message VALUES (?, ?, ?, ?, ?, ?, ?)') + .run(id, 'session', type, ++sequence, JSON.stringify(data), time, time) + return { db, path, insert } +} + +describe('OpenCode 2 native transcript', () => { + it('paginates real v2 user/assistant records past bookkeeping without losing messages', () => { + const { path, insert } = fixture() + insert('one', 'user', { text: 'first prompt' }) + insert('empty', 'idle', { outcome: 'succeeded' }) + insert('two', 'assistant', { content: [{ type: 'text', text: 'first reply' }] }) + insert('three', 'user', { text: 'second prompt' }) + insert('last-empty', 'idle', { outcome: 'succeeded' }) + const page = readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'session', limit: 2 }) + expect(page?.items.map((item) => item.message.id)).toEqual(['opencode:two', 'opencode:three']) + expect(page?.hasMore).toBe(true) + const older = readOpenCodeTranscriptPage({ + dbPath: path, + sessionId: 'session', + limit: 2, + beforeMessageRowId: page?.beforeMessageRowId ?? undefined + }) + expect(older?.items.map((item) => item.message.id)).toEqual(['opencode:one']) + expect(older?.hasMore).toBe(false) + }) + + it('preserves tool identity, inputs, outputs and errors across in-place updates', () => { + const { db, path, insert } = fixture() + const content = [ + { + type: 'tool', + id: 'call', + name: 'bash', + state: { status: 'running', input: { command: 'pwd' } } + } + ] + insert('tool', 'assistant', { content }) + const before = readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'session', limit: 2 }) + expect(before?.items[0]?.message.blocks).toEqual([ + { + type: 'tool-call', + callId: 'call', + name: 'bash', + state: 'running', + input: { command: 'pwd' } + } + ]) + db.prepare('UPDATE session_message SET data = ?, time_updated = 2 WHERE id = ?').run( + JSON.stringify({ + content: [ + { + ...content[0], + state: { + status: 'error', + input: { command: 'pwd' }, + error: { message: 'denied' }, + content: [] + } + } + ] + }), + 'tool' + ) + const after = readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'session', limit: 2 }) + expect(after?.items[0]?.fingerprint).not.toBe(before?.items[0]?.fingerprint) + expect(after?.items[0]?.message.blocks[0]).toMatchObject({ state: 'failed', callId: 'call' }) + expect(after?.items[0]?.message.blocks[1]).toMatchObject({ isError: true, callId: 'call' }) + expect(readOpenCodeTranscriptSignal(path, 'session')?.maxPartTimeUpdated).toBe(2) + }) + + it('surfaces provider errors and explicit interrupted boundaries', () => { + const { path, insert } = fixture() + insert('error', 'assistant', { error: { type: 'provider', message: 'Rate limit exceeded' } }) + insert('boundary', 'idle', { outcome: 'interrupted' }) + const page = readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'session', limit: 2 }) + expect(page?.items.map((item) => item.message.blocks)).toEqual([ + [{ type: 'text', text: 'Rate limit exceeded' }], + [{ type: 'text', text: 'Conversation interrupted' }] + ]) + }) + + it.each(['x', '😀'])( + 'omits oversized %s content with a stable cursor and time fingerprint', + (character) => { + const { db, path, insert } = fixture() + insert('before', 'user', { text: 'before' }) + insert('large', 'user', { + text: '', + files: [{ mime: 'image/png', data: character.repeat(2 * 1024 * 1024) }] + }) + insert('after', 'assistant', { text: 'after' }) + const args = { dbPath: path, sessionId: 'session', limit: 2 } + const page = readOpenCodeTranscriptPage(args) + expect(page?.items.map((item) => item.message.id)).toEqual([ + 'opencode:large', + 'opencode:after' + ]) + expect(page?.items[0]).toMatchObject({ + rowid: 2, + message: { + role: 'user', + timestamp: 1, + blocks: [{ type: 'text', text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'] }] + } + }) + expect(readOpenCodeTranscriptPage(args)?.items[0]?.fingerprint).toBe( + page?.items[0]?.fingerprint + ) + db.prepare("UPDATE session_message SET time_updated = 2 WHERE id = 'large'").run() + expect(readOpenCodeTranscriptPage(args)?.items[0]?.fingerprint).not.toBe( + page?.items[0]?.fingerprint + ) + expect( + readOpenCodeTranscriptPage({ + ...args, + beforeMessageRowId: page?.beforeMessageRowId ?? undefined + })?.items.map((item) => item.message.id) + ).toEqual(['opencode:before']) + } + ) + + it('refuses excessive sparse scans instead of returning a partial transcript', () => { + const { db, path, insert } = fixture() + db.exec('BEGIN') + for (let index = 0; index < 10001; index++) { + insert(String(index), 'idle', {}) + } + db.exec('COMMIT') + expect(() => + readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'session', limit: 1 }) + ).toThrow('read limit') + }) + + it('keeps the aggregate page byte budget when individual rows fit', () => { + const { db, path, insert } = fixture() + db.exec('BEGIN') + for (let index = 0; index < 18; index++) { + insert(String(index), 'user', { text: 'x'.repeat(1024 * 1024) }) + } + db.exec('COMMIT') + expect(() => + readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'session', limit: 18 }) + ).toThrow('read limit') + }) + + it('reconciles live streaming updates and stops after unsubscribe', async () => { + const { db, path, insert } = fixture() + insert('reply', 'assistant', { content: [{ type: 'text', text: 'partial' }] }) + const snapshots: unknown[] = [] + const replacements: unknown[] = [] + const subscription = subscribeOpenCodeNativeChatTranscript( + { + agent: 'opencode', + sessionId: 'session', + resolvePollIntervalMs: 5, + onAppend: () => {}, + onInitialSnapshot: (messages) => snapshots.push(messages), + onReplace: (messages) => replacements.push(messages) + }, + undefined, + { + resolveDbPath: async () => path, + readSignal: async (dbPath, sessionId) => readOpenCodeTranscriptSignal(dbPath, sessionId), + readPage: async (args) => readOpenCodeTranscriptPage(args) + } + ) + try { + await expect.poll(() => snapshots.length).toBe(1) + db.prepare('UPDATE session_message SET data = ?, time_updated = 2').run( + JSON.stringify({ content: [{ type: 'text', text: 'complete' }] }) + ) + await expect.poll(() => replacements.length).toBe(1) + expect(replacements[0]).toMatchObject([{ blocks: [{ text: 'complete' }] }]) + subscription.unsubscribe() + db.prepare('UPDATE session_message SET time_updated = 3').run() + await new Promise((resolve) => setTimeout(resolve, 25)) + expect(replacements).toHaveLength(1) + } finally { + subscription.unsubscribe() + } + }) +}) + +it('preserves real v2 user image files with or without accompanying text', () => { + const { path, insert } = fixture() + const file = { data: 'YQ==', mime: 'image/png', source: { type: 'inline' }, name: 'tiny.png' } + insert('text-image', 'user', { text: 'see this', files: [file] }) + insert('image-only', 'user', { text: '', files: [file] }) + const page = readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'session', limit: 10 }) + expect(page?.items.map((item) => item.message.blocks)).toEqual([ + [ + { type: 'text', text: 'see this' }, + { type: 'image-ref', url: `data:${file.mime};base64,${file.data}`, alt: 'tiny.png' } + ], + [{ type: 'image-ref', url: `data:${file.mime};base64,${file.data}`, alt: 'tiny.png' }] + ]) +}) + +it('prefers the migrated live v2 session over retained legacy tables for pages and signals', () => { + const { db, path, insert } = fixture() + db.exec(`CREATE TABLE session (id TEXT PRIMARY KEY); + CREATE TABLE message (id TEXT, session_id TEXT, time_created INTEGER, time_updated INTEGER, data TEXT); + CREATE TABLE part (id TEXT, message_id TEXT, session_id TEXT, time_updated INTEGER, data TEXT); + INSERT INTO session VALUES ('session'); + INSERT INTO message VALUES ('old', 'session', 1, 1, '{"role":"user"}'); + INSERT INTO part VALUES ('part', 'old', 'session', 1, '{"type":"text","text":"frozen legacy"}');`) + insert('imported', 'user', { text: 'migrated prompt' }) + insert('live', 'assistant', { content: [{ type: 'text', text: 'live reply' }] }) + expect(readOpenCodeTranscriptSignal(path, 'session')?.messageCount).toBe(2) + expect( + readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'session', limit: 10 })?.items.map( + (item) => item.message.id + ) + ).toEqual(['opencode:imported', 'opencode:live']) +}) diff --git a/src/main/native-chat/transcript-opencode2-sqlite.ts b/src/main/native-chat/transcript-opencode2-sqlite.ts new file mode 100644 index 00000000000..2ebfe950e10 --- /dev/null +++ b/src/main/native-chat/transcript-opencode2-sqlite.ts @@ -0,0 +1,234 @@ +import { createHash } from 'node:crypto' +import type SyncDatabase from '../sqlite/sync-database' +import { openCodeTranscriptPageLimit } from '../../shared/opencode-transcript-page-limit' +import { columnExists, tableExists } from '../opencode-usage/schema-helpers' +import { asRecord, parseJsonObject } from '../ai-vault/session-scanner-values' +import { + opencodeMessageBlocks, + OPENCODE_TRANSCRIPT_MAX_ROW_BYTES +} from './transcript-opencode-part-blocks' +import type { + OpenCodeTranscriptPage, + OpenCodeTranscriptSignal, + OpenCodeTranscriptItem +} from './transcript-opencode-sqlite-query' + +const MAX_PAGE_BYTES = 16 * 1024 * 1024 +const MAX_SCAN_ROWS = 10_000 + +function sessionExists(db: SyncDatabase, sessionId: string): boolean { + return ( + tableExists(db, 'session_v2') && + tableExists(db, 'session_message') && + ['id', 'session_id', 'type', 'seq', 'data', 'time_created', 'time_updated'].every((column) => + columnExists(db, 'session_message', column) + ) && + db.prepare('SELECT 1 FROM session_v2 WHERE id = ?').get(sessionId) != null + ) +} + +export function readOpenCode2TranscriptSignal( + db: SyncDatabase, + sessionId: string +): OpenCodeTranscriptSignal | null { + if (!sessionExists(db, sessionId)) { + return null + } + const row = asRecord( + db + .prepare(`SELECT COUNT(*) AS count, COALESCE(MAX(seq), 0) AS latest, + COALESCE(MAX(time_updated), 0) AS updated FROM session_message WHERE session_id = ?`) + .get(sessionId) + ) + if ( + typeof row?.count !== 'number' || + typeof row.latest !== 'number' || + typeof row.updated !== 'number' + ) { + throw new Error('OpenCode transcript signal is invalid') + } + return { + messageCount: row.count, + partCount: 0, + maxMessageRowId: row.latest, + maxPartTimeUpdated: row.updated + } +} + +function messageItem(value: unknown): OpenCodeTranscriptItem | null { + const row = asRecord(value) + if ( + typeof row?.id !== 'string' || + typeof row.cursor !== 'number' || + typeof row.time_created !== 'number' || + typeof row.time_updated !== 'number' + ) { + throw new Error('OpenCode transcript message is invalid') + } + if ( + row.data === null || + (typeof row.data === 'string' && + Buffer.byteLength(row.data) > OPENCODE_TRANSCRIPT_MAX_ROW_BYTES) + ) { + return { + rowid: row.cursor, + fingerprint: `${row.time_updated}:omitted`, + message: { + id: `opencode:${row.id}`, + role: row.type === 'user' ? 'user' : row.type === 'assistant' ? 'assistant' : 'system', + blocks: opencodeMessageBlocks([ + { message_id: row.id, time_updated: row.time_updated, data: null } + ]), + timestamp: row.time_created, + source: 'transcript' + } + } + } + if (typeof row.data !== 'string') { + throw new Error('OpenCode transcript message is invalid') + } + const messageId = row.id + const updatedAt = row.time_updated + const record = parseJsonObject(row.data) + if (!record) { + throw new Error('OpenCode transcript message contains invalid JSON') + } + const content = Array.isArray(record.content) + ? record.content + : typeof record.text === 'string' + ? [{ type: 'text', text: record.text }] + : [] + if (row.type === 'user' && Array.isArray(record.files)) { + for (const file of record.files) { + const attachment = asRecord(file) + if (!attachment) { + continue + } + const url = + typeof attachment.uri === 'string' + ? attachment.uri + : typeof attachment.data === 'string' && typeof attachment.mime === 'string' + ? `data:${attachment.mime};base64,${attachment.data}` + : null + if (url) { + content.push({ type: 'file', url, mime: attachment.mime, filename: attachment.name }) + } + } + } + const parts = content.flatMap((value) => { + const item = asRecord(value) + if (!item) { + return [] + } + if (item.type !== 'tool') { + return [{ message_id: messageId, time_updated: updatedAt, data: JSON.stringify(item) }] + } + const state = asRecord(item.state) + const output = Array.isArray(state?.content) + ? state.content + .flatMap((value) => { + const part = asRecord(value) + return typeof part?.text === 'string' ? [part.text] : [] + }) + .join('\n') + : undefined + return [ + { + message_id: messageId, + time_updated: updatedAt, + data: JSON.stringify({ ...item, tool: item.name, state: { ...state, output } }) + } + ] + }) + const blocks = opencodeMessageBlocks(parts) + const error = asRecord(record.error) + if (typeof error?.message === 'string') { + blocks.push({ type: 'text', text: error.message }) + } + if (row.type === 'idle' && record.outcome === 'interrupted') { + blocks.push({ type: 'text', text: 'Conversation interrupted' }) + } + if ( + blocks.length === 0 || + (row.type !== 'user' && + row.type !== 'assistant' && + row.type !== 'system' && + row.type !== 'idle') + ) { + return null + } + return { + rowid: row.cursor, + fingerprint: `${updatedAt}:${createHash('sha256').update(row.data).digest('hex')}`, + message: { + id: `opencode:${row.id}`, + role: row.type === 'idle' ? 'system' : row.type, + blocks, + timestamp: row.time_created, + source: 'transcript' + } + } +} + +export function readOpenCode2TranscriptPage( + db: SyncDatabase, + args: { + sessionId: string + limit: number + beforeMessageRowId?: number + } +): OpenCodeTranscriptPage | null { + if (!sessionExists(db, args.sessionId)) { + return null + } + const limit = openCodeTranscriptPageLimit(args.limit) + const statement = db.prepare(`SELECT seq AS cursor, id, type, time_created, time_updated, + CASE WHEN length(CAST(data AS BLOB)) <= ${OPENCODE_TRANSCRIPT_MAX_ROW_BYTES} THEN data ELSE NULL END AS data + FROM session_message WHERE session_id = ? AND seq < ? ORDER BY seq DESC LIMIT ?`) + const items: OpenCodeTranscriptItem[] = [] + let cursor = args.beforeMessageRowId ?? Number.MAX_SAFE_INTEGER + let scannedRows = 0 + let bytes = 0 + let pageBytes = 0 + let hasMore = false + while (items.length < limit) { + const rows = statement.all(args.sessionId, cursor, Math.min(limit + 1, 8)) + hasMore = rows.length === Math.min(limit + 1, 8) + if (rows.length === 0) { + break + } + for (const value of rows) { + const row = asRecord(value) + if (typeof row?.cursor !== 'number') { + throw new Error('OpenCode transcript cursor is invalid') + } + bytes += Object.values(row).reduce( + (size, value) => size + (typeof value === 'string' ? Buffer.byteLength(value) : 0), + 0 + ) + if (++scannedRows > MAX_SCAN_ROWS || bytes > MAX_PAGE_BYTES) { + throw new Error('OpenCode transcript page exceeds its read limit') + } + const item = messageItem(value) + cursor = row.cursor + if (item) { + pageBytes += Buffer.byteLength(JSON.stringify(item)) + if (pageBytes > MAX_PAGE_BYTES) { + throw new Error('OpenCode transcript page exceeds its read limit') + } + items.push(item) + } + if (items.length >= limit) { + hasMore = + db + .prepare('SELECT 1 FROM session_message WHERE session_id = ? AND seq < ? LIMIT 1') + .get(args.sessionId, cursor) != null + break + } + } + if (!hasMore) { + break + } + } + return { items: items.toReversed(), hasMore, beforeMessageRowId: scannedRows ? cursor : null } +} diff --git a/src/main/native-chat/transcript-read-cache.ts b/src/main/native-chat/transcript-read-cache.ts index 828521e2e3d..7233f001de4 100644 --- a/src/main/native-chat/transcript-read-cache.ts +++ b/src/main/native-chat/transcript-read-cache.ts @@ -1,3 +1,5 @@ +import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support' +import { readOpenCodeNativeChatTranscriptFull } from './transcript-opencode' import type { AgentType } from '../../shared/native-chat-types' import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe' import { resolveSessionFilePath } from './session-file-resolver' @@ -104,6 +106,9 @@ export async function readNativeChatTranscriptCached( /** Hook-reported authoritative transcript path, preferred over the id glob. */ transcriptPath?: string ): Promise { + if (resolveNativeChatTranscriptAgent(agent) === 'opencode') { + return readOpenCodeNativeChatTranscriptFull(sessionId) + } const epoch = cacheEpoch let filePath: string | null try { diff --git a/src/main/native-chat/transcript-reader.ts b/src/main/native-chat/transcript-reader.ts index a4d3acb7465..97ab76802d0 100644 --- a/src/main/native-chat/transcript-reader.ts +++ b/src/main/native-chat/transcript-reader.ts @@ -1,3 +1,4 @@ +import { readOpenCodeNativeChatTranscriptFull } from './transcript-opencode' import type { AgentType, NativeChatMessage, @@ -42,6 +43,9 @@ export async function readNativeChatTranscript( sessionId: string, options: ReadTranscriptOptions = {} ): Promise { + if (resolveNativeChatTranscriptAgent(agent) === 'opencode') { + return readOpenCodeNativeChatTranscriptFull(sessionId) + } let filePath: string | null try { filePath = options.filePath ?? (await resolveSessionFilePath(agent, sessionId, options)) diff --git a/src/main/native-chat/transcript-tail-reader.ts b/src/main/native-chat/transcript-tail-reader.ts index dc212f332af..7afef93571f 100644 --- a/src/main/native-chat/transcript-tail-reader.ts +++ b/src/main/native-chat/transcript-tail-reader.ts @@ -1,3 +1,4 @@ +import { readOpenCodeNativeChatTranscriptTail } from './transcript-opencode' import type { AgentType, NativeChatMessage, @@ -232,6 +233,9 @@ export async function readNativeChatTranscriptTail( } | { error: string; notFound?: true } > { + if (resolveNativeChatTranscriptAgent(args.agent) === 'opencode') { + return readOpenCodeNativeChatTranscriptTail(args, {}, signal) + } const decode = nativeChatLineDecoderForAgent(args.agent) const decodeLifecycle = nativeChatTurnLifecycleDecoderForAgent(args.agent) if (!decode) { diff --git a/src/main/native-chat/transcript-watch-contract.ts b/src/main/native-chat/transcript-watch-contract.ts index 13463e524ef..c264d9ab514 100644 --- a/src/main/native-chat/transcript-watch-contract.ts +++ b/src/main/native-chat/transcript-watch-contract.ts @@ -1,3 +1,6 @@ +export const DESKTOP_READ_WINDOW = 300 +export const UNFLUSHED_SETTLE_MS = 1_500 + import type { AgentType, NativeChatMessage, diff --git a/src/main/native-chat/transcript-watch.ts b/src/main/native-chat/transcript-watch.ts index 97e7b75ec61..a5090b4c800 100644 --- a/src/main/native-chat/transcript-watch.ts +++ b/src/main/native-chat/transcript-watch.ts @@ -1,3 +1,6 @@ +import { UNFLUSHED_SETTLE_MS } from './transcript-watch-contract' +import { subscribeOpenCodeNativeChatTranscript } from './transcript-opencode-subscribe' +import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support' import { extname } from 'node:path' import type { NativeChatMessage } from '../../shared/native-chat-types' import { @@ -55,7 +58,6 @@ const FALLBACK_RESOLVE_POLL_MS = 5_000 // delay — a fresh session that has yet to be prompted never flushes, so the // spinner is permanent. Long enough that a merely slow resolve still wins the // race and paints history directly. -const UNFLUSHED_SETTLE_MS = 1_500 function exactTranscriptPath(args: SubscribeNativeChatTranscriptArgs): string | null { const path = args.transcriptPath?.trim() @@ -264,6 +266,9 @@ export async function subscribeNativeChatTranscript( setupSignal?: AbortSignal ): Promise { setupSignal?.throwIfAborted() + if (resolveNativeChatTranscriptAgent(args.agent) === 'opencode') { + return subscribeOpenCodeNativeChatTranscript(args, setupSignal) + } const decode = nativeChatLineDecoderForAgent(args.agent) if (!decode) { // Nothing watchable — return a no-op teardown so callers can unconditionally diff --git a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx index 303ab2472c7..1f3ded16110 100644 --- a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx @@ -39,7 +39,8 @@ const mocks = { sendAnswer: vi.fn(), sendRaw: vi.fn(), cancelPending: vi.fn(), - cancel: vi.fn() + cancel: vi.fn(), + cancelAsk: vi.fn() } function renderCard(canSend = true): ReturnType { @@ -90,7 +91,8 @@ function CardHarness({ sendAnswer: mocks.sendAnswer, sendRaw: mocks.sendRaw, cancelPending: mocks.cancelPending, - cancel: mocks.cancel + cancel: mocks.cancel, + cancelAsk: mocks.cancelAsk }} /> ) @@ -135,6 +137,16 @@ function chooseSpacesAndSubmit(): void { } describe('NativeChatInteractiveCard answer lifecycle', () => { + it('routes question Cancel to rejection and releases the composer slot without Stop', () => { + const onShowingQuestionChange = vi.fn() + render(cardElement(true, undefined, onShowingQuestionChange)) + expect(screen.getByTestId('native-chat-question-card-title')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(mocks.cancelAsk).toHaveBeenCalledOnce() + expect(mocks.cancel).not.toHaveBeenCalled() + expect(screen.queryByTestId('native-chat-question-card-title')).not.toBeInTheDocument() + expect(onShowingQuestionChange).toHaveBeenLastCalledWith(false) + }) beforeEach(() => { vi.clearAllMocks() storeState.agentStatusByPaneKey['tab-1:leaf-1'].interactivePrompt = INITIAL_PROMPT diff --git a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx index 9d57a6ab5e1..6411bb1e9b1 100644 --- a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx @@ -36,7 +36,7 @@ export function NativeChatInteractiveCard({ * a target while the composer is unmounted. */ answerInputRef?: React.RefObject }): React.JSX.Element | null { - const { sendAnswer, sendRaw, cancelPending, cancel } = send + const { sendAnswer, sendRaw, cancelPending, cancelAsk } = send const cardKey = useMemo(() => nativeChatCardDismissKey(card), [card]) const [dismissedKey, setDismissedKey] = useState(null) // A question answer is a paced multi-step write (body→Enter per question); keep @@ -135,7 +135,7 @@ export function NativeChatInteractiveCard({ onCancel={() => { clearDismissTimer() setDismissedKey(cardKey) - cancel() + cancelAsk() }} /> ) diff --git a/src/renderer/src/components/native-chat/native-chat-availability.test.ts b/src/renderer/src/components/native-chat/native-chat-availability.test.ts index a08bc476d2f..737d7d4a077 100644 --- a/src/renderer/src/components/native-chat/native-chat-availability.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-availability.test.ts @@ -117,19 +117,16 @@ describe('canToggleNativeChat', () => { ).toBe(true) }) - it.each(['gemini', 'opencode'] as const)( - 'rejects unsupported agent %s detected live', - (agent) => { - expect( - canToggleNativeChat({ - experimentalNativeChatEnabled: true, - contentType: 'terminal', - launchAgent: null, - detectedAgent: agent - }) - ).toBe(false) - } - ) + it.each(['gemini'] as const)('rejects unsupported agent %s detected live', (agent) => { + expect( + canToggleNativeChat({ + experimentalNativeChatEnabled: true, + contentType: 'terminal', + launchAgent: null, + detectedAgent: agent + }) + ).toBe(false) + }) it('accepts Grok when resolved from the title', () => { expect( diff --git a/src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts b/src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts index 3921d862526..4cbc4c692ea 100644 --- a/src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts @@ -295,3 +295,10 @@ describe('hasAskAnswer', () => { expect(hasAskAnswer(single(['A', 'B']), [])).toBe(false) }) }) + +for (const agent of ['opencode', 'opencode2']) { + it(`${agent} approves the captured default selector with Enter`, () => { + const card = parseApprovalFromStatus(JSON.stringify({ approval: { tool: 'shell' } }), agent) + expect(card?.options[0].send).toBe('\r') + }) +} diff --git a/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts b/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts index 038ce1e7523..6454dd8257b 100644 --- a/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts +++ b/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts @@ -1,3 +1,4 @@ +import { nativeChatApprovalAcceptKey } from '../../../../shared/native-chat-agent-support' import { translate } from '@/i18n/i18n' import type { AgentJournalApprovalMatchedAskRule, @@ -54,7 +55,8 @@ const ESCAPE = String.fromCharCode(27) /** Parse the desktop-only approval envelope; question parsing stays cross-platform. */ export function parseApprovalFromStatus( - interactivePrompt: string | undefined | null + interactivePrompt: string | undefined | null, + agent?: string ): ChatApproval | null { if (!interactivePrompt) { return null @@ -83,7 +85,10 @@ export function parseApprovalFromStatus( }), detail: typeof summary === 'string' && summary.length > 0 ? summary : undefined, options: [ - { label: translate('components.native-chat.approval.allow', 'Allow'), send: '1' }, + { + label: translate('components.native-chat.approval.allow', 'Allow'), + send: nativeChatApprovalAcceptKey(agent) + }, { label: translate('components.native-chat.approval.deny', 'Deny'), send: ESCAPE } ] } @@ -91,12 +96,13 @@ export function parseApprovalFromStatus( export function parseInteractivePrompt( interactivePrompt: string | undefined | null, - toolName?: string + toolName?: string, + agent?: string ): InteractivePromptCard { const prompt = parseAskFromStatus(interactivePrompt, toolName) if (prompt) { return { kind: 'question', prompt } } - const approval = parseApprovalFromStatus(interactivePrompt) + const approval = parseApprovalFromStatus(interactivePrompt, agent) return approval ? { kind: 'approval', approval } : null } diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-prompt-card.ts b/src/renderer/src/components/native-chat/use-native-chat-interactive-prompt-card.ts index 243fea0ac57..4f59534cf6e 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-interactive-prompt-card.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-prompt-card.ts @@ -33,8 +33,13 @@ export function useNativeChatInteractivePromptCard({ const entry = s.agentStatusByPaneKey[paneKey] return entry?.interactivePrompt ? (entry.toolName ?? null) : null }) + const agent = useAppStore((s) => s.agentStatusByPaneKey[paneKey]?.agentType) return useMemo(() => { - const statusCard = parseInteractivePrompt(interactivePrompt, interactiveToolName ?? undefined) + const statusCard = parseInteractivePrompt( + interactivePrompt, + interactiveToolName ?? undefined, + agent + ) if (statusCard?.kind === 'approval') { return statusCard } @@ -44,5 +49,5 @@ export function useNativeChatInteractivePromptCard({ transcriptSettled }) return prompt ? { kind: 'question' as const, prompt } : null - }, [interactivePrompt, interactiveToolName, messages, transcriptSettled]) + }, [interactivePrompt, interactiveToolName, agent, messages, transcriptSettled]) } diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx index 913e8fe9fea..4fe977a22fb 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx @@ -184,6 +184,58 @@ describe('useNativeChatInteractiveSend', () => { ) }) + it.each(['opencode', 'opencode2'] as const)( + 'paces two Escape writes for %s Stop and cancels them on rebind', + (agent) => { + const { result, rerender } = renderHook( + ({ ptyId }) => useNativeChatInteractiveSend('tab-1', PANE_KEY, ptyId, agent), + { initialProps: { ptyId: 'pty-1' } } + ) + act(() => result.current.cancel()) + expect(mocks.sendNativeChatAskAnswer).toHaveBeenCalledWith( + { terminalTabId: 'tab-1' }, + 'pty-1', + [{ raw: '\x1b' }, { raw: '\x1b' }] + ) + rerender({ ptyId: 'pty-2' }) + expect(mocks.cancel).toHaveBeenCalledOnce() + } + ) + + it.each(['opencode', 'opencode2'] as const)( + 'rejects a %s question with one Escape and no delayed Stop', + (agent) => { + const { result } = renderHook(() => + useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', agent) + ) + act(() => result.current.cancelAsk()) + expect(mocks.sendRuntimePtyInput).toHaveBeenCalledExactlyOnceWith( + { terminalTabId: 'tab-1' }, + 'pty-1', + '\x1b', + 'driving' + ) + expect(mocks.sendNativeChatAskAnswer).not.toHaveBeenCalled() + } + ) + + it.each(['opencode', 'opencode2'] as const)( + 'delivers a non-default %s answer through selector keys', + (agent) => { + const { result } = renderHook(() => + useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', agent) + ) + act(() => result.current.sendAnswer(PROMPT, [{ indices: [1] }])) + expect(mocks.sendNativeChatAskAnswer).toHaveBeenCalledWith( + { terminalTabId: 'tab-1' }, + 'pty-1', + [{ raw: '2' }], + expect.any(Function) + ) + expect(mocks.sendNativeChatMessage).not.toHaveBeenCalled() + } + ) + it('can cancel delayed writes without interrupting the replacement prompt', () => { const { result } = renderHook(() => useNativeChatInteractiveSend('tab-1', PANE_KEY, 'pty-1', 'claude') diff --git a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts index 3d270cde49b..d0930253ab2 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts @@ -38,7 +38,9 @@ export type NativeChatInteractiveSend = { sendRaw: (raw: string) => void /** Stop delayed writes without interrupting the agent. */ cancelPending: () => void - /** Send ESC to interrupt — cancels a question / denies an approval. */ + /** Reject the active question without requesting session interruption. */ + cancelAsk: () => void + /** Interrupt the active turn. */ cancel: () => void } @@ -46,7 +48,7 @@ export type NativeChatInteractiveSend = { * Reuse the desktop composer's exact send path for the interactive cards: * resolve this tab's live ptyId + runtime owner settings, then write bytes via * `sendRuntimePtyInput` (which branches local pty:write vs remote runtime RPC, - * so SSH panes work unchanged). Claude and Codex answers use their respective + * so SSH panes work unchanged). Selector answers use their respective * selector keystrokes via `sendNativeChatAskAnswer`; other agents still go through * `sendNativeChatMessage`. Control strings (option digits, ESC) are written raw. */ @@ -98,8 +100,7 @@ export function useNativeChatInteractiveSend( // Cancel any prior in-flight answer before starting a new one. cancelInFlight() const settings = getSettingsForAgentTabRuntimeOwner(terminalTabId) - // Claude and Codex ignore pasted labels but have different selector state - // machines; Grok commits pasted text. OpenClaude follows Claude's path. + // Selector TUIs ignore pasted labels; Codex uses a different key sequence. const stepsAnswer = shouldStepNativeChatAskAnswer(agent) const buildsCodexAnswer = resolveNativeChatTranscriptAgent(agent) === 'codex' // Why: pin the answered question's baseline BEFORE delivery. A late settle @@ -156,11 +157,24 @@ export function useNativeChatInteractiveSend( [terminalTabId, paneKey, targetPtyId, agent, cancelInFlight] ) - // Stop/cancel: drop any pending answer writes, then send ESC to interrupt. - const cancel = useCallback(() => { + const cancelAsk = useCallback(() => { cancelInFlight() sendRaw(ESC) }, [cancelInFlight, sendRaw]) - return { sendAnswer, sendRaw, cancelPending: cancelInFlight, cancel } + const cancel = useCallback(() => { + cancelInFlight() + if (resolveNativeChatTranscriptAgent(agent) === 'opencode' && targetPtyId) { + // OpenCode confirms interruption with a second Escape; pace writes like mobile Stop. + inFlightRef.current = sendNativeChatAskAnswer( + getSettingsForAgentTabRuntimeOwner(terminalTabId), + targetPtyId, + [{ raw: ESC }, { raw: ESC }] + ) + return + } + sendRaw(ESC) + }, [agent, cancelInFlight, sendRaw, targetPtyId, terminalTabId]) + + return { sendAnswer, sendRaw, cancelPending: cancelInFlight, cancelAsk, cancel } } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-continuation.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-continuation.test.ts index 28ccfbe056f..b978d9ad2b8 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-continuation.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-continuation.test.ts @@ -1,3 +1,7 @@ +import { + buildAgentSessionContinuationPrompt, + hasFullAgentSessionContext +} from '@/lib/agent-session-continuation' import { describe, expect, it } from 'vitest' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import { @@ -65,6 +69,33 @@ describe('AI Vault session continuation', () => { expect(request.source.capturedText).toContain('assistant: The component tests still need work.') }) + it.each(['opencode', 'opencode2'] as const)( + 'uses bounded %s vault previews for plain and synthetic database paths', + (agent) => { + for (const filePath of [ + '/data/opencode/opencode.db', + '/data/opencode/opencode.db#session', + '/data/private-account.db' + ]) { + const sourceSession = { ...session(agent), filePath } + const request = prepareAiVaultSessionContinuation({ + session: sourceSession, + targetWorktreeId: 'folder:project', + targetWorkspacePath: '/project' + }) + expect(hasFullAgentSessionContext(request.source)).toBe(false) + expect(buildAgentSessionContinuationPrompt(request.source, 'focused')).toContain( + 'The component tests still need work.' + ) + expect(buildAgentSessionContinuationPrompt(request.source, 'focused')).not.toContain( + filePath + ) + sourceSession.previewMessages = [] + expect(canContinueAiVaultSessionInNewSession(sourceSession, 'folder:project')).toBe(false) + } + } + ) + it('never treats a preview tool result as the user prompt', () => { const sourceSession = session() sourceSession.lastUserPrompt = null diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-continuation.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-continuation.ts index 3baff2bf272..26f2549ab74 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-continuation.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-continuation.ts @@ -7,7 +7,7 @@ export function canContinueAiVaultSessionInNewSession( ): boolean { return Boolean( targetWorktreeId && - (session.filePath.trim() || session.previewMessages.some((message) => message.text.trim())) + (vaultTranscriptPath(session) || session.previewMessages.some((message) => message.text.trim())) ) } @@ -23,7 +23,7 @@ export function prepareAiVaultSessionContinuation(args: { sourceAgent: session.agent, sourceTitle: session.title, sourceWorkingDirectory: session.cwd, - transcriptPath: session.filePath.trim() || null, + transcriptPath: vaultTranscriptPath(session), // Why: preview user entries can be tool results or injected skill text; only provider-authenticated prompts are safe hints. lastPrompt: session.lastUserPrompt ?? null, lastAssistantMessage: latestAssistantPreview(session) @@ -46,3 +46,11 @@ function previewTranscript(session: AiVaultSession): string { .map((message) => `${message.role}: ${message.text.trim()}`) .join('\n\n') } + +function vaultTranscriptPath(session: AiVaultSession): string | null { + // OpenCode vault paths identify its database or storage metadata, not a full transcript. + if (session.agent === 'opencode' || session.agent === 'opencode2') { + return null + } + return session.filePath.trim() || null +} diff --git a/src/renderer/src/components/settings/NativeChatSupportedAgents.test.tsx b/src/renderer/src/components/settings/NativeChatSupportedAgents.test.tsx index e18f9ed2d42..18c4ab37182 100644 --- a/src/renderer/src/components/settings/NativeChatSupportedAgents.test.tsx +++ b/src/renderer/src/components/settings/NativeChatSupportedAgents.test.tsx @@ -17,7 +17,9 @@ const EXPECTED_SUPPORTED_AGENTS = [ 'openclaude', 'codex', 'grok', - 'omp' + 'omp', + 'opencode', + 'opencode2' ] as const satisfies readonly TuiAgent[] const SUPPORTED_AGENTS_LABEL_KEY = 'auto.components.settings.NativeChatSupportedAgents.label' @@ -41,7 +43,7 @@ describe('NativeChatSupportedAgents', () => { expect(NATIVE_CHAT_SUPPORTED_AGENT_LIST).toEqual(EXPECTED_SUPPORTED_AGENTS) for (const entry of getAgentCatalog()) { expect(isNativeChatSupportedAgent(entry.id), entry.id).toBe( - EXPECTED_SUPPORTED_AGENTS.includes(entry.id as (typeof EXPECTED_SUPPORTED_AGENTS)[number]) + EXPECTED_SUPPORTED_AGENTS.some((agent) => agent === entry.id) ) } }) @@ -58,15 +60,15 @@ describe('NativeChatSupportedAgents', () => { } }) - it('omits agents native chat cannot render, including OpenCode', () => { + it('omits agents outside the supported transcript contract', () => { const rendered = getRenderedChips().map((chip) => chip.agent) for (const entry of getAgentCatalog()) { - if (!isNativeChatSupportedAgent(entry.id)) { + if (!EXPECTED_SUPPORTED_AGENTS.some((agent) => agent === entry.id)) { expect(rendered).not.toContain(entry.id) } } - expect(rendered).not.toContain('opencode') + expect(rendered).not.toContain('cursor') }) it('keeps the label in the English catalog', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-agent-session-continuation.test.ts b/src/renderer/src/components/terminal-pane/terminal-agent-session-continuation.test.ts index 5b1a415219e..803ec298ed5 100644 --- a/src/renderer/src/components/terminal-pane/terminal-agent-session-continuation.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-agent-session-continuation.test.ts @@ -1,6 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ManagedPane } from '@/lib/pane-manager/pane-manager' -import { buildAgentSessionContinuationPrompt } from '@/lib/agent-session-continuation' +import { + buildAgentSessionContinuationPrompt, + hasFullAgentSessionContext +} from '@/lib/agent-session-continuation' import { prepareAgentSessionContinuationFromPane } from './terminal-agent-session-continuation' const LEAF_ID = '11111111-1111-4111-8111-111111111111' @@ -64,6 +67,30 @@ describe('buildAgentSessionContinuationPrompt', () => { ) expect(buildAgentSessionContinuationPrompt(source, 'full')).toBeNull() }) + it.each(['opencode', 'opencode2'] as const)( + 'uses captured context for %s SQLite references', + (sourceAgent) => { + const source = { + sourceAgent, + transcriptPath: '/home/u/.local/share/opencode/opencode.db#ses_proof', + capturedText: 'user: fix the parser\nassistant: tests remain' + } + expect(hasFullAgentSessionContext(source)).toBe(false) + expect(buildAgentSessionContinuationPrompt(source, 'focused')).toContain('tests remain') + expect(buildAgentSessionContinuationPrompt(source, 'focused')).not.toContain('opencode.db#') + expect(buildAgentSessionContinuationPrompt(source, 'full')).toBeNull() + } + ) + + it('preserves real transcript paths containing a hash', () => { + const source = { + sourceAgent: 'opencode' as const, + transcriptPath: '/home/u/project#one/session.jsonl', + capturedText: 'fallback' + } + expect(hasFullAgentSessionContext(source)).toBe(true) + expect(buildAgentSessionContinuationPrompt(source, 'full')).toContain(source.transcriptPath) + }) }) describe('prepareAgentSessionContinuationFromPane', () => { @@ -104,6 +131,30 @@ describe('prepareAgentSessionContinuationFromPane', () => { }) }) + it.each(['opencode', 'opencode2'] as const)( + 'captures scrollback for a %s SQLite session', + (sourceAgent) => { + store.agentStatusByPaneKey[`tab-1:${LEAF_ID}`] = { + agentType: sourceAgent, + providerSession: { transcriptPath: '/tmp/opencode.db#ses_proof' } + } + const pane = makePane('latest terminal context') + const request = prepareAgentSessionContinuationFromPane({ + pane, + tabId: 'tab-1', + worktreeId: 'wt-1', + groupId: null, + workspacePath: '/repo', + initialCwd: '/repo' + }) + expect(pane.serializeAddon.serialize).toHaveBeenCalledWith({ scrollback: 800 }) + expect(request?.source.capturedText).toBe('latest terminal context') + expect(buildAgentSessionContinuationPrompt(request!.source, 'focused')).toContain( + 'latest terminal context' + ) + } + ) + it('falls back to terminal capture when the provider transcript path is blank', () => { store.agentStatusByPaneKey[`tab-1:${LEAF_ID}`]!.providerSession = { transcriptPath: ' ' } const pane = makePane('latest terminal context') diff --git a/src/renderer/src/components/terminal-pane/terminal-agent-session-continuation.ts b/src/renderer/src/components/terminal-pane/terminal-agent-session-continuation.ts index 806197d5323..c71c27d9dbd 100644 --- a/src/renderer/src/components/terminal-pane/terminal-agent-session-continuation.ts +++ b/src/renderer/src/components/terminal-pane/terminal-agent-session-continuation.ts @@ -2,6 +2,7 @@ import { toast } from 'sonner' import type { ManagedPane } from '@/lib/pane-manager/pane-manager' import { buildAgentSessionContinuationPrompt, + resolveAgentSessionContinuationTranscriptPath, type AgentSessionContinuationRequest } from '@/lib/agent-session-continuation' import { useAppStore } from '@/store' @@ -53,7 +54,10 @@ export function prepareAgentSessionContinuationFromPane({ const paneKey = makePaneKey(tabId, pane.leafId) const status = state.agentStatusByPaneKey[paneKey] const sourceAgent = resolveSourceAgent({ pane, tabId, worktreeId }) - const transcriptPath = status?.providerSession?.transcriptPath?.trim() || null + const transcriptPath = resolveAgentSessionContinuationTranscriptPath({ + sourceAgent, + transcriptPath: status?.providerSession?.transcriptPath + }) const capturedText = transcriptPath ? '' : pane.serializeAddon.serialize({ scrollback: 800 }) const source = { // Why: prefer the same-host transcript so opening the dialog does not serialize large scrollback. diff --git a/src/renderer/src/lib/agent-session-continuation.ts b/src/renderer/src/lib/agent-session-continuation.ts index 3e910fd5d36..41c5c72bd30 100644 --- a/src/renderer/src/lib/agent-session-continuation.ts +++ b/src/renderer/src/lib/agent-session-continuation.ts @@ -1,3 +1,4 @@ +import { splitOpenCodeSqliteCandidate } from '../../../shared/opencode-sqlite-session-path' import { buildBoundedSessionTranscript } from '@/lib/agent-session-fork-context' import type { LaunchSource } from '../../../shared/telemetry-events' import type { TuiAgent } from '../../../shared/tui-agent' @@ -31,14 +32,31 @@ function markdownFenceFor(value: string): string { } export function hasFullAgentSessionContext(source: AgentSessionContinuationSource): boolean { - return Boolean(source.transcriptPath?.trim()) + return resolveAgentSessionContinuationTranscriptPath(source) !== null +} + +export function resolveAgentSessionContinuationTranscriptPath( + source: Pick +): string | null { + const path = source.transcriptPath?.trim() || null + if (!path) { + return null + } + const agent = source.sourceAgent + if ( + (agent === 'opencode' || agent === 'opencode2') && + splitOpenCodeSqliteCandidate(path, agent) + ) { + return null + } + return path } export function buildAgentSessionContinuationPrompt( source: AgentSessionContinuationSource, mode: AgentSessionContinuationContextMode ): string | null { - const transcriptPath = source.transcriptPath?.trim() || null + const transcriptPath = resolveAgentSessionContinuationTranscriptPath(source) const capturedTranscript = transcriptPath ? null : buildBoundedSessionTranscript(source.capturedText) diff --git a/src/renderer/src/lib/native-chat-initial-view-mode.test.ts b/src/renderer/src/lib/native-chat-initial-view-mode.test.ts index 957e9b62ce2..52385894c03 100644 --- a/src/renderer/src/lib/native-chat-initial-view-mode.test.ts +++ b/src/renderer/src/lib/native-chat-initial-view-mode.test.ts @@ -47,18 +47,15 @@ describe('decideInitialAgentTabViewMode', () => { ).toBeUndefined() }) - it.each(['gemini', 'opencode'] as const)( - 'keeps unsupported agent %s in terminal view', - (agent) => { - expect( - decideInitialAgentTabViewMode({ - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true, - agent - }) - ).toBeUndefined() - } - ) + it.each(['gemini'] as const)('keeps unsupported agent %s in terminal view', (agent) => { + expect( + decideInitialAgentTabViewMode({ + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true, + agent + }) + ).toBeUndefined() + }) it.each([ ['local', null], diff --git a/src/shared/native-chat-agent-profiles.ts b/src/shared/native-chat-agent-profiles.ts index 82fe88e9ac3..386584c1279 100644 --- a/src/shared/native-chat-agent-profiles.ts +++ b/src/shared/native-chat-agent-profiles.ts @@ -31,6 +31,12 @@ const NATIVE_CHAT_AGENT_PROFILES: Partial { }) it('passes codex, grok and omp through and rejects everything else', () => { + expect(resolveNativeChatTranscriptAgent('opencode')).toBe('opencode') + expect(resolveNativeChatTranscriptAgent('opencode2')).toBe('opencode') expect(resolveNativeChatTranscriptAgent('codex')).toBe('codex') expect(resolveNativeChatTranscriptAgent('grok')).toBe('grok') expect(resolveNativeChatTranscriptAgent('omp')).toBe('omp') @@ -24,6 +26,8 @@ describe('resolveNativeChatTranscriptAgent', () => { describe('isNativeChatSupportedAgent', () => { it('recognizes the parseable agents and rejects unknown / nullish input', () => { + expect(isNativeChatSupportedAgent('opencode')).toBe(true) + expect(isNativeChatSupportedAgent('opencode2')).toBe(true) expect(isNativeChatSupportedAgent('claude')).toBe(true) expect(isNativeChatSupportedAgent('openclaude')).toBe(true) expect(isNativeChatSupportedAgent('omp')).toBe(true) @@ -37,6 +41,8 @@ describe('nativeChatRequiresLocalTranscript', () => { it('covers the agents whose hook discloses no transcript path', () => { // Claude/Codex report `transcript_path`; Grok and omp report only an id, so // 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('grok')).toBe(true) expect(nativeChatRequiresLocalTranscript('omp')).toBe(true) expect(nativeChatRequiresLocalTranscript('claude')).toBe(false) @@ -55,6 +61,8 @@ describe('shouldStepNativeChatAskAnswer', () => { // Codex 0.145's request_user_input card ignores typed labels and commits on // the highlighted row, so pasted answers misdeliver like STA-1860. expect(shouldStepNativeChatAskAnswer('codex')).toBe(true) + expect(shouldStepNativeChatAskAnswer('opencode')).toBe(true) + expect(shouldStepNativeChatAskAnswer('opencode2')).toBe(true) }) it('does not step other or unknown agents', () => { diff --git a/src/shared/native-chat-agent-support.ts b/src/shared/native-chat-agent-support.ts index 70fa92a92bf..e2bb9c40cac 100644 --- a/src/shared/native-chat-agent-support.ts +++ b/src/shared/native-chat-agent-support.ts @@ -1,6 +1,6 @@ import type { TuiAgent } from './tui-agent' -export type NativeChatTranscriptAgent = 'claude' | 'codex' | 'grok' | 'omp' +export type NativeChatTranscriptAgent = 'claude' | 'codex' | 'grok' | 'omp' | 'opencode' /** Agents whose transcripts the native chat view can parse and render, in the * order the settings pane advertises them. */ @@ -9,7 +9,9 @@ export const NATIVE_CHAT_SUPPORTED_AGENT_LIST: readonly TuiAgent[] = [ 'openclaude', 'codex', 'grok', - 'omp' + 'omp', + 'opencode', + 'opencode2' ] export const NATIVE_CHAT_SUPPORTED_AGENTS: ReadonlySet = new Set( @@ -24,17 +26,15 @@ export function isNativeChatSupportedAgent(agent: string | null | undefined): bo * does not establish owning-host reads, so OMP remains gated even with metadata. */ export function nativeChatRequiresLocalTranscript(agent: string | null | undefined): boolean { const transcriptAgent = resolveNativeChatTranscriptAgent(agent) - return transcriptAgent === 'grok' || transcriptAgent === 'omp' + return transcriptAgent === 'grok' || transcriptAgent === 'omp' || transcriptAgent === 'opencode' } -/** True when the agent renders a digit-commit question selector that ignores - * typed label text (pasting "Blue" + Enter commits the highlighted FIRST - * option — STA-1860): Claude's AskUserQuestion and Codex 0.145's - * request_user_input card both behave this way, so answers must be delivered - * as per-option keystrokes. Other agents commit a pasted answer. */ +/** Selector TUIs require key steps rather than pasted option labels. */ export function shouldStepNativeChatAskAnswer(agent: string | null | undefined): boolean { const transcriptAgent = resolveNativeChatTranscriptAgent(agent) - return transcriptAgent === 'claude' || transcriptAgent === 'codex' + return ( + transcriptAgent === 'claude' || transcriptAgent === 'codex' || transcriptAgent === 'opencode' + ) } export function resolveNativeChatTranscriptAgent( @@ -45,8 +45,15 @@ export function resolveNativeChatTranscriptAgent( if (agent === 'claude' || agent === 'openclaude') { return 'claude' } + if (agent === 'opencode' || agent === 'opencode2') { + return 'opencode' + } if (agent === 'codex' || agent === 'grok' || agent === 'omp') { return agent } return null } + +export function nativeChatApprovalAcceptKey(agent: string | null | undefined): string { + return resolveNativeChatTranscriptAgent(agent) === 'opencode' ? '\r' : '1' +} diff --git a/src/shared/native-chat-ask.test.ts b/src/shared/native-chat-ask.test.ts index 29220967157..0c9ff11753e 100644 --- a/src/shared/native-chat-ask.test.ts +++ b/src/shared/native-chat-ask.test.ts @@ -168,6 +168,26 @@ describe('extractPendingAsk', () => { }) describe('parseAskFromStatus', () => { + it('preserves OpenCode question envelope multiple selection on live status and replay', () => { + const input = { + id: 'question', + sessionID: 'session', + questions: [ + { + header: 'Choice', + question: 'Pick both?', + multiple: true, + options: [ + { label: 'One', description: 'first' }, + { label: 'Two', description: 'second' } + ] + } + ] + } + const prompt = parseAskFromStatus(JSON.stringify(input), 'AskUserQuestion') + expect(prompt?.questions[0]).toMatchObject({ multiSelect: true, header: 'Choice' }) + expect(extractPendingAsk([message('m1', [call('question', input)])])).toEqual(prompt) + }) it('accepts the canonical shape from any tool name and rejects broken JSON', () => { expect( parseAskFromStatus(JSON.stringify(QUESTIONS_INPUT), 'SomeNewTool')?.questions diff --git a/src/shared/native-chat-ask.ts b/src/shared/native-chat-ask.ts index f166de48c1f..44ee53c6c7d 100644 --- a/src/shared/native-chat-ask.ts +++ b/src/shared/native-chat-ask.ts @@ -20,10 +20,10 @@ export function registerQuestionTool(toolName: string, parser: InteractiveQuesti } function parseCanonicalQuestionsInput(input: unknown): AskPrompt | null { - if (!input || typeof input !== 'object') { + if (!input || typeof input !== 'object' || !('questions' in input)) { return null } - const rawQuestions = (input as { questions?: unknown }).questions + const rawQuestions = input.questions if (!Array.isArray(rawQuestions) || rawQuestions.length === 0) { return null } @@ -32,14 +32,16 @@ function parseCanonicalQuestionsInput(input: unknown): AskPrompt | null { if (!raw || typeof raw !== 'object') { continue } - const question = raw as Record - const text = typeof question.question === 'string' ? question.question : '' - const options = parseOptions(question.options) + const text = 'question' in raw && typeof raw.question === 'string' ? raw.question : '' + const options = parseOptions('options' in raw ? raw.options : undefined) if (text || options.length > 0) { questions.push({ question: text, - header: typeof question.header === 'string' ? question.header : undefined, - multiSelect: question.multiSelect === true, + header: 'header' in raw && typeof raw.header === 'string' ? raw.header : undefined, + multiSelect: + 'multiSelect' in raw + ? raw.multiSelect === true + : 'multiple' in raw && raw.multiple === true, options }) } @@ -59,12 +61,15 @@ function parseOptions(raw: unknown): AskOption[] { if ( option && typeof option === 'object' && - typeof (option as { label?: unknown }).label === 'string' + 'label' in option && + typeof option.label === 'string' ) { - const value = option as { label: string; description?: unknown } return { - label: value.label, - description: typeof value.description === 'string' ? value.description : undefined + label: option.label, + description: + 'description' in option && typeof option.description === 'string' + ? option.description + : undefined } } return null diff --git a/src/shared/opencode-sqlite-session-path.ts b/src/shared/opencode-sqlite-session-path.ts new file mode 100644 index 00000000000..c89dbd4119f --- /dev/null +++ b/src/shared/opencode-sqlite-session-path.ts @@ -0,0 +1,29 @@ +const OPENCODE_SQLITE_PATH_SEPARATOR = '#' + +export function buildOpenCodeSqliteCandidatePath(dbPath: string, sessionId: string): string { + return `${dbPath}${OPENCODE_SQLITE_PATH_SEPARATOR}${sessionId}` +} + +// A database row reference is metadata, never a transcript file path. +export function splitOpenCodeSqliteCandidate( + candidatePath: string, + agent: 'opencode' | 'opencode2' | 'zcode' = 'opencode' +): { dbPath: string; sessionId: string } | null { + const separatorIndex = candidatePath.lastIndexOf(OPENCODE_SQLITE_PATH_SEPARATOR) + if (separatorIndex <= 0 || separatorIndex === candidatePath.length - 1) { + return null + } + const dbPath = candidatePath.slice(0, separatorIndex) + const sessionId = candidatePath.slice(separatorIndex + 1) + // Host paths may use either separator when consumed by a browser client. + const databaseName = dbPath.split(/[\\/]/).at(-1) ?? '' + const validName = + agent === 'zcode' + ? databaseName.toLowerCase() === 'db.sqlite' + : /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/i.test(databaseName) + return validName ? { dbPath, sessionId } : null +} + +export function looksLikeOpenCodeSqliteCandidate(candidatePath: string): boolean { + return splitOpenCodeSqliteCandidate(candidatePath) !== null +} diff --git a/src/shared/opencode-transcript-page-limit.ts b/src/shared/opencode-transcript-page-limit.ts new file mode 100644 index 00000000000..4f589c22f83 --- /dev/null +++ b/src/shared/opencode-transcript-page-limit.ts @@ -0,0 +1,7 @@ +export const OPENCODE_TRANSCRIPT_MAX_WINDOW = 2400 + +export function openCodeTranscriptPageLimit(limit: number): number { + return Number.isFinite(limit) + ? Math.min(OPENCODE_TRANSCRIPT_MAX_WINDOW, Math.max(1, Math.floor(limit))) + : 300 +}