From 1f804dd5283bfc56f81da71ae6b4f8c785969f72 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 01:24:55 -0700 Subject: [PATCH 01/16] 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 --- ...sion-scanner-opencode-sqlite-paths.test.ts | 11 ++++ .../session-scanner-opencode-sqlite-paths.ts | 66 ++----------------- .../ai-vault-session-continuation.test.ts | 31 +++++++++ .../ai-vault-session-continuation.ts | 12 +++- ...erminal-agent-session-continuation.test.ts | 53 ++++++++++++++- .../terminal-agent-session-continuation.ts | 6 +- .../src/lib/agent-session-continuation.ts | 22 ++++++- src/shared/opencode-sqlite-session-path.ts | 29 ++++++++ 8 files changed, 163 insertions(+), 67 deletions(-) create mode 100644 src/shared/opencode-sqlite-session-path.ts 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 db9c60f0b703..c46fcd195e2a 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 77c0e464154b..cc9d93781dfb 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/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 28ccfbe056fe..b978d9ad2b84 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 3baff2bf272c..26f2549ab74c 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/terminal-pane/terminal-agent-session-continuation.test.ts b/src/renderer/src/components/terminal-pane/terminal-agent-session-continuation.test.ts index 5b1a415219e3..803ec298ed57 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 806197d5323b..c71c27d9dbd6 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 3e910fd5d360..41c5c72bd30f 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/shared/opencode-sqlite-session-path.ts b/src/shared/opencode-sqlite-session-path.ts new file mode 100644 index 000000000000..c89dbd4119f8 --- /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 +} From 8bb7fc1faa53075619acf9f6b82b28121de17c23 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 01:33:00 -0700 Subject: [PATCH 02/16] 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 --- .../mobile-native-chat-eligibility.test.ts | 16 + ...ession-scanner-opencode-sqlite-dispatch.ts | 14 + .../session-scanner-opencode-sqlite-open.ts | 2 +- ...n-scanner-opencode-sqlite-process-entry.ts | 17 + ...n-scanner-opencode-sqlite-worker-client.ts | 13 + ...scanner-opencode-sqlite-worker-protocol.ts | 16 + ...on-scanner-opencode-sqlite-worker-spawn.ts | 40 ++ src/main/ipc/native-chat.ts | 2 +- .../journal-legacy-import.ts | 2 +- src/main/native-chat/session-file-resolver.ts | 5 +- .../transcript-opencode-part-blocks.ts | 120 +++++ .../transcript-opencode-sqlite-query.test.ts | 438 ++++++++++++++++++ .../transcript-opencode-sqlite-query.ts | 252 ++++++++++ .../transcript-opencode-subscribe.ts | 275 +++++++++++ .../native-chat/transcript-opencode.test.ts | 230 +++++++++ src/main/native-chat/transcript-opencode.ts | 164 +++++++ .../transcript-opencode2-sqlite.test.ts | 164 +++++++ .../transcript-opencode2-sqlite.ts | 190 ++++++++ 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 +- .../native-chat-availability.test.ts | 23 +- .../use-native-chat-interactive-send.test.tsx | 18 + .../use-native-chat-interactive-send.ts | 11 +- .../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 | 6 + src/shared/native-chat-agent-support.ts | 11 +- 30 files changed, 2045 insertions(+), 34 deletions(-) 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.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 diff --git a/mobile/src/session/mobile-native-chat-eligibility.test.ts b/mobile/src/session/mobile-native-chat-eligibility.test.ts index 829af1c3d8cf..58b26e2a0168 100644 --- a/mobile/src/session/mobile-native-chat-eligibility.test.ts +++ b/mobile/src/session/mobile-native-chat-eligibility.test.ts @@ -80,6 +80,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() }) 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 e483f9482e11..d9bc0b3a27c6 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 32f807cb1d9f..168a7ee66201 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-process-entry.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-process-entry.ts index e53d89eeeaba..bcd380bc51e4 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 c5f96c030e3b..e297b806e5ba 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 @@ -1,3 +1,7 @@ +import type { + OpenCodeNativeChatReadRequest, + OpenCodeNativeChatReadValue +} from './session-scanner-opencode-sqlite-worker-protocol' import type { WorkerThreadFactory } from '../lazy-worker-thread-host' import { WorkerThreadRequestQueue } from '../worker-thread-request-queue' import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types' @@ -223,6 +227,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 9c8e42a5c7f4..afee7778e3db 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 68b1bacf6981..76bf1c619965 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 @@ -245,3 +245,43 @@ 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, 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/ipc/native-chat.ts b/src/main/ipc/native-chat.ts index 9e5058282ef4..02e2928f3c69 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 cf9ff45805cf..ba6a918cc6e4 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 0f7d7c7367f7..08de49763b45 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-part-blocks.ts b/src/main/native-chat/transcript-opencode-part-blocks.ts new file mode 100644 index 000000000000..fbfe5d7a834a --- /dev/null +++ b/src/main/native-chat/transcript-opencode-part-blocks.ts @@ -0,0 +1,120 @@ +import { fileURLToPath } from 'node:url' +import type { NativeChatBlock, NativeChatImageRefBlock } from '../../shared/native-chat-types' +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 +} + +export function opencodeMessageBlocks(partRows: OpenCodePartRow[]): NativeChatBlock[] { + const blocks: NativeChatBlock[] = [] + for (const partRow of partRows) { + 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 000000000000..f7eade0618ec --- /dev/null +++ b/src/main/native-chat/transcript-opencode-sqlite-query.test.ts @@ -0,0 +1,438 @@ +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 } from 'vitest' +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('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() + }) +}) 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 000000000000..4ef73bff1d7e --- /dev/null +++ b/src/main/native-chat/transcript-opencode-sqlite-query.ts @@ -0,0 +1,252 @@ +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 { 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 } 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[] { + const rows: ReturnType = [] + let bytes = 0 + for (const row of statement.iterate(...params)) { + bytes += Object.values(row).reduce( + (size, value) => size + (typeof value === 'string' ? Buffer.byteLength(value) : 0), + 0 + ) + if (rows.length >= 10000 || 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 { + if (!sessionExists(db, sessionId)) { + return readOpenCode2TranscriptSignal(db, sessionId) + } + // 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 { + if (!sessionExists(db, args.sessionId)) { + return readOpenCode2TranscriptPage(db, args) + } + const limit = Math.min(2400, Math.max(1, Math.floor(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(data) <= 2097152 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 + let cursor: number | undefined = args.beforeMessageRowId + let hasMore = false + for (;;) { + const rows = rowsOf( + select, + 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 + if (selected.some((row) => typeof row.data !== 'string')) { + throw new Error('OpenCode transcript message exceeds its byte limit') + } + const mapped = mapMessageRows(db, args.sessionId, selected) + 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 +} + +type PartRow = { + message_id: string + time_updated: number + data: string +} + +function mapMessageRows( + db: SyncDatabase, + sessionId: string, + rows: MessageRow[] +): 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 = rowsOf( + db.prepare( + `SELECT message_id, time_updated, CASE WHEN length(data) <= 2097152 THEN data ELSE NULL END AS data FROM part + WHERE session_id = ? AND message_id IN (${placeholders}) + ORDER BY rowid LIMIT 10001` + ), + sessionId, + ...batch + ) + if (partRows.length > 10000 || partRows.some((row) => typeof row.data !== 'string')) { + 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]) + } + } + } + if ( + [...partsByMessage.values()] + .flat() + .reduce((bytes, row) => bytes + Buffer.byteLength(row.data), 0) > + 16 * 1024 * 1024 + ) { + throw new Error('OpenCode transcript page exceeds its byte limit') + } + const items: OpenCodeTranscriptItem[] = [] + for (const row of rows) { + const partList = partsByMessage.get(row.id) ?? [] + const blocks = opencodeMessageBlocks(partList) + if (blocks.length === 0) { + continue + } + const record = 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.ts b/src/main/native-chat/transcript-opencode-subscribe.ts new file mode 100644 index 000000000000..04018cd751de --- /dev/null +++ b/src/main/native-chat/transcript-opencode-subscribe.ts @@ -0,0 +1,275 @@ +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' +// so each module stays under the repo's file-size cap. + +const OPENCODE_POLL_MS = 1_000 +const WATCH_DIFF_WINDOW = 100 +const WATCH_REPLACE_MAX_WINDOW = 2400 + +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 = + 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 + 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 : WATCH_DIFF_WINDOW + }) + if (closed) { + return + } + if (!page) { + // The session vanished between signal and page reads; keep polling. + scheduleTick() + return + } + if (firstSnapshot) { + lastSignal = fingerprint + 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 + } + const changed = page.items.some( + (item) => + item.rowid <= lastEmittedRowId && + fingerprints.has(item.rowid) && + fingerprints.get(item.rowid) !== item.fingerprint + ) + if (changed) { + if (await replaceWithBridgedWindow(dbPath)) { + lastSignal = fingerprint + } + 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)) { + lastSignal = fingerprint + } + scheduleTick() + return + } + if (appended.length > 0) { + rememberItems(appended) + const appendedMessages = appended.map((item) => item.message) + emitSafely(() => args.onAppend(appendedMessages), 'append') + } + lastSignal = fingerprint + 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, WATCH_REPLACE_MAX_WINDOW) + } + + // 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): Promise { + let limit = initialLimit + for (;;) { + const replacement = 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 + emitSafely(() => args.onReplace?.([], hasMore, before), 'replace') + return true + } + if (oldest <= lastEmittedRowId) { + 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 >= WATCH_REPLACE_MAX_WINDOW) { + return false + } + limit = Math.min(limit * 2, WATCH_REPLACE_MAX_WINDOW) + } + } + + 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 000000000000..6cf17a8cb75d --- /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 000000000000..8c75beb1842d --- /dev/null +++ b/src/main/native-chat/transcript-opencode.ts @@ -0,0 +1,164 @@ +import { + OPENCODE_CAPTURE_RECORD_LIMIT, + OPENCODE_CAPTURE_TEXT_LIMIT +} from '../ai-vault/opencode-transcript-capture-limits' +import { basename } from 'node:path' +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 { listOpenCodeDatabases } from '../opencode-usage/opencode-database-discovery' +import type { + OpenCodeTranscriptPage, + OpenCodeTranscriptSignal +} from './transcript-opencode-sqlite-query' +import { DESKTOP_READ_WINDOW } from './transcript-watch-contract' + +// 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 +} + +async function defaultResolveDbPath( + sessionId?: string, + signal?: AbortSignal +): Promise { + const paths = (await listOpenCodeDatabases()).sort( + (a, b) => Number(basename(b) === 'opencode.db') - Number(basename(a) === 'opencode.db') + ) + const deadline = Date.now() + 5000 + for (const dbPath of paths.slice(0, 32)) { + signal?.throwIfAborted() + if (Date.now() > deadline) { + throw new Error('OpenCode transcript database discovery exceeded its time limit') + } + if ( + !sessionId || + (await readOpenCodeTranscriptSignalViaWorker({ dbPath, sessionId }, signal)) + ) { + return dbPath + } + } + return null +} + +export function resolveOpenCodeTranscriptDbPath(sessionId?: string): Promise { + return defaultResolveDbPath(sessionId) +} + +export const openCodeTranscriptDefaultDeps: Required = { + resolveDbPath: defaultResolveDbPath, + 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 = args.limit > 0 ? Math.floor(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 000000000000..725426cbf83c --- /dev/null +++ b/src/main/native-chat/transcript-opencode2-sqlite.test.ts @@ -0,0 +1,164 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +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('refuses oversized JSON and excessive sparse scans instead of returning a partial transcript', () => { + const { db, path, insert } = fixture() + insert('large', 'user', { text: 'x'.repeat(2 * 1024 * 1024) }) + expect(() => + readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'session', limit: 1 }) + ).toThrow('byte limit') + db.exec('DELETE FROM session_message; 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('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() + } + }) +}) 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 000000000000..1bc7d8f178f7 --- /dev/null +++ b/src/main/native-chat/transcript-opencode2-sqlite.ts @@ -0,0 +1,190 @@ +import { createHash } from 'node:crypto' +import type SyncDatabase from '../sqlite/sync-database' +import { columnExists, tableExists } from '../opencode-usage/schema-helpers' +import { asRecord, parseJsonObject } from '../ai-vault/session-scanner-values' +import { opencodeMessageBlocks } from './transcript-opencode-part-blocks' +import type { + OpenCodeTranscriptPage, + OpenCodeTranscriptSignal, + OpenCodeTranscriptItem +} from './transcript-opencode-sqlite-query' + +const MAX_MESSAGE_BYTES = 2 * 1024 * 1024 +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?.data !== 'string') { + throw new Error('OpenCode transcript message exceeds its byte limit') + } + 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 (Buffer.byteLength(row.data) > MAX_MESSAGE_BYTES) { + throw new Error('OpenCode transcript message exceeds its byte limit') + } + 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 }] + : [] + 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 = Math.min(2400, Math.max(1, Math.floor(args.limit))) + const statement = db.prepare(`SELECT seq AS cursor, id, type, time_created, time_updated, + CASE WHEN length(data) <= ${MAX_MESSAGE_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 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 += typeof row.data === 'string' ? Buffer.byteLength(row.data) : MAX_MESSAGE_BYTES + 1 + 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) { + 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 828521e2e3d5..7233f001de48 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 a4d3acb7465f..97ab76802d00 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 b6fa9bafdb0a..ff0fcfeb72be 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, @@ -230,6 +231,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 13463e524ef3..c264d9ab514c 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 97e7b75ec61a..a5090b4c8008 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/native-chat-availability.test.ts b/src/renderer/src/components/native-chat/native-chat-availability.test.ts index a08bc476d2f4..737d7d4a0774 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/use-native-chat-interactive-send.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx index 913e8fe9fea1..de954209ba2d 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,24 @@ 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('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 3d270cde49bf..06ec504d2690 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 @@ -159,8 +159,17 @@ export function useNativeChatInteractiveSend( // Stop/cancel: drop any pending answer writes, then send ESC to interrupt. 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) - }, [cancelInFlight, sendRaw]) + }, [agent, cancelInFlight, sendRaw, targetPtyId, terminalTabId]) return { sendAnswer, sendRaw, cancelPending: cancelInFlight, cancel } } 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 957e9b62ce20..52385894c034 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 82fe88e9ac3b..386584c1279b 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) diff --git a/src/shared/native-chat-agent-support.ts b/src/shared/native-chat-agent-support.ts index 70fa92a92bf6..fd3c882bc381 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,7 +26,7 @@ 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 @@ -45,6 +47,9 @@ 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 } From 763afa827102e3be9bf99ce0a6e4f8502bf2cfee Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 01:34:30 -0700 Subject: [PATCH 03/16] fix(opencode): publish approval cards for permission requests --- .../providers/opencode-family-tool-fields.ts | 7 +++++-- src/shared/opencode-permission-status.test.ts | 19 ++++++++++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/shared/agent-hook-listener/providers/opencode-family-tool-fields.ts b/src/shared/agent-hook-listener/providers/opencode-family-tool-fields.ts index 4236cace7165..b28aa552d76d 100644 --- a/src/shared/agent-hook-listener/providers/opencode-family-tool-fields.ts +++ b/src/shared/agent-hook-listener/providers/opencode-family-tool-fields.ts @@ -63,10 +63,13 @@ export function extractOpenCodeToolFields( (pattern): pattern is string => typeof pattern === 'string' && pattern.length > 0 ) : [] + const toolName = readString(hookPayload, 'permission') + const toolInput = metadataInput ?? (patterns.length > 0 ? patterns.join(', ') : undefined) return toolUpdate( { - toolName: readString(hookPayload, 'permission'), - toolInput: metadataInput ?? (patterns.length > 0 ? patterns.join(', ') : undefined) + toolName, + toolInput, + interactivePrompt: deriveInteractivePrompt(toolName, { command: toolInput }, eventName) }, { hasToolInputField: hasAnyOwnField(hookPayload, ['metadata', 'patterns']) } ) diff --git a/src/shared/opencode-permission-status.test.ts b/src/shared/opencode-permission-status.test.ts index 31316e303918..5d56134681bd 100644 --- a/src/shared/opencode-permission-status.test.ts +++ b/src/shared/opencode-permission-status.test.ts @@ -27,7 +27,7 @@ describe('OpenCode-family permission request status', () => { state = createHookListenerState() }) - const SOURCES = ['opencode', 'mimo-code'] as const + const SOURCES = ['opencode', 'opencode2', 'mimo-code'] as const function permissionEvent( source: (typeof SOURCES)[number], @@ -87,6 +87,21 @@ describe('OpenCode-family permission request status', () => { expect(event?.payload.toolInput).toBe('rm -rf build/') }) + it.each(SOURCES)('carries the real v2 shell request as an approval card for %s', (source) => { + const event = permissionEvent(source, { + id: 'per_native', + sessionID: 'ses_native', + permission: 'shell', + patterns: ['printf PERMISSION_NATIVE_OK'], + source: { type: 'tool', messageID: 'msg_native', id: 'functions.shell:0' } + }) + expect(event?.payload.interactivePrompt).toBe( + JSON.stringify({ + approval: { tool: 'shell', summary: 'printf PERMISSION_NATIVE_OK' } + }) + ) + }) + it.each(SOURCES)('names the edited file for %s', (source) => { // Captured verbatim from opencode 1.18.18 — an `edit` request keys its path as // `filepath` (one word) and ships the whole patch alongside it. @@ -189,6 +204,7 @@ describe('OpenCode-family permission request status', () => { expect(resumed?.payload.state).toBe('working') expect(resumed?.payload.toolName).toBeUndefined() expect(resumed?.payload.toolInput).toBeUndefined() + expect(resumed?.payload.interactivePrompt).toBeUndefined() }) it.each(SOURCES)('retires the permission when the session goes idle for %s', (source) => { @@ -198,6 +214,7 @@ describe('OpenCode-family permission request status', () => { expect(idle?.payload.state).toBe('done') expect(idle?.payload.toolName).toBeUndefined() expect(idle?.payload.toolInput).toBeUndefined() + expect(idle?.payload.interactivePrompt).toBeUndefined() }) it.each(SOURCES)( From 9d8dbbc214f23d83203c826b1df8e95b183af7c7 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 01:38:11 -0700 Subject: [PATCH 04/16] Send OpenCode native approval through its Enter selector --- .../session/mobile-native-chat-permission.test.ts | 7 +++++++ .../src/session/mobile-native-chat-permission.ts | 6 ++++-- .../src/session/use-mobile-native-chat-prompts.ts | 2 +- .../native-chat-interactive-prompt.test.ts | 7 +++++++ .../native-chat/native-chat-interactive-prompt.ts | 14 ++++++++++---- .../use-native-chat-interactive-prompt-card.ts | 9 +++++++-- src/shared/native-chat-agent-support.ts | 4 ++++ 7 files changed, 40 insertions(+), 9 deletions(-) diff --git a/mobile/src/session/mobile-native-chat-permission.test.ts b/mobile/src/session/mobile-native-chat-permission.test.ts index 4744c394756f..3062e35c238a 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 72650b2dd225..220599d73bdf 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-prompts.ts b/mobile/src/session/use-mobile-native-chat-prompts.ts index 35acecbe844d..8f1f3f6ea6d9 100644 --- a/mobile/src/session/use-mobile-native-chat-prompts.ts +++ b/mobile/src/session/use-mobile-native-chat-prompts.ts @@ -33,7 +33,7 @@ export function useMobileNativeChatPrompts(args: { lastAssistantMessage: status.lastAssistantMessage, toolName: status.toolName, toolInput: status.toolInput - }) ?? parseApprovalFromStatus(status.interactivePrompt)) + }) ?? parseApprovalFromStatus(status.interactivePrompt, status.agentType)) : null const question = blocked && status && !permission ? parseAgentQuestion(status.lastAssistantMessage ?? '') : null 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 3921d862526c..4cbc4c692eae 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 038ce1e7523b..6454dd8257b8 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 243fea0ac577..4f59534cf6eb 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/shared/native-chat-agent-support.ts b/src/shared/native-chat-agent-support.ts index fd3c882bc381..a956a7e85ed3 100644 --- a/src/shared/native-chat-agent-support.ts +++ b/src/shared/native-chat-agent-support.ts @@ -55,3 +55,7 @@ export function resolveNativeChatTranscriptAgent( } return null } + +export function nativeChatApprovalAcceptKey(agent: string | null | undefined): string { + return resolveNativeChatTranscriptAgent(agent) === 'opencode' ? '\r' : '1' +} From b9b3c95bb809863b6c9a5329c349001d12bc8f9a Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 01:47:40 -0700 Subject: [PATCH 05/16] Resolve mobile Chat readability for folder workspaces --- .../mobile-native-chat-eligibility.test.ts | 15 ++++++++++ .../session/mobile-native-chat-eligibility.ts | 23 +++++++++++++++ .../use-mobile-native-chat-readability.ts | 29 ++++++++++++++++++- 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/mobile/src/session/mobile-native-chat-eligibility.test.ts b/mobile/src/session/mobile-native-chat-eligibility.test.ts index 58b26e2a0168..f2affc55db6c 100644 --- a/mobile/src/session/mobile-native-chat-eligibility.test.ts +++ b/mobile/src/session/mobile-native-chat-eligibility.test.ts @@ -1,3 +1,4 @@ +import { isMobileFolderNativeChatReadable } from './mobile-native-chat-eligibility' import { describe, expect, it } from 'vitest' import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' import { @@ -182,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 c04f5ec72dc1..1ad104f5c2d1 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/use-mobile-native-chat-readability.ts b/mobile/src/session/use-mobile-native-chat-readability.ts index 167e70f832f1..c8c67f62119d 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) => { From 430ada0f2c93b8acaeeb9da7c213c5a7308e0fb8 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 01:57:34 -0700 Subject: [PATCH 06/16] Bound OpenCode part batches and preserve v2 image attachments --- .../transcript-opencode-sqlite-query.test.ts | 33 ++++++++++++++++++- .../transcript-opencode-sqlite-query.ts | 33 ++++++++++--------- .../transcript-opencode2-sqlite.test.ts | 15 +++++++++ .../transcript-opencode2-sqlite.ts | 17 ++++++++++ 4 files changed, 82 insertions(+), 16 deletions(-) diff --git a/src/main/native-chat/transcript-opencode-sqlite-query.test.ts b/src/main/native-chat/transcript-opencode-sqlite-query.test.ts index f7eade0618ec..179f6bfca38b 100644 --- a/src/main/native-chat/transcript-opencode-sqlite-query.test.ts +++ b/src/main/native-chat/transcript-opencode-sqlite-query.test.ts @@ -2,7 +2,7 @@ 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 } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import Database from '../sqlite/sync-database' import { readOpenCodeTranscriptPage, @@ -436,3 +436,34 @@ describe('readOpenCodeTranscriptSignal', () => { 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 index 4ef73bff1d7e..91ebbd247beb 100644 --- a/src/main/native-chat/transcript-opencode-sqlite-query.ts +++ b/src/main/native-chat/transcript-opencode-sqlite-query.ts @@ -41,14 +41,21 @@ function sessionExists(db: SyncDatabase, sessionId: string): boolean { } 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 = [] - let bytes = 0 for (const row of statement.iterate(...params)) { - bytes += Object.values(row).reduce( + budget.bytes += Object.values(row).reduce( (size, value) => size + (typeof value === 'string' ? Buffer.byteLength(value) : 0), 0 ) - if (rows.length >= 10000 || bytes > 16 * 1024 * 1024) { + if (rows.length >= 10000 || budget.bytes > 16 * 1024 * 1024) { throw new Error('OpenCode transcript query exceeds its read limit') } rows.push(row) @@ -115,11 +122,13 @@ export function readOpenCodeTranscriptPage(args: { 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 = rowsOf( + const rows = rowsWithinBudget( select, + rawBudget, args.sessionId, cursor ?? Number.MAX_SAFE_INTEGER, limit + 1 @@ -138,7 +147,7 @@ export function readOpenCodeTranscriptPage(args: { if (selected.some((row) => typeof row.data !== 'string')) { throw new Error('OpenCode transcript message exceeds its byte limit') } - const mapped = mapMessageRows(db, args.sessionId, selected) + 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') @@ -178,7 +187,8 @@ type PartRow = { function mapMessageRows( db: SyncDatabase, sessionId: string, - rows: MessageRow[] + rows: MessageRow[], + rawBudget: { bytes: number } ): OpenCodeTranscriptItem[] { if (rows.length === 0) { return [] @@ -188,12 +198,13 @@ function mapMessageRows( 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 = rowsOf( + const partRows = rowsWithinBudget( db.prepare( `SELECT message_id, time_updated, CASE WHEN length(data) <= 2097152 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 ) @@ -209,14 +220,6 @@ function mapMessageRows( } } } - if ( - [...partsByMessage.values()] - .flat() - .reduce((bytes, row) => bytes + Buffer.byteLength(row.data), 0) > - 16 * 1024 * 1024 - ) { - throw new Error('OpenCode transcript page exceeds its byte limit') - } const items: OpenCodeTranscriptItem[] = [] for (const row of rows) { const partList = partsByMessage.get(row.id) ?? [] diff --git a/src/main/native-chat/transcript-opencode2-sqlite.test.ts b/src/main/native-chat/transcript-opencode2-sqlite.test.ts index 725426cbf83c..d42508512707 100644 --- a/src/main/native-chat/transcript-opencode2-sqlite.test.ts +++ b/src/main/native-chat/transcript-opencode2-sqlite.test.ts @@ -162,3 +162,18 @@ describe('OpenCode 2 native transcript', () => { } }) }) + +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' }] + ]) +}) diff --git a/src/main/native-chat/transcript-opencode2-sqlite.ts b/src/main/native-chat/transcript-opencode2-sqlite.ts index 1bc7d8f178f7..6a59e610b978 100644 --- a/src/main/native-chat/transcript-opencode2-sqlite.ts +++ b/src/main/native-chat/transcript-opencode2-sqlite.ts @@ -79,6 +79,23 @@ function messageItem(value: unknown): OpenCodeTranscriptItem | null { : 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) { From 211ea3275babbc8d4baa553d16cea7293b27ec15 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 02:00:07 -0700 Subject: [PATCH 07/16] Prefer live migrated OpenCode sessions over legacy copies --- .../transcript-opencode-sqlite-query.ts | 12 ++++++++++-- .../transcript-opencode2-sqlite.test.ts | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/main/native-chat/transcript-opencode-sqlite-query.ts b/src/main/native-chat/transcript-opencode-sqlite-query.ts index 91ebbd247beb..cb4950539608 100644 --- a/src/main/native-chat/transcript-opencode-sqlite-query.ts +++ b/src/main/native-chat/transcript-opencode-sqlite-query.ts @@ -70,8 +70,12 @@ export function readOpenCodeTranscriptSignal( ): OpenCodeTranscriptSignal | null { const db = openOpenCodeDatabaseReadonly(dbPath) try { + const v2 = readOpenCode2TranscriptSignal(db, sessionId) + if (v2) { + return v2 + } if (!sessionExists(db, sessionId)) { - return readOpenCode2TranscriptSignal(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 }>( @@ -105,8 +109,12 @@ export function readOpenCodeTranscriptPage(args: { }): OpenCodeTranscriptPage | null { const db = openOpenCodeDatabaseReadonly(args.dbPath) try { + const v2 = readOpenCode2TranscriptPage(db, args) + if (v2) { + return v2 + } if (!sessionExists(db, args.sessionId)) { - return readOpenCode2TranscriptPage(db, args) + return null } const limit = Math.min(2400, Math.max(1, Math.floor(args.limit))) // The upper bound is always bound: batching advances the cursor mid-page, diff --git a/src/main/native-chat/transcript-opencode2-sqlite.test.ts b/src/main/native-chat/transcript-opencode2-sqlite.test.ts index d42508512707..942b99cd1404 100644 --- a/src/main/native-chat/transcript-opencode2-sqlite.test.ts +++ b/src/main/native-chat/transcript-opencode2-sqlite.test.ts @@ -177,3 +177,21 @@ it('preserves real v2 user image files with or without accompanying text', () => [{ 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']) +}) From a5c605aecd8f5545200bbe6cb515402b69b39f3c Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 02:12:40 -0700 Subject: [PATCH 08/16] Consolidate mobile Chat eligibility test imports --- mobile/src/session/mobile-native-chat-eligibility.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/src/session/mobile-native-chat-eligibility.test.ts b/mobile/src/session/mobile-native-chat-eligibility.test.ts index f2affc55db6c..91ee3a518c71 100644 --- a/mobile/src/session/mobile-native-chat-eligibility.test.ts +++ b/mobile/src/session/mobile-native-chat-eligibility.test.ts @@ -1,8 +1,8 @@ -import { isMobileFolderNativeChatReadable } from './mobile-native-chat-eligibility' 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' From 581e9ae2c4776ebbe1507e6f257c99623460b8f3 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 04:07:46 -0700 Subject: [PATCH 09/16] Consolidate OpenCode SQLite protocol type imports --- .../session-scanner-opencode-sqlite-worker-client.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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 e297b806e5ba..f303de524434 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 @@ -1,11 +1,9 @@ -import type { - OpenCodeNativeChatReadRequest, - OpenCodeNativeChatReadValue -} from './session-scanner-opencode-sqlite-worker-protocol' 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, From d0154b5655b0e09adb56a905cc4d6f1760487665 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 05:40:11 -0700 Subject: [PATCH 10/16] fix(native-chat): preserve OpenCode reasoning and patch parts Separate genuine reasoning from answer blocks in both native SQLite schemas and retain recorded patches as completed patch tools. Keep each database row together at page boundaries so the existing raw-row cursors cannot drop half of a mixed row. Adapted from @akhan157's OpenCode native history work in #13287 at bb661d10d716764fb472d824cd434678875b1947; retains the current bounded reader and account discovery instead of restoring the older capture and cursor implementation. Verified against genuine private installed 2.0.16 and official 1.18.30 CLI ingestion. Co-authored-by: Adnan Khan --- .../transcript-opencode-part-blocks.ts | 51 +++- .../transcript-opencode-read-bounds.test.ts | 67 +++++ ...nscript-opencode-semantic-fidelity.test.ts | 251 ++++++++++++++++++ .../transcript-opencode-sqlite-query.ts | 40 +-- .../transcript-opencode2-sqlite.ts | 47 ++-- 5 files changed, 414 insertions(+), 42 deletions(-) create mode 100644 src/main/native-chat/transcript-opencode-read-bounds.test.ts create mode 100644 src/main/native-chat/transcript-opencode-semantic-fidelity.test.ts diff --git a/src/main/native-chat/transcript-opencode-part-blocks.ts b/src/main/native-chat/transcript-opencode-part-blocks.ts index fbfe5d7a834a..e81ed569e5dd 100644 --- a/src/main/native-chat/transcript-opencode-part-blocks.ts +++ b/src/main/native-chat/transcript-opencode-part-blocks.ts @@ -1,5 +1,9 @@ import { fileURLToPath } from 'node:url' -import type { NativeChatBlock, NativeChatImageRefBlock } from '../../shared/native-chat-types' +import type { + NativeChatBlock, + NativeChatImageRefBlock, + NativeChatMessage +} from '../../shared/native-chat-types' 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). @@ -10,8 +14,35 @@ export type OpenCodePartRow = { data: string } -export function opencodeMessageBlocks(partRows: OpenCodePartRow[]): NativeChatBlock[] { +export function opencodeMessages( + message: Pick, + partRows: OpenCodePartRow[], + additionalBlocks: NativeChatBlock[] = [] +): NativeChatMessage[] { + const { blocks, reasoning } = opencodeMessageContent(partRows) + blocks.push(...additionalBlocks) + const messages: NativeChatMessage[] = [] + if (reasoning.length > 0) { + messages.push({ + ...message, + id: blocks.length > 0 ? `${message.id}:reasoning` : message.id, + role: 'reasoning', + blocks: reasoning, + source: 'transcript' + }) + } + if (blocks.length > 0) { + messages.push({ ...message, blocks, source: 'transcript' }) + } + return messages +} + +function opencodeMessageContent(partRows: OpenCodePartRow[]): { + blocks: NativeChatBlock[] + reasoning: NativeChatBlock[] +} { const blocks: NativeChatBlock[] = [] + const reasoning: NativeChatBlock[] = [] for (const partRow of partRows) { const part = parseJsonObject(partRow.data) if (!part) { @@ -31,7 +62,7 @@ export function opencodeMessageBlocks(partRows: OpenCodePartRow[]): NativeChatBl case 'reasoning': { const text = extractString(part.text) if (text) { - blocks.push({ type: 'text', text }) + reasoning.push({ type: 'text', text }) } break } @@ -39,6 +70,18 @@ export function opencodeMessageBlocks(partRows: OpenCodePartRow[]): NativeChatBl blocks.push(...opencodeToolBlocks(part)) break } + case 'patch': { + const files = Array.isArray(part.files) + ? part.files.filter((file): file is string => typeof file === 'string') + : [] + blocks.push({ + type: 'tool-call', + name: 'patch', + state: 'completed', + input: { hash: extractString(part.hash), files } + }) + break + } case 'file': { const block = opencodeFileBlock(part) if (block) { @@ -51,7 +94,7 @@ export function opencodeMessageBlocks(partRows: OpenCodePartRow[]): NativeChatBl break } } - return blocks + return { blocks, reasoning } } function opencodeFileBlock(part: Record): NativeChatImageRefBlock | null { diff --git a/src/main/native-chat/transcript-opencode-read-bounds.test.ts b/src/main/native-chat/transcript-opencode-read-bounds.test.ts new file mode 100644 index 000000000000..46f4ace57c1a --- /dev/null +++ b/src/main/native-chat/transcript-opencode-read-bounds.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { + readOpenCodeNativeChatTranscriptFull, + readOpenCodeNativeChatTranscriptTail +} from './transcript-opencode' + +vi.mock('../managed-data-accounts/service', () => ({ getManagedDataAccountService: vi.fn() })) + +describe('OpenCode full native read bounds', () => { + it('counts reasoning, answer and image bytes cumulatively across pages', async () => { + let calls = 0 + const chunk = 'x'.repeat(1_000_000) + const messages: NativeChatMessage[] = Array.from({ length: 12 }, (_, index) => ({ + id: String(index), + role: index % 2 === 0 ? 'reasoning' : 'assistant', + timestamp: 1, + source: 'transcript', + blocks: [{ type: 'text', text: chunk }] + })) + messages.push({ + id: 'image', + role: 'user', + timestamp: 1, + source: 'transcript', + blocks: [{ type: 'image-ref', url: `data:image/png;base64,${chunk}` }] + }) + const result = await readOpenCodeNativeChatTranscriptFull('session', { + resolveDbPath: async () => 'private-db', + readPage: async () => ({ + items: messages.map((message) => ({ rowid: 100 - calls, fingerprint: '', message })), + hasMore: true, + beforeMessageRowId: 100 - ++calls + }) + }) + expect(result).toEqual({ error: 'OpenCode transcript exceeds its full read limit' }) + expect(calls).toBe(6) + }) + + it.each(['tail', 'full'] as const)( + 'keeps cancellation attached to %s discovery and reads', + async (kind) => { + const controller = new AbortController() + const resolveDbPath = vi.fn(async (_sessionId?: string, signal?: AbortSignal) => { + expect(signal).toBe(controller.signal) + return 'private-db' + }) + const readPage = vi.fn(async (_args: unknown, signal?: AbortSignal) => { + expect(signal).toBe(controller.signal) + controller.abort(new Error('Cancelled private read')) + signal?.throwIfAborted() + return null + }) + const deps = { resolveDbPath, readPage } + const result = + kind === 'tail' + ? await readOpenCodeNativeChatTranscriptTail( + { sessionId: 'session', limit: 1 }, + deps, + controller.signal + ) + : await readOpenCodeNativeChatTranscriptFull('session', deps, controller.signal) + expect(result).toEqual({ error: 'Cancelled private read' }) + expect(readPage).toHaveBeenCalledOnce() + } + ) +}) diff --git a/src/main/native-chat/transcript-opencode-semantic-fidelity.test.ts b/src/main/native-chat/transcript-opencode-semantic-fidelity.test.ts new file mode 100644 index 000000000000..d2902312ada9 --- /dev/null +++ b/src/main/native-chat/transcript-opencode-semantic-fidelity.test.ts @@ -0,0 +1,251 @@ +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 type { NativeChatMessage } from '../../shared/native-chat-types' +import Database from '../sqlite/sync-database' +import { opencodeMessages } from './transcript-opencode-part-blocks' +import { + readOpenCodeTranscriptPage, + readOpenCodeTranscriptSignal +} from './transcript-opencode-sqlite-query' +import { readOpenCodeNativeChatTranscriptFull } from './transcript-opencode' +import { subscribeOpenCodeNativeChatTranscript } from './transcript-opencode-subscribe' + +vi.mock('../managed-data-accounts/service', () => ({ getManagedDataAccountService: vi.fn() })) + +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(version: 'v1' | 'v2') { + const root = mkdtempSync(join(tmpdir(), 'orca-opencode-semantics-')) + const path = join(root, 'opencode.db') + const db = new Database(path) + fixtures.push({ db, root }) + if (version === 'v1') { + 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');`) + } else { + db.exec(`CREATE TABLE session_v2 (id TEXT PRIMARY KEY); + CREATE TABLE session_message (id TEXT, 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, parts: unknown[]) => { + sequence += 7 + if (version === 'v1') { + db.prepare('INSERT INTO message VALUES (?, ?, ?, ?, ?)').run( + id, + 'session', + sequence, + sequence, + JSON.stringify({ role: 'assistant' }) + ) + parts.forEach((part, index) => { + db.prepare('INSERT INTO part VALUES (?, ?, ?, ?, ?)').run( + `${id}:${index}`, + id, + 'session', + sequence, + JSON.stringify(part) + ) + }) + } else { + db.prepare('INSERT INTO session_message VALUES (?, ?, ?, ?, ?, ?, ?)').run( + id, + 'session', + 'assistant', + sequence, + JSON.stringify({ content: parts }), + sequence, + sequence + ) + } + } + const prefix = version === 'v1' ? '' : 'opencode:' + return { db, path, insert, prefix } +} + +const mixed = (answer = 'ANSWER_SENTINEL') => [ + { type: 'reasoning', text: 'REASONING_SENTINEL' }, + { type: 'text', text: answer } +] + +it('keeps reasoning apart from prose, image refs, tools, and applied patch records', () => { + // Contracts captured through isolated 2.0.16 and official 1.18.30 CLI ingestion. + const parts = [ + ...mixed(), + { type: 'text', text: 'injected context', synthetic: true }, + { type: 'step-finish' }, + { type: 'file', mime: 'image/png', url: 'data:image/png;base64,YQ==' }, + { type: 'patch', hash: 'snapshot-before-edit', files: ['sentinel.txt', 3] } + ].map((part) => ({ message_id: 'row', time_updated: 1, data: JSON.stringify(part) })) + const messages = opencodeMessages({ id: 'row', role: 'assistant', timestamp: 1 }, parts) + expect(messages).toEqual([ + { + id: 'row:reasoning', + role: 'reasoning', + timestamp: 1, + source: 'transcript', + blocks: [{ type: 'text', text: 'REASONING_SENTINEL' }] + }, + { + id: 'row', + role: 'assistant', + timestamp: 1, + source: 'transcript', + blocks: [ + { type: 'text', text: 'ANSWER_SENTINEL' }, + { type: 'image-ref', url: 'data:image/png;base64,YQ==' }, + { + type: 'tool-call', + name: 'patch', + state: 'completed', + input: { hash: 'snapshot-before-edit', files: ['sentinel.txt'] } + } + ] + } + ]) + expect( + opencodeMessages({ id: 'thinking', role: 'assistant', timestamp: null }, parts.slice(0, 1)) + ).toEqual([ + { + id: 'thinking', + role: 'reasoning', + timestamp: null, + source: 'transcript', + blocks: [{ type: 'text', text: 'REASONING_SENTINEL' }] + } + ]) +}) + +describe.each(['v1', 'v2'] as const)('%s semantic pagination', (version) => { + it.each([1, 2, 3])('reconstructs sparse mixed history once with a page limit of %i', (limit) => { + const { path, insert, prefix } = fixture(version) + const expected: string[] = [] + for (let index = 0; index < 12; index++) { + const id = String(index) + if (index % 3 === 1) { + insert(id, [{ type: 'step-start' }]) + } else if (index % 3 === 2) { + insert(id, mixed()) + expected.push(`${prefix}${id}:reasoning`, `${prefix}${id}`) + } else { + insert(id, [{ type: 'text', text: id }]) + expected.push(`${prefix}${id}`) + } + } + const pages: NativeChatMessage[][] = [] + let cursor: number | undefined + for (let pageIndex = 0; pageIndex < 30; pageIndex++) { + const page = readOpenCodeTranscriptPage({ + dbPath: path, + sessionId: 'session', + limit, + beforeMessageRowId: cursor + })! + expect(page.items.length).toBeLessThanOrEqual(limit + 1) + for (const item of page.items.filter((item) => item.message.role === 'reasoning')) { + expect( + page.items.some( + (other) => other.rowid === item.rowid && other.message.role === 'assistant' + ) + ).toBe(true) + } + pages.push(page.items.map((item) => item.message)) + if (!page.hasMore) { + break + } + expect(page.beforeMessageRowId).toBeLessThan(cursor ?? Number.MAX_SAFE_INTEGER) + cursor = page.beforeMessageRowId ?? undefined + } + expect( + pages + .toReversed() + .flat() + .map((message) => message.id) + ).toEqual(expected) + }) + + it('reconstructs a full read beyond its 500-message window without losing a split row', async () => { + const { path, insert, prefix } = fixture(version) + const expected: string[] = [] + for (let index = 0; index < 260; index++) { + insert(String(index), mixed()) + expected.push(`${prefix}${index}:reasoning`, `${prefix}${index}`) + } + const result = await readOpenCodeNativeChatTranscriptFull('session', { + resolveDbPath: async () => path, + readPage: async (args) => readOpenCodeTranscriptPage(args) + }) + expect('messages' in result && result.messages.map((message) => message.id)).toEqual(expected) + }) + + it('replaces both presentations on streaming mutation and appends both on a new row', async () => { + const { db, path, insert } = fixture(version) + insert('reply', mixed()) + const snapshots: NativeChatMessage[][] = [] + const replacements: NativeChatMessage[][] = [] + const appends: NativeChatMessage[][] = [] + const subscription = subscribeOpenCodeNativeChatTranscript( + { + agent: 'opencode', + sessionId: 'session', + initialLimit: 1, + resolvePollIntervalMs: 5, + onAppend: (messages) => appends.push(messages), + 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) + expect(snapshots[0].map((message) => message.role)).toEqual(['reasoning', 'assistant']) + if (version === 'v1') { + db.prepare("UPDATE part SET data = ?, time_updated = 100 WHERE id = 'reply:1'").run( + JSON.stringify({ type: 'text', text: 'complete' }) + ) + } else { + db.prepare('UPDATE session_message SET data = ?, time_updated = 100').run( + JSON.stringify({ content: mixed('complete') }) + ) + } + await expect.poll(() => replacements.length).toBe(1) + expect(replacements[0].map((message) => message.role)).toEqual(['reasoning', 'assistant']) + expect(replacements[0][1].blocks).toEqual([{ type: 'text', text: 'complete' }]) + insert('next', mixed('next answer')) + await expect.poll(() => appends.length).toBe(1) + expect(appends[0].map((message) => message.role)).toEqual(['reasoning', 'assistant']) + } finally { + subscription.unsubscribe() + } + }) + + it('enforces one page byte budget across multiple mixed rows', () => { + const { path, insert } = fixture(version) + const large = 'x'.repeat(1_000_000) + for (let index = 0; index < 20; index++) { + insert(String(index), [ + { type: 'reasoning', text: large }, + { type: 'text', text: large } + ]) + } + expect(() => + readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'session', limit: 40 }) + ).toThrow('read limit') + }) +}) diff --git a/src/main/native-chat/transcript-opencode-sqlite-query.ts b/src/main/native-chat/transcript-opencode-sqlite-query.ts index cb4950539608..700e96878232 100644 --- a/src/main/native-chat/transcript-opencode-sqlite-query.ts +++ b/src/main/native-chat/transcript-opencode-sqlite-query.ts @@ -9,7 +9,7 @@ import type SyncDatabase from '../sqlite/sync-database' type BindValue = SyncDatabase.BindValue type SqliteStatement = SyncDatabase.Statement -import { opencodeMessageBlocks } from './transcript-opencode-part-blocks' +import { opencodeMessages } from './transcript-opencode-part-blocks' // Cursors are opaque provider order: SQLite rowid in v1, session sequence in v2. export type OpenCodeTranscriptItem = { @@ -165,8 +165,16 @@ export function readOpenCodeTranscriptPage(args: { break } } - const overshot = collected.length > limit - const trimmed = overshot ? collected.slice(0, limit) : collected + let retained = Math.min(limit, collected.length) + // A raw-row cursor cannot resume inside a reasoning/answer pair. + while ( + retained < collected.length && + collected[retained].rowid === collected[retained - 1].rowid + ) { + retained++ + } + const overshot = retained < collected.length + const trimmed = overshot ? collected.slice(0, retained) : collected const items = trimmed.toReversed() return { items, @@ -231,23 +239,23 @@ function mapMessageRows( const items: OpenCodeTranscriptItem[] = [] for (const row of rows) { const partList = partsByMessage.get(row.id) ?? [] - const blocks = opencodeMessageBlocks(partList) - if (blocks.length === 0) { - continue - } const record = parseJsonObject(row.data) const role = extractString(record?.role) - items.push({ - rowid: row.message_rowid, - fingerprint: `${row.time_updated}:${partList.length}:${maxPartTimeUpdated(partList)}`, - message: { + const messages = opencodeMessages( + { id: row.id, role: role === 'user' ? 'user' : role === 'assistant' ? 'assistant' : 'system', - blocks, - timestamp: Number.isFinite(row.time_created) ? row.time_created : null, - source: 'transcript' - } - }) + timestamp: Number.isFinite(row.time_created) ? row.time_created : null + }, + partList + ) + for (const message of messages.toReversed()) { + items.push({ + rowid: row.message_rowid, + fingerprint: `${row.time_updated}:${partList.length}:${maxPartTimeUpdated(partList)}`, + message + }) + } } return items } diff --git a/src/main/native-chat/transcript-opencode2-sqlite.ts b/src/main/native-chat/transcript-opencode2-sqlite.ts index 6a59e610b978..95880c3bc8ec 100644 --- a/src/main/native-chat/transcript-opencode2-sqlite.ts +++ b/src/main/native-chat/transcript-opencode2-sqlite.ts @@ -2,7 +2,8 @@ import { createHash } from 'node:crypto' import type SyncDatabase from '../sqlite/sync-database' import { columnExists, tableExists } from '../opencode-usage/schema-helpers' import { asRecord, parseJsonObject } from '../ai-vault/session-scanner-values' -import { opencodeMessageBlocks } from './transcript-opencode-part-blocks' +import { opencodeMessages } from './transcript-opencode-part-blocks' +import type { NativeChatBlock } from '../../shared/native-chat-types' import type { OpenCodeTranscriptPage, OpenCodeTranscriptSignal, @@ -52,7 +53,7 @@ export function readOpenCode2TranscriptSignal( } } -function messageItem(value: unknown): OpenCodeTranscriptItem | null { +function messageItems(value: unknown): OpenCodeTranscriptItem[] { const row = asRecord(value) if (typeof row?.data !== 'string') { throw new Error('OpenCode transcript message exceeds its byte limit') @@ -69,6 +70,7 @@ function messageItem(value: unknown): OpenCodeTranscriptItem | null { throw new Error('OpenCode transcript message exceeds its byte limit') } const messageId = row.id + const messageCursor = row.cursor const updatedAt = row.time_updated const record = parseJsonObject(row.data) if (!record) { @@ -121,7 +123,7 @@ function messageItem(value: unknown): OpenCodeTranscriptItem | null { } ] }) - const blocks = opencodeMessageBlocks(parts) + const blocks: NativeChatBlock[] = [] const error = asRecord(record.error) if (typeof error?.message === 'string') { blocks.push({ type: 'text', text: error.message }) @@ -130,25 +132,28 @@ function messageItem(value: unknown): OpenCodeTranscriptItem | null { blocks.push({ type: 'text', text: 'Conversation interrupted' }) } if ( - blocks.length === 0 || - (row.type !== 'user' && - row.type !== 'assistant' && - row.type !== 'system' && - row.type !== 'idle') + row.type !== 'user' && + row.type !== 'assistant' && + row.type !== 'system' && + row.type !== 'idle' ) { - return null + return [] } - return { - rowid: row.cursor, - fingerprint: `${updatedAt}:${createHash('sha256').update(row.data).digest('hex')}`, - message: { + const messages = opencodeMessages( + { id: `opencode:${row.id}`, role: row.type === 'idle' ? 'system' : row.type, - blocks, - timestamp: row.time_created, - source: 'transcript' - } - } + timestamp: row.time_created + }, + parts, + blocks + ) + const fingerprint = `${updatedAt}:${createHash('sha256').update(row.data).digest('hex')}` + return messages.toReversed().map((message) => ({ + rowid: messageCursor, + fingerprint, + message + })) } export function readOpenCode2TranscriptPage( @@ -186,11 +191,9 @@ export function readOpenCode2TranscriptPage( if (++scannedRows > MAX_SCAN_ROWS || bytes > MAX_PAGE_BYTES) { throw new Error('OpenCode transcript page exceeds its read limit') } - const item = messageItem(value) + const mapped = messageItems(value) cursor = row.cursor - if (item) { - items.push(item) - } + items.push(...mapped) if (items.length >= limit) { hasMore = db From b5937bc5b341e7677398f79e888acf6a2cd7d2db Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 05:40:12 -0700 Subject: [PATCH 11/16] fix(native-chat): keep split OpenCode rows intact on desktop and mobile Preserve the native reader's bounded OpenCode row groups in paired reads and snapshot/replacement frames so a second presentation-count slice cannot drop reasoning while advancing the database cursor. Sort derived reasoning before its answer under the same provider timestamp while retaining journal order. These two boundaries were reproduced with genuine installed 2.0.16 and official 1.18.30 sessions in a hidden desktop renderer and the current mobile view over an actual authenticated encrypted pairing. Completes the semantic presentation from @akhan157's #13287 without importing its older clipping or cursor implementation. Co-authored-by: Adnan Khan --- ...ative-chat-opencode-semantic-pages.test.ts | 144 ++++++++++++++++++ src/main/runtime/rpc/methods/native-chat.ts | 16 +- .../native-chat-reasoning-order.test.ts | 46 ++++++ .../native-chat-transcript-projection.ts | 11 +- 4 files changed, 209 insertions(+), 8 deletions(-) create mode 100644 src/main/runtime/rpc/methods/native-chat-opencode-semantic-pages.test.ts create mode 100644 src/shared/native-chat-reasoning-order.test.ts diff --git a/src/main/runtime/rpc/methods/native-chat-opencode-semantic-pages.test.ts b/src/main/runtime/rpc/methods/native-chat-opencode-semantic-pages.test.ts new file mode 100644 index 000000000000..358b723e1ac3 --- /dev/null +++ b/src/main/runtime/rpc/methods/native-chat-opencode-semantic-pages.test.ts @@ -0,0 +1,144 @@ +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 { asRecord } from '../../../ai-vault/session-scanner-values' +import { readOpenCodeTranscriptPage } from '../../../native-chat/transcript-opencode-sqlite-query' +import { readOpenCodeNativeChatTranscriptTail } from '../../../native-chat/transcript-opencode' +import type { SubscribeNativeChatTranscriptArgs } from '../../../native-chat/transcript-watch-contract' +import { buildRegistry, isStreamingMethod, type RpcContext } from '../core' + +const state: { path: string; watcher: SubscribeNativeChatTranscriptArgs | null } = vi.hoisted( + () => ({ + path: '', + watcher: null + }) +) +vi.mock('../../../managed-data-accounts/service', () => ({ getManagedDataAccountService: vi.fn() })) +vi.mock('../../../native-chat/transcript-watch', () => ({ + readNativeChatTranscriptTail: (args: { + sessionId: string + limit: number + beforeOffset?: number + }) => + readOpenCodeNativeChatTranscriptTail(args, { + resolveDbPath: async () => state.path, + readPage: async (page) => readOpenCodeTranscriptPage(page) + }), + subscribeNativeChatTranscript: async (args: SubscribeNativeChatTranscriptArgs) => { + state.watcher = args + return { watching: true, unsubscribe: vi.fn() } + } +})) +import { NATIVE_CHAT_METHODS } from './native-chat' + +const roots: string[] = [] +afterEach(() => { + state.watcher = null + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'orca-opencode-paired-pages-')) + roots.push(root) + state.path = join(root, 'opencode.db') + const db = new Database(state.path) + db.exec(`CREATE TABLE session_v2 (id TEXT PRIMARY KEY); + CREATE TABLE session_message (id TEXT, session_id TEXT, type TEXT, seq INTEGER, + data TEXT, time_created INTEGER, time_updated INTEGER); + INSERT INTO session_v2 VALUES ('session');`) + for (let index = 1; index <= 3; index++) { + db.prepare('INSERT INTO session_message VALUES (?, ?, ?, ?, ?, ?, ?)').run( + String(index), + 'session', + 'assistant', + index * 7, + JSON.stringify({ + content: [ + { type: 'reasoning', text: `thinking ${index}` }, + { type: 'text', text: `answer ${index}` } + ] + }), + 1, + 1 + ) + } + db.close() +} + +function context(): RpcContext { + const runtime = { registerSubscriptionCleanup: vi.fn(), cleanupSubscription: vi.fn() } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These handlers only register or invoke subscription cleanup on the runtime. + return { runtime: runtime as unknown as RpcContext['runtime'], clientKind: 'mobile' } +} + +describe.each(['opencode', 'opencode2'])('%s paired semantic pages', (agent) => { + it('reconstructs reasoning and answers across raw-row cursors at limit 1', async () => { + fixture() + const method = buildRegistry(NATIVE_CHAT_METHODS).get('nativeChat.readSession')! + if (isStreamingMethod(method)) { + throw new Error('Expected a page read') + } + const pages: string[][] = [] + let beforeOffset: number | undefined + for (let index = 0; index < 4; index++) { + const params = method.params?.parse({ agent, sessionId: 'session', limit: 1, beforeOffset }) + const result = asRecord(await method.handler(params, context())) + expect(result?.hasMore).toBe(index < 2) + const messages = Array.isArray(result?.messages) ? result.messages : [] + expect(messages.map((message) => asRecord(message)?.role)).toEqual(['reasoning', 'assistant']) + pages.push(messages.map((message) => String(asRecord(message)?.id))) + if (result?.hasMore !== true) { + break + } + expect(typeof result.beforeOffset).toBe('number') + beforeOffset = typeof result.beforeOffset === 'number' ? result.beforeOffset : undefined + } + expect(pages.toReversed().flat()).toEqual([ + 'opencode:1:reasoning', + 'opencode:1', + 'opencode:2:reasoning', + 'opencode:2', + 'opencode:3:reasoning', + 'opencode:3' + ]) + }) + + it('keeps the pair in snapshot and replacement frames while sanitizing mobile text', async () => { + fixture() + const method = buildRegistry(NATIVE_CHAT_METHODS).get('nativeChat.subscribe')! + if (!isStreamingMethod(method)) { + throw new Error('Expected a subscription') + } + const frames: unknown[] = [] + await method.handler( + method.params?.parse({ agent, sessionId: 'session', limit: 1 }), + context(), + (frame) => frames.push(frame) + ) + const page = readOpenCodeTranscriptPage({ dbPath: state.path, sessionId: 'session', limit: 1 })! + const messages = page.items.map((item) => item.message) + messages[0].blocks = [{ type: 'text', text: 'x'.repeat(100_000) }] + const watcher = state.watcher + if (!watcher) { + throw new Error('Expected a native watcher') + } + watcher.onInitialSnapshot?.(messages, page.hasMore, page.beforeMessageRowId ?? 0) + watcher.onReplace?.(messages, page.hasMore, page.beforeMessageRowId ?? 0) + expect(frames).toHaveLength(2) + for (const frame of frames) { + const record = asRecord(frame) + const published = Array.isArray(record?.messages) ? record.messages : [] + expect(published.map((message) => asRecord(message)?.role)).toEqual([ + 'reasoning', + 'assistant' + ]) + expect(record?.beforeOffset).toBe(page.beforeMessageRowId) + expect(JSON.stringify(published).length).toBeLessThan(100_000) + } + expect(messages[0].blocks[0]).toEqual({ type: 'text', text: 'x'.repeat(100_000) }) + }) +}) diff --git a/src/main/runtime/rpc/methods/native-chat.ts b/src/main/runtime/rpc/methods/native-chat.ts index 05f8f7f87261..5e78b9816db5 100644 --- a/src/main/runtime/rpc/methods/native-chat.ts +++ b/src/main/runtime/rpc/methods/native-chat.ts @@ -1,4 +1,5 @@ import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { resolveNativeChatTranscriptAgent } from '../../../../shared/native-chat-agent-support' import { readNativeChatTranscriptTail, subscribeNativeChatTranscript, @@ -57,9 +58,14 @@ function windowTranscript( function windowForClient( messages: readonly NativeChatMessage[], clientKind: RpcContext['clientKind'], - limit = MOBILE_NATIVE_CHAT_DEFAULT_WINDOW + limit = MOBILE_NATIVE_CHAT_DEFAULT_WINDOW, + agent?: string ): NativeChatMessage[] { - const windowed = windowTranscript(messages, limit) + // OpenCode pages keep a reasoning/answer pair together under one raw-row cursor. + const windowed = + resolveNativeChatTranscriptAgent(agent) === 'opencode' + ? messages + : windowTranscript(messages, limit) return windowed.map((message) => sanitizeMessage(message, clientKind)) } @@ -81,7 +87,7 @@ export const NATIVE_CHAT_METHODS = [ ) return 'messages' in result ? { - messages: windowForClient(result.messages, clientKind, limit), + messages: windowForClient(result.messages, clientKind, limit, params.agent), hasMore: result.hasMore, beforeOffset: result.beforeOffset, ...(result.lifecycle ? { lifecycle: result.lifecycle } : {}) @@ -144,7 +150,7 @@ export const NATIVE_CHAT_METHODS = [ // instead of stranding the view at 'loading' when the read keeps throwing. emit({ type: 'snapshot', - messages: windowForClient(messages, clientKind, limit), + messages: windowForClient(messages, clientKind, limit, params.agent), hasMore, beforeOffset, ...(error ? { error } : {}), @@ -166,7 +172,7 @@ export const NATIVE_CHAT_METHODS = [ } emit({ type: 'replacement', - messages: windowForClient(messages, clientKind, limit), + messages: windowForClient(messages, clientKind, limit, params.agent), hasMore, beforeOffset, ...(lifecycle ? { lifecycle } : {}) diff --git a/src/shared/native-chat-reasoning-order.test.ts b/src/shared/native-chat-reasoning-order.test.ts new file mode 100644 index 000000000000..ea3fd56305b4 --- /dev/null +++ b/src/shared/native-chat-reasoning-order.test.ts @@ -0,0 +1,46 @@ +import { expect, it } from 'vitest' +import type { NativeChatMessage } from './native-chat-types' +import { + compareNativeChatTranscriptMessages, + projectNativeChatTranscriptMessages +} from './native-chat-transcript-projection' + +function row(id: string, role: NativeChatMessage['role'], timestamp = 1): NativeChatMessage { + return { id, role, timestamp, source: 'transcript', blocks: [{ type: 'text', text: id }] } +} + +it('keeps derived reasoning immediately before its answer without changing provider timestamps', () => { + const messages = [ + row('opencode:msg_b', 'assistant'), + row('opencode:msg_a:other', 'assistant'), + row('opencode:msg_a', 'assistant'), + row('opencode:msg_b:reasoning', 'reasoning'), + row('opencode:msg_a:reasoning', 'reasoning') + ] + const sorted = [...messages].sort(compareNativeChatTranscriptMessages) + expect(sorted.map((message) => message.id)).toEqual([ + 'opencode:msg_a:reasoning', + 'opencode:msg_a', + 'opencode:msg_a:other', + 'opencode:msg_b:reasoning', + 'opencode:msg_b' + ]) + expect(projectNativeChatTranscriptMessages(messages)).toEqual(sorted) + expect(messages.every((message) => message.timestamp === 1)).toBe(true) + for (let first = 0; first < sorted.length; first++) { + for (let second = first + 1; second < sorted.length; second++) { + expect(compareNativeChatTranscriptMessages(sorted[first], sorted[second])).toBeLessThan(0) + expect(compareNativeChatTranscriptMessages(sorted[second], sorted[first])).toBeGreaterThan(0) + } + } +}) + +it('keeps timestamp and journal authority ahead of a derived reasoning key', () => { + const answer = row('row', 'assistant', 1) + const laterReasoning = row('row:reasoning', 'reasoning', 2) + expect(compareNativeChatTranscriptMessages(answer, laterReasoning)).toBeLessThan(0) + answer.journalPosition = { sequence: 1, index: 0 } + laterReasoning.journalPosition = { sequence: 2, index: 0 } + laterReasoning.timestamp = 0 + expect(compareNativeChatTranscriptMessages(answer, laterReasoning)).toBeLessThan(0) +}) diff --git a/src/shared/native-chat-transcript-projection.ts b/src/shared/native-chat-transcript-projection.ts index d826ecaf552e..82f6a2c0a12d 100644 --- a/src/shared/native-chat-transcript-projection.ts +++ b/src/shared/native-chat-transcript-projection.ts @@ -25,13 +25,18 @@ export function compareNativeChatMessagesByTime( if (at !== bt) { return at - bt } - if (a.id < b.id) { + // Split reasoning shares its provider row's key, before that row's answer. + const aReasoning = a.role === 'reasoning' && a.id.endsWith(':reasoning') + const bReasoning = b.role === 'reasoning' && b.id.endsWith(':reasoning') + const aId = aReasoning ? a.id.slice(0, -':reasoning'.length) : a.id + const bId = bReasoning ? b.id.slice(0, -':reasoning'.length) : b.id + if (aId < bId) { return -1 } - if (a.id > b.id) { + if (aId > bId) { return 1 } - return 0 + return Number(bReasoning) - Number(aReasoning) } /** Rows the journal holds read in the journal's own order, never its clock: a From b9a92b6e4c3dc52b604007b365b5b16d1a962d28 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 21:50:30 -0700 Subject: [PATCH 12/16] fix(native-chat): keep reasoning and answers together in live windows --- .../mobile-native-chat-pair-window.test.ts | 108 ++++++++++++++++++ src/shared/native-chat-merge-pairs.test.ts | 103 +++++++++++++++++ src/shared/native-chat-merge.ts | 19 ++- .../native-chat-transcript-projection.ts | 10 +- src/shared/native-chat-types.ts | 7 ++ 5 files changed, 237 insertions(+), 10 deletions(-) create mode 100644 mobile/src/session/mobile-native-chat-pair-window.test.ts create mode 100644 src/shared/native-chat-merge-pairs.test.ts diff --git a/mobile/src/session/mobile-native-chat-pair-window.test.ts b/mobile/src/session/mobile-native-chat-pair-window.test.ts new file mode 100644 index 000000000000..8c6b908cadc6 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-pair-window.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest' +import { createNativeChatMerger, replaceList } from '../../../src/shared/native-chat-merge' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { applyMobileNativeChatStreamFrame } from './mobile-native-chat-stream-frame' + +function row(id: string, role: NativeChatMessage['role'] = 'assistant'): NativeChatMessage { + return { id, role, source: 'transcript', timestamp: 1, blocks: [{ type: 'text', text: id }] } +} + +function pair(id: string): NativeChatMessage[] { + return [row(`${id}:reasoning`, 'reasoning'), row(id)] +} + +describe('mobile complete-pair stream windows', () => { + it.each(['appended', 'snapshot'])( + 'keeps a pair at the %s cutoff without moving the cursor', + (type) => { + const merger = createNativeChatMerger() + const base = [ + ...pair('opencode:oldest'), + ...Array.from({ length: 38 }, (_, i) => row(`m${i}`)) + ] + replaceList(merger, base) + const result = applyMobileNativeChatStreamFrame({ + merger, + frame: { + type, + messages: type === 'snapshot' ? [...base.slice(-1), row('latest')] : [row('latest')], + hasMore: true, + beforeOffset: 1 + }, + limit: 40, + replaceSnapshot: false + }) + expect(result).toEqual({ kind: 'messages', messages: [...base, row('latest')] }) + expect(merger.indexById.get('opencode:oldest:reasoning')).toBe(0) + } + ) + + it('invalidates the cursor when the oldest complete pair leaves', () => { + const merger = createNativeChatMerger() + const base = [...pair('opencode:oldest'), ...Array.from({ length: 39 }, (_, i) => row(`m${i}`))] + replaceList(merger, base) + const result = applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'appended', messages: [row('latest')] }, + limit: 40, + replaceSnapshot: false + }) + expect(result).toEqual({ + kind: 'messages', + messages: [...base.slice(2), row('latest')], + cursorInvalidated: true + }) + expect(merger.indexById.has('opencode:oldest')).toBe(false) + expect(merger.indexById.has('opencode:oldest:reasoning')).toBe(false) + }) + + it('retains a host page with an extra pair row across a user append followed by a pair append', () => { + const merger = createNativeChatMerger() + const base = [ + ...pair('opencode:first'), + ...Array.from({ length: 13 }, (_, i) => [ + row(`user-${i}`, 'user'), + ...pair(`opencode:${i}`) + ]).flat() + ] + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'snapshot', messages: base, hasMore: true, beforeOffset: 123 }, + limit: 40, + replaceSnapshot: true + }) + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'appended', messages: [row('latest-user', 'user')] }, + limit: 40, + replaceSnapshot: false + }) + const result = applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'appended', messages: pair('opencode:latest') }, + limit: 40, + replaceSnapshot: false + }) + expect(result).toMatchObject({ kind: 'messages', cursorInvalidated: true }) + expect(merger.list).toHaveLength(41) + expect(merger.list.slice(0, 2)).toEqual(pair('opencode:0')) + expect(merger.list.slice(-2)).toEqual(pair('opencode:latest')) + }) + + it('keeps legacy peers with independent reasoning IDs at the raw row limit', () => { + const merger = createNativeChatMerger() + replaceList(merger, [row('claude-thinking', 'reasoning'), row('claude-answer')]) + const result = applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'appended', messages: [row('latest')] }, + limit: 2, + replaceSnapshot: false + }) + expect(result).toEqual({ + kind: 'messages', + messages: [row('claude-answer'), row('latest')], + cursorInvalidated: true + }) + expect(merger.list).toHaveLength(2) + }) +}) diff --git a/src/shared/native-chat-merge-pairs.test.ts b/src/shared/native-chat-merge-pairs.test.ts new file mode 100644 index 000000000000..a16c32eb04a6 --- /dev/null +++ b/src/shared/native-chat-merge-pairs.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest' +import { + applyAppend, + boundNativeChatWindow, + createNativeChatMerger, + replaceList +} from './native-chat-merge' +import type { NativeChatMessage } from './native-chat-types' + +function row(id: string, overrides: Partial = {}): NativeChatMessage { + return { + id, + role: 'assistant', + source: 'transcript', + timestamp: 1, + blocks: [{ type: 'text', text: id }], + ...overrides + } +} + +function pair(id: string): NativeChatMessage[] { + return [row(`${id}:reasoning`, { role: 'reasoning' }), row(id)] +} + +describe('native chat complete-pair windows', () => { + it.each(['msg-provider', 'opencode:msg-provider'])('keeps the cutoff pair for %s', (id) => { + const messages = [row('older'), ...pair(id), row('latest')] + expect(boundNativeChatWindow(messages, 2)).toEqual(messages.slice(1)) + expect(messages.map((message) => message.id)).toEqual([ + 'older', + `${id}:reasoning`, + id, + 'latest' + ]) + }) + + it('keeps the newest complete pair even at limit 1', () => { + const messages = [...pair('older'), ...pair('latest')] + expect(boundNativeChatWindow(messages, 1)).toEqual(messages.slice(2)) + }) + + it('does not group unrelated reasoning, suffixes, or user rows', () => { + const unrelated = [row('thinking', { role: 'reasoning' }), row('answer'), row('latest')] + expect(boundNativeChatWindow(unrelated, 2)).toEqual(unrelated.slice(1)) + const suffixOnly = [row('answer:reasoning'), row('answer'), row('latest')] + expect(boundNativeChatWindow(suffixOnly, 2)).toEqual(suffixOnly.slice(1)) + const user = [row('prompt:reasoning', { role: 'reasoning' }), row('prompt', { role: 'user' })] + expect(boundNativeChatWindow(user, 1)).toEqual(user.slice(1)) + }) + + it('keeps ordering, source precedence and cached indexes after paired trims', () => { + const merger = createNativeChatMerger() + const [reasoning, answer] = pair('opencode:first') + if (!reasoning || !answer) { + throw new Error('Expected a pair') + } + replaceList(merger, [row('older'), reasoning, { ...answer, source: 'hook' }]) + const authoritative = { ...answer, blocks: [{ type: 'text' as const, text: 'final answer' }] } + const newest = row('newest') + expect(applyAppend(merger, [authoritative, newest], 2)).toEqual([ + reasoning, + authoritative, + newest + ]) + expect([...merger.indexById]).toEqual([ + [reasoning.id, 0], + [answer.id, 1], + ['newest', 2] + ]) + expect(applyAppend(merger, [{ ...answer, source: 'scrape' }], 2)[1]).toBe(authoritative) + expect(applyAppend(merger, pair('opencode:next'), 2).map((message) => message.id)).toEqual([ + 'opencode:next:reasoning', + 'opencode:next' + ]) + expect([...merger.indexById]).toEqual([ + ['opencode:next:reasoning', 0], + ['opencode:next', 1] + ]) + }) + + it.each([1, 40, 2000])('stays at most one row above limit %i across long appends', (limit) => { + const merger = createNativeChatMerger() + for (let index = 0; index < limit + 20; index++) { + const messages = applyAppend( + merger, + index % 2 === 0 ? pair(`opencode:${index}`) : [row(`user-${index}`, { role: 'user' })], + limit + ) + expect(messages.length).toBeLessThanOrEqual(limit + 1) + expect(messages[0]?.role).not.toBe('assistant') + expect(merger.indexById.size).toBe(messages.length) + } + for (let at = 0; at < merger.list.length; at++) { + const message = merger.list[at] + expect(merger.indexById.get(message.id)).toBe(at) + if (message.role === 'reasoning') { + expect(merger.list[at + 1]?.id).toBe(message.id.slice(0, -':reasoning'.length)) + } else if (message.role === 'assistant') { + expect(merger.list[at - 1]?.id).toBe(`${message.id}:reasoning`) + } + } + }) +}) diff --git a/src/shared/native-chat-merge.ts b/src/shared/native-chat-merge.ts index a2e12ad8302b..5fc50a86a3c1 100644 --- a/src/shared/native-chat-merge.ts +++ b/src/shared/native-chat-merge.ts @@ -1,6 +1,7 @@ // Pure id-dedup and windowing for both desktop and mobile native-chat streams. import { + nativeChatSemanticRowId, NATIVE_CHAT_SOURCE_PRIORITY, type NativeChatMessage, type NativeChatSource @@ -35,10 +36,8 @@ export function mergeNativeChatMessages( return mergeNativeChatMessagesWith(existing, incoming, NATIVE_CHAT_SOURCE_PRIORITY) } -/** Cap a message list to its most-recent `limit` entries. The base read is - * already windowed; this keeps the live-append tail bounded to the same window - * so a long run can't grow the list without limit. A non-positive limit means - * "no cap". Returns the input reference when no trim is needed. */ +/** Keep the recent tail, retaining one extra row if the cutoff splits a provider's + * reasoning/answer pair. A non-positive limit means no cap. */ export function boundNativeChatWindow( messages: readonly NativeChatMessage[], limit: number @@ -46,7 +45,17 @@ export function boundNativeChatWindow( if (limit <= 0 || messages.length <= limit) { return messages as NativeChatMessage[] } - return messages.slice(messages.length - limit) + let start = messages.length - limit + const preceding = messages[start - 1] + const first = messages[start] + if ( + preceding?.role === 'reasoning' && + first?.role === 'assistant' && + nativeChatSemanticRowId(preceding) === first.id + ) { + start -= 1 + } + return messages.slice(start) } /** Stateful id-dedup merger that caches the id→index map across appends so a diff --git a/src/shared/native-chat-transcript-projection.ts b/src/shared/native-chat-transcript-projection.ts index 82f6a2c0a12d..3ab78007edb0 100644 --- a/src/shared/native-chat-transcript-projection.ts +++ b/src/shared/native-chat-transcript-projection.ts @@ -7,7 +7,7 @@ // conversation: each subagent's rows are that subagent's, kept apart and folded on // their own, for the surface that shows them beside the spawn that ran it. -import type { NativeChatMessage } from './native-chat-types' +import { nativeChatSemanticRowId, type NativeChatMessage } from './native-chat-types' import { compareAgentJournalPositions } from './agent-session-journal-position' import { agentJournalItemSubagentId } from './agent-session-journal-producer' import { stripNoiseMessages } from './native-chat-noise' @@ -26,10 +26,10 @@ export function compareNativeChatMessagesByTime( return at - bt } // Split reasoning shares its provider row's key, before that row's answer. - const aReasoning = a.role === 'reasoning' && a.id.endsWith(':reasoning') - const bReasoning = b.role === 'reasoning' && b.id.endsWith(':reasoning') - const aId = aReasoning ? a.id.slice(0, -':reasoning'.length) : a.id - const bId = bReasoning ? b.id.slice(0, -':reasoning'.length) : b.id + const aId = nativeChatSemanticRowId(a) + const bId = nativeChatSemanticRowId(b) + const aReasoning = aId !== a.id + const bReasoning = bId !== b.id if (aId < bId) { return -1 } diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index db7b7024cdc1..26c33f3b0ebc 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -223,6 +223,13 @@ export type NativeChatMessage = AgentJournalProducerLinkage & { foldedJournalPosition?: AgentJournalPosition } +/** Split reasoning and its answer share the provider's row identity. */ +export function nativeChatSemanticRowId(message: NativeChatMessage): string { + return message.role === 'reasoning' && message.id.endsWith(':reasoning') + ? message.id.slice(0, -':reasoning'.length) + : message.id +} + export const NATIVE_CHAT_TURN_LIFECYCLE_STATES = ['working', 'completed', 'interrupted'] as const export type NativeChatTurnLifecycleState = (typeof NATIVE_CHAT_TURN_LIFECYCLE_STATES)[number] From ae502c5391fe29ada203af3fa2b0e53ba549e6d1 Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 3 Oct 2026 04:20:28 -0700 Subject: [PATCH 13/16] Bound OpenCode transcript RPC pages and present omission notices --- .../session/MobileNativeChatMessage.test.tsx | 74 +++++++++++++++++++ .../src/session/MobileNativeChatMessage.tsx | 11 +++ .../mobile-native-chat-message-styles.ts | 5 ++ .../transcript-opencode-sqlite-query.test.ts | 22 ++++-- .../transcript-opencode2-sqlite.test.ts | 11 ++- 5 files changed, 114 insertions(+), 9 deletions(-) create mode 100644 mobile/src/session/MobileNativeChatMessage.test.tsx diff --git a/mobile/src/session/MobileNativeChatMessage.test.tsx b/mobile/src/session/MobileNativeChatMessage.test.tsx new file mode 100644 index 000000000000..ae181631f90a --- /dev/null +++ b/mobile/src/session/MobileNativeChatMessage.test.tsx @@ -0,0 +1,74 @@ +import { createElement } from 'react' +import { act, create } from 'react-test-renderer' +import { describe, expect, it, vi } from 'vitest' +import { AGENT_SESSION_HOST_STATUS_COPY } from '../../../src/shared/agent-session-host-status-rows' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { colors } from '../theme/mobile-theme' +import { MobileNativeChatMessage } from './MobileNativeChatMessage' + +vi.mock('react-native', () => ({ + Image: 'Image', + Text: 'Text', + View: 'View', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 } +})) +vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' })) +vi.mock('./MobileNativeChatToolRun', () => ({ ToolRun: 'ToolRun' })) +vi.mock('./MobileNativeChatTurnStatus', () => ({ MobileNativeChatTurnStatus: 'TurnStatus' })) + +function message( + role: NativeChatMessage['role'], + presentation?: 'history-item-too-large' +): NativeChatMessage { + return { + id: 'notice', + role, + timestamp: 1, + blocks: [{ type: 'text', text: 'provider fallback', presentation }] + } +} + +describe('MobileNativeChatMessage host notices', () => { + it.each(['system', 'user'] as const)( + 'shows a selectable muted notice for a %s row without a markdown answer', + async (role) => { + const renderer = create(createElement('View')) + try { + await act(async () => + renderer.update( + createElement(MobileNativeChatMessage, { + message: message(role, 'history-item-too-large'), + fontScale: 1.5 + }) + ) + ) + expect( + renderer.root.findAll((node) => String(node.type) === 'MobileMarkdown') + ).toHaveLength(0) + const text = renderer.root.find((node) => String(node.type) === 'Text') + expect(text.props.children).toBe(AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large']) + expect(text.props.selectable).toBe(true) + expect(Object.assign({}, ...text.props.style)).toMatchObject({ + color: colors.textMuted, + fontSize: 25.5 + }) + } finally { + act(() => renderer.unmount()) + } + } + ) + + it('preserves an ordinary assistant answer without treating its text as a host notice', async () => { + const renderer = create(createElement('View')) + try { + await act(async () => + renderer.update(createElement(MobileNativeChatMessage, { message: message('assistant') })) + ) + expect( + renderer.root.find((node) => String(node.type) === 'MobileMarkdown').props.content + ).toBe('provider fallback') + } finally { + act(() => renderer.unmount()) + } + }) +}) diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index 9c894e3cd7fc..40635166281f 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -4,6 +4,10 @@ import { Image, Text as NativeText, View } from 'react-native' import { splitNativeChatBlocks } from '../../../src/shared/native-chat-tool-fold' import { selectActiveToolCall } from '../../../src/shared/native-chat-tool-activity' import { isImageRefBlock, isTextBlock } from '../../../src/shared/native-chat-types' +import { + AGENT_SESSION_HOST_STATUS_COPY, + isAgentSessionHostStatusPresentation +} from '../../../src/shared/agent-session-host-status-rows' import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types' import { MobileMarkdown } from '../components/MobileMarkdown' import { MobileNativeChatTurnStatus } from './MobileNativeChatTurnStatus' @@ -24,6 +28,13 @@ function Prose({ onOpenFile?: (relativePath: string) => void }): React.JSX.Element | null { if (isTextBlock(block)) { + if (isAgentSessionHostStatusPresentation(block.presentation)) { + return ( + + {AGENT_SESSION_HOST_STATUS_COPY[block.presentation]} + + ) + } // Inverted (user) bubbles use a fixed dark-on-light text rather than the // markdown renderer's light-on-dark palette. if (invert) { diff --git a/mobile/src/session/mobile-native-chat-message-styles.ts b/mobile/src/session/mobile-native-chat-message-styles.ts index 51ff9ab1f36c..225d664e65e4 100644 --- a/mobile/src/session/mobile-native-chat-message-styles.ts +++ b/mobile/src/session/mobile-native-chat-message-styles.ts @@ -29,6 +29,11 @@ export const styles = StyleSheet.create({ lineHeight: TEXT_SIZE + 6, fontWeight: '500' }, + hostNotice: { + color: colors.textMuted, + fontSize: TEXT_SIZE, + lineHeight: TEXT_SIZE + 6 + }, reasoning: { opacity: 0.7 }, diff --git a/src/main/native-chat/transcript-opencode-sqlite-query.test.ts b/src/main/native-chat/transcript-opencode-sqlite-query.test.ts index d82613650729..6e500b7b9748 100644 --- a/src/main/native-chat/transcript-opencode-sqlite-query.test.ts +++ b/src/main/native-chat/transcript-opencode-sqlite-query.test.ts @@ -115,7 +115,11 @@ describe('readOpenCodeTranscriptPage', () => { 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'] } + { + type: 'text', + text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'], + presentation: 'history-item-too-large' + } ]) expect(page?.items[0]?.rowid).toBe(messageRowid(db, 'msg-2')) expect(readOpenCodeTranscriptPage(args)?.items[0]?.fingerprint).toBe( @@ -152,13 +156,17 @@ describe('readOpenCodeTranscriptPage', () => { 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'] } + const page = readOpenCodeTranscriptPage({ dbPath: path, sessionId: 'ses-1', limit: 1 }) + expect(page?.items.map((item) => item.message)).toMatchObject([ + { id: 'msg', role: 'user', blocks: [{ type: 'text', text: 'see this' }] }, + { + id: 'msg:omission', + role: 'system', + blocks: [{ type: 'text', presentation: 'history-item-too-large' }] + } ]) + expect(page?.items[0]?.rowid).toBe(page?.items[1]?.rowid) + expect(page?.items[0]?.message.transcriptOffset).toBe(page?.items[1]?.message.transcriptOffset) }) it('returns null when the session row does not exist', () => { diff --git a/src/main/native-chat/transcript-opencode2-sqlite.test.ts b/src/main/native-chat/transcript-opencode2-sqlite.test.ts index 333788c82bbd..7ddd653071d1 100644 --- a/src/main/native-chat/transcript-opencode2-sqlite.test.ts +++ b/src/main/native-chat/transcript-opencode2-sqlite.test.ts @@ -129,9 +129,16 @@ describe('OpenCode 2 native transcript', () => { expect(page?.items[0]).toMatchObject({ rowid: 2, message: { - role: 'user', + role: 'system', timestamp: 1, - blocks: [{ type: 'text', text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'] }] + transcriptOffset: 2, + blocks: [ + { + type: 'text', + text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'], + presentation: 'history-item-too-large' + } + ] } }) expect(readOpenCodeTranscriptPage(args)?.items[0]?.fingerprint).toBe( From d66ba4ff6079fab12a9fb0acb01b8295544d21f8 Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 3 Oct 2026 04:21:48 -0700 Subject: [PATCH 14/16] Bound OpenCode transcript RPC pages and present omission notices --- .../mobile-native-chat-pair-window.test.ts | 26 +++++++++++++++++++ ...ive-chat-opencode-wire-page-budget.test.ts | 2 ++ 2 files changed, 28 insertions(+) diff --git a/mobile/src/session/mobile-native-chat-pair-window.test.ts b/mobile/src/session/mobile-native-chat-pair-window.test.ts index 8c6b908cadc6..fb2bd15605c2 100644 --- a/mobile/src/session/mobile-native-chat-pair-window.test.ts +++ b/mobile/src/session/mobile-native-chat-pair-window.test.ts @@ -37,6 +37,32 @@ describe('mobile complete-pair stream windows', () => { } ) + it.each(['appended', 'snapshot'])( + 'keeps reasoning, answer and omission from one raw row at the %s cutoff', + (type) => { + const group = [...pair('opencode:oldest'), row('opencode:oldest:omission', 'system')].map( + (message) => ({ ...message, transcriptOffset: 7 }) + ) + const base = [...group, ...Array.from({ length: 38 }, (_, i) => row(`m${i}`))] + const merger = createNativeChatMerger() + replaceList(merger, base) + const result = applyMobileNativeChatStreamFrame({ + merger, + frame: { + type, + messages: type === 'snapshot' ? [...base.slice(-1), row('latest')] : [row('latest')], + hasMore: true, + beforeOffset: 7 + }, + limit: 40, + replaceSnapshot: false + }) + expect(result).toEqual({ kind: 'messages', messages: [...base, row('latest')] }) + expect(merger.list.slice(0, 3)).toEqual(group) + expect(merger.list).toHaveLength(42) + } + ) + it('invalidates the cursor when the oldest complete pair leaves', () => { const merger = createNativeChatMerger() const base = [...pair('opencode:oldest'), ...Array.from({ length: 39 }, (_, i) => row(`m${i}`))] diff --git a/src/main/runtime/rpc/methods/native-chat-opencode-wire-page-budget.test.ts b/src/main/runtime/rpc/methods/native-chat-opencode-wire-page-budget.test.ts index c37eedd161f7..ec9d872d5d34 100644 --- a/src/main/runtime/rpc/methods/native-chat-opencode-wire-page-budget.test.ts +++ b/src/main/runtime/rpc/methods/native-chat-opencode-wire-page-budget.test.ts @@ -121,6 +121,8 @@ describe.each(['v1', 'v2'] as const)('%s byte-bounded semantic pages', (version) const dbPath = denseFixture(version) const page = readOpenCodeTranscriptPage({ dbPath, sessionId: 'session', limit: 2400 })! expect(page.items).toHaveLength(2401) + expect(page.items.every((item) => Number.isFinite(item.message.transcriptOffset))).toBe(true) + expect(page.items.every((item) => item.message.transcriptOffset === item.rowid)).toBe(true) const method = buildRegistry(NATIVE_CHAT_METHODS).get('nativeChat.subscribe')! if (!isStreamingMethod(method)) { throw new Error('Expected a subscription') From f63bf8e59c9604b7ea6151f005a35ca0513a42aa Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 3 Oct 2026 04:33:11 -0700 Subject: [PATCH 15/16] Bound OpenCode transcript RPC pages and present omission notices --- .../session/MobileNativeChatMessage.test.ts | 35 +++++++++ .../session/MobileNativeChatMessage.test.tsx | 74 ------------------- 2 files changed, 35 insertions(+), 74 deletions(-) delete mode 100644 mobile/src/session/MobileNativeChatMessage.test.tsx diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts index 67e901328667..c8193641fefb 100644 --- a/mobile/src/session/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.test.ts @@ -3,6 +3,8 @@ import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'rea import { afterEach, describe, expect, it, vi } from 'vitest' import { MAX_TOOL_DETAIL_LENGTH } from '../../../src/shared/native-chat-tool-summary' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { AGENT_SESSION_HOST_STATUS_COPY } from '../../../src/shared/agent-session-host-status-rows' +import { colors } from '../theme/mobile-theme' vi.mock('react-native', async () => { const React = await import('react') @@ -60,6 +62,7 @@ describe('MobileNativeChatMessage', () => { function render( message: NativeChatMessage, props: { + fontScale?: number toolsExpanded?: boolean structuredActivityUi?: boolean activeTurnIsWorking?: boolean @@ -81,6 +84,38 @@ describe('MobileNativeChatMessage', () => { const textIn = (node: ReactTestInstance): string[] => node.findAllByType('Text' as never).map((text) => String(text.children.join(''))) + it.each(['system', 'user'] as const)( + 'renders a %s host notice as selectable muted text rather than a markdown answer', + (role) => { + const tree = render( + { + id: 'notice', + role, + timestamp: 1, + blocks: [ + { type: 'text', text: 'provider fallback', presentation: 'history-item-too-large' } + ] + }, + { fontScale: 1.5 } + ) + expect(tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')).toHaveLength(0) + const text = tree.root.find((node) => String(node.type) === 'Text') + expect(text.props.children).toBe(AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large']) + expect(text.props.selectable).toBe(true) + expect(Object.assign({}, ...text.props.style)).toMatchObject({ + color: colors.textMuted, + fontSize: 25.5 + }) + } + ) + + it('preserves an ordinary assistant answer without interpreting its text as a host notice', () => { + const tree = render(toolMessage([{ type: 'text', text: 'provider fallback' }])) + expect(tree.root.find((node) => String(node.type) === 'MobileMarkdown').props.content).toBe( + 'provider fallback' + ) + }) + it('renders a loadable preview URI as an image thumbnail', () => { const tree = render(userMessage([{ type: 'image-ref', url: 'file:///a.jpg', alt: 'a photo' }])) const image = tree.root.findByType('Image' as never) diff --git a/mobile/src/session/MobileNativeChatMessage.test.tsx b/mobile/src/session/MobileNativeChatMessage.test.tsx deleted file mode 100644 index ae181631f90a..000000000000 --- a/mobile/src/session/MobileNativeChatMessage.test.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { createElement } from 'react' -import { act, create } from 'react-test-renderer' -import { describe, expect, it, vi } from 'vitest' -import { AGENT_SESSION_HOST_STATUS_COPY } from '../../../src/shared/agent-session-host-status-rows' -import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { colors } from '../theme/mobile-theme' -import { MobileNativeChatMessage } from './MobileNativeChatMessage' - -vi.mock('react-native', () => ({ - Image: 'Image', - Text: 'Text', - View: 'View', - StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 } -})) -vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' })) -vi.mock('./MobileNativeChatToolRun', () => ({ ToolRun: 'ToolRun' })) -vi.mock('./MobileNativeChatTurnStatus', () => ({ MobileNativeChatTurnStatus: 'TurnStatus' })) - -function message( - role: NativeChatMessage['role'], - presentation?: 'history-item-too-large' -): NativeChatMessage { - return { - id: 'notice', - role, - timestamp: 1, - blocks: [{ type: 'text', text: 'provider fallback', presentation }] - } -} - -describe('MobileNativeChatMessage host notices', () => { - it.each(['system', 'user'] as const)( - 'shows a selectable muted notice for a %s row without a markdown answer', - async (role) => { - const renderer = create(createElement('View')) - try { - await act(async () => - renderer.update( - createElement(MobileNativeChatMessage, { - message: message(role, 'history-item-too-large'), - fontScale: 1.5 - }) - ) - ) - expect( - renderer.root.findAll((node) => String(node.type) === 'MobileMarkdown') - ).toHaveLength(0) - const text = renderer.root.find((node) => String(node.type) === 'Text') - expect(text.props.children).toBe(AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large']) - expect(text.props.selectable).toBe(true) - expect(Object.assign({}, ...text.props.style)).toMatchObject({ - color: colors.textMuted, - fontSize: 25.5 - }) - } finally { - act(() => renderer.unmount()) - } - } - ) - - it('preserves an ordinary assistant answer without treating its text as a host notice', async () => { - const renderer = create(createElement('View')) - try { - await act(async () => - renderer.update(createElement(MobileNativeChatMessage, { message: message('assistant') })) - ) - expect( - renderer.root.find((node) => String(node.type) === 'MobileMarkdown').props.content - ).toBe('provider fallback') - } finally { - act(() => renderer.unmount()) - } - }) -}) From 1c33cdc02c41ed16fd363bba4f77d00085135ca5 Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 3 Oct 2026 05:38:06 -0700 Subject: [PATCH 16/16] Update native worker oversized-history notice contract --- .../session-scanner-opencode-native-worker.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 index 9e8511967f2e..9b41f700b245 100644 --- a/src/main/ai-vault/session-scanner-opencode-native-worker.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-native-worker.test.ts @@ -73,8 +73,13 @@ describe('OpenCode reads through the production shared worker', () => { throw new Error('Expected the bounded native page') } expect(page.items.map((item) => item.rowid)).toEqual([2, 3]) + expect(page.items[0]?.message.role).toBe('system') expect(page.items[0]?.message.blocks).toEqual([ - { type: 'text', text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'] } + { + type: 'text', + text: AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large'], + presentation: 'history-item-too-large' + } ]) const repeat = await client.readNativeChat(args) expect(repeat).toEqual(page)