diff --git a/src/agents/tools/web-shared.test.ts b/src/agents/tools/web-shared.test.ts index 358588ba44a4..64f0a15e061b 100644 --- a/src/agents/tools/web-shared.test.ts +++ b/src/agents/tools/web-shared.test.ts @@ -23,14 +23,26 @@ function responseFromReader(params: { chunks: string[]; cancel: () => Promise; releaseLock: () => void; + readError?: Error; }): Response { const chunks: Array> = params.chunks.map((chunk) => ({ done: false, value: new TextEncoder().encode(chunk), })); - chunks.push({ done: true, value: undefined }); + if (!params.readError) { + chunks.push({ done: true, value: undefined }); + } const reader = { - read: async () => chunks.shift() ?? { done: true, value: undefined }, + read: async () => { + const next = chunks.shift(); + if (next) { + return next; + } + if (params.readError) { + throw params.readError; + } + return { done: true, value: undefined }; + }, cancel: params.cancel, releaseLock: params.releaseLock, } as ReadableStreamDefaultReader; @@ -147,6 +159,97 @@ describe("readResponseText", () => { expect(releaseLock).toHaveBeenCalledTimes(1); }); + it("does not mark exact-limit streamed responses as truncated", async () => { + const cancel = vi.fn(async () => undefined); + const releaseLock = vi.fn(); + const response = responseFromReader({ + chunks: ["hello"], + cancel, + releaseLock, + }); + + await expect(readResponseText(response, { maxBytes: 5 })).resolves.toEqual({ + text: "hello", + truncated: false, + bytesRead: 5, + }); + expect(cancel).not.toHaveBeenCalled(); + expect(releaseLock).toHaveBeenCalledTimes(1); + }); + + it("does not mark multi-chunk exact-limit streamed responses as truncated", async () => { + const cancel = vi.fn(async () => undefined); + const releaseLock = vi.fn(); + const response = responseFromReader({ + chunks: ["hel", "lo"], + cancel, + releaseLock, + }); + + await expect(readResponseText(response, { maxBytes: 5 })).resolves.toEqual({ + text: "hello", + truncated: false, + bytesRead: 5, + }); + expect(cancel).not.toHaveBeenCalled(); + expect(releaseLock).toHaveBeenCalledTimes(1); + }); + + it("marks responses that exceed the limit as truncated after confirming overflow", async () => { + const cancel = vi.fn(async () => undefined); + const releaseLock = vi.fn(); + const response = responseFromReader({ + chunks: ["hello", "!"], + cancel, + releaseLock, + }); + + await expect(readResponseText(response, { maxBytes: 5 })).resolves.toEqual({ + text: "hello", + truncated: true, + bytesRead: 5, + }); + expect(cancel).toHaveBeenCalledTimes(1); + expect(releaseLock).toHaveBeenCalledTimes(1); + }); + + it("does not mark exact-limit responses as truncated when followed by zero-byte chunks", async () => { + const cancel = vi.fn(async () => undefined); + const releaseLock = vi.fn(); + const response = responseFromReader({ + chunks: ["hello", ""], + cancel, + releaseLock, + }); + + await expect(readResponseText(response, { maxBytes: 5 })).resolves.toEqual({ + text: "hello", + truncated: false, + bytesRead: 5, + }); + expect(cancel).not.toHaveBeenCalled(); + expect(releaseLock).toHaveBeenCalledTimes(1); + }); + + it("keeps exact-limit responses truncated when the confirming read fails", async () => { + const cancel = vi.fn(async () => undefined); + const releaseLock = vi.fn(); + const response = responseFromReader({ + chunks: ["hello"], + cancel, + releaseLock, + readError: new Error("stream failed before EOF"), + }); + + await expect(readResponseText(response, { maxBytes: 5 })).resolves.toEqual({ + text: "hello", + truncated: true, + bytesRead: 5, + }); + expect(cancel).toHaveBeenCalledTimes(1); + expect(releaseLock).toHaveBeenCalledTimes(1); + }); + it("does not invoke whole-body fallbacks when maxBytes is set", async () => { const arrayBuffer = vi.fn(async () => new TextEncoder().encode("hello").buffer); const text = vi.fn(async () => "hello"); diff --git a/src/agents/tools/web-shared.ts b/src/agents/tools/web-shared.ts index 523cc6694ae5..a40ee1f9a057 100644 --- a/src/agents/tools/web-shared.ts +++ b/src/agents/tools/web-shared.ts @@ -272,8 +272,25 @@ export async function readResponseText( bytesRead += chunk.byteLength; parts.push(chunk); - if (truncated || bytesRead >= maxBytes) { + if (truncated) { + break; + } + if (bytesRead >= maxBytes) { + // Reached the byte cap. A body that is exactly maxBytes bytes is + // complete only once EOF confirms it. Keep the conservative result + // if that confirming read fails or the body continues. truncated = true; + while (true) { + const { done: atEnd, value: extra } = await reader.read(); + if (atEnd) { + truncated = false; + break; + } + if (extra && extra.byteLength > 0) { + truncated = true; + break; + } + } break; } }