Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions docs/design/2026-08-03-acp-tool-result-text-projection.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion docs/design/final-tool-response-budget.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
81 changes: 81 additions & 0 deletions packages/cli/src/acp-integration/session/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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<string, unknown>;
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',
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/acp-integration/session/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -4712,7 +4713,7 @@ export class Session implements SessionContext {
async sendUpdate(update: SessionUpdate): Promise<void> {
const params: SessionNotification = {
sessionId: this.sessionId,
update,
update: projectAcpToolResultUpdate(update),
};

if (update.sessionUpdate === 'plan') {
Expand Down
Loading
Loading