From 58fc413a9670c512c39803fc8e2a1b8dfc8de8ec Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sun, 30 Aug 2026 21:19:09 -0400 Subject: [PATCH] fix(ci): restore remote transcript and file compatibility --- .../browser-screencast-lifecycle.test.ts | 12 +- src/main/index.ts | 2 +- .../native-chat/transcript-watch-engine.ts | 56 ++++-- .../transcript-watch-source-access.ts | 4 +- src/main/runtime/orca-runtime-files.ts | 8 +- .../native-chat-rpc-message-sanitizer.ts | 123 +++++++++++++ src/main/runtime/rpc/methods/native-chat.ts | 170 ++---------------- 7 files changed, 199 insertions(+), 176 deletions(-) create mode 100644 src/main/runtime/rpc/methods/native-chat-rpc-message-sanitizer.ts diff --git a/src/main/browser/browser-screencast-lifecycle.test.ts b/src/main/browser/browser-screencast-lifecycle.test.ts index c97facbebc8..9d6ffe385cc 100644 --- a/src/main/browser/browser-screencast-lifecycle.test.ts +++ b/src/main/browser/browser-screencast-lifecycle.test.ts @@ -7,6 +7,12 @@ import { startBrowserScreencast } from './browser-screencast-stream' function createWebContents() { let attached = false + const webContents = new EventEmitter() as EventEmitter & { + isDestroyed: ReturnType + getURL: ReturnType + getTitle: ReturnType + debugger: typeof debuggerApi + } const debuggerApi = new EventEmitter() as EventEmitter & { isAttached: ReturnType attach: ReturnType @@ -21,7 +27,11 @@ function createWebContents() { attached = false }) debuggerApi.sendCommand = vi.fn(async () => ({})) - return { isDestroyed: vi.fn(() => false), debugger: debuggerApi } + webContents.isDestroyed = vi.fn(() => false) + webContents.getURL = vi.fn(() => 'https://example.test/') + webContents.getTitle = vi.fn(() => 'Example') + webContents.debugger = debuggerApi + return webContents } describe('browser screencast lifecycle', () => { diff --git a/src/main/index.ts b/src/main/index.ts index 94931d16803..0db09d69435 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -757,7 +757,6 @@ if (app.isPackaged && process.platform !== 'win32') { } configureDevUserDataPath(is.dev) configureOrcaUserDataPathEnv() -installServeSupervisorDisconnectQuit(isServeMode) // Why: just past createMainWindow's 10s ready-to-show fallback, so a window revealed that way still gets its tray icon. const TRAY_CREATE_FALLBACK_MS = 12_000 @@ -938,6 +937,7 @@ if (hasSingleInstanceLock) { // timing, in particular not the pre-ready Keychain service-name resolution and // the app.setName ordering the userData captures below depend on. setAppEnvironment(new ElectronAppEnvironment()) + installServeSupervisorDisconnectQuit(isServeMode) setSecretStore(new ElectronSecretStore()) // Why at process level, not per-window: pty.ts registers against injected surfaces so // it can load without electron, and an Electron main process always has ipcMain — diff --git a/src/main/native-chat/transcript-watch-engine.ts b/src/main/native-chat/transcript-watch-engine.ts index b0ebbeaebfc..7ec9736b2e9 100644 --- a/src/main/native-chat/transcript-watch-engine.ts +++ b/src/main/native-chat/transcript-watch-engine.ts @@ -1,10 +1,5 @@ import type { NativeChatMessage, NativeChatTurnLifecycle } from '../../shared/native-chat-types' -import { - boundaryFingerprint, - readTranscriptFileVersion, - transcriptFileVersionChanged, - type TranscriptFileVersion -} from './transcript-file-version' +import { transcriptFileVersionChanged, type TranscriptFileVersion } from './transcript-file-version' import { createIncrementalTranscriptState, readIncrementalTranscriptMessages, @@ -19,9 +14,14 @@ import type { SubscribeNativeChatTranscriptArgs } from './transcript-watch-contract' import { createTranscriptWatchScheduler } from './transcript-watch-scheduler' +import { + createSourceAwareTranscriptNativeWatcher, + probeTranscriptWatchFile, + readTranscriptWatchBoundary, + readTranscriptWatchFileVersion +} from './transcript-watch-source-access' import { WslTranscriptFsError } from './wsl-transcript-fs-gate' import { - createRunningGuardedTranscriptNativeWatcher, isWslTranscriptWatcherPath, transcriptWatcherPathIsRunning } from './wsl-transcript-watcher-running-guard' @@ -35,8 +35,15 @@ export async function installTranscriptWatcher( args: SubscribeNativeChatTranscriptArgs, signal?: AbortSignal ): Promise { + const fileSource = args.fileSource const isWslPath = isWslTranscriptWatcherPath(filePath) - if (!(await transcriptWatcherPathIsInstallable(filePath, signal))) { + if (fileSource) { + try { + await probeTranscriptWatchFile(filePath, fileSource, signal) + } catch { + return null + } + } else if (!(await transcriptWatcherPathIsInstallable(filePath, signal))) { return null } const { onAppend, onInitialSnapshot, onReplace, initialLimit } = args @@ -82,7 +89,7 @@ export async function installTranscriptWatcher( (nextLifecycle) => { lifecycle = nextLifecycle }, - gateAbort.signal + { fileSource, signal: gateAbort.signal } ) if (!closed && (remaining.length > 0 || lifecycle)) { onAppend(remaining, lifecycle) @@ -90,8 +97,17 @@ export async function installTranscriptWatcher( } async function finishSuccessfulDrain(startVersion: TranscriptFileVersion): Promise { - watchedBoundary = await boundaryFingerprint(filePath, state.offset, gateAbort.signal) - const completedVersion = await readTranscriptFileVersion(filePath, gateAbort.signal) + watchedBoundary = await readTranscriptWatchBoundary( + filePath, + state.offset, + fileSource, + gateAbort.signal + ) + const completedVersion = await readTranscriptWatchFileVersion( + filePath, + fileSource, + gateAbort.signal + ) if (transcriptFileVersionChanged(completedVersion, startVersion)) { // Why: a write racing this drain needs another pass even when the reader // happened to reach its new EOF; timestamp-only rewrites may need replace. @@ -113,8 +129,13 @@ export async function installTranscriptWatcher( } async function drainOnce(): Promise { - const current = await readTranscriptFileVersion(filePath, gateAbort.signal) - const currentBoundary = await boundaryFingerprint(filePath, state.offset, gateAbort.signal) + const current = await readTranscriptWatchFileVersion(filePath, fileSource, gateAbort.signal) + const currentBoundary = await readTranscriptWatchBoundary( + filePath, + state.offset, + fileSource, + gateAbort.signal + ) if (closed) { return } @@ -149,6 +170,7 @@ export async function installTranscriptWatcher( false, undefined, decodeLifecycle, + fileSource, gateAbort.signal ) : null @@ -178,6 +200,7 @@ export async function installTranscriptWatcher( false, undefined, decodeLifecycle, + fileSource, gateAbort.signal ) : null @@ -208,7 +231,7 @@ export async function installTranscriptWatcher( (nextLifecycle) => { lifecycle = nextLifecycle }, - gateAbort.signal + { fileSource, signal: gateAbort.signal } ) if (closed) { return @@ -271,7 +294,7 @@ export async function installTranscriptWatcher( return } try { - const current = await readTranscriptFileVersion(filePath, gateAbort.signal) + const current = await readTranscriptWatchFileVersion(filePath, fileSource, gateAbort.signal) if (closed) { return } @@ -292,8 +315,9 @@ export async function installTranscriptWatcher( drain: () => void drain(), reconcile: reconcileKnownRunning }) - const nativeWatcher = createRunningGuardedTranscriptNativeWatcher( + const nativeWatcher = createSourceAwareTranscriptNativeWatcher( filePath, + fileSource, () => scheduler.scheduleEventDrain(), scheduleRotationRetry ) diff --git a/src/main/native-chat/transcript-watch-source-access.ts b/src/main/native-chat/transcript-watch-source-access.ts index d2b21eb86b4..0ebdee6be10 100644 --- a/src/main/native-chat/transcript-watch-source-access.ts +++ b/src/main/native-chat/transcript-watch-source-access.ts @@ -1,4 +1,4 @@ -import { createTranscriptNativeWatcher } from './transcript-native-watcher' +import { createRunningGuardedTranscriptNativeWatcher } from './wsl-transcript-watcher-running-guard' import { readTranscriptBoundaryFingerprint } from './transcript-boundary-fingerprint' import type { TranscriptFileSource } from './transcript-file-source' import { @@ -49,7 +49,7 @@ export function createSourceAwareTranscriptNativeWatcher( onRetry: () => void ) { return !fileSource || fileSource.supportsNativeWatch - ? createTranscriptNativeWatcher(filePath, onEvent, onRetry) + ? createRunningGuardedTranscriptNativeWatcher(filePath, onEvent, onRetry) : { bind: () => false, invalidate: () => {}, diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index 00ea355ac74..f05cd98aef1 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -84,6 +84,7 @@ import { SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE } from '../providers/ssh-filesystem-dispatch' import type { FileReadLimits, FileStat, IFilesystemProvider } from '../providers/types' +import { readSshFileExplorerChunk } from './ssh-file-explorer-chunk-read' import { FileReadCapExceededError } from '../ssh/ssh-filesystem-stream-reader' import { isWatcherProcessFailure, @@ -1835,10 +1836,9 @@ export class RuntimeFileCommands { if (fileStat.type === 'directory') { throw new Error('Cannot download a directory') } - if (!provider.readFileChunk) { - throw new Error('SSH runtime chunked download requires an updated Orca host') - } - return provider.readFileChunk(target.path, offset, length) + return provider.readFileChunk + ? provider.readFileChunk(target.path, offset, length) + : readSshFileExplorerChunk(provider, target.path, fileStat.size, offset, length) } const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) diff --git a/src/main/runtime/rpc/methods/native-chat-rpc-message-sanitizer.ts b/src/main/runtime/rpc/methods/native-chat-rpc-message-sanitizer.ts new file mode 100644 index 00000000000..13ec9ead8a5 --- /dev/null +++ b/src/main/runtime/rpc/methods/native-chat-rpc-message-sanitizer.ts @@ -0,0 +1,123 @@ +import type { NativeChatBlock, NativeChatMessage } from '../../../../shared/native-chat-types' +import type { RpcContext } from '../core' +import { sanitizeNativeChatRpcImageBlock } from './native-chat-rpc-image-block' + +export const MOBILE_NATIVE_CHAT_DEFAULT_WINDOW = 40 +export const MOBILE_NATIVE_CHAT_MAX_WINDOW = 2000 +const MOBILE_BLOCK_CHAR_CAP = 4000 +const MOBILE_TEXT_BLOCK_CHAR_CAP = 64_000 +const MOBILE_TOOL_INPUT_ITEMS_CAP = 20 +const MOBILE_TOOL_INPUT_NODE_CAP = 100 +const TRUNCATION_MARKER = '\n… (truncated)' + +function clip(text: string, cap: number): string { + return text.length > cap ? text.slice(0, cap) + TRUNCATION_MARKER : text +} + +function sanitizeBlock( + block: NativeChatBlock, + clientKind: RpcContext['clientKind'] +): NativeChatBlock { + if (block.type === 'image-ref') { + return sanitizeNativeChatRpcImageBlock(block) + } + if (clientKind !== 'mobile') { + return block + } + if (block.type === 'text') { + return block.text.length > MOBILE_TEXT_BLOCK_CHAR_CAP + ? { ...block, text: clip(block.text, MOBILE_TEXT_BLOCK_CHAR_CAP) } + : block + } + if (block.type === 'tool-result') { + return block.output.length > MOBILE_BLOCK_CHAR_CAP + ? { ...block, output: clip(block.output, MOBILE_BLOCK_CHAR_CAP) } + : block + } + if (block.type === 'tool-call') { + const budget = { remaining: MOBILE_BLOCK_CHAR_CAP, nodes: MOBILE_TOOL_INPUT_NODE_CAP } + return { ...block, input: sanitizeToolInput(block.input, budget, 0) } + } + return block +} + +function sanitizeToolInput( + value: unknown, + budget: { remaining: number; nodes: number }, + depth: number +): unknown { + budget.nodes-- + if (budget.nodes < 0 || budget.remaining <= 0) { + return '… (truncated)' + } + if (typeof value === 'string') { + const length = Math.min(value.length, budget.remaining) + budget.remaining -= length + return length < value.length ? `${value.slice(0, length)}… (truncated)` : value + } + if (!value || typeof value !== 'object' || depth >= 5) { + return value && typeof value === 'object' ? '… (truncated)' : value + } + if (Array.isArray(value)) { + const result = value + .slice(0, MOBILE_TOOL_INPUT_ITEMS_CAP) + .map((item) => sanitizeToolInput(item, budget, depth + 1)) + if (value.length > MOBILE_TOOL_INPUT_ITEMS_CAP) { + result.push('… (truncated)') + } + return result + } + const result: Record = {} + let count = 0 + for (const key in value) { + if (!Object.hasOwn(value, key)) { + continue + } + if (count >= MOBILE_TOOL_INPUT_ITEMS_CAP || budget.remaining <= 0) { + result['…'] = 'truncated' + break + } + let boundedKey = key.slice(0, Math.min(key.length, budget.remaining, 128)) + if (Object.hasOwn(result, boundedKey)) { + boundedKey = `${boundedKey}~${count}` + } + budget.remaining -= boundedKey.length + result[boundedKey] = sanitizeToolInput( + (value as Record)[key], + budget, + depth + 1 + ) + count++ + } + return result +} + +function sanitizeMessage( + message: NativeChatMessage, + clientKind: RpcContext['clientKind'] +): NativeChatMessage { + return { ...message, blocks: message.blocks.map((block) => sanitizeBlock(block, clientKind)) } +} + +export function sanitizeAppendForClient( + messages: readonly NativeChatMessage[], + clientKind: RpcContext['clientKind'] +): NativeChatMessage[] { + return messages.map((message) => sanitizeMessage(message, clientKind)) +} + +function windowTranscript( + messages: readonly NativeChatMessage[], + limit = MOBILE_NATIVE_CHAT_DEFAULT_WINDOW +): NativeChatMessage[] { + const window = Math.min(Math.max(limit, 1), MOBILE_NATIVE_CHAT_MAX_WINDOW) + return messages.length > window ? messages.slice(-window) : messages.slice() +} + +export function windowForClient( + messages: readonly NativeChatMessage[], + clientKind: RpcContext['clientKind'], + limit = MOBILE_NATIVE_CHAT_DEFAULT_WINDOW +): NativeChatMessage[] { + return windowTranscript(messages, limit).map((message) => sanitizeMessage(message, clientKind)) +} diff --git a/src/main/runtime/rpc/methods/native-chat.ts b/src/main/runtime/rpc/methods/native-chat.ts index fac44f781ed..23465e13bde 100644 --- a/src/main/runtime/rpc/methods/native-chat.ts +++ b/src/main/runtime/rpc/methods/native-chat.ts @@ -1,18 +1,19 @@ import { z } from 'zod' -import type { - NativeChatBlock, - NativeChatMessage, - AgentType -} from '../../../../shared/native-chat-types' +import type { AgentType } from '../../../../shared/native-chat-types' import { readNativeChatTranscriptTail, subscribeNativeChatTranscript, type NativeChatTranscriptSubscription, type SubscribeNativeChatTranscriptArgs } from '../../../native-chat/transcript-watch' -import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core' +import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' import { resolveNativeChatTranscriptSource } from '../native-chat-transcript-source' -import { sanitizeNativeChatRpcImageBlock } from './native-chat-rpc-image-block' +import { + MOBILE_NATIVE_CHAT_DEFAULT_WINDOW, + MOBILE_NATIVE_CHAT_MAX_WINDOW, + sanitizeAppendForClient, + windowForClient +} from './native-chat-rpc-message-sanitizer' // Why: native chat renders an agent's own transcript (Claude/Codex JSONL). The // desktop reaches the readers via Electron IPC; mobile/web clients reach the @@ -50,6 +51,7 @@ const NativeChatSession = z // locate the file directly when the session id no longer names it (recent // Claude Code). Optional for back-compat with older clients. transcriptPath: z.string().min(1).optional(), + capabilities: z.object({ transcriptPending: z.literal(1).optional() }).optional(), worktreeId: z.string().min(1).optional(), terminal: z.string().min(1).optional(), beforeOffset: z.number().int().nonnegative().optional() @@ -62,151 +64,6 @@ const NativeChatUnsubscribe = z.object({ subscriptionId: z.string().min(1).optional() }) -// Why: a long agent session can hold thousands of turns (with full tool I/O). -// Shipping all of them over the paired connection and rendering them at once -// freezes the mobile app, so the runtime RPC windows to the most recent slice — -// the conversation tail is what the chat view shows first. The desktop IPC path -// is unaffected (it reads locally with a virtualized list). -// Small first page for a fast initial paint; the client raises `limit` to load -// older history as the user scrolls back. -const MOBILE_NATIVE_CHAT_DEFAULT_WINDOW = 40 -const MOBILE_NATIVE_CHAT_MAX_WINDOW = 2000 -// Why: a single tool result (a big file read, a long diff) can be hundreds of KB. -// The mobile view only previews tool block bodies, so truncate them on the wire -// to keep the payload small; the marker tells the user content was clipped. -const MOBILE_BLOCK_CHAR_CAP = 4000 -// Why: text blocks are the message body itself, rendered in full by the chat -// view — a preview-sized cap cut long assistant replies mid-sentence with no way -// to read on (STA-3230). Keep only a generous safety ceiling: a transcript -// record can legally reach 2MB, and shipping that much markdown in one block -// would freeze the phone. -const MOBILE_TEXT_BLOCK_CHAR_CAP = 64_000 -const MOBILE_TOOL_INPUT_ITEMS_CAP = 20 -const MOBILE_TOOL_INPUT_NODE_CAP = 100 -const TRUNCATION_MARKER = '\n… (truncated)' - -function clip(text: string, cap: number): string { - return text.length > cap ? text.slice(0, cap) + TRUNCATION_MARKER : text -} - -function sanitizeBlock( - block: NativeChatBlock, - clientKind: RpcContext['clientKind'] -): NativeChatBlock { - if (block.type === 'image-ref') { - return sanitizeNativeChatRpcImageBlock(block) - } - if (clientKind !== 'mobile') { - return block - } - if (block.type === 'text') { - return block.text.length > MOBILE_TEXT_BLOCK_CHAR_CAP - ? { ...block, text: clip(block.text, MOBILE_TEXT_BLOCK_CHAR_CAP) } - : block - } - if (block.type === 'tool-result') { - return block.output.length > MOBILE_BLOCK_CHAR_CAP - ? { ...block, output: clip(block.output, MOBILE_BLOCK_CHAR_CAP) } - : block - } - if (block.type === 'tool-call') { - const budget = { remaining: MOBILE_BLOCK_CHAR_CAP, nodes: MOBILE_TOOL_INPUT_NODE_CAP } - return { ...block, input: sanitizeToolInput(block.input, budget, 0) } - } - return block -} - -function sanitizeToolInput( - value: unknown, - budget: { remaining: number; nodes: number }, - depth: number -): unknown { - budget.nodes-- - if (budget.nodes < 0 || budget.remaining <= 0) { - return '… (truncated)' - } - if (typeof value === 'string') { - const length = Math.min(value.length, budget.remaining) - budget.remaining -= length - return length < value.length ? `${value.slice(0, length)}… (truncated)` : value - } - if (!value || typeof value !== 'object' || depth >= 5) { - return value && typeof value === 'object' ? '… (truncated)' : value - } - if (Array.isArray(value)) { - const result = value - .slice(0, MOBILE_TOOL_INPUT_ITEMS_CAP) - .map((item) => sanitizeToolInput(item, budget, depth + 1)) - if (value.length > MOBILE_TOOL_INPUT_ITEMS_CAP) { - result.push('… (truncated)') - } - return result - } - const result: Record = {} - let count = 0 - for (const key in value) { - if (!Object.hasOwn(value, key)) { - continue - } - if (count >= MOBILE_TOOL_INPUT_ITEMS_CAP || budget.remaining <= 0) { - result['…'] = 'truncated' - break - } - let boundedKey = key.slice(0, Math.min(key.length, budget.remaining, 128)) - // Why: sibling keys sharing a >=128-char (or budget-truncated) prefix collapse - // to the same bounded key; suffix collisions so neither field is silently lost. - if (Object.hasOwn(result, boundedKey)) { - boundedKey = `${boundedKey}~${count}` - } - budget.remaining -= boundedKey.length - result[boundedKey] = sanitizeToolInput( - (value as Record)[key], - budget, - depth + 1 - ) - count++ - } - return result -} - -function sanitizeMessage( - message: NativeChatMessage, - clientKind: RpcContext['clientKind'] -): NativeChatMessage { - return { ...message, blocks: message.blocks.map((block) => sanitizeBlock(block, clientKind)) } -} - -function sanitizeAppendForClient( - messages: readonly NativeChatMessage[], - clientKind: RpcContext['clientKind'] -): NativeChatMessage[] { - return messages.map((message) => sanitizeMessage(message, clientKind)) -} - -/** Window a transcript to its most recent `limit` messages so a long session - * can't freeze the client. Windowing by count applies to ALL RPC clients — - * shipping thousands of turns over the paired link is bad for web and mobile - * alike. Char-clipping (the mobile-only payload diet) is applied separately. */ -function windowTranscript( - messages: readonly NativeChatMessage[], - limit = MOBILE_NATIVE_CHAT_DEFAULT_WINDOW -): NativeChatMessage[] { - const window = Math.min(Math.max(limit, 1), MOBILE_NATIVE_CHAT_MAX_WINDOW) - return messages.length > window ? messages.slice(-window) : messages.slice() -} - -/** Apply the windowed slice and keep inline image bytes off every RPC transport. - * Mobile clients additionally receive bounded text and tool bodies; runtime - * clients keep those bodies intact. */ -function windowForClient( - messages: readonly NativeChatMessage[], - clientKind: RpcContext['clientKind'], - limit = MOBILE_NATIVE_CHAT_DEFAULT_WINDOW -): NativeChatMessage[] { - const windowed = windowTranscript(messages, limit) - return windowed.map((message) => sanitizeMessage(message, clientKind)) -} - export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ defineMethod({ name: 'nativeChat.readSession', @@ -297,6 +154,15 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ ...(lifecycle ? { lifecycle } : {}) }) }, + ...(params.capabilities?.transcriptPending === 1 + ? { + onTranscriptPending: () => { + if (!closed) { + emit({ type: 'snapshot', messages: [], hasMore: false, pending: true }) + } + } + } + : {}), onReplace: (messages, hasMore, beforeOffset, lifecycle) => { if (closed) { return