From a2896f5470a10ba0ba55f329d5908204bf5a04e5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:00:40 -0700 Subject: [PATCH] Support real OpenCode sessions in native Chat (#24647) * Use bounded OpenCode context for vault session continuation OpenCode database and synthetic row paths are not text transcripts. Use the vault preview or captured pane context, preserving actual transcript paths containing a hash and supporting both OpenCode lanes and Windows paths. Adapted the intent of #11859 and extended it to actual installed v2 vault rows. Co-authored-by: mrcha033 * Read real OpenCode sessions in terminal-backed native Chat Reuse the bounded AI Vault SQLite worker for v1 and v2 session pages and live updates. Keep terminal input as the real execution path and pace OpenCode Stop through its two-Escape interrupt. Co-authored-by: xodmd45-ctrl * fix(opencode): publish approval cards for permission requests * Send OpenCode native approval through its Enter selector * Resolve mobile Chat readability for folder workspaces * Bound OpenCode part batches and preserve v2 image attachments * Prefer live migrated OpenCode sessions over legacy copies * Consolidate mobile Chat eligibility test imports * Consolidate OpenCode SQLite protocol type imports * Update native chat settings contract for both OpenCode agents * fix(native-chat): reconcile bounded OpenCode transcript reads * fix(native-chat): dispatch OpenCode questions safely * fix(native-chat): keep native discovery and transcript windows current * feat(accounts): link standalone GLM Coding Plans (#24618) * feat(accounts): link standalone GLM Coding Plans Adapt the reviewed GLM accounts contribution to current main, retain Antigravity behavior, guard late credential results, expose storage protection, and redact quota errors. Co-authored-by: Luchong * fix(accounts): retain GLM credential results during quota refresh * fix(accounts): make GLM credential editing desktop-only * fix(accounts): mirror the host GLM site in paired clients * fix(accounts): report unknown GLM host details and split web settings tests Apply the independently reviewed Accounts correction from697284a without the v2 adapter commits. Preserve the saved-key store and serialized write behavior. * fix(zcode): ship required GLM account translation entries * chore: record GLM reconciliation hook validation * chore: validate installed GLM commit hooks * test: complete GLM account fixtures and web API inventory --------- Co-authored-by: Luchong * fix(native-chat): route transcript requests through shared SQLite worker * fix(ci): prevent concurrent pnpm refresh during mobile typechecks (#24776) * fix(ci): run mobile typechecks without concurrent dependency refresh * test(ci): check effective Linux E2E package list * test(ci): preserve the mobile production compiler barrier --------- Co-authored-by: Orca Integration Recovery * test(terminal): restore the live fish fixture prerequisites (#24947) A restored pane waits for the initial status replay before subscribing to PTY output. This fixture never settled that replay, so fish printed its mode-2031 arm before the renderer connected. Its PTY API also omitted the reset-input listener required by the serializer, aborting attachment. Settle and dispose the existing startup-snapshot registration and provide the same reset-listener mock used by the other PTY tests. The real fish child-stdin assertions and timeouts remain unchanged. No production change. * fix(shortcuts): defer TUI editing chords in terminal-first mode (#24640) Restack the original focused change onto current main, preserving every owned source and test blob and the merged CI contract and journal cleanup fixes. Original-commit: f707cde14ab8cb2670b4076916cdfb7e28c79983 fix(shortcuts): defer TUI editing chords in terminal-first mode Original-commit: 0c6348e49e0d968e0170961d294a018adceaa9f3 docs(shortcuts): describe deferred preview terminal chords Original-commit: be62c1b6c6b305cb8091161c1ccdd943a6e1d470 Align worktree history shortcut metadata with terminal conflict policy Restacked-from: be62c1b6c6b305cb8091161c1ccdd943a6e1d470 Restacked-onto: f7b1f9d8be14d12244957ee0b710a3482ec62383 * Register supervised Qoder China and Qwen Code (#24616) * Add Qoder session history and search with real CLI coverage * Allow the real Qoder marker file to end with a newline * Keep Qoder tool output out of history previews and search * Keep Qoder search pages readable by older clients * Verify persisted Qoder history after a real generated and resumed task * Negotiate Qoder filters before searching an older execution host * Combine search client imports for the CI plugin gate * Keep the relay search oracle aligned with legacy agent filtering * Register supervised Qoder China and Qwen lifecycle integration * Cover Qoder China mobile assets and mixed-host resume gates * Verify Qoder provider tags against the older released wire parser * Verify China and Qwen keep independent Windows hook scripts * Verify Qoder registrations against the installed older Windows release * test(qoder): align search capability contracts and pin old-host fencing * fix(qoder): rank exact picker identities and command aliases first * test(qoder): preserve the regional CLI shared icon expectation Keep the full bundled-asset and no-remote-image checks, with an explicit shared-logo basename for Qoder China. The map also works with older catalog type unions. * fix(qoder): align China catalog entry with fallback order --------- Co-authored-by: Orca Integration Recovery * Use the measured pnpm lookup policy automatically in hosted root CI (#24951) * Select lookup automatically for the measured hosted root-install profile * Record hosted automatic-mode cold cache publication proof * fix(native-chat): keep OpenCode history usable at read limits Continue past failed database probes while preserving discovery cancellation. Verify rows displaced by a capped tail before deciding whether to replace history. Represent oversized v1/v2 rows with the existing omission text and stable cursors. * Continue Antigravity IDE and 2.0 history in new CLI conversations (#24692) * feat(antigravity): bridge IDE history into new CLI conversations * fix(antigravity): preserve fresh-launch model and environment for IDE references * fix(antigravity): forward IDE history opt-in through desktop IPC * fix(antigravity): rebuild remote IDE reference startup on its host * fix(antigravity): register IDE continuation action labels * fix(antigravity): confine IDE references and bound metadata reads * fix(antigravity): localize IDE continuation badges * Preserve scanner service cache assertions and refresh Antigravity opening metadata * Preserve Antigravity opening joins and target folder runtime authority * fix(opencode): retry timed-out SSH plugin updates (#24666) Preserve bounded retry behavior and the current-main status-envelope fields. Original-PR: #24124 Reviewed-source: 103144f9c5029f9217f64970271a5b7f69d3f823 Co-authored-by: Justas Brazauskas * fix(opencode): keep Go credentials private and resolve backend keys (#24615) Preserve the complete credential storage, migration, IPC, Settings and rate-limit refresh change alongside standalone GLM plans, current-main database diagnostics and the reviewed unknown-backend environment correction. Keep native discovery cancellation third and selected environment fourth. Original-topic-commit: 7903f1cddbc08fe1179f8bd964fff3dcb0e5d020 Original-topic-commit: 588117b5cf1dd57e2310c80068e3ddcc9f9044ec Original-topic-commit: dedd4f8c862a961393977dc3c2547f049391b509 Original-topic-commit: 6645dae104ee5271139fcc0e9564d9223b74835d Original-topic-commit: a25b80c02c1af7830b0e6a65e72d965b3ad98276 Restacked-from: a25b80c02c1af7830b0e6a65e72d965b3ad98276 Restacked-onto: b032867021c7ef1436ca606559e9d786580a84dc Co-authored-by: kespineira Co-authored-by: kevimux Reported-by: pullfrog Reviewed-full-source: 849fe093073f4c1606bd65d79a0c725d955d0d1f Native-helper-source: 80dbe23237db191c1e6280001c7e0563f3ca8846 Reviewed-full-current-source: 36acb57d44adb3d378c0289c8c15f7da0fda214c --------- Co-authored-by: mrcha033 Co-authored-by: xodmd45-ctrl Co-authored-by: Luchong Co-authored-by: Orca Integration Recovery Co-authored-by: Justas Brazauskas --- .../mobile-native-chat-eligibility.test.ts | 31 + .../session/mobile-native-chat-eligibility.ts | 23 + .../mobile-native-chat-permission.test.ts | 7 + .../session/mobile-native-chat-permission.ts | 6 +- ...use-mobile-native-chat-answer-send.test.ts | 90 +-- .../use-mobile-native-chat-cancel-ask.test.ts | 76 +++ .../use-mobile-native-chat-prompts.test.ts | 33 ++ .../session/use-mobile-native-chat-prompts.ts | 16 +- .../use-mobile-native-chat-readability.ts | 29 +- ...scanner-opencode-native-page-limit.test.ts | 46 ++ ...ion-scanner-opencode-native-worker.test.ts | 153 +++++ .../session-scanner-opencode-sources.ts | 47 +- ...ession-scanner-opencode-sqlite-dispatch.ts | 14 + .../session-scanner-opencode-sqlite-open.ts | 2 +- ...sion-scanner-opencode-sqlite-paths.test.ts | 11 + .../session-scanner-opencode-sqlite-paths.ts | 66 +-- ...n-scanner-opencode-sqlite-process-entry.ts | 17 + ...n-scanner-opencode-sqlite-worker-client.ts | 11 + ...scanner-opencode-sqlite-worker-protocol.ts | 16 + ...on-scanner-opencode-sqlite-worker-spawn.ts | 46 ++ .../foreign-sqlite-reader-entry.ts | 8 +- src/main/ipc/native-chat.ts | 2 +- .../journal-legacy-import.ts | 2 +- src/main/native-chat/session-file-resolver.ts | 5 +- .../transcript-opencode-database.test.ts | 215 +++++++ .../transcript-opencode-database.ts | 109 ++++ .../transcript-opencode-part-blocks.ts | 127 ++++ .../transcript-opencode-sqlite-query.test.ts | 543 ++++++++++++++++++ .../transcript-opencode-sqlite-query.ts | 268 +++++++++ .../transcript-opencode-subscribe.test.ts | 288 ++++++++++ .../transcript-opencode-subscribe.ts | 330 +++++++++++ .../native-chat/transcript-opencode.test.ts | 230 ++++++++ src/main/native-chat/transcript-opencode.ts | 141 +++++ .../transcript-opencode2-sqlite.test.ts | 246 ++++++++ .../transcript-opencode2-sqlite.ts | 234 ++++++++ src/main/native-chat/transcript-read-cache.ts | 5 + src/main/native-chat/transcript-reader.ts | 4 + .../native-chat/transcript-tail-reader.ts | 4 + .../native-chat/transcript-watch-contract.ts | 3 + src/main/native-chat/transcript-watch.ts | 7 +- .../NativeChatInteractiveCard.test.tsx | 16 +- .../native-chat/NativeChatInteractiveCard.tsx | 4 +- .../native-chat-availability.test.ts | 23 +- .../native-chat-interactive-prompt.test.ts | 7 + .../native-chat-interactive-prompt.ts | 14 +- ...use-native-chat-interactive-prompt-card.ts | 9 +- .../use-native-chat-interactive-send.test.tsx | 52 ++ .../use-native-chat-interactive-send.ts | 28 +- .../ai-vault-session-continuation.test.ts | 31 + .../ai-vault-session-continuation.ts | 12 +- .../NativeChatSupportedAgents.test.tsx | 12 +- ...erminal-agent-session-continuation.test.ts | 53 +- .../terminal-agent-session-continuation.ts | 6 +- .../src/lib/agent-session-continuation.ts | 22 +- .../lib/native-chat-initial-view-mode.test.ts | 21 +- src/shared/native-chat-agent-profiles.ts | 6 + src/shared/native-chat-agent-support.test.ts | 8 + src/shared/native-chat-agent-support.ts | 25 +- src/shared/native-chat-ask.test.ts | 20 + src/shared/native-chat-ask.ts | 27 +- src/shared/opencode-sqlite-session-path.ts | 29 + src/shared/opencode-transcript-page-limit.ts | 7 + 62 files changed, 3721 insertions(+), 222 deletions(-) create mode 100644 mobile/src/session/use-mobile-native-chat-cancel-ask.test.ts create mode 100644 src/main/ai-vault/session-scanner-opencode-native-page-limit.test.ts create mode 100644 src/main/ai-vault/session-scanner-opencode-native-worker.test.ts create mode 100644 src/main/native-chat/transcript-opencode-database.test.ts create mode 100644 src/main/native-chat/transcript-opencode-database.ts create mode 100644 src/main/native-chat/transcript-opencode-part-blocks.ts create mode 100644 src/main/native-chat/transcript-opencode-sqlite-query.test.ts create mode 100644 src/main/native-chat/transcript-opencode-sqlite-query.ts create mode 100644 src/main/native-chat/transcript-opencode-subscribe.test.ts create mode 100644 src/main/native-chat/transcript-opencode-subscribe.ts create mode 100644 src/main/native-chat/transcript-opencode.test.ts create mode 100644 src/main/native-chat/transcript-opencode.ts create mode 100644 src/main/native-chat/transcript-opencode2-sqlite.test.ts create mode 100644 src/main/native-chat/transcript-opencode2-sqlite.ts create mode 100644 src/shared/opencode-sqlite-session-path.ts create mode 100644 src/shared/opencode-transcript-page-limit.ts 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 +}