}).arrayBuffer;
+ if (typeof readBytes === "function") {
+ try {
+ const bytes = new Uint8Array(await readBytes.call(res));
+ return {
+ text: decodeResponseBytes(res, bytes),
+ truncated: false,
+ bytesRead: bytes.byteLength,
+ };
+ } catch {
+ // Fall back to text() for lightweight Response-like mocks that do not expose bytes.
+ }
}
try {
diff --git a/src/agents/tools/web-tools.fetch.test.ts b/src/agents/tools/web-tools.fetch.test.ts
index 97f0974b7b0..f447a99b77d 100644
--- a/src/agents/tools/web-tools.fetch.test.ts
+++ b/src/agents/tools/web-tools.fetch.test.ts
@@ -231,6 +231,83 @@ describe("web_fetch extraction fallbacks", () => {
expect(details.truncated).toBe(true);
});
+ it("decodes response bytes with a charset from Content-Type", async () => {
+ installMockFetch((input: RequestInfo | URL) => {
+ const response = new Response(new Uint8Array([0x63, 0x61, 0x66, 0xe9]), {
+ status: 200,
+ headers: { "content-type": "text/plain; charset=iso-8859-1" },
+ });
+ Object.defineProperty(response, "url", { value: resolveRequestUrl(input) });
+ return Promise.resolve(response);
+ });
+
+ const tool = createFetchTool({ firecrawl: { enabled: false } });
+ const result = await executeFetch(tool, {
+ url: "https://example.com/latin1",
+ extractMode: "text",
+ });
+ const details = result?.details as { text?: string };
+
+ expect(details.text).toContain("café");
+ expect(details.text).not.toContain("caf�");
+ });
+
+ it("decodes HTML using a meta http-equiv charset before extraction", async () => {
+ const encoder = new TextEncoder();
+ const japanese = new Uint8Array([0x93, 0xfa, 0x96, 0x7b, 0x8c, 0xea]);
+ const responseBytes = new Uint8Array([
+ ...encoder.encode(
+ '',
+ ),
+ ...japanese,
+ ...encoder.encode(""),
+ ...japanese,
+ ...encoder.encode("
"),
+ ]);
+ installMockFetch((input: RequestInfo | URL) => {
+ const response = new Response(responseBytes, {
+ status: 200,
+ headers: { "content-type": "text/html" },
+ });
+ Object.defineProperty(response, "url", { value: resolveRequestUrl(input) });
+ return Promise.resolve(response);
+ });
+
+ const tool = createFetchTool({ firecrawl: { enabled: false } });
+ const result = await executeFetch(tool, {
+ url: "https://example.com/shift-jis",
+ extractMode: "text",
+ });
+ const details = result?.details as { text?: string; title?: string };
+ const output = `${details.title ?? ""}\n${details.text ?? ""}`;
+
+ expect(output).toContain("日本語");
+ expect(output).not.toContain("�");
+ });
+
+ it("ignores charset text in unrelated meta content", async () => {
+ const body =
+ '日本語日本語';
+ installMockFetch((input: RequestInfo | URL) => {
+ const response = new Response(new TextEncoder().encode(body), {
+ status: 200,
+ headers: { "content-type": "text/html" },
+ });
+ Object.defineProperty(response, "url", { value: resolveRequestUrl(input) });
+ return Promise.resolve(response);
+ });
+
+ const tool = createFetchTool({ firecrawl: { enabled: false } });
+ const result = await executeFetch(tool, {
+ url: "https://example.com/content-only-charset",
+ extractMode: "text",
+ });
+ const details = result?.details as { text?: string; title?: string };
+ const output = `${details.title ?? ""}\n${details.text ?? ""}`;
+
+ expect(output).toContain("日本語");
+ });
+
it("caps response bytes and does not hang on endless streams", async () => {
const chunk = new TextEncoder().encode("hi
");
const stream = new ReadableStream({