perf: bound whitespace normalization for tool previews (#20332)

This commit is contained in:
Neil
2026-09-12 19:40:31 -07:00
committed by GitHub
parent 7b0701aefa
commit 2d1bd1eb48
4 changed files with 202 additions and 7 deletions
@@ -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 }))
}
@@ -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
}
@@ -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<typeof createToolInputDisplay>
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)
}
})
})
+10 -7
View File
@@ -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}`