From 6952c8abab66cf618ef0d8c1c53fd66619f78c51 Mon Sep 17 00:00:00 2001 From: Ryan Vogel Date: Thu, 12 Feb 2026 09:51:56 -0500 Subject: [PATCH] feat(tool): return image attachments from webfetch --- packages/opencode/src/tool/webfetch.ts | 28 +++++- packages/opencode/test/tool/webfetch.test.ts | 97 ++++++++++++++++++++ 2 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/test/tool/webfetch.test.ts diff --git a/packages/opencode/src/tool/webfetch.ts b/packages/opencode/src/tool/webfetch.ts index c9479b9df81c..cd0d8dcdec16 100644 --- a/packages/opencode/src/tool/webfetch.ts +++ b/packages/opencode/src/tool/webfetch.ts @@ -3,6 +3,7 @@ import { Tool } from "./tool" import TurndownService from "turndown" import DESCRIPTION from "./webfetch.txt" import { abortAfterAny } from "../util/abort" +import { Identifier } from "../id/id" const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds @@ -87,11 +88,34 @@ export const WebFetchTool = Tool.define("webfetch", { throw new Error("Response too large (exceeds 5MB limit)") } - const content = new TextDecoder().decode(arrayBuffer) const contentType = response.headers.get("content-type") || "" - + const mime = contentType.split(";")[0]?.trim().toLowerCase() || "" const title = `${params.url} (${contentType})` + // Check if response is an image + const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet" + + if (isImage) { + const base64Content = Buffer.from(arrayBuffer).toString("base64") + return { + title, + output: "Image fetched successfully", + metadata: {}, + attachments: [ + { + id: Identifier.ascending("part"), + sessionID: ctx.sessionID, + messageID: ctx.messageID, + type: "file", + mime, + url: `data:${mime};base64,${base64Content}`, + }, + ], + } + } + + const content = new TextDecoder().decode(arrayBuffer) + // Handle content based on requested format and actual content type switch (params.format) { case "markdown": diff --git a/packages/opencode/test/tool/webfetch.test.ts b/packages/opencode/test/tool/webfetch.test.ts new file mode 100644 index 000000000000..10178af8fab7 --- /dev/null +++ b/packages/opencode/test/tool/webfetch.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { Instance } from "../../src/project/instance" +import { WebFetchTool } from "../../src/tool/webfetch" + +const projectRoot = path.join(import.meta.dir, "../..") + +const ctx = { + sessionID: "test", + messageID: "message", + callID: "", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, +} + +async function withFetch( + mockFetch: (input: string | URL | Request, init?: RequestInit) => Promise, + fn: () => Promise, +) { + const originalFetch = globalThis.fetch + globalThis.fetch = mockFetch as unknown as typeof fetch + try { + await fn() + } finally { + globalThis.fetch = originalFetch + } +} + +describe("tool.webfetch", () => { + test("returns image responses as file attachments", async () => { + const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]) + await withFetch( + async () => new Response(bytes, { status: 200, headers: { "content-type": "IMAGE/PNG; charset=binary" } }), + async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const webfetch = await WebFetchTool.init() + const result = await webfetch.execute({ url: "https://example.com/image.png", format: "markdown" }, ctx) + expect(result.output).toBe("Image fetched successfully") + expect(result.attachments).toBeDefined() + expect(result.attachments?.length).toBe(1) + expect(result.attachments?.[0].type).toBe("file") + expect(result.attachments?.[0].mime).toBe("image/png") + expect(result.attachments?.[0].url.startsWith("data:image/png;base64,")).toBe(true) + }, + }) + }, + ) + }) + + test("keeps svg as text output", async () => { + const svg = 'hello' + await withFetch( + async () => + new Response(svg, { + status: 200, + headers: { "content-type": "image/svg+xml; charset=UTF-8" }, + }), + async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const webfetch = await WebFetchTool.init() + const result = await webfetch.execute({ url: "https://example.com/image.svg", format: "html" }, ctx) + expect(result.output).toContain(" { + await withFetch( + async () => + new Response("hello from webfetch", { + status: 200, + headers: { "content-type": "text/plain; charset=utf-8" }, + }), + async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const webfetch = await WebFetchTool.init() + const result = await webfetch.execute({ url: "https://example.com/file.txt", format: "text" }, ctx) + expect(result.output).toBe("hello from webfetch") + expect(result.attachments).toBeUndefined() + }, + }) + }, + ) + }) +})