From 7f2745f3458b2f9abf03dbec0f5a7268746ac43c Mon Sep 17 00:00:00 2001 From: Alan Date: Sat, 18 Jul 2026 17:59:50 +0800 Subject: [PATCH] fix(tool): keep truncated results within maxBytes for small caps applyResultSizeLimit reserved room for the truncation suffix with Math.max(0, maxBytes - suffixBytes) but still appended the full suffix when maxBytes was smaller than it. A one-byte result with maxBytes=0 returned the full 46-byte notice and metadata.returnedBytes recorded 46, both exceeding the requested cap. When maxBytes cannot fit the suffix, cap the notice itself so the returned text and metadata.returnedBytes stay within maxBytes. Add boundary regression tests (maxBytes 0, around the suffix length, and a multibyte UTF-8 boundary). --- src/tool/protocol/result.ts | 9 ++++++-- tests/tool/tool-result-size-limit.spec.ts | 28 +++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/tool/protocol/result.ts b/src/tool/protocol/result.ts index 486ffac4..0de0427a 100644 --- a/src/tool/protocol/result.ts +++ b/src/tool/protocol/result.ts @@ -161,9 +161,14 @@ export function applyResultSizeLimit( const suffix = "\n[Tool output truncated: head and tail shown.]"; const suffixBytes = Buffer.byteLength(suffix, "utf8"); - const budget = Math.max(0, maxBytes - suffixBytes); const text = content.map(contentToText).join("\n"); - const truncatedText = headTailTruncateUtf8(text, budget) + suffix; + // If maxBytes cannot even fit the suffix, cap the notice itself instead of + // appending it in full and blowing past the limit; otherwise reserve room + // for the suffix so head + tail + suffix stays within maxBytes. + const truncatedText = + maxBytes <= suffixBytes + ? truncateUtf8(suffix, maxBytes) + : headTailTruncateUtf8(text, maxBytes - suffixBytes) + suffix; const returnedBytes = Buffer.byteLength(truncatedText, "utf8"); return { diff --git a/tests/tool/tool-result-size-limit.spec.ts b/tests/tool/tool-result-size-limit.spec.ts index 4db7ca9b..46144c7d 100644 --- a/tests/tool/tool-result-size-limit.spec.ts +++ b/tests/tool/tool-result-size-limit.spec.ts @@ -21,3 +21,31 @@ test("applyResultSizeLimit keeps both head and tail when truncating text output" assert.match(text, /middle omitted/); assert.match(text, /head and tail shown/); }); + +test("applyResultSizeLimit never returns more bytes than a small maxBytes cap", () => { + const suffixBytes = Buffer.byteLength("\n[Tool output truncated: head and tail shown.]", "utf8"); + const original = "x".repeat(500); + + for (const maxBytes of [0, 1, suffixBytes - 1, suffixBytes, suffixBytes + 1]) { + const { content, metadata } = applyResultSizeLimit([{ type: "text", text: original }], maxBytes); + const text = content[0]?.type === "text" ? content[0].text : ""; + const returnedBytes = Buffer.byteLength(text, "utf8"); + + assert.ok(returnedBytes <= maxBytes, `maxBytes=${maxBytes}: returned ${returnedBytes} bytes`); + assert.equal(metadata?.truncated, true); + assert.equal(metadata?.originalBytes, 500); + assert.equal(metadata?.returnedBytes, returnedBytes); + } +}); + +test("applyResultSizeLimit stays UTF-8 safe and within the cap at a byte boundary", () => { + const original = "€".repeat(300); // 3 bytes per character + const maxBytes = 121; + const { content, metadata } = applyResultSizeLimit([{ type: "text", text: original }], maxBytes); + const text = content[0]?.type === "text" ? content[0].text : ""; + const returnedBytes = Buffer.byteLength(text, "utf8"); + + assert.ok(returnedBytes <= maxBytes, `returned ${returnedBytes} bytes`); + assert.equal(metadata?.returnedBytes, returnedBytes); + assert.ok(!text.includes("�"), "output must not contain replacement characters"); +});