From ea406ac3893a1bb29221e87beec6fa592efed4d2 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Mon, 7 Sep 2026 00:26:57 -0700 Subject: [PATCH] fix(native-chat): bound the subagent roster on every boundary that carries it The spawn-group arm was the one collection in the worker-transcript payload with no cap, and the one block type mobile's `sanitizeBlock` forwarded verbatim. The producer's `MAX_CODEX_SUBAGENTS_PER_GROUP` does not reach either boundary: the journal schema declares no maximum on `agents`, and a remote host may run a build with a different cap. Both transports now cap the roster and bound `id`, `label` and the open `state` string; `label` and `id` also take the standard inline bound on the journal write path, where every other provider string already does. A token count is now persisted onto its entry at write time. `write` rebuilt `tokens` from the LRU-capped thread map on every write, so an eviction silently retracted a count the durable row had already shown. Adds the first coverage of the three roster caps, including the group eviction that rewrites a row from N children down to one. --- src/main/codex/codex-subagent-roster.test.ts | 115 ++++++++++++++++ src/main/codex/codex-subagent-roster.ts | 32 ++++- .../worker-transcript-payload.test.ts | 71 ++++++++++ .../worker-transcript-payload.ts | 34 ++++- .../methods/native-chat-rpc-block-sanitize.ts | 130 ++++++++++++++++++ .../runtime/rpc/methods/native-chat.test.ts | 33 +++++ src/main/runtime/rpc/methods/native-chat.ts | 110 +-------------- 7 files changed, 411 insertions(+), 114 deletions(-) create mode 100644 src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.ts diff --git a/src/main/codex/codex-subagent-roster.test.ts b/src/main/codex/codex-subagent-roster.test.ts index 905f2f3054c..dbfb827c92c 100644 --- a/src/main/codex/codex-subagent-roster.test.ts +++ b/src/main/codex/codex-subagent-roster.test.ts @@ -12,6 +12,11 @@ import { codexSubagentGroupId } from './codex-subagent-roster' import type { CodexThreadItem } from './codex-structured-item-translation' +import { + MAX_CODEX_SUBAGENT_GROUPS, + MAX_CODEX_SUBAGENTS_PER_GROUP, + MAX_CODEX_TOKEN_USAGE_THREADS +} from './codex-structured-journal-limits' const THREAD = 'thread-parent' const TURN = 'turn-1' @@ -53,6 +58,10 @@ function createHarness(options: { threadId?: string | null } = {}): { return { roster, appended, agents, latest: () => appended.at(-1) } } +function latestIdentity(appended: Appended[]): AgentJournalItemIdentity | undefined { + return appended.at(-1)?.identity +} + function activity(input: { id?: string kind: string @@ -461,6 +470,112 @@ describe('CodexSubagentRoster', () => { expect(agents()[0]).not.toHaveProperty('tokens') }) + it('bounds the provider strings the roster row carries into the journal', () => { + const { roster, agents, latest } = createHarness() + const oversized = 'a'.repeat(20 * 1024) + + deliver( + roster, + activity({ kind: 'started', agentThreadId: oversized, agentPath: `/root/${oversized}` }) + ) + + const entry = agents()[0] + expect(entry?.label).toContain('output truncated') + expect(entry?.label.length).toBeLessThan(oversized.length) + expect(entry?.id).toContain('output truncated') + expect(entry?.id.length).toBeLessThan(oversized.length) + expect(isAdmissibleAgentJournalItemBody(latest()?.body)).toBe(true) + }) + + it('caps the children one spawn group admits', () => { + const { roster, agents, appended } = createHarness() + for (let index = 0; index < MAX_CODEX_SUBAGENTS_PER_GROUP; index++) { + deliver( + roster, + activity({ kind: 'started', agentThreadId: `child-${index}`, agentPath: '/root/read' }) + ) + } + const atCap = appended.length + + deliver( + roster, + activity({ kind: 'started', agentThreadId: 'child-over-cap', agentPath: '/root/read' }) + ) + + expect(agents()).toHaveLength(MAX_CODEX_SUBAGENTS_PER_GROUP) + expect(agents().map((agent) => agent.id)).not.toContain('child-over-cap') + // Refusing the child must not burn a revision either. + expect(appended).toHaveLength(atCap) + }) + + // The eviction is the KNOWN LIMITATION the module documents: `groups` is never + // seeded from the journal, so the evicted group's next child rebuilds its + // durable row from that one child. Pinned so the boundary cannot move silently. + it('caps live spawn groups, and an evicted group rebuilds its row from one child', () => { + const { roster, appended, agents } = createHarness() + for (let index = 0; index <= MAX_CODEX_SUBAGENT_GROUPS; index++) { + deliver( + roster, + activity({ kind: 'started', agentThreadId: `child-${index}`, agentPath: '/root/read' }), + `turn-${index}` + ) + } + const evicted = codexSubagentGroupIdentity(codexSubagentGroupId(THREAD, 'turn-0')) + const rowsFor = (identity: AgentJournalItemIdentity): Appended[] => + appended.filter((entry) => JSON.stringify(entry.identity) === JSON.stringify(identity)) + expect(rowsFor(evicted)).toHaveLength(1) + + deliver( + roster, + activity({ kind: 'started', agentThreadId: 'child-late', agentPath: '/root/search' }), + 'turn-0' + ) + + expect(latestIdentity(appended)).toEqual(evicted) + expect(agents().map((agent) => agent.id)).toEqual(['child-late']) + }) + + it('keeps a token count a later thread-map eviction would otherwise retract', () => { + const { roster, agents } = createHarness() + deliver( + roster, + activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) + ) + roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 4242 } } }) + expect(agents()).toMatchObject([{ tokens: 4242 }]) + + for (let index = 0; index < MAX_CODEX_TOKEN_USAGE_THREADS; index++) { + roster.handleTokenUsage({ + threadId: `other-${index}`, + tokenUsage: { total: { totalTokens: index } } + }) + } + deliver( + roster, + activity({ kind: 'completed', agentThreadId: 'child-1', agentPath: '/root/read' }) + ) + + expect(agents()).toMatchObject([{ state: 'completed', tokens: 4242 }]) + }) + + it('caps retained usage threads, so a frame evicted before its child is dropped', () => { + const { roster, agents } = createHarness() + roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 900 } } }) + for (let index = 0; index < MAX_CODEX_TOKEN_USAGE_THREADS; index++) { + roster.handleTokenUsage({ + threadId: `other-${index}`, + tokenUsage: { total: { totalTokens: index } } + }) + } + + deliver( + roster, + activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) + ) + + expect(agents()[0]).not.toHaveProperty('tokens') + }) + it('declines a payload that is not a subagent item or a usage frame', () => { const { roster } = createHarness() diff --git a/src/main/codex/codex-subagent-roster.ts b/src/main/codex/codex-subagent-roster.ts index b5aaa29ed4d..67691215e22 100644 --- a/src/main/codex/codex-subagent-roster.ts +++ b/src/main/codex/codex-subagent-roster.ts @@ -28,6 +28,10 @@ import { subagentGroupFallbackText } from '../../shared/native-chat-subagent-summary' import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' import type { StructuredAgentSessionEventSink, StructuredAgentSessionSinkAdmission @@ -263,9 +267,16 @@ export class CodexSubagentRoster { } private write(group: RosterGroup): StructuredAgentSessionSinkAdmission { - const agents = [...group.entries.values()].map((entry) => { - const tokens = this.tokensByThread.get(entry.id) - return typeof tokens === 'number' ? { ...entry, tokens } : entry + const agents = [...group.entries].map(([id, entry]) => { + const tokens = this.tokensByThread.get(id) + if (typeof tokens !== 'number' || tokens === entry.tokens) { + return entry + } + // Persisted, not merely read: the thread map is LRU-capped, and reading it + // afresh each write would retract a count this row has already shown. + const merged = { ...entry, tokens } + group.entries.set(id, merged) + return merged }) const body = codexSubagentGroupBody(group.groupId, agents) const serialized = JSON.stringify(body) @@ -307,12 +318,23 @@ export function codexSubagentGroupBody( groupId: string, agents: readonly NativeChatSubagentEntry[] ): AgentJournalItemBody { + const bounded = agents.map((agent) => ({ + ...agent, + id: boundSubagentField(agent.id), + label: boundSubagentField(agent.label) + })) return { kind: 'message', role: 'system', blocks: [ - { type: 'text', text: subagentGroupFallbackText(agents) }, - { type: 'subagent-group', groupId, agents: [...agents] } + { type: 'text', text: subagentGroupFallbackText(bounded) }, + { type: 'subagent-group', groupId, agents: bounded } ] } } + +/** `id` and `label` are provider strings, so they take the same inline bound + * every other piece of journal-bound text takes before it reaches a row. */ +function boundSubagentField(value: string): string { + return boundInlineText(value, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text +} diff --git a/src/main/runtime/orchestration/worker-transcript-payload.test.ts b/src/main/runtime/orchestration/worker-transcript-payload.test.ts index 7899a63fb73..85a98cdc2ed 100644 --- a/src/main/runtime/orchestration/worker-transcript-payload.test.ts +++ b/src/main/runtime/orchestration/worker-transcript-payload.test.ts @@ -57,6 +57,77 @@ describe('worker transcript wire bounds', () => { ) }) + // The producer's per-group cap never reaches this boundary: the journal schema + // declares no maximum and a remote host may run a build with a different one, + // so the transport keeps its own — the invariant that no single block is huge. + it('caps and redacts a spawn group the way every other collection is capped', () => { + const result = boundWorkerTranscriptMessages([ + { + id: 'message-roster', + role: 'system', + timestamp: null, + source: 'transcript', + blocks: [ + { + type: 'subagent-group', + groupId: 'thread-1:turn-1', + agents: Array.from({ length: 40 }, (_unused, index) => ({ + id: `child-${index}`, + label: index === 0 ? `dcap_${'A'.repeat(24)}` : 'read', + state: 'working' as const + })) + } + ] + } + ]) + + const block = result.messages[0]?.blocks[0] + expect(block?.type).toBe('subagent-group') + expect(block?.type === 'subagent-group' ? block.agents : []).toHaveLength(20) + expect(JSON.stringify(result.messages)).not.toContain('dcap_') + expect(result.limited).toBe(true) + expect(result.warnings).toEqual( + expect.arrayContaining([ + 'Some subagents were omitted from oversized spawn groups.', + 'Dispatch capability tokens were redacted from transcript output.' + ]) + ) + }) + + it('bounds a spawn-group state a newer build wrote as an oversized open string', () => { + const result = boundWorkerTranscriptMessages([ + { + id: 'message-roster-state', + role: 'system', + timestamp: null, + source: 'transcript', + blocks: [ + { + type: 'subagent-group', + groupId: 'g'.repeat(900), + agents: [ + { + id: 'i'.repeat(900), + label: 'l'.repeat(900), + state: 's'.repeat(900) as 'working' + } + ] + } + ] + } + ]) + + const block = result.messages[0]?.blocks[0] + const agent = block?.type === 'subagent-group' ? block.agents[0] : undefined + expect(block?.type === 'subagent-group' ? block.groupId.length : 0).toBe(512) + expect(agent?.id.length).toBe(512) + expect(agent?.label.length).toBe(512) + // A clipped state names no state any build knows, which is what + // `unverifiable` records — a 512-character fragment is not a state at all. + expect(agent?.state).toBe('unverifiable') + expect(result.limited).toBe(true) + }) + it('keeps complete bounded messages unlimited', () => { const result = boundWorkerTranscriptMessages([ { diff --git a/src/main/runtime/orchestration/worker-transcript-payload.ts b/src/main/runtime/orchestration/worker-transcript-payload.ts index c52f2f2a7db..a57f9bc9c08 100644 --- a/src/main/runtime/orchestration/worker-transcript-payload.ts +++ b/src/main/runtime/orchestration/worker-transcript-payload.ts @@ -1,5 +1,10 @@ import { createHash } from 'node:crypto' -import type { NativeChatBlock, NativeChatMessage } from '../../../shared/native-chat-types' +import { normalizeSubagentState } from '../../../shared/native-chat-subagent-summary' +import type { + NativeChatBlock, + NativeChatMessage, + NativeChatSubagentState +} from '../../../shared/native-chat-types' export const DEFAULT_WORKER_TRANSCRIPT_MESSAGE_LIMIT = 40 export const MAX_WORKER_TRANSCRIPT_MESSAGE_LIMIT = 50 @@ -7,6 +12,9 @@ const MAX_WORKER_TRANSCRIPT_BLOCKS = 6 const MAX_WORKER_TRANSCRIPT_BLOCK_CHARS = 1_200 const MAX_WORKER_TRANSCRIPT_INPUT_ITEMS = 20 const MAX_WORKER_TRANSCRIPT_INPUT_NODES = 100 +// The producer's per-group cap does not reach this boundary: the journal schema +// declares no maximum, and a remote host may run a build with a different one. +const MAX_WORKER_TRANSCRIPT_SUBAGENTS = 20 const MAX_WORKER_TRANSCRIPT_RESPONSE_BYTES = 512 * 1024 const TRUNCATION_MARKER = '\n… (truncated)' const DISPATCH_CAPABILITY_PATTERN = /\bdcap_[A-Za-z0-9_-]{20,}\b/g @@ -129,15 +137,20 @@ function boundBlock(block: NativeChatBlock, state: TranscriptBoundState): Native } } if (block.type === 'subagent-group') { - // Labels come from provider-supplied agent paths, so they get the same - // redaction and clipping every other piece of transcript metadata gets. + const agents = block.agents.slice(0, MAX_WORKER_TRANSCRIPT_SUBAGENTS) + if (agents.length < block.agents.length) { + markClipped(state, 'Some subagents were omitted from oversized spawn groups.') + } + // Labels, ids and states come from provider-supplied strings, so they get the + // same redaction and clipping every other piece of transcript metadata gets. return { ...block, groupId: clipMetadata(block.groupId, state), - agents: block.agents.map((agent) => ({ + agents: agents.map((agent) => ({ ...agent, id: clipMetadata(agent.id, state), - label: clipMetadata(agent.label, state) + label: clipMetadata(agent.label, state), + state: clipSubagentState(agent.state, state) })) } } @@ -185,6 +198,17 @@ function clipMetadata(value: string, state: TranscriptBoundState): string { return redacted.slice(0, 512) } +/** `state` is an open string on the wire, so it takes the same bound. A value + * that had to be redacted or clipped names no state any build knows, which is + * exactly what `unverifiable` records. */ +function clipSubagentState( + value: NativeChatSubagentState, + state: TranscriptBoundState +): NativeChatSubagentState { + const clipped = clipMetadata(value, state) + return clipped === value ? value : normalizeSubagentState(clipped) +} + function clipText(value: string, state: TranscriptBoundState): string { const redacted = redactSensitiveText(value, state.warnings) if (redacted.length <= MAX_WORKER_TRANSCRIPT_BLOCK_CHARS) { diff --git a/src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.ts b/src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.ts new file mode 100644 index 00000000000..a9763836bcc --- /dev/null +++ b/src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.ts @@ -0,0 +1,130 @@ +import { normalizeSubagentState } from '../../../../shared/native-chat-subagent-summary' +import type { NativeChatBlock, NativeChatSubagentState } from '../../../../shared/native-chat-types' +import type { RpcContext } from '../core' +import { sanitizeNativeChatRpcImageBlock } from './native-chat-rpc-image-block' + +// Why: the mobile-only payload diet. Inline image bytes are kept off every RPC +// transport; everything below that only applies to `mobile` clients, whose +// renderer previews block bodies rather than showing them whole. + +// 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 +// Why: a spawn group's roster is metadata, not a body — provider-supplied agent +// paths and an open-string lifecycle whose schema declares no maximum, so a +// journal from a newer build can carry more children and longer strings than +// this build ever writes. +const MOBILE_SUBAGENT_CAP = 64 +const MOBILE_SUBAGENT_FIELD_CHAR_CAP = 512 +const TRUNCATION_MARKER = '\n… (truncated)' + +function clip(text: string, cap: number): string { + return text.length > cap ? text.slice(0, cap) + TRUNCATION_MARKER : text +} + +export function sanitizeNativeChatRpcBlock( + 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) } + } + if (block.type === 'subagent-group') { + return { + ...block, + groupId: clip(block.groupId, MOBILE_SUBAGENT_FIELD_CHAR_CAP), + agents: block.agents.slice(0, MOBILE_SUBAGENT_CAP).map((agent) => ({ + ...agent, + id: clip(agent.id, MOBILE_SUBAGENT_FIELD_CHAR_CAP), + label: clip(agent.label, MOBILE_SUBAGENT_FIELD_CHAR_CAP), + state: clipSubagentState(agent.state) + })) + } + } + return block +} + +/** A state too long to be one this build knows names no state at all, which is + * what `unverifiable` records — clipping it would ship a truncated word. */ +function clipSubagentState(value: NativeChatSubagentState): NativeChatSubagentState { + return value.length > MOBILE_SUBAGENT_FIELD_CHAR_CAP ? normalizeSubagentState(value) : value +} + +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 +} diff --git a/src/main/runtime/rpc/methods/native-chat.test.ts b/src/main/runtime/rpc/methods/native-chat.test.ts index 65bb525e798..1417e716770 100644 --- a/src/main/runtime/rpc/methods/native-chat.test.ts +++ b/src/main/runtime/rpc/methods/native-chat.test.ts @@ -266,6 +266,39 @@ describe('nativeChat.readSession clientKind truncation gating', () => { expect(JSON.stringify(input)).toContain('truncated') }) + // The roster block reached mobile through a bare fall-through, uncapped, on the + // one path that exists to keep the payload off the phone. + it('bounds a spawn-group roster before sending it to mobile', async () => { + cachedResult.value = { + messages: [ + { + ...makeMessage('ignored'), + blocks: [ + { + type: 'subagent-group', + groupId: 'thread-1:turn-1', + agents: Array.from({ length: 80 }, (_unused, index) => ({ + id: `child-${index}`, + label: index === 0 ? OVERSIZED : 'read', + state: index === 0 ? (OVERSIZED as 'working') : ('working' as const) + })) + } + ] + } + ] + } + + const result = await readSessionHandler()({ agent: 'codex', sessionId: 's' }, ctxWith('mobile')) + const block = (result as { messages: NativeChatMessage[] }).messages[0].blocks[0] + if (block.type !== 'subagent-group') { + throw new Error('expected a subagent-group block') + } + + expect(block.agents).toHaveLength(64) + expect(block.agents[0].label.length).toBeLessThan(OVERSIZED.length) + expect(block.agents[0].state).toBe('unverifiable') + }) + it('preserves AskUserQuestion option objects at the supported nesting depth', async () => { cachedResult.value = { messages: [ diff --git a/src/main/runtime/rpc/methods/native-chat.ts b/src/main/runtime/rpc/methods/native-chat.ts index 8fc86bf695a..e1a92dd52db 100644 --- a/src/main/runtime/rpc/methods/native-chat.ts +++ b/src/main/runtime/rpc/methods/native-chat.ts @@ -1,9 +1,5 @@ import { z } from 'zod' -import type { - NativeChatBlock, - NativeChatMessage, - AgentType -} from '../../../../shared/native-chat-types' +import type { NativeChatMessage, AgentType } from '../../../../shared/native-chat-types' import { readNativeChatTranscriptTail, subscribeNativeChatTranscript, @@ -11,7 +7,7 @@ import { 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 { sanitizeNativeChatRpcBlock } from './native-chat-rpc-block-sanitize' // 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 @@ -68,109 +64,15 @@ const NativeChatUnsubscribe = z.object({ // 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)) } + return { + ...message, + blocks: message.blocks.map((block) => sanitizeNativeChatRpcBlock(block, clientKind)) + } } function sanitizeAppendForClient(