mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 00:04:01 +00:00
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:
@@ -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))
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user