From ff0c1a9cbc832911d39d1beaeda18f86a45ab77c Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Mon, 3 Aug 2026 17:50:04 +0800 Subject: [PATCH 1/4] fix(cli): Bound ACP textual tool-result payloads Co-authored-by: Qwen-Coder --- ...6-08-03-acp-tool-result-text-projection.md | 62 +++ docs/design/final-tool-response-budget.md | 2 +- .../acp-integration/session/Session.test.ts | 81 ++++ .../src/acp-integration/session/Session.ts | 3 +- .../acp-tool-result-text-projection.test.ts | 373 ++++++++++++++++++ .../acp-tool-result-text-projection.ts | 373 ++++++++++++++++++ .../session/history-replay-page.test.ts | 83 ++++ .../session/history-replay-page.ts | 6 +- .../cli/src/ui/utils/export/collect.test.ts | 78 ++++ 9 files changed, 1057 insertions(+), 4 deletions(-) create mode 100644 docs/design/2026-08-03-acp-tool-result-text-projection.md create mode 100644 packages/cli/src/acp-integration/session/acp-tool-result-text-projection.test.ts create mode 100644 packages/cli/src/acp-integration/session/acp-tool-result-text-projection.ts diff --git a/docs/design/2026-08-03-acp-tool-result-text-projection.md b/docs/design/2026-08-03-acp-tool-result-text-projection.md new file mode 100644 index 00000000000..9cb83ac38ec --- /dev/null +++ b/docs/design/2026-08-03-acp-tool-result-text-projection.md @@ -0,0 +1,62 @@ +# ACP Tool-Result Text Projection + +## Problem + +ACP tool-result display fields are assembled after model-facing response +finalization. A complete display value can therefore appear in both structured +`content` and `rawOutput`, producing a much larger ACP frame than the bounded +model response. Live delivery and history replay also leave through different +paths, so applying a limit at only one of them would leave a replay bypass. + +## Contract + +Each eligible field has a fixed 65,536-byte budget measured as the UTF-8 byte +length of that field's JSON serialization. Eligible `content` is an array made +only of canonical ACP text-content blocks with no extra fields. Eligible +`rawOutput` is a primitive string. The fields are evaluated independently and +remain present after projection. + +A2UI tool updates are exempt as a whole because the daemon extracts command +JSON after the child ACP wire boundary. Structured, diff, terminal, media, +mixed, and otherwise non-canonical content remains unchanged. The projection +does not change the canonical transcript, model response, artifact metadata, +or offline export. + +## Projection + +String size is computed with a linear scanner that matches native JSON escaping +for controls, quotes, backslashes, Unicode, valid surrogate pairs, and lone +surrogates. Oversized strings retain an approximately 20 percent head and 80 +percent tail around a fixed transport-truncation marker. Selected slices are +copied so a bounded preview does not retain an oversized backing string. + +For multi-block content, the array and object wrappers count toward the same +field budget. A deterministic max-min allocation preserves block order and +lets small blocks remain complete. Reduced blocks reserve marker space before +sharing the remaining payload budget. If the empty structure or minimum marker +set cannot fit, the field collapses to one canonical omission marker. + +The projector does not stringify an original oversized field, join blocks, or +perform repeated binary searches. Native serialization is used only to verify +the already bounded result. Applying the projector twice is a no-op after the +first projection. + +## Boundaries + +Live updates are projected in `Session.sendUpdate()`. Replay updates are +projected when the replay collector accepts them, covering bulk load, +`qwen/session/loadUpdates`, paged transcript routes, and virtual subagent +replay. The canonical replay machine and transcript update constructors remain +unchanged, and offline export continues to replay through its dedicated export +context without this transport projection. + +## Compatibility and Non-Goals + +ACP schemas, capabilities, and field types do not change. Keeping both +`content` and `rawOutput` avoids a wire-deduplication compatibility decision. +When no artifact exists, the marker makes no recovery claim. + +This design is not a universal ACP frame limit. Structured payload bounds, +generic NDJSON caps, backpressure, replay aggregate limits, Headless display +projection, diagnostics, and artifact lifecycle remain separate work tracked +by #8091, #8447, #8448, and the later phases of #7306. diff --git a/docs/design/final-tool-response-budget.md b/docs/design/final-tool-response-budget.md index 6eb30cb0a5c..cf0e9f64d9e 100644 --- a/docs/design/final-tool-response-budget.md +++ b/docs/design/final-tool-response-budget.md @@ -55,7 +55,7 @@ The finalizer recomputes `contentLength` from the returned parts. Infinite or di - Core scheduler finalizes before `PostToolBatch` hooks to bound hook input and again after the hook to bound hook output. - Interactive mode merges executable, duplicate, and synthetic responses in original ordinal order, then performs the outer finalization before recording and submission. - Headless mode collects the whole turn, including duplicate, skipped, cancelled, and executed calls, then finalizes once before recording and submission. -- ACP collects the complete tool-call turn, finalizes it before transcript recording, and returns the same parts for the next message. Immediate ACP display events remain unchanged. +- ACP collects the complete tool-call turn, finalizes it before transcript recording, and returns the same parts for the next message. Canonical and model-facing parts remain unchanged, while eligible textual fields in immediate ACP display events may be projected to a fixed JSON UTF-8 byte budget at the transport boundary. - Agent runtime and speculative follow-up finalize their aggregate before emitting model-facing results or appending history. - The chat send boundary applies a no-I/O safety cap to tool-response fields only. It should normally be a no-op and protects future callers that miss an outer aggregation boundary. diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 3201e1f6091..bf2aff1c7c0 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -5,6 +5,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Buffer } from 'node:buffer'; import { randomUUID } from 'node:crypto'; import { EventEmitter } from 'node:events'; import * as fsSync from 'node:fs'; @@ -53,6 +54,10 @@ import { MessageType } from '../../ui/types.js'; import { buildAcpModelOptions } from '../../utils/acpModelUtils.js'; import { CAPTURE_SCREEN_CONTEXT_TOOL_NAME } from '../../serve/live/capture-screen-context.js'; import { SPEAK_TO_USER_TOOL_NAME } from '../../serve/live/live-speak-to-user.js'; +import { + collectHistoryReplayUpdates, + createReplayCumulativeUsage, +} from './history-replay-page.js'; const debugLoggerWarnSpy = vi.hoisted(() => vi.fn()); const debugLoggerDebugSpy = vi.hoisted(() => vi.fn()); @@ -746,6 +751,82 @@ describe('Session', () => { vi.clearAllTimers(); }); + it('bounds textual tool results at the live ACP delivery boundary', async () => { + const source = `head-${'x'.repeat(499_999)}-tail`; + await session.sendUpdate({ + sessionUpdate: 'tool_call_update', + toolCallId: 'large-call', + status: 'completed', + content: [ + { + type: 'content', + content: { type: 'text', text: source }, + }, + ], + rawOutput: source, + _meta: { toolName: 'read_file' }, + }); + + const params = vi + .mocked(mockClient.sessionUpdate) + .mock.calls.at(-1)?.[0] as SessionNotification | undefined; + expect(params).toBeDefined(); + const delivered = params?.update as unknown as Record; + expect( + Buffer.byteLength(JSON.stringify(delivered['content']), 'utf8'), + ).toBeLessThanOrEqual(65_536); + expect( + Buffer.byteLength(JSON.stringify(delivered['rawOutput']), 'utf8'), + ).toBeLessThanOrEqual(65_536); + expect(delivered).toHaveProperty('content'); + expect(delivered).toHaveProperty('rawOutput'); + + await session.sendUpdate(params!.update); + const redelivered = vi + .mocked(mockClient.sessionUpdate) + .mock.calls.at(-1)?.[0]?.update; + expect(redelivered).toBe(params?.update); + + const replay = await collectHistoryReplayUpdates({ + sessionId: 'test-session-id', + records: [ + chatRecord({ + uuid: 'large-tool-result', + parentUuid: 'large-assistant', + type: 'tool_result', + message: { + role: 'user', + parts: [ + { + functionResponse: { + id: 'large-replay-call', + name: 'read_file', + response: { output: source }, + }, + }, + ], + }, + toolCallResult: { + callId: 'large-replay-call', + responseParts: [], + resultDisplay: source, + }, + }), + ], + cumulativeUsage: createReplayCumulativeUsage(), + }); + const replayUpdate = replay.updates.find( + (update) => update.sessionUpdate === 'tool_call_update', + ); + expect(replayUpdate).toBeDefined(); + + await session.sendUpdate(replayUpdate!); + const replayDelivered = vi + .mocked(mockClient.sessionUpdate) + .mock.calls.at(-1)?.[0]?.update; + expect(replayDelivered).toBe(replayUpdate); + }); + it('bridges workflow approvals through ACP permission requests', async () => { mockToolRegistry.getTool.mockReturnValue({ displayName: 'Shell', diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index d012a65cd26..ad503a5296d 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -279,6 +279,7 @@ import type { ToolCallStartParams, } from './types.js'; import { HistoryReplayer } from './history-replayer.js'; +import { projectAcpToolResultUpdate } from './acp-tool-result-text-projection.js'; import { ToolCallEmitter } from './emitters/tool-call-emitter.js'; import { ToolCallPreparationTracker } from './tool-call-preparation-tracker.js'; import { PlanEmitter } from './emitters/PlanEmitter.js'; @@ -4712,7 +4713,7 @@ export class Session implements SessionContext { async sendUpdate(update: SessionUpdate): Promise { const params: SessionNotification = { sessionId: this.sessionId, - update, + update: projectAcpToolResultUpdate(update), }; if (update.sessionUpdate === 'plan') { diff --git a/packages/cli/src/acp-integration/session/acp-tool-result-text-projection.test.ts b/packages/cli/src/acp-integration/session/acp-tool-result-text-projection.test.ts new file mode 100644 index 00000000000..880d1e3eebe --- /dev/null +++ b/packages/cli/src/acp-integration/session/acp-tool-result-text-projection.test.ts @@ -0,0 +1,373 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Buffer } from 'node:buffer'; +import type { SessionUpdate } from '@agentclientprotocol/sdk'; +import { describe, expect, it } from 'vitest'; +import { + ACP_TOOL_RESULT_TEXT_JSON_BYTE_BUDGET, + ACP_TOOL_RESULT_TEXT_TRUNCATION_MARKER, + jsonStringJsonByteLength, + projectAcpToolResultUpdate, +} from './acp-tool-result-text-projection.js'; + +function textBlock(text: string) { + return { type: 'content', content: { type: 'text', text } } as const; +} + +function toolUpdate( + content: unknown, + rawOutput?: unknown, + meta: Record = { toolName: 'read_file' }, +): SessionUpdate { + return { + sessionUpdate: 'tool_call_update', + toolCallId: 'call-1', + status: 'completed', + content, + ...(rawOutput === undefined ? {} : { rawOutput }), + _meta: meta, + } as unknown as SessionUpdate; +} + +function asRecord(update: SessionUpdate): Record { + return update as unknown as Record; +} + +function contentTexts(update: SessionUpdate): string[] { + return ( + asRecord(update)['content'] as Array<{ + content: { text: string }; + }> + ).map((block) => block.content.text); +} + +function jsonBytes(value: unknown): number { + return Buffer.byteLength(JSON.stringify(value), 'utf8'); +} + +describe('ACP tool-result text projection', () => { + it('matches native JSON string byte accounting for Unicode and escapes', () => { + const samples = [ + '', + 'plain ASCII', + '"\\\n\b\f\r\t', + '\0\u0001\u001f', + '汉字', + '😀', + '\ud83d\ude00', + '\ud800', + '\udc00', + '\u2028\u2029', + '\u007f\u0080\u07ff\u0800', + ]; + for (const sample of samples) { + expect(jsonStringJsonByteLength(sample)).toBe(jsonBytes(sample)); + } + }); + + it('matches native JSON byte accounting under fixed-seed fuzzing', () => { + const atoms = [ + 'a', + '"', + '\\', + '\n', + '\0', + '汉', + '😀', + '\ud800', + '\udc00', + '\u2028', + ]; + let state = 0x5eed1234; + const random = () => { + state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0; + return state; + }; + for (let sampleIndex = 0; sampleIndex < 250; sampleIndex++) { + const length = random() % 200; + let value = ''; + for (let index = 0; index < length; index++) { + value += atoms[random() % atoms.length]; + } + expect(jsonStringJsonByteLength(value)).toBe(jsonBytes(value)); + } + }); + + it.each([65_535, 65_536, 65_537])( + 'enforces the rawOutput boundary at %i JSON bytes', + (targetBytes) => { + const rawOutput = 'r'.repeat(targetBytes - 2); + const update = toolUpdate([], rawOutput); + const projected = projectAcpToolResultUpdate(update); + const projectedRaw = asRecord(projected)['rawOutput']; + + expect(jsonBytes(projectedRaw)).toBeLessThanOrEqual( + ACP_TOOL_RESULT_TEXT_JSON_BYTE_BUDGET, + ); + expect(projected === update).toBe(targetBytes <= 65_536); + }, + ); + + it.each([65_535, 65_536, 65_537])( + 'enforces the content boundary at %i JSON bytes', + (targetBytes) => { + const content = [textBlock('c'.repeat(targetBytes - 56))]; + expect(jsonBytes(content)).toBe(targetBytes); + const update = toolUpdate(content); + const projected = projectAcpToolResultUpdate(update); + + expect(jsonBytes(asRecord(projected)['content'])).toBeLessThanOrEqual( + ACP_TOOL_RESULT_TEXT_JSON_BYTE_BUDGET, + ); + expect(projected === update).toBe(targetBytes <= 65_536); + }, + ); + + it('keeps an approximately 20/80 head and tail preview', () => { + const source = + `HEAD-${'h'.repeat(200_000)}` + `-${'t'.repeat(200_000)}-TAIL`; + const projected = projectAcpToolResultUpdate(toolUpdate([], source)); + const rawOutput = asRecord(projected)['rawOutput'] as string; + + expect(rawOutput).toContain(ACP_TOOL_RESULT_TEXT_TRUNCATION_MARKER); + expect(rawOutput.startsWith('HEAD-')).toBe(true); + expect(rawOutput.endsWith('-TAIL')).toBe(true); + const [head, tail] = rawOutput.split( + ACP_TOOL_RESULT_TEXT_TRUNCATION_MARKER, + ); + expect(tail.length).toBeGreaterThan(head.length * 3.9); + expect(tail.length).toBeLessThan(head.length * 4.1); + }); + + it('never splits valid surrogate pairs at preview boundaries', () => { + const source = `head-${'😀'.repeat(100_000)}-tail`; + const projected = projectAcpToolResultUpdate(toolUpdate([], source)); + const rawOutput = asRecord(projected)['rawOutput'] as string; + + expect(rawOutput).not.toMatch(/[\ud800-\udbff](?![\udc00-\udfff])/u); + expect(rawOutput).not.toMatch(/(? { + const content = [ + textBlock(`first-${'a'.repeat(300_000)}-first-tail`), + textBlock(`second-${'b'.repeat(300_000)}-second-tail`), + ]; + const projected = projectAcpToolResultUpdate(toolUpdate(content)); + const texts = contentTexts(projected); + + expect(jsonBytes(asRecord(projected)['content'])).toBeLessThanOrEqual( + 65_536, + ); + expect(texts).toHaveLength(2); + expect(texts[0]).toContain(ACP_TOOL_RESULT_TEXT_TRUNCATION_MARKER); + expect(texts[1]).toContain(ACP_TOOL_RESULT_TEXT_TRUNCATION_MARKER); + expect(texts[0].startsWith('first-')).toBe(true); + expect(texts[0].endsWith('-first-tail')).toBe(true); + expect(texts[1].startsWith('second-')).toBe(true); + expect(texts[1].endsWith('-second-tail')).toBe(true); + expect(Math.abs(texts[0].length - texts[1].length)).toBeLessThanOrEqual(1); + }); + + it('keeps small blocks complete while distributing the remaining budget', () => { + const content = [ + textBlock('small'), + textBlock('x'.repeat(200_000)), + textBlock('y'.repeat(200_000)), + ]; + const projected = projectAcpToolResultUpdate(toolUpdate(content)); + const texts = contentTexts(projected); + + expect(texts[0]).toBe('small'); + expect(texts[1]).toContain(ACP_TOOL_RESULT_TEXT_TRUNCATION_MARKER); + expect(texts[2]).toContain(ACP_TOOL_RESULT_TEXT_TRUNCATION_MARKER); + expect(jsonBytes(asRecord(projected)['content'])).toBeLessThanOrEqual( + 65_536, + ); + }); + + it('collapses content when the empty structure cannot fit', () => { + const content = Array.from({ length: 1_192 }, () => textBlock('')); + const projected = projectAcpToolResultUpdate(toolUpdate(content)); + + expect(contentTexts(projected)).toEqual([ + ACP_TOOL_RESULT_TEXT_TRUNCATION_MARKER, + ]); + }); + + it('collapses content when the minimum marker set cannot fit', () => { + const content = Array.from({ length: 1_000 }, () => + textBlock('x'.repeat(100)), + ); + const projected = projectAcpToolResultUpdate(toolUpdate(content)); + + expect(contentTexts(projected)).toEqual([ + ACP_TOOL_RESULT_TEXT_TRUNCATION_MARKER, + ]); + }); + + it('reuses the stricter content preview when content and rawOutput match', () => { + const source = `head-${'x'.repeat(499_999)}-tail`; + const update = toolUpdate([textBlock(source)], source); + const projected = projectAcpToolResultUpdate(update); + const projectedText = contentTexts(projected)[0]; + + expect(asRecord(projected)['rawOutput']).toBe(projectedText); + expect(jsonBytes(asRecord(projected)['content'])).toBeLessThanOrEqual( + 65_536, + ); + expect(jsonBytes(asRecord(projected)['rawOutput'])).toBeLessThanOrEqual( + 65_536, + ); + }); + + it('projects eligible fields independently', () => { + const longRaw = 'r'.repeat(100_000); + const mixedContent = [ + textBlock('text'), + { type: 'diff', path: 'file.ts', oldText: '', newText: 'new' }, + ]; + const structuredRaw = { value: 'r'.repeat(100_000) }; + const longContent = [textBlock('c'.repeat(100_000))]; + + const rawProjected = projectAcpToolResultUpdate( + toolUpdate(mixedContent, longRaw), + ); + expect(asRecord(rawProjected)['content']).toBe(mixedContent); + expect(jsonBytes(asRecord(rawProjected)['rawOutput'])).toBeLessThanOrEqual( + 65_536, + ); + + const contentProjected = projectAcpToolResultUpdate( + toolUpdate(longContent, structuredRaw), + ); + expect(asRecord(contentProjected)['rawOutput']).toBe(structuredRaw); + expect( + jsonBytes(asRecord(contentProjected)['content']), + ).toBeLessThanOrEqual(65_536); + }); + + it('exempts content blocks with extra fields', () => { + const content = [ + { + ...textBlock('x'.repeat(100_000)), + annotations: { audience: ['assistant'] }, + }, + ]; + const update = toolUpdate(content); + + expect(projectAcpToolResultUpdate(update)).toBe(update); + }); + + it.each([ + [ + 'diff', + { + type: 'diff', + path: 'file.ts', + oldText: '', + newText: 'x'.repeat(100_000), + }, + ], + [ + 'terminal', + { + type: 'terminal', + terminalId: 'terminal-1', + output: 'x'.repeat(100_000), + }, + ], + [ + 'media', + { type: 'image', data: 'x'.repeat(100_000), mimeType: 'image/png' }, + ], + ])('exempts oversized %s content', (_name, block) => { + const content = [block]; + const update = toolUpdate(content); + + expect(projectAcpToolResultUpdate(update)).toBe(update); + expect(asRecord(update)['content']).toBe(content); + }); + + it('exempts oversized A2UI tool updates as a whole', () => { + const text = `[${' '.repeat(100_000)}]`; + const update = toolUpdate([textBlock(text)], text, { + toolName: 'mcp__ui__present_ui', + serverId: 'a2ui-ui', + }); + + expect(projectAcpToolResultUpdate(update)).toBe(update); + }); + + it('is immutable and idempotent', () => { + const source = 'x'.repeat(100_000); + const content = [textBlock(source)]; + const update = toolUpdate(content, source); + const projected = projectAcpToolResultUpdate(update); + + expect(asRecord(update)['content']).toBe(content); + expect(content[0].content.text).toBe(source); + expect(projectAcpToolResultUpdate(projected)).toBe(projected); + }); + + it('shares unchanged blocks and metadata when one block is projected', () => { + const smallBlock = textBlock('small'); + const largeBlock = textBlock('x'.repeat(100_000)); + const meta = { toolName: 'read_file', nested: { stable: true } }; + const update = toolUpdate([smallBlock, largeBlock], undefined, meta); + const projected = projectAcpToolResultUpdate(update); + const blocks = asRecord(projected)['content'] as unknown[]; + + expect(projected).not.toBe(update); + expect(blocks[0]).toBe(smallBlock); + expect(blocks[1]).not.toBe(largeBlock); + expect(asRecord(projected)['_meta']).toBe(meta); + }); + + it('leaves unrelated and small updates unchanged by reference', () => { + const small = toolUpdate([textBlock('ok')], 'ok'); + const message = { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'x'.repeat(100_000) }, + } as SessionUpdate; + + expect(projectAcpToolResultUpdate(small)).toBe(small); + expect(projectAcpToolResultUpdate(message)).toBe(message); + }); + + it.each([ + ['499,999-byte ASCII', 'a'.repeat(499_999)], + ['500,001-code-unit CJK', '汉'.repeat(500_001)], + ])('bounds the %s baseline fixture', (_name, source) => { + const projected = projectAcpToolResultUpdate( + toolUpdate([textBlock(source)], source), + ); + expect(jsonBytes(asRecord(projected)['content'])).toBeLessThanOrEqual( + 65_536, + ); + expect(jsonBytes(asRecord(projected)['rawOutput'])).toBeLessThanOrEqual( + 65_536, + ); + }); + + it('keeps an ordinary projected JSON-RPC frame below 256 KiB', () => { + const source = 'x'.repeat(499_999); + const update = projectAcpToolResultUpdate( + toolUpdate([textBlock(source)], source), + ); + const frame = { + jsonrpc: '2.0', + method: 'session/update', + params: { sessionId: 'session-1', update }, + }; + const encoded = JSON.stringify(frame); + + expect(() => JSON.parse(encoded)).not.toThrow(); + expect(Buffer.byteLength(encoded, 'utf8')).toBeLessThan(256 * 1024); + }); +}); diff --git a/packages/cli/src/acp-integration/session/acp-tool-result-text-projection.ts b/packages/cli/src/acp-integration/session/acp-tool-result-text-projection.ts new file mode 100644 index 00000000000..59136aea4fa --- /dev/null +++ b/packages/cli/src/acp-integration/session/acp-tool-result-text-projection.ts @@ -0,0 +1,373 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Buffer } from 'node:buffer'; +import type { SessionUpdate } from '@agentclientprotocol/sdk'; +import { isA2uiToolMeta } from '@qwen-code/acp-bridge/bridgeClient'; + +export const ACP_TOOL_RESULT_TEXT_JSON_BYTE_BUDGET = 65_536; +export const ACP_TOOL_RESULT_TEXT_TRUNCATION_MARKER = + '\n[... truncated for ACP transport ...]\n'; + +interface CanonicalTextContentBlock { + type: 'content'; + content: { + type: 'text'; + text: string; + }; +} + +const EMPTY_CONTENT_ARRAY_JSON_BYTES = Buffer.byteLength('[]', 'utf8'); +const EMPTY_TEXT_BLOCK_JSON_BYTES = Buffer.byteLength( + JSON.stringify(createTextBlock('')), + 'utf8', +); +const JSON_ARRAY_SEPARATOR_BYTES = 1; +const JSON_STRING_DELIMITER_BYTES = 2; +const TRUNCATION_MARKER_PAYLOAD_BYTES = + jsonStringJsonByteLength(ACP_TOOL_RESULT_TEXT_TRUNCATION_MARKER) - + JSON_STRING_DELIMITER_BYTES; +const MAX_CANONICAL_TEXT_BLOCKS = Math.floor( + (ACP_TOOL_RESULT_TEXT_JSON_BYTE_BUDGET - + EMPTY_CONTENT_ARRAY_JSON_BYTES + + JSON_ARRAY_SEPARATOR_BYTES) / + (EMPTY_TEXT_BLOCK_JSON_BYTES + JSON_ARRAY_SEPARATOR_BYTES), +); + +function createTextBlock(text: string): CanonicalTextContentBlock { + return { + type: 'content', + content: { type: 'text', text }, + }; +} + +function isObjectRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasExactKeys( + value: Record, + first: string, + second: string, +): boolean { + const keys = Object.keys(value); + return ( + keys.length === 2 && + Object.hasOwn(value, first) && + Object.hasOwn(value, second) + ); +} + +function canonicalTextBlocks( + value: unknown, +): CanonicalTextContentBlock[] | undefined { + if (!Array.isArray(value)) return undefined; + for (const block of value) { + if ( + !isObjectRecord(block) || + !hasExactKeys(block, 'type', 'content') || + block['type'] !== 'content' || + !isObjectRecord(block['content']) || + !hasExactKeys(block['content'], 'type', 'text') || + block['content']['type'] !== 'text' || + typeof block['content']['text'] !== 'string' + ) { + return undefined; + } + } + return value as CanonicalTextContentBlock[]; +} + +function jsonPayloadBytesAt( + value: string, + index: number, +): { bytes: number; width: number } { + const code = value.charCodeAt(index); + if (code === 0x22 || code === 0x5c) return { bytes: 2, width: 1 }; + if (code <= 0x1f) { + return { + bytes: + code === 0x08 || + code === 0x09 || + code === 0x0a || + code === 0x0c || + code === 0x0d + ? 2 + : 6, + width: 1, + }; + } + if (code <= 0x7f) return { bytes: 1, width: 1 }; + if (code <= 0x7ff) return { bytes: 2, width: 1 }; + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + return next >= 0xdc00 && next <= 0xdfff + ? { bytes: 4, width: 2 } + : { bytes: 6, width: 1 }; + } + if (code >= 0xdc00 && code <= 0xdfff) { + return { bytes: 6, width: 1 }; + } + return { bytes: 3, width: 1 }; +} + +export function jsonStringJsonByteLength(value: string): number { + let bytes = JSON_STRING_DELIMITER_BYTES; + for (let index = 0; index < value.length; ) { + const part = jsonPayloadBytesAt(value, index); + bytes += part.bytes; + index += part.width; + } + return bytes; +} + +function jsonPayloadBytesBefore( + value: string, + end: number, +): { bytes: number; width: number } { + const last = value.charCodeAt(end - 1); + if (last >= 0xdc00 && last <= 0xdfff && end >= 2) { + const previous = value.charCodeAt(end - 2); + if (previous >= 0xd800 && previous <= 0xdbff) { + return { bytes: 4, width: 2 }; + } + } + return jsonPayloadBytesAt(value, end - 1); +} + +function selectPrefix(value: string, budget: number): number { + let end = 0; + let bytes = 0; + while (end < value.length) { + const part = jsonPayloadBytesAt(value, end); + if (bytes + part.bytes > budget) break; + bytes += part.bytes; + end += part.width; + } + return end; +} + +function selectSuffix(value: string, budget: number): number { + let start = value.length; + let bytes = 0; + while (start > 0) { + const part = jsonPayloadBytesBefore(value, start); + if (bytes + part.bytes > budget) break; + bytes += part.bytes; + start -= part.width; + } + return start; +} + +function copyString(value: string): string { + return value.split('').join(''); +} + +function truncateStringPayload( + value: string, + originalPayloadBytes: number, + payloadBudget: number, +): string { + if (originalPayloadBytes <= payloadBudget) return value; + if (payloadBudget < TRUNCATION_MARKER_PAYLOAD_BYTES) { + return ACP_TOOL_RESULT_TEXT_TRUNCATION_MARKER; + } + const sourceBudget = payloadBudget - TRUNCATION_MARKER_PAYLOAD_BYTES; + const headBudget = Math.floor(sourceBudget * 0.2); + const tailBudget = sourceBudget - headBudget; + const headEnd = selectPrefix(value, headBudget); + const tailStart = selectSuffix(value, tailBudget); + return ( + copyString(value.slice(0, headEnd)) + + ACP_TOOL_RESULT_TEXT_TRUNCATION_MARKER + + copyString(value.slice(tailStart)) + ); +} + +function contentSkeletonBytes(blockCount: number): number { + if (blockCount === 0) return EMPTY_CONTENT_ARRAY_JSON_BYTES; + return ( + EMPTY_CONTENT_ARRAY_JSON_BYTES + + blockCount * EMPTY_TEXT_BLOCK_JSON_BYTES + + (blockCount - 1) * JSON_ARRAY_SEPARATOR_BYTES + ); +} + +function fallbackContent(): CanonicalTextContentBlock[] { + return [createTextBlock(ACP_TOOL_RESULT_TEXT_TRUNCATION_MARKER)]; +} + +function jsonByteLength(value: unknown): number { + return Buffer.byteLength(JSON.stringify(value), 'utf8'); +} + +function allocatePayloadBudgets( + payloadBytes: readonly number[], + availableBytes: number, +): number[] | undefined { + const base = payloadBytes.map((bytes) => + Math.min(bytes, TRUNCATION_MARKER_PAYLOAD_BYTES), + ); + let remaining = availableBytes - base.reduce((sum, bytes) => sum + bytes, 0); + if (remaining < 0) return undefined; + + const capacities = payloadBytes.map((bytes, index) => ({ + index, + capacity: bytes - base[index], + })); + const sorted = capacities + .filter(({ capacity }) => capacity > 0) + .sort((left, right) => + left.capacity === right.capacity + ? left.index - right.index + : left.capacity - right.capacity, + ); + let active = sorted.length; + let position = 0; + let level = 0; + let remainder = 0; + while (position < sorted.length && active > 0 && remaining > 0) { + const nextLevel = sorted[position].capacity; + const cost = (nextLevel - level) * active; + if (cost > remaining) { + level += Math.floor(remaining / active); + remainder = remaining % active; + remaining = 0; + break; + } + level = nextLevel; + remaining -= cost; + while ( + position < sorted.length && + sorted[position].capacity === nextLevel + ) { + position++; + active--; + } + } + + const allocations = base.map( + (bytes, index) => bytes + Math.min(capacities[index].capacity, level), + ); + if (remainder > 0) { + for (const { index, capacity } of capacities) { + if (remainder === 0) break; + if (capacity > level) { + allocations[index]++; + remainder--; + } + } + } + return allocations; +} + +function projectContent( + original: CanonicalTextContentBlock[], +): CanonicalTextContentBlock[] { + if (original.length > MAX_CANONICAL_TEXT_BLOCKS) return fallbackContent(); + const skeletonBytes = contentSkeletonBytes(original.length); + const payloadBytes = original.map( + (block) => + jsonStringJsonByteLength(block.content.text) - + JSON_STRING_DELIMITER_BYTES, + ); + const totalPayloadBytes = payloadBytes.reduce((sum, bytes) => sum + bytes, 0); + if ( + skeletonBytes + totalPayloadBytes <= + ACP_TOOL_RESULT_TEXT_JSON_BYTE_BUDGET + ) { + return original; + } + + const allocations = allocatePayloadBudgets( + payloadBytes, + ACP_TOOL_RESULT_TEXT_JSON_BYTE_BUDGET - skeletonBytes, + ); + if (!allocations) return fallbackContent(); + + const projected = original.map((block, index) => { + if (payloadBytes[index] <= allocations[index]) return block; + return createTextBlock( + truncateStringPayload( + block.content.text, + payloadBytes[index], + allocations[index], + ), + ); + }); + return jsonByteLength(projected) <= ACP_TOOL_RESULT_TEXT_JSON_BYTE_BUDGET + ? projected + : fallbackContent(); +} + +function projectRawOutput(value: string): string { + const jsonBytes = jsonStringJsonByteLength(value); + if (jsonBytes <= ACP_TOOL_RESULT_TEXT_JSON_BYTE_BUDGET) return value; + const projected = truncateStringPayload( + value, + jsonBytes - JSON_STRING_DELIMITER_BYTES, + ACP_TOOL_RESULT_TEXT_JSON_BYTE_BUDGET - JSON_STRING_DELIMITER_BYTES, + ); + return jsonByteLength(projected) <= ACP_TOOL_RESULT_TEXT_JSON_BYTE_BUDGET + ? projected + : ACP_TOOL_RESULT_TEXT_TRUNCATION_MARKER; +} + +function a2uiMeta( + meta: Record | undefined, +): { toolName?: string; serverId?: string } | undefined { + if (!meta) return undefined; + const toolName = + typeof meta['toolName'] === 'string' ? meta['toolName'] : undefined; + const serverId = + typeof meta['serverId'] === 'string' ? meta['serverId'] : undefined; + return toolName === undefined && serverId === undefined + ? undefined + : { + ...(toolName === undefined ? {} : { toolName }), + ...(serverId === undefined ? {} : { serverId }), + }; +} + +export function projectAcpToolResultUpdate( + update: SessionUpdate, +): SessionUpdate { + const record = update as unknown as Record; + if (record['sessionUpdate'] !== 'tool_call_update') return update; + const meta = isObjectRecord(record['_meta']) ? record['_meta'] : undefined; + if (isA2uiToolMeta(a2uiMeta(meta))) return update; + + const content = canonicalTextBlocks(record['content']); + const rawOutput = record['rawOutput']; + if ( + content?.length === 1 && + typeof rawOutput === 'string' && + rawOutput === content[0].content.text + ) { + const projectedContent = projectContent(content); + if (projectedContent === content) return update; + const projectedText = projectedContent[0].content.text; + return { + ...record, + content: projectedContent, + rawOutput: projectedText, + } as unknown as SessionUpdate; + } + + const projectedContent = content ? projectContent(content) : undefined; + const projectedRawOutput = + typeof rawOutput === 'string' ? projectRawOutput(rawOutput) : undefined; + const contentChanged = + projectedContent !== undefined && projectedContent !== content; + const rawOutputChanged = + projectedRawOutput !== undefined && projectedRawOutput !== rawOutput; + if (!contentChanged && !rawOutputChanged) return update; + return { + ...record, + ...(contentChanged ? { content: projectedContent } : {}), + ...(rawOutputChanged ? { rawOutput: projectedRawOutput } : {}), + } as unknown as SessionUpdate; +} diff --git a/packages/cli/src/acp-integration/session/history-replay-page.test.ts b/packages/cli/src/acp-integration/session/history-replay-page.test.ts index e51a78b45ef..47c3ae8fb40 100644 --- a/packages/cli/src/acp-integration/session/history-replay-page.test.ts +++ b/packages/cli/src/acp-integration/session/history-replay-page.test.ts @@ -10,7 +10,9 @@ import type { SessionTranscriptCursorState, SessionTranscriptRecordPage, } from '@qwen-code/qwen-code-core'; +import { Buffer } from 'node:buffer'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { projectAcpToolResultUpdate } from './acp-tool-result-text-projection.js'; import { HistoryReplayer, MISSING_TOOL_RESULT_MESSAGE, @@ -109,6 +111,40 @@ function toolResultRecord(): ChatRecord { }; } +function largeToolResultRecord( + textParts: string[], + resultDisplay: string, +): ChatRecord { + return { + uuid: 'tool-record', + parentUuid: 'assistant-record', + sessionId: SESSION_ID, + timestamp: TIMESTAMP, + type: 'tool_result', + cwd: '/workspace', + version: '1.0.0', + message: { + role: 'user', + parts: textParts.map((output) => ({ + functionResponse: { + id: 'call-1', + name: 'read_file', + response: { output }, + }, + })), + }, + toolCallResult: { + callId: 'call-1', + responseParts: [], + resultDisplay, + }, + }; +} + +function jsonBytes(value: unknown): number { + return Buffer.byteLength(JSON.stringify(value), 'utf8'); +} + function cursorState(): SessionTranscriptCursorState { return { v: 1, @@ -142,6 +178,53 @@ afterEach(() => { }); describe('history replay page', () => { + it('bounds textual tool results collected for bulk replay', async () => { + const source = 'x'.repeat(499_999); + const result = await collectHistoryReplayUpdates({ + sessionId: SESSION_ID, + records: [largeToolResultRecord([source], source)], + cumulativeUsage: createReplayCumulativeUsage(), + }); + const update = result.updates.find( + (candidate) => candidate.sessionUpdate === 'tool_call_update', + ); + + expect(update).toBeDefined(); + const record = update as unknown as Record; + expect(jsonBytes(record['content'])).toBeLessThanOrEqual(65_536); + expect(jsonBytes(record['rawOutput'])).toBeLessThanOrEqual(65_536); + expect(projectAcpToolResultUpdate(update!)).toBe(update); + }); + + it('bounds multi-block textual tool results in paged replay', async () => { + const page = recordPage({ + records: [ + largeToolResultRecord( + ['a'.repeat(300_000), 'b'.repeat(300_000)], + 'r'.repeat(600_001), + ), + ], + }); + const result = await replayTranscriptRecordPage({ + sessionId: SESSION_ID, + page, + encodeCursor: vi.fn(), + }); + const update = result.updates.find( + (candidate) => candidate.sessionUpdate === 'tool_call_update', + ); + + expect(update).toBeDefined(); + const record = update as unknown as Record; + expect(jsonBytes(record['content'])).toBeLessThanOrEqual(65_536); + expect(jsonBytes(record['rawOutput'])).toBeLessThanOrEqual(65_536); + expect( + (record['content'] as Array<{ content: { text: string } }>).map( + (block) => block.content.text, + ), + ).toHaveLength(2); + }); + it('lifts record timestamps for bulk replay callers', async () => { const result = await collectHistoryReplayUpdates({ sessionId: SESSION_ID, diff --git a/packages/cli/src/acp-integration/session/history-replay-page.ts b/packages/cli/src/acp-integration/session/history-replay-page.ts index 906f648ca8b..3b584f64345 100644 --- a/packages/cli/src/acp-integration/session/history-replay-page.ts +++ b/packages/cli/src/acp-integration/session/history-replay-page.ts @@ -15,6 +15,7 @@ import { } from '@qwen-code/qwen-code-core'; import type { SessionUpdate } from '@agentclientprotocol/sdk'; import type { TranscriptReplayStateV1 } from '@qwen-code/acp-bridge/transcriptReplay'; +import { projectAcpToolResultUpdate } from './acp-tool-result-text-projection.js'; import { HistoryReplayer } from './history-replayer.js'; import type { PendingReplayToolCall } from './history-replayer.js'; import type { CumulativeUsage, SessionEmitterContext } from './types.js'; @@ -158,11 +159,12 @@ function replayContext( return { sessionId, sendUpdate: async (update) => { + const projectedUpdate = projectAcpToolResultUpdate(update); if (activeRecordId === null) { - updates.push(update); + updates.push(projectedUpdate); return; } - const record = update as unknown as Record; + const record = projectedUpdate as unknown as Record; const meta = isObjectRecord(record['_meta']) ? record['_meta'] : {}; updates.push({ ...record, diff --git a/packages/cli/src/ui/utils/export/collect.test.ts b/packages/cli/src/ui/utils/export/collect.test.ts index 13a5a9cdd09..224e3dbc4ef 100644 --- a/packages/cli/src/ui/utils/export/collect.test.ts +++ b/packages/cli/src/ui/utils/export/collect.test.ts @@ -5,6 +5,7 @@ */ import { describe, expect, it, vi } from 'vitest'; +import { createHash } from 'node:crypto'; import type { ChatRecord, Config } from '@qwen-code/qwen-code-core'; import { collectSessionData } from './collect.js'; import type { ExportConfig } from './types.js'; @@ -16,6 +17,83 @@ describe('collectSessionData', () => { }), } as unknown as Config; + it('keeps oversized canonical tool results lossless in offline export', async () => { + const source = `head-${'x'.repeat(499_999)}-tail`; + const records: ChatRecord[] = [ + { + uuid: 'assistant-large', + parentUuid: null, + sessionId: 'session-large', + timestamp: '2026-08-03T00:00:00.000Z', + type: 'assistant', + cwd: '/workspace', + version: '1.0.0', + message: { + role: 'model', + parts: [ + { + functionCall: { + id: 'call-large', + name: 'read_file', + args: { path: '/workspace/large.txt' }, + }, + }, + ], + }, + }, + { + uuid: 'tool-large', + parentUuid: 'assistant-large', + sessionId: 'session-large', + timestamp: '2026-08-03T00:00:01.000Z', + type: 'tool_result', + cwd: '/workspace', + version: '1.0.0', + message: { + role: 'user', + parts: [ + { + functionResponse: { + id: 'call-large', + name: 'read_file', + response: { output: source }, + }, + }, + ], + }, + toolCallResult: { + callId: 'call-large', + responseParts: [], + resultDisplay: source, + }, + }, + ]; + + const data = await collectSessionData( + { + sessionId: 'session-large', + startTime: '2026-08-03T00:00:00.000Z', + messages: records, + }, + config, + ); + const toolCall = data.messages.find( + (message) => message.type === 'tool_call', + ); + const exportedText = ( + toolCall?.toolCall?.content?.[0] as + | { content?: { text?: string } } + | undefined + )?.content?.text; + + expect(exportedText?.length).toBe(source.length); + expect( + createHash('sha256') + .update(exportedText ?? '') + .digest('hex'), + ).toBe(createHash('sha256').update(source).digest('hex')); + }); + it('skips line-count fallback for truncated saved-session previews', async () => { const records: ChatRecord[] = [ { From 81ea3d99aff53b6d691f4b398f0ba4e8dc094739 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Tue, 4 Aug 2026 00:49:47 +0800 Subject: [PATCH 2/4] fix(cli): Harden ACP text projection budgets Co-authored-by: Qwen-Coder --- .../acp-tool-result-text-projection.test.ts | 35 +++++ .../acp-tool-result-text-projection.ts | 120 +++++++++--------- 2 files changed, 92 insertions(+), 63 deletions(-) diff --git a/packages/cli/src/acp-integration/session/acp-tool-result-text-projection.test.ts b/packages/cli/src/acp-integration/session/acp-tool-result-text-projection.test.ts index 880d1e3eebe..23f738d5e03 100644 --- a/packages/cli/src/acp-integration/session/acp-tool-result-text-projection.test.ts +++ b/packages/cli/src/acp-integration/session/acp-tool-result-text-projection.test.ts @@ -191,6 +191,21 @@ describe('ACP tool-result text projection', () => { ); }); + it('saturates the budget after smaller blocks reach their capacity', () => { + const content = [ + textBlock('a'.repeat(10_000)), + textBlock('b'.repeat(50_000)), + textBlock('c'.repeat(200_000)), + ]; + const projected = projectAcpToolResultUpdate(toolUpdate(content)); + const texts = contentTexts(projected); + + expect(texts[0]).toBe(content[0].content.text); + expect(texts[1]).toContain(ACP_TOOL_RESULT_TEXT_TRUNCATION_MARKER); + expect(texts[2]).toContain(ACP_TOOL_RESULT_TEXT_TRUNCATION_MARKER); + expect(jsonBytes(asRecord(projected)['content'])).toBe(65_536); + }); + it('collapses content when the empty structure cannot fit', () => { const content = Array.from({ length: 1_192 }, () => textBlock('')); const projected = projectAcpToolResultUpdate(toolUpdate(content)); @@ -304,6 +319,16 @@ describe('ACP tool-result text projection', () => { expect(projectAcpToolResultUpdate(update)).toBe(update); }); + it('exempts A2UI updates identified only by serverId', () => { + const text = `[${' '.repeat(100_000)}]`; + const update = toolUpdate([textBlock(text)], text, { + toolName: 'present_quality_report', + serverId: 'dq-A2UI', + }); + + expect(projectAcpToolResultUpdate(update)).toBe(update); + }); + it('is immutable and idempotent', () => { const source = 'x'.repeat(100_000); const content = [textBlock(source)]; @@ -340,6 +365,16 @@ describe('ACP tool-result text projection', () => { expect(projectAcpToolResultUpdate(message)).toBe(message); }); + it('bounds active tool-call updates before completion', () => { + const update = toolUpdate([], 'x'.repeat(100_000)); + asRecord(update)['status'] = 'in_progress'; + const projected = projectAcpToolResultUpdate(update); + + expect(jsonBytes(asRecord(projected)['rawOutput'])).toBeLessThanOrEqual( + 65_536, + ); + }); + it.each([ ['499,999-byte ASCII', 'a'.repeat(499_999)], ['500,001-code-unit CJK', '汉'.repeat(500_001)], diff --git a/packages/cli/src/acp-integration/session/acp-tool-result-text-projection.ts b/packages/cli/src/acp-integration/session/acp-tool-result-text-projection.ts index 59136aea4fa..b870e670bda 100644 --- a/packages/cli/src/acp-integration/session/acp-tool-result-text-projection.ts +++ b/packages/cli/src/acp-integration/session/acp-tool-result-text-projection.ts @@ -81,71 +81,69 @@ function canonicalTextBlocks( return value as CanonicalTextContentBlock[]; } -function jsonPayloadBytesAt( - value: string, - index: number, -): { bytes: number; width: number } { +function jsonPayloadBytesAt(value: string, index: number): number { const code = value.charCodeAt(index); - if (code === 0x22 || code === 0x5c) return { bytes: 2, width: 1 }; + if (code === 0x22 || code === 0x5c) return 2; if (code <= 0x1f) { - return { - bytes: - code === 0x08 || - code === 0x09 || - code === 0x0a || - code === 0x0c || - code === 0x0d - ? 2 - : 6, - width: 1, - }; + return code === 0x08 || + code === 0x09 || + code === 0x0a || + code === 0x0c || + code === 0x0d + ? 2 + : 6; } - if (code <= 0x7f) return { bytes: 1, width: 1 }; - if (code <= 0x7ff) return { bytes: 2, width: 1 }; + if (code <= 0x7f) return 1; + if (code <= 0x7ff) return 2; if (code >= 0xd800 && code <= 0xdbff) { const next = value.charCodeAt(index + 1); - return next >= 0xdc00 && next <= 0xdfff - ? { bytes: 4, width: 2 } - : { bytes: 6, width: 1 }; - } - if (code >= 0xdc00 && code <= 0xdfff) { - return { bytes: 6, width: 1 }; + return next >= 0xdc00 && next <= 0xdfff ? 4 : 6; } - return { bytes: 3, width: 1 }; + if (code >= 0xdc00 && code <= 0xdfff) return 6; + return 3; } -export function jsonStringJsonByteLength(value: string): number { - let bytes = JSON_STRING_DELIMITER_BYTES; +function jsonPayloadWidthAt(value: string, index: number): number { + const code = value.charCodeAt(index); + if (code < 0xd800 || code > 0xdbff) return 1; + const next = value.charCodeAt(index + 1); + return next >= 0xdc00 && next <= 0xdfff ? 2 : 1; +} + +function jsonStringPayloadByteLength( + value: string, + stopAfterBytes = Number.POSITIVE_INFINITY, +): number { + let bytes = 0; for (let index = 0; index < value.length; ) { - const part = jsonPayloadBytesAt(value, index); - bytes += part.bytes; - index += part.width; + bytes += jsonPayloadBytesAt(value, index); + if (bytes > stopAfterBytes) return bytes; + index += jsonPayloadWidthAt(value, index); } return bytes; } -function jsonPayloadBytesBefore( - value: string, - end: number, -): { bytes: number; width: number } { +export function jsonStringJsonByteLength(value: string): number { + return JSON_STRING_DELIMITER_BYTES + jsonStringPayloadByteLength(value); +} + +function jsonPayloadWidthBefore(value: string, end: number): number { const last = value.charCodeAt(end - 1); if (last >= 0xdc00 && last <= 0xdfff && end >= 2) { const previous = value.charCodeAt(end - 2); - if (previous >= 0xd800 && previous <= 0xdbff) { - return { bytes: 4, width: 2 }; - } + if (previous >= 0xd800 && previous <= 0xdbff) return 2; } - return jsonPayloadBytesAt(value, end - 1); + return 1; } function selectPrefix(value: string, budget: number): number { let end = 0; let bytes = 0; while (end < value.length) { - const part = jsonPayloadBytesAt(value, end); - if (bytes + part.bytes > budget) break; - bytes += part.bytes; - end += part.width; + const partBytes = jsonPayloadBytesAt(value, end); + if (bytes + partBytes > budget) break; + bytes += partBytes; + end += jsonPayloadWidthAt(value, end); } return end; } @@ -154,10 +152,11 @@ function selectSuffix(value: string, budget: number): number { let start = value.length; let bytes = 0; while (start > 0) { - const part = jsonPayloadBytesBefore(value, start); - if (bytes + part.bytes > budget) break; - bytes += part.bytes; - start -= part.width; + const partWidth = jsonPayloadWidthBefore(value, start); + const partBytes = jsonPayloadBytesAt(value, start - partWidth); + if (bytes + partBytes > budget) break; + bytes += partBytes; + start -= partWidth; } return start; } @@ -173,7 +172,7 @@ function truncateStringPayload( ): string { if (originalPayloadBytes <= payloadBudget) return value; if (payloadBudget < TRUNCATION_MARKER_PAYLOAD_BYTES) { - return ACP_TOOL_RESULT_TEXT_TRUNCATION_MARKER; + return copyString(value.slice(0, selectPrefix(value, payloadBudget))); } const sourceBudget = payloadBudget - TRUNCATION_MARKER_PAYLOAD_BYTES; const headBudget = Math.floor(sourceBudget * 0.2); @@ -269,22 +268,19 @@ function projectContent( ): CanonicalTextContentBlock[] { if (original.length > MAX_CANONICAL_TEXT_BLOCKS) return fallbackContent(); const skeletonBytes = contentSkeletonBytes(original.length); - const payloadBytes = original.map( - (block) => - jsonStringJsonByteLength(block.content.text) - - JSON_STRING_DELIMITER_BYTES, + const availablePayloadBytes = + ACP_TOOL_RESULT_TEXT_JSON_BYTE_BUDGET - skeletonBytes; + const payloadBytes = original.map((block) => + jsonStringPayloadByteLength(block.content.text, availablePayloadBytes), ); const totalPayloadBytes = payloadBytes.reduce((sum, bytes) => sum + bytes, 0); - if ( - skeletonBytes + totalPayloadBytes <= - ACP_TOOL_RESULT_TEXT_JSON_BYTE_BUDGET - ) { + if (totalPayloadBytes <= availablePayloadBytes) { return original; } const allocations = allocatePayloadBudgets( payloadBytes, - ACP_TOOL_RESULT_TEXT_JSON_BYTE_BUDGET - skeletonBytes, + availablePayloadBytes, ); if (!allocations) return fallbackContent(); @@ -304,13 +300,11 @@ function projectContent( } function projectRawOutput(value: string): string { - const jsonBytes = jsonStringJsonByteLength(value); - if (jsonBytes <= ACP_TOOL_RESULT_TEXT_JSON_BYTE_BUDGET) return value; - const projected = truncateStringPayload( - value, - jsonBytes - JSON_STRING_DELIMITER_BYTES, - ACP_TOOL_RESULT_TEXT_JSON_BYTE_BUDGET - JSON_STRING_DELIMITER_BYTES, - ); + const payloadBudget = + ACP_TOOL_RESULT_TEXT_JSON_BYTE_BUDGET - JSON_STRING_DELIMITER_BYTES; + const payloadBytes = jsonStringPayloadByteLength(value, payloadBudget); + if (payloadBytes <= payloadBudget) return value; + const projected = truncateStringPayload(value, payloadBytes, payloadBudget); return jsonByteLength(projected) <= ACP_TOOL_RESULT_TEXT_JSON_BYTE_BUDGET ? projected : ACP_TOOL_RESULT_TEXT_TRUNCATION_MARKER; From 806cb6e4266b0c43e8eac7db80c339518e7aa3d2 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Tue, 4 Aug 2026 04:11:16 +0800 Subject: [PATCH 3/4] perf(cli): Cap multi-block ACP projection scans Co-authored-by: Qwen-Coder --- ...6-08-03-acp-tool-result-text-projection.md | 6 ++- .../acp-tool-result-text-projection.test.ts | 20 ++++++-- .../acp-tool-result-text-projection.ts | 46 ++++++++++++++++--- 3 files changed, 61 insertions(+), 11 deletions(-) diff --git a/docs/design/2026-08-03-acp-tool-result-text-projection.md b/docs/design/2026-08-03-acp-tool-result-text-projection.md index 9cb83ac38ec..746b6dedf22 100644 --- a/docs/design/2026-08-03-acp-tool-result-text-projection.md +++ b/docs/design/2026-08-03-acp-tool-result-text-projection.md @@ -33,8 +33,10 @@ copied so a bounded preview does not retain an oversized backing string. For multi-block content, the array and object wrappers count toward the same field budget. A deterministic max-min allocation preserves block order and lets small blocks remain complete. Reduced blocks reserve marker space before -sharing the remaining payload budget. If the empty structure or minimum marker -set cannot fit, the field collapses to one canonical omission marker. +sharing the remaining payload budget. Fit is decided with a cumulative +early-stopping scan, then each block is scanned only through its largest +possible allocation. If the empty structure or minimum marker set cannot fit, +the field collapses to one canonical omission marker. The projector does not stringify an original oversized field, join blocks, or perform repeated binary searches. Native serialization is used only to verify diff --git a/packages/cli/src/acp-integration/session/acp-tool-result-text-projection.test.ts b/packages/cli/src/acp-integration/session/acp-tool-result-text-projection.test.ts index 23f738d5e03..625d148d91e 100644 --- a/packages/cli/src/acp-integration/session/acp-tool-result-text-projection.test.ts +++ b/packages/cli/src/acp-integration/session/acp-tool-result-text-projection.test.ts @@ -186,9 +186,7 @@ describe('ACP tool-result text projection', () => { expect(texts[0]).toBe('small'); expect(texts[1]).toContain(ACP_TOOL_RESULT_TEXT_TRUNCATION_MARKER); expect(texts[2]).toContain(ACP_TOOL_RESULT_TEXT_TRUNCATION_MARKER); - expect(jsonBytes(asRecord(projected)['content'])).toBeLessThanOrEqual( - 65_536, - ); + expect(jsonBytes(asRecord(projected)['content'])).toBe(65_536); }); it('saturates the budget after smaller blocks reach their capacity', () => { @@ -206,6 +204,22 @@ describe('ACP tool-result text projection', () => { expect(jsonBytes(asRecord(projected)['content'])).toBe(65_536); }); + it('bounds pathological many-block content without collapsing it', () => { + const content = Array.from({ length: 600 }, (_, index) => + textBlock(`${index}:${'x'.repeat(40_000)}`), + ); + const projected = projectAcpToolResultUpdate(toolUpdate(content)); + const texts = contentTexts(projected); + + expect(texts).toHaveLength(content.length); + expect( + texts.every((text) => + text.includes(ACP_TOOL_RESULT_TEXT_TRUNCATION_MARKER), + ), + ).toBe(true); + expect(jsonBytes(asRecord(projected)['content'])).toBe(65_536); + }); + it('collapses content when the empty structure cannot fit', () => { const content = Array.from({ length: 1_192 }, () => textBlock('')); const projected = projectAcpToolResultUpdate(toolUpdate(content)); diff --git a/packages/cli/src/acp-integration/session/acp-tool-result-text-projection.ts b/packages/cli/src/acp-integration/session/acp-tool-result-text-projection.ts index b870e670bda..5779e42bbe1 100644 --- a/packages/cli/src/acp-integration/session/acp-tool-result-text-projection.ts +++ b/packages/cli/src/acp-integration/session/acp-tool-result-text-projection.ts @@ -270,13 +270,47 @@ function projectContent( const skeletonBytes = contentSkeletonBytes(original.length); const availablePayloadBytes = ACP_TOOL_RESULT_TEXT_JSON_BYTE_BUDGET - skeletonBytes; - const payloadBytes = original.map((block) => - jsonStringPayloadByteLength(block.content.text, availablePayloadBytes), - ); - const totalPayloadBytes = payloadBytes.reduce((sum, bytes) => sum + bytes, 0); - if (totalPayloadBytes <= availablePayloadBytes) { - return original; + let remainingPayloadBytes = availablePayloadBytes; + let needsProjection = false; + for (const block of original) { + const payloadBytes = jsonStringPayloadByteLength( + block.content.text, + remainingPayloadBytes, + ); + if (payloadBytes > remainingPayloadBytes) { + needsProjection = true; + break; + } + remainingPayloadBytes -= payloadBytes; } + if (!needsProjection) return original; + + const baseScans = original.map((block) => + jsonStringPayloadByteLength( + block.content.text, + TRUNCATION_MARKER_PAYLOAD_BYTES, + ), + ); + const basePayloadBytes = baseScans.map((bytes) => + Math.min(bytes, TRUNCATION_MARKER_PAYLOAD_BYTES), + ); + const baseTotal = basePayloadBytes.reduce((sum, bytes) => sum + bytes, 0); + if (baseTotal > availablePayloadBytes) return fallbackContent(); + + const payloadBytes = original.map((block, index) => { + if (baseScans[index] <= TRUNCATION_MARKER_PAYLOAD_BYTES) { + return baseScans[index]; + } + const maximumAllocation = + availablePayloadBytes - baseTotal + basePayloadBytes[index]; + const bytes = jsonStringPayloadByteLength( + block.content.text, + maximumAllocation, + ); + // Other blocks always retain their base, so this block cannot receive + // more than maximumAllocation. One extra byte is enough to mean truncated. + return bytes <= maximumAllocation ? bytes : maximumAllocation + 1; + }); const allocations = allocatePayloadBudgets( payloadBytes, From 2f7daf6f5c2957693cc4b4cba2291119cd6eba1f Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Wed, 5 Aug 2026 19:40:23 +0800 Subject: [PATCH 4/4] test(cli): Cover ACP projection boundary guards Co-authored-by: Qwen-Coder --- .../acp-tool-result-text-projection.test.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/cli/src/acp-integration/session/acp-tool-result-text-projection.test.ts b/packages/cli/src/acp-integration/session/acp-tool-result-text-projection.test.ts index 625d148d91e..0b70d941ec1 100644 --- a/packages/cli/src/acp-integration/session/acp-tool-result-text-projection.test.ts +++ b/packages/cli/src/acp-integration/session/acp-tool-result-text-projection.test.ts @@ -229,6 +229,14 @@ describe('ACP tool-result text projection', () => { ]); }); + it('keeps content at exactly the block-count maximum', () => { + const content = Array.from({ length: 1_191 }, () => textBlock('')); + const update = toolUpdate(content); + + expect(projectAcpToolResultUpdate(update)).toBe(update); + expect(contentTexts(update)).toHaveLength(1_191); + }); + it('collapses content when the minimum marker set cannot fit', () => { const content = Array.from({ length: 1_000 }, () => textBlock('x'.repeat(100)), @@ -293,6 +301,22 @@ describe('ACP tool-result text projection', () => { expect(projectAcpToolResultUpdate(update)).toBe(update); }); + it('exempts content blocks whose inner text carries extra fields', () => { + const content = [ + { + type: 'content', + content: { + type: 'text', + text: 'x'.repeat(100_000), + annotations: { audience: ['assistant'] }, + }, + }, + ]; + const update = toolUpdate(content); + + expect(projectAcpToolResultUpdate(update)).toBe(update); + }); + it.each([ [ 'diff', @@ -343,6 +367,15 @@ describe('ACP tool-result text projection', () => { expect(projectAcpToolResultUpdate(update)).toBe(update); }); + it('exempts legacy A2UI updates identified only by toolName', () => { + const text = `[${' '.repeat(100_000)}]`; + const update = toolUpdate([textBlock(text)], text, { + toolName: 'mcp__ui__present_ui', + }); + + expect(projectAcpToolResultUpdate(update)).toBe(update); + }); + it('is immutable and idempotent', () => { const source = 'x'.repeat(100_000); const content = [textBlock(source)];