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"); +});