mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
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.
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<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
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
@@ -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<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)) }
|
||||
return {
|
||||
...message,
|
||||
blocks: message.blocks.map((block) => sanitizeNativeChatRpcBlock(block, clientKind))
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeAppendForClient(
|
||||
|
||||
Reference in New Issue
Block a user