From ba068abcd5d411d2818e424148fc2a0d947c552f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:07:05 -0700 Subject: [PATCH] refactor(rpc): split native-chat client transcript windowing Merging main's capabilities.transcriptPending with this branch's preservesTranscriptOrder put native-chat.ts at 303/300 lines. Extract the client windowing and sanitization helpers to a sibling module instead of bumping the cap. --- .../native-chat-client-transcript-window.ts | 148 ++++++++++++++++ src/main/runtime/rpc/methods/native-chat.ts | 160 +----------------- 2 files changed, 156 insertions(+), 152 deletions(-) create mode 100644 src/main/runtime/rpc/methods/native-chat-client-transcript-window.ts diff --git a/src/main/runtime/rpc/methods/native-chat-client-transcript-window.ts b/src/main/runtime/rpc/methods/native-chat-client-transcript-window.ts new file mode 100644 index 00000000000..8848d987bfb --- /dev/null +++ b/src/main/runtime/rpc/methods/native-chat-client-transcript-window.ts @@ -0,0 +1,148 @@ +import type { NativeChatBlock, NativeChatMessage } from '../../../../shared/native-chat-types' +import type { RpcContext } from '../core' +import { sanitizeNativeChatRpcImageBlock } from './native-chat-rpc-image-block' + +// 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. +export const MOBILE_NATIVE_CHAT_DEFAULT_WINDOW = 40 +export 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)) } +} + +export 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. */ +export 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)) +} diff --git a/src/main/runtime/rpc/methods/native-chat.ts b/src/main/runtime/rpc/methods/native-chat.ts index cd19eaed518..b73b4cf668f 100644 --- a/src/main/runtime/rpc/methods/native-chat.ts +++ b/src/main/runtime/rpc/methods/native-chat.ts @@ -1,17 +1,18 @@ 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 { sanitizeNativeChatRpcImageBlock } from './native-chat-rpc-image-block' +import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' +import { + MOBILE_NATIVE_CHAT_DEFAULT_WINDOW, + MOBILE_NATIVE_CHAT_MAX_WINDOW, + sanitizeAppendForClient, + windowForClient +} from './native-chat-client-transcript-window' // 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 @@ -60,151 +61,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',