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..746b6dedf22 --- /dev/null +++ b/docs/design/2026-08-03-acp-tool-result-text-projection.md @@ -0,0 +1,64 @@ +# 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. 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 +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..0b70d941ec1 --- /dev/null +++ b/packages/cli/src/acp-integration/session/acp-tool-result-text-projection.test.ts @@ -0,0 +1,455 @@ +/** + * @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'])).toBe(65_536); + }); + + 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('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)); + + expect(contentTexts(projected)).toEqual([ + ACP_TOOL_RESULT_TEXT_TRUNCATION_MARKER, + ]); + }); + + 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)), + ); + 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('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', + { + 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('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('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)]; + 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('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)], + ])('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..5779e42bbe1 --- /dev/null +++ b/packages/cli/src/acp-integration/session/acp-tool-result-text-projection.ts @@ -0,0 +1,401 @@ +/** + * @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): number { + const code = value.charCodeAt(index); + if (code === 0x22 || code === 0x5c) return 2; + if (code <= 0x1f) { + return code === 0x08 || + code === 0x09 || + code === 0x0a || + code === 0x0c || + code === 0x0d + ? 2 + : 6; + } + 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 ? 4 : 6; + } + if (code >= 0xdc00 && code <= 0xdfff) return 6; + return 3; +} + +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; ) { + bytes += jsonPayloadBytesAt(value, index); + if (bytes > stopAfterBytes) return bytes; + index += jsonPayloadWidthAt(value, index); + } + return bytes; +} + +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 2; + } + return 1; +} + +function selectPrefix(value: string, budget: number): number { + let end = 0; + let bytes = 0; + while (end < value.length) { + const partBytes = jsonPayloadBytesAt(value, end); + if (bytes + partBytes > budget) break; + bytes += partBytes; + end += jsonPayloadWidthAt(value, end); + } + return end; +} + +function selectSuffix(value: string, budget: number): number { + let start = value.length; + let bytes = 0; + while (start > 0) { + const partWidth = jsonPayloadWidthBefore(value, start); + const partBytes = jsonPayloadBytesAt(value, start - partWidth); + if (bytes + partBytes > budget) break; + bytes += partBytes; + start -= partWidth; + } + 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 copyString(value.slice(0, selectPrefix(value, payloadBudget))); + } + 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 availablePayloadBytes = + ACP_TOOL_RESULT_TEXT_JSON_BYTE_BUDGET - skeletonBytes; + 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, + availablePayloadBytes, + ); + 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 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; +} + +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[] = [ {