fix(ci): restore remote transcript and file compatibility

This commit is contained in:
Jinwoo-H
2026-08-30 21:19:09 -04:00
parent 0ccba6df52
commit 58fc413a96
7 changed files with 199 additions and 176 deletions
@@ -7,6 +7,12 @@ import { startBrowserScreencast } from './browser-screencast-stream'
function createWebContents() {
let attached = false
const webContents = new EventEmitter() as EventEmitter & {
isDestroyed: ReturnType<typeof vi.fn>
getURL: ReturnType<typeof vi.fn>
getTitle: ReturnType<typeof vi.fn>
debugger: typeof debuggerApi
}
const debuggerApi = new EventEmitter() as EventEmitter & {
isAttached: ReturnType<typeof vi.fn>
attach: ReturnType<typeof vi.fn>
@@ -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', () => {
+1 -1
View File
@@ -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 —
+40 -16
View File
@@ -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<NativeChatTranscriptSubscription | null> {
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<void> {
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<void> {
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
)
@@ -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: () => {},
+4 -4
View File
@@ -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())
@@ -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<string, unknown> = {}
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<string, unknown>)[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))
}
+18 -152
View File
@@ -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<string, unknown> = {}
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<string, unknown>)[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