From 2d1bd1eb4829fcd811851774e950c905118a0dc3 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 12 Sep 2026 19:40:31 -0700 Subject: [PATCH] perf: bound whitespace normalization for tool previews (#20332) --- .../tool-preview-whitespace-benchmark.mjs | 114 ++++++++++++++++++ src/shared/native-chat-tool-preview-prefix.ts | 29 +++++ .../native-chat-tool-preview-scan.test.ts | 49 ++++++++ src/shared/native-chat-tool-summary.ts | 17 +-- 4 files changed, 202 insertions(+), 7 deletions(-) create mode 100644 config/scripts/tool-preview-whitespace-benchmark.mjs create mode 100644 src/shared/native-chat-tool-preview-prefix.ts create mode 100644 src/shared/native-chat-tool-preview-scan.test.ts diff --git a/config/scripts/tool-preview-whitespace-benchmark.mjs b/config/scripts/tool-preview-whitespace-benchmark.mjs new file mode 100644 index 00000000000..df590e52520 --- /dev/null +++ b/config/scripts/tool-preview-whitespace-benchmark.mjs @@ -0,0 +1,114 @@ +import fs from 'node:fs' +import { execFileSync } from 'node:child_process' +import { dirname, resolve } from 'node:path' +import assert from 'node:assert/strict' +import { build } from 'esbuild' +const file = 'src/shared/native-chat-tool-summary.ts' +const baseline = process.argv[2] ?? '20ab9950654' +const beforeSource = execFileSync('git', ['show', `${baseline}:${file}`], { + encoding: 'utf8', + windowsHide: true +}) +const afterSource = fs.readFileSync(file, 'utf8') +async function load(contents) { + const { outputFiles } = await build({ + stdin: { contents, loader: 'ts', resolveDir: dirname(resolve(file)) }, + bundle: true, + platform: 'node', + format: 'esm', + write: false + }) + return import( + `data:text/javascript;base64,${Buffer.from(outputFiles[0].text).toString('base64')}` + ) +} +const before = await load(beforeSource), + after = await load(afterSource) +const display = (m, input) => { + const d = m.createToolInputDisplay(input) + return { ...d, formatDetail: d.formatDetail() } +} +let seed = 8121 +const random = (max) => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + return Math.floor((seed / 4294967296) * max) +} +const pieces = [ + 'a', + 'b', + '…', + '😀', + '\ud800', + '\udc00', + '\0', + '\t', + '\r', + '\n', + ' ', + '\v', + '\f', + '\u00a0', + '\u1680', + '\u2000', + '\u200a', + '\u2028', + '\u2029', + '\u202f', + '\u205f', + '\u3000', + '\ufeff', + '\u0085', + '\u200b' +] +for (let i = 0; i < 3000; i++) { + const input = Array.from({ length: random(500) }, () => pieces[random(pieces.length)]).join('') + assert.equal(after.summarizeToolInput(input), before.summarizeToolInput(input)) + assert.deepEqual(display(after, input), display(before, input)) +} +for (const input of [ + `${'a'.repeat(79)}…`, + 'a'.repeat(80) + ' '.repeat(500), + ' '.repeat(100000), + `${'\n'.repeat(100000)}x`, + { command: 'a '.repeat(50000) }, + { file_path: 'a '.repeat(500) }, + { x: 'a '.repeat(500) }, + JSON.stringify({ command: 'a '.repeat(50000) }) +]) { + assert.deepEqual(display(after, input), display(before, input)) +} +for (const [shape, input] of [ + ['tiny', 'ls -la'], + ['100KB', 'a b\n\t'.repeat(15000)], + ['1MB', 'a b\n\t'.repeat(150000)], + ['all-space', ' '.repeat(1000000)], + ['leading', `${' '.repeat(1000000)}x`], + ['trailing', `x${' '.repeat(1000000)}`], + ['long-word', 'x'.repeat(1000000)] +]) { + const samples = { before: [], after: [] } + for (let i = 0; i < 20; i++) { + before.createToolInputDisplay(input) + after.createToolInputDisplay(input) + } + for (let r = 0; r < 8; r++) { + for (const [label, m] of r % 2 + ? [ + ['after', after], + ['before', before] + ] + : [ + ['before', before], + ['after', after] + ]) { + global.gc() + const start = process.cpuUsage() + for (let i = 0; i < 20; i++) { + m.createToolInputDisplay(input) + } + const cpu = process.cpuUsage(start) + samples[label].push((cpu.user + cpu.system) / 20000) + } + } + console.log(JSON.stringify({ shape, samples })) +} diff --git a/src/shared/native-chat-tool-preview-prefix.ts b/src/shared/native-chat-tool-preview-prefix.ts new file mode 100644 index 00000000000..0a3c10a37d1 --- /dev/null +++ b/src/shared/native-chat-tool-preview-prefix.ts @@ -0,0 +1,29 @@ +export const MAX_TOOL_PREVIEW_LENGTH = 80 +const SHORT_INPUT_LENGTH = 160 + +// One extra normalized code unit proves truncation and inequality with an 80-unit label. +export function collapsedToolInputPrefix(input: string): string { + if (input.length <= SHORT_INPUT_LENGTH) { + return input.replace(/\s+/g, ' ').trim() + } + let collapsed = '' + let pendingSpace = false + const whitespace = /\s+/y + for (let index = 0; index < input.length;) { + whitespace.lastIndex = index + if (whitespace.test(input)) { + index = whitespace.lastIndex + pendingSpace = collapsed.length > 0 + continue + } + if (pendingSpace) { + collapsed += ' ' + pendingSpace = false + } + collapsed += input[index++] + if (collapsed.length > MAX_TOOL_PREVIEW_LENGTH) { + return collapsed + } + } + return collapsed +} diff --git a/src/shared/native-chat-tool-preview-scan.test.ts b/src/shared/native-chat-tool-preview-scan.test.ts new file mode 100644 index 00000000000..9aaab44c073 --- /dev/null +++ b/src/shared/native-chat-tool-preview-scan.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from 'vitest' +import { createToolInputDisplay, summarizeToolInput } from './native-chat-tool-summary' + +const originalSummary = (input: string): string => { + const collapsed = input.replace(/\s+/g, ' ').trim() + return collapsed.length <= 80 ? collapsed : `${collapsed.slice(0, 79)}…` +} + +describe('bounded tool preview whitespace normalization', () => { + it('avoids whole-input replacement for long prose previews', () => { + const input = 'a b\n\t'.repeat(20_000) + const spy = vi.spyOn(String.prototype, 'replace') + let fullReplacements: number + let display: ReturnType + try { + display = createToolInputDisplay(input) + fullReplacements = spy.mock.instances.filter((receiver) => String(receiver) === input).length + } finally { + spy.mockRestore() + } + expect(display.label).toBe(originalSummary(input)) + expect(display.hasDetail).toBe(true) + expect(fullReplacements).toBe(0) + }) + + it('preserves exact labels and detail flags across whitespace and UTF-16 boundaries', () => { + const whitespace = '\t\n\v\f\r \u00a0\u1680\u2000\u200a\u2028\u2029\u202f\u205f\u3000\ufeff' + const inputs = [ + '', + whitespace.repeat(50), + `${'x'.repeat(79)}…`, + '😀'.repeat(41), + '\ud800'.repeat(82), + '\u0085\u200b'.repeat(50) + ] + for (const length of [0, 1, 78, 79, 80, 81, 159, 160, 161]) { + inputs.push(`${whitespace}${'x'.repeat(length)}${whitespace.repeat(30)}`) + inputs.push(`${'x'.repeat(length)}${whitespace}tail`) + } + for (const input of inputs) { + const expected = originalSummary(input) + const display = createToolInputDisplay(input) + expect(summarizeToolInput(input)).toBe(expected) + expect(display.label).toBe(expected) + expect(display.hasDetail).toBe(input.replace(/\s+/g, ' ').trim() !== expected) + expect(display.formatDetail()).toBe(input.length > 4000 ? `${input.slice(0, 4000)}…` : input) + } + }) +}) diff --git a/src/shared/native-chat-tool-summary.ts b/src/shared/native-chat-tool-summary.ts index 38f02ee34f8..ac631031916 100644 --- a/src/shared/native-chat-tool-summary.ts +++ b/src/shared/native-chat-tool-summary.ts @@ -1,7 +1,10 @@ +import { + collapsedToolInputPrefix, + MAX_TOOL_PREVIEW_LENGTH +} from './native-chat-tool-preview-prefix' import type { NativeChatMcpIdentity } from './native-chat-tool-identity' import { isToolCallBlock, type NativeChatBlock } from './native-chat-types' -const MAX_PREVIEW_LENGTH = 80 const MAX_PREVIEW_STRING_INPUT = 160 const MAX_PREVIEW_COLLECTION_ITEMS = 8 const MAX_PREVIEW_DEPTH = 2 @@ -34,10 +37,10 @@ export type ToolInputDisplay = { } export function summarizeToolInput(input: unknown): string { - const collapsed = toRawPreview(input).replace(/\s+/g, ' ').trim() - return collapsed.length <= MAX_PREVIEW_LENGTH + const collapsed = collapsedToolInputPrefix(toRawPreview(input)) + return collapsed.length <= MAX_TOOL_PREVIEW_LENGTH ? collapsed - : `${collapsed.slice(0, MAX_PREVIEW_LENGTH - 1)}…` + : `${collapsed.slice(0, MAX_TOOL_PREVIEW_LENGTH - 1)}…` } /** Build the renderer-independent row model from one normalization pass. Detail @@ -124,7 +127,7 @@ function normalizedToolInputHasDetail(input: unknown, label: string): boolean { if (isStructuredNormalizedToolInput(input)) { return true } - return typeof input === 'string' && input.replace(/\s+/g, ' ').trim() !== label + return typeof input === 'string' && collapsedToolInputPrefix(input) !== label } export function toolFilePath(input: unknown): string | null { @@ -241,10 +244,10 @@ function firstPrimaryToolArg( * absolute path drops the filename, the one part that tells two rows apart. */ function summarizeToolPath(path: string): string { const collapsed = path.replace(/\s+/g, ' ').trim() - if (collapsed.length <= MAX_PREVIEW_LENGTH) { + if (collapsed.length <= MAX_TOOL_PREVIEW_LENGTH) { return collapsed } - const tail = collapsed.slice(collapsed.length - (MAX_PREVIEW_LENGTH - 1)) + const tail = collapsed.slice(collapsed.length - (MAX_TOOL_PREVIEW_LENGTH - 1)) // Start at a segment boundary so the label doesn't open mid-name. const boundary = tail.search(/[\\/]/) return `…${boundary > 0 ? tail.slice(boundary) : tail}`