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.
This commit is contained in:
Brennan Benson
2026-08-28 11:07:05 -07:00
parent 354ea847de
commit ba068abcd5
2 changed files with 156 additions and 152 deletions
@@ -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<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)) }
}
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))
}
+8 -152
View File
@@ -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<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',