From 8e18c950ddca08a97734e9afd58ca5dc39442231 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 18 Apr 2026 09:16:28 -0400 Subject: [PATCH 1/5] fix: detect attachment mime from file contents Some screenshot files keep a .png name even when their bytes are JPEG, which causes providers like Anthropic to reject the attachment. Sniff common binary signatures before building the data URL so the declared MIME matches the payload. --- packages/opencode/src/tool/read.ts | 24 ++++++++++++++++++++++-- packages/opencode/test/tool/read.test.ts | 12 ++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 18c668ca0701..8cc84eeb755e 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -18,6 +18,24 @@ const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)` const MAX_BYTES = 50 * 1024 const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB` +const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value) + +const sniffAttachmentMime = (bytes: Uint8Array, fallback: string) => { + if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png" + if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg" + if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif" + if (startsWith(bytes, [0x42, 0x4d])) return "image/bmp" + if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf" + if ( + startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && + startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50]) + ) { + return "image/webp" + } + + return fallback +} + const parameters = z.object({ filePath: z.string().describe("The absolute path to the file or directory to read"), offset: z.coerce.number().describe("The line number to start reading from (1-indexed)").optional(), @@ -146,6 +164,8 @@ export const ReadTool = Tool.define( const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet" const isPdf = mime === "application/pdf" if (isImage || isPdf) { + const bytes = yield* fs.readFile(filepath) + const attachmentMime = sniffAttachmentMime(bytes, mime) const msg = `${isImage ? "Image" : "PDF"} read successfully` return { title, @@ -158,8 +178,8 @@ export const ReadTool = Tool.define( attachments: [ { type: "file" as const, - mime, - url: `data:${mime};base64,${Buffer.from(yield* fs.readFile(filepath)).toString("base64")}`, + mime: attachmentMime, + url: `data:${attachmentMime};base64,${Buffer.from(bytes).toString("base64")}`, }, ], } diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 7456990ad0ee..e7c51c09d5ea 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -394,6 +394,18 @@ describe("tool.read truncation", () => { }), ) + it.live("prefers detected image MIME over filename extension", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01]) + yield* put(path.join(dir, "image.png"), jpeg) + + const result = yield* exec(dir, { filePath: path.join(dir, "image.png") }) + expect(result.attachments?.[0].mime).toBe("image/jpeg") + expect(result.attachments?.[0].url.startsWith("data:image/jpeg;base64,")).toBe(true) + }), + ) + it.live("large image files are properly attached without error", () => Effect.gen(function* () { const result = yield* exec(FIXTURES_DIR, { filePath: path.join(FIXTURES_DIR, "large-image.png") }) From 4a8ef934fc057cfd9b5f4e93ecdd95a3df6df276 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 18 Apr 2026 09:29:30 -0400 Subject: [PATCH 2/5] refactor: sniff read media before branching Drive read-tool attachment classification from a small file header sample so mislabeled files are treated consistently. Reuse the same sampling helper for binary detection and strengthen the regression to cover the branch decision, not just the data URL MIME. --- packages/opencode/src/tool/read.ts | 52 ++++++++++++++---------- packages/opencode/test/tool/read.test.ts | 7 ++-- 2 files changed, 35 insertions(+), 24 deletions(-) diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 8cc84eeb755e..14636f023f35 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -17,6 +17,7 @@ const MAX_LINE_LENGTH = 2000 const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)` const MAX_BYTES = 50 * 1024 const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB` +const MIME_SAMPLE_BYTES = 12 const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value) @@ -36,6 +37,19 @@ const sniffAttachmentMime = (bytes: Uint8Array, fallback: string) => { return fallback } +async function readSample(filepath: string, fileSize: number, sampleSize: number) { + if (fileSize === 0) return new Uint8Array() + + const fh = await open(filepath, "r") + try { + const bytes = Buffer.alloc(Math.min(sampleSize, fileSize)) + const result = await fh.read(bytes, 0, bytes.length, 0) + return bytes.subarray(0, result.bytesRead) + } finally { + await fh.close() + } +} + const parameters = z.object({ filePath: z.string().describe("The absolute path to the file or directory to read"), offset: z.coerce.number().describe("The line number to start reading from (1-indexed)").optional(), @@ -160,12 +174,14 @@ export const ReadTool = Tool.define( const loaded = yield* instruction.resolve(ctx.messages, filepath, ctx.messageID) - const mime = AppFileSystem.mimeType(filepath) + const mime = sniffAttachmentMime( + yield* Effect.promise(() => readSample(filepath, Number(stat.size), MIME_SAMPLE_BYTES)), + AppFileSystem.mimeType(filepath), + ) const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet" const isPdf = mime === "application/pdf" if (isImage || isPdf) { const bytes = yield* fs.readFile(filepath) - const attachmentMime = sniffAttachmentMime(bytes, mime) const msg = `${isImage ? "Image" : "PDF"} read successfully` return { title, @@ -178,8 +194,8 @@ export const ReadTool = Tool.define( attachments: [ { type: "file" as const, - mime: attachmentMime, - url: `data:${attachmentMime};base64,${Buffer.from(bytes).toString("base64")}`, + mime, + url: `data:${mime};base64,${Buffer.from(bytes).toString("base64")}`, }, ], } @@ -321,23 +337,17 @@ async function isBinaryFile(filepath: string, fileSize: number): Promise 13 && bytes[i] < 32)) { - nonPrintableCount++ - } + const bytes = await readSample(filepath, fileSize, 4096) + if (bytes.length === 0) return false + + let nonPrintableCount = 0 + for (let i = 0; i < bytes.length; i++) { + if (bytes[i] === 0) return true + if (bytes[i] < 9 || (bytes[i] > 13 && bytes[i] < 32)) { + nonPrintableCount++ } - // If >30% non-printable characters, consider it binary - return nonPrintableCount / result.bytesRead > 0.3 - } finally { - await fh.close() } + + // If >30% non-printable characters, consider it binary + return nonPrintableCount / bytes.length > 0.3 } diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index e7c51c09d5ea..42817d15dfa4 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -394,13 +394,14 @@ describe("tool.read truncation", () => { }), ) - it.live("prefers detected image MIME over filename extension", () => + it.live("detects attachment media from file contents", () => Effect.gen(function* () { const dir = yield* tmpdirScoped() const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01]) - yield* put(path.join(dir, "image.png"), jpeg) + yield* put(path.join(dir, "image.bin"), jpeg) - const result = yield* exec(dir, { filePath: path.join(dir, "image.png") }) + const result = yield* exec(dir, { filePath: path.join(dir, "image.bin") }) + expect(result.output).toBe("Image read successfully") expect(result.attachments?.[0].mime).toBe("image/jpeg") expect(result.attachments?.[0].url.startsWith("data:image/jpeg;base64,")).toBe(true) }), From b750f7ee952125a2d01de70b6e5ec5354c826428 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 18 Apr 2026 09:33:14 -0400 Subject: [PATCH 3/5] refactor: use effect filesystem for read sampling Use the scoped FileSystem.open and File.readAlloc APIs from effect-smol instead of raw fs/promises handles. This keeps read-tool sampling and binary detection inside Effect, removes promise wrappers from the hot path, and matches the repo's existing filesystem service boundary. --- packages/opencode/src/tool/read.ts | 133 ++++++++++++++--------------- 1 file changed, 62 insertions(+), 71 deletions(-) diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 14636f023f35..1dd7c8cb80a3 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -1,7 +1,6 @@ import z from "zod" -import { Effect, Scope } from "effect" +import { Effect, Option, Scope } from "effect" import { createReadStream } from "fs" -import { open } from "fs/promises" import * as path from "path" import { createInterface } from "readline" import * as Tool from "./tool" @@ -37,19 +36,6 @@ const sniffAttachmentMime = (bytes: Uint8Array, fallback: string) => { return fallback } -async function readSample(filepath: string, fileSize: number, sampleSize: number) { - if (fileSize === 0) return new Uint8Array() - - const fh = await open(filepath, "r") - try { - const bytes = Buffer.alloc(Math.min(sampleSize, fileSize)) - const result = await fh.read(bytes, 0, bytes.length, 0) - return bytes.subarray(0, result.bytesRead) - } finally { - await fh.close() - } -} - const parameters = z.object({ filePath: z.string().describe("The absolute path to the file or directory to read"), offset: z.coerce.number().describe("The line number to start reading from (1-indexed)").optional(), @@ -109,6 +95,65 @@ export const ReadTool = Tool.define( yield* lsp.touchFile(filepath, false).pipe(Effect.ignore, Effect.forkIn(scope)) }) + const readSample = Effect.fn("ReadTool.readSample")(function* (filepath: string, fileSize: number, sampleSize: number) { + if (fileSize === 0) return new Uint8Array() + + return yield* Effect.scoped( + Effect.gen(function* () { + const file = yield* fs.open(filepath, { flag: "r" }) + return Option.getOrElse(yield* file.readAlloc(Math.min(sampleSize, fileSize)), () => new Uint8Array()) + }), + ) + }) + + const isBinaryFile = Effect.fn("ReadTool.isBinaryFile")(function* (filepath: string, fileSize: number) { + const ext = path.extname(filepath).toLowerCase() + switch (ext) { + case ".zip": + case ".tar": + case ".gz": + case ".exe": + case ".dll": + case ".so": + case ".class": + case ".jar": + case ".war": + case ".7z": + case ".doc": + case ".docx": + case ".xls": + case ".xlsx": + case ".ppt": + case ".pptx": + case ".odt": + case ".ods": + case ".odp": + case ".bin": + case ".dat": + case ".obj": + case ".o": + case ".a": + case ".lib": + case ".wasm": + case ".pyc": + case ".pyo": + return true + } + + const bytes = yield* readSample(filepath, fileSize, 4096) + if (bytes.length === 0) return false + + let nonPrintableCount = 0 + for (let i = 0; i < bytes.length; i++) { + if (bytes[i] === 0) return true + if (bytes[i] < 9 || (bytes[i] > 13 && bytes[i] < 32)) { + nonPrintableCount++ + } + } + + return nonPrintableCount / bytes.length > 0.3 + }) + const run = Effect.fn("ReadTool.execute")(function* (params: z.infer, ctx: Tool.Context) { if (params.offset !== undefined && params.offset < 1) { return yield* Effect.fail(new Error("offset must be greater than or equal to 1")) @@ -175,7 +220,7 @@ export const ReadTool = Tool.define( const loaded = yield* instruction.resolve(ctx.messages, filepath, ctx.messageID) const mime = sniffAttachmentMime( - yield* Effect.promise(() => readSample(filepath, Number(stat.size), MIME_SAMPLE_BYTES)), + yield* readSample(filepath, Number(stat.size), MIME_SAMPLE_BYTES), AppFileSystem.mimeType(filepath), ) const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet" @@ -201,7 +246,7 @@ export const ReadTool = Tool.define( } } - if (yield* Effect.promise(() => isBinaryFile(filepath, Number(stat.size)))) { + if (yield* isBinaryFile(filepath, Number(stat.size))) { return yield* Effect.fail(new Error(`Cannot read binary file: ${filepath}`)) } @@ -297,57 +342,3 @@ async function lines(filepath: string, opts: { limit: number; offset: number }) return { raw, count, cut, more, offset: opts.offset } } - -async function isBinaryFile(filepath: string, fileSize: number): Promise { - const ext = path.extname(filepath).toLowerCase() - // binary check for common non-text extensions - switch (ext) { - case ".zip": - case ".tar": - case ".gz": - case ".exe": - case ".dll": - case ".so": - case ".class": - case ".jar": - case ".war": - case ".7z": - case ".doc": - case ".docx": - case ".xls": - case ".xlsx": - case ".ppt": - case ".pptx": - case ".odt": - case ".ods": - case ".odp": - case ".bin": - case ".dat": - case ".obj": - case ".o": - case ".a": - case ".lib": - case ".wasm": - case ".pyc": - case ".pyo": - return true - default: - break - } - - if (fileSize === 0) return false - - const bytes = await readSample(filepath, fileSize, 4096) - if (bytes.length === 0) return false - - let nonPrintableCount = 0 - for (let i = 0; i < bytes.length; i++) { - if (bytes[i] === 0) return true - if (bytes[i] < 9 || (bytes[i] > 13 && bytes[i] < 32)) { - nonPrintableCount++ - } - } - - // If >30% non-printable characters, consider it binary - return nonPrintableCount / bytes.length > 0.3 -} From d33db4d0e90d5eb67d36cea23952089ef500a41e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 18 Apr 2026 09:43:03 -0400 Subject: [PATCH 4/5] refactor: share media attachment rules Extract shared media MIME helpers so read, webfetch, and message conversion use the same image/PDF rules. Add an Anthropic regression that preserves JPEG tool-result media through model-message conversion and provider transforms. --- packages/opencode/src/session/message-v2.ts | 6 +- packages/opencode/src/tool/read.ts | 26 +----- packages/opencode/src/tool/webfetch.ts | 6 +- packages/opencode/src/util/media.ts | 30 +++++++ .../opencode/test/session/message-v2.test.ts | 84 +++++++++++++++++++ 5 files changed, 122 insertions(+), 30 deletions(-) create mode 100644 packages/opencode/src/util/media.ts diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 46686947e1fc..057b5eb66abe 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -11,6 +11,7 @@ import { MessageTable, PartTable, SessionTable } from "./session.sql" import { ProviderError } from "@/provider" import { iife } from "@/util/iife" import { errorMessage } from "@/util/error" +import { isMedia } from "@/util/media" import type { SystemError } from "bun" import type { Provider } from "@/provider" import { ModelID, ProviderID } from "@/provider/schema" @@ -25,10 +26,7 @@ interface FetchDecompressionError extends Error { } export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached image(s) from tool result:" - -export function isMedia(mime: string) { - return mime.startsWith("image/") || mime === "application/pdf" -} +export { isMedia } export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({})) export const AbortedError = NamedError.create("MessageAbortedError", z.object({ message: z.string() })) diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 1dd7c8cb80a3..a9a8e900fb79 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -10,6 +10,7 @@ import DESCRIPTION from "./read.txt" import { Instance } from "../project/instance" import { assertExternalDirectoryEffect } from "./external-directory" import { Instruction } from "../session/instruction" +import { isImageAttachment, mediaKind, sniffAttachmentMime } from "@/util/media" const DEFAULT_READ_LIMIT = 2000 const MAX_LINE_LENGTH = 2000 @@ -18,24 +19,6 @@ const MAX_BYTES = 50 * 1024 const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB` const MIME_SAMPLE_BYTES = 12 -const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value) - -const sniffAttachmentMime = (bytes: Uint8Array, fallback: string) => { - if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png" - if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg" - if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif" - if (startsWith(bytes, [0x42, 0x4d])) return "image/bmp" - if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf" - if ( - startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && - startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50]) - ) { - return "image/webp" - } - - return fallback -} - const parameters = z.object({ filePath: z.string().describe("The absolute path to the file or directory to read"), offset: z.coerce.number().describe("The line number to start reading from (1-indexed)").optional(), @@ -223,11 +206,10 @@ export const ReadTool = Tool.define( yield* readSample(filepath, Number(stat.size), MIME_SAMPLE_BYTES), AppFileSystem.mimeType(filepath), ) - const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet" - const isPdf = mime === "application/pdf" - if (isImage || isPdf) { + const kind = mediaKind(mime) + if (isImageAttachment(mime) || kind === "pdf") { const bytes = yield* fs.readFile(filepath) - const msg = `${isImage ? "Image" : "PDF"} read successfully` + const msg = `${kind === "image" ? "Image" : "PDF"} read successfully` return { title, output: msg, diff --git a/packages/opencode/src/tool/webfetch.ts b/packages/opencode/src/tool/webfetch.ts index 6498b871f83a..1d988b8d4f2b 100644 --- a/packages/opencode/src/tool/webfetch.ts +++ b/packages/opencode/src/tool/webfetch.ts @@ -4,6 +4,7 @@ import { HttpClient, HttpClientRequest } from "effect/unstable/http" import * as Tool from "./tool" import TurndownService from "turndown" import DESCRIPTION from "./webfetch.txt" +import { isImageAttachment } from "@/util/media" const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds @@ -104,10 +105,7 @@ export const WebFetchTool = Tool.define( 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) { + if (isImageAttachment(mime)) { const base64Content = Buffer.from(arrayBuffer).toString("base64") return { title, diff --git a/packages/opencode/src/util/media.ts b/packages/opencode/src/util/media.ts new file mode 100644 index 000000000000..137c2a3229ed --- /dev/null +++ b/packages/opencode/src/util/media.ts @@ -0,0 +1,30 @@ +const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value) + +export function mediaKind(mime: string) { + if (mime.startsWith("image/")) return "image" as const + if (mime === "application/pdf") return "pdf" as const +} + +export function isMedia(mime: string) { + return mediaKind(mime) !== undefined +} + +export function isImageAttachment(mime: string) { + return mediaKind(mime) === "image" && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet" +} + +export function sniffAttachmentMime(bytes: Uint8Array, fallback: string) { + if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png" + if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg" + if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif" + if (startsWith(bytes, [0x42, 0x4d])) return "image/bmp" + if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf" + if ( + startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && + startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50]) + ) { + return "image/webp" + } + + return fallback +} diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 6d4e994a8791..55ae65c56029 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import { APICallError } from "ai" import { MessageV2 } from "../../src/session/message-v2" +import { ProviderTransform } from "../../src/provider" import type { Provider } from "../../src/provider" import { ModelID, ProviderID } from "../../src/provider/schema" import { SessionID, MessageID, PartID } from "../../src/session/schema" @@ -359,6 +360,89 @@ describe("session.message-v2.toModelMessage", () => { ]) }) + test("preserves jpeg tool-result media for anthropic models", async () => { + const anthropicModel: Provider.Model = { + ...model, + id: ModelID.make("anthropic/claude-opus-4-7"), + providerID: ProviderID.make("anthropic"), + api: { + id: "claude-opus-4-7-20250805", + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + capabilities: { + ...model.capabilities, + attachment: true, + input: { + ...model.capabilities.input, + image: true, + pdf: true, + }, + }, + } + const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01]).toString( + "base64", + ) + const userID = "m-user-anthropic" + const assistantID = "m-assistant-anthropic" + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "u1-anthropic"), + type: "text", + text: "run tool", + }, + ] as MessageV2.Part[], + }, + { + info: assistantInfo(assistantID, userID), + parts: [ + { + ...basePart(assistantID, "a1-anthropic"), + type: "tool", + callID: "call-anthropic-1", + tool: "read", + state: { + status: "completed", + input: { filePath: "/tmp/rails-demo.png" }, + output: "Image read successfully", + title: "Read", + metadata: {}, + time: { start: 0, end: 1 }, + attachments: [ + { + ...basePart(assistantID, "file-anthropic-1"), + type: "file", + mime: "image/jpeg", + filename: "rails-demo.png", + url: `data:image/jpeg;base64,${jpeg}`, + }, + ], + }, + }, + ] as MessageV2.Part[], + }, + ] + + const result = ProviderTransform.message(await MessageV2.toModelMessages(input, anthropicModel), anthropicModel, {}) + expect(result).toHaveLength(3) + expect(result[2].role).toBe("tool") + expect(result[2].content[0]).toMatchObject({ + type: "tool-result", + toolCallId: "call-anthropic-1", + toolName: "read", + output: { + type: "content", + value: [ + { type: "text", text: "Image read successfully" }, + { type: "media", mediaType: "image/jpeg", data: jpeg }, + ], + }, + }) + }) + test("omits provider metadata when assistant model differs", async () => { const userID = "m-user" const assistantID = "m-assistant" From 27218eec122c322cef90706a24ca72e346f971a7 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 18 Apr 2026 11:00:03 -0400 Subject: [PATCH 5/5] refactor: reuse read samples for media detection Use one sampled buffer for both MIME sniffing and binary detection so text-file reads do not reopen the file just to classify it. Simplify the shared media helper to explicit predicates so callers do not mix overlapping kind and predicate APIs. --- packages/opencode/src/tool/read.ts | 22 +++++++++------------- packages/opencode/src/util/media.ts | 9 ++++----- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index a9a8e900fb79..29d36692c667 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -10,14 +10,14 @@ import DESCRIPTION from "./read.txt" import { Instance } from "../project/instance" import { assertExternalDirectoryEffect } from "./external-directory" import { Instruction } from "../session/instruction" -import { isImageAttachment, mediaKind, sniffAttachmentMime } from "@/util/media" +import { isImageAttachment, isPdfAttachment, sniffAttachmentMime } from "@/util/media" const DEFAULT_READ_LIMIT = 2000 const MAX_LINE_LENGTH = 2000 const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)` const MAX_BYTES = 50 * 1024 const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB` -const MIME_SAMPLE_BYTES = 12 +const SAMPLE_BYTES = 4096 const parameters = z.object({ filePath: z.string().describe("The absolute path to the file or directory to read"), @@ -89,7 +89,7 @@ export const ReadTool = Tool.define( ) }) - const isBinaryFile = Effect.fn("ReadTool.isBinaryFile")(function* (filepath: string, fileSize: number) { + const isBinaryFile = (filepath: string, bytes: Uint8Array) => { const ext = path.extname(filepath).toLowerCase() switch (ext) { case ".zip": @@ -123,7 +123,6 @@ export const ReadTool = Tool.define( return true } - const bytes = yield* readSample(filepath, fileSize, 4096) if (bytes.length === 0) return false let nonPrintableCount = 0 @@ -135,7 +134,7 @@ export const ReadTool = Tool.define( } return nonPrintableCount / bytes.length > 0.3 - }) + } const run = Effect.fn("ReadTool.execute")(function* (params: z.infer, ctx: Tool.Context) { if (params.offset !== undefined && params.offset < 1) { @@ -201,15 +200,12 @@ export const ReadTool = Tool.define( } const loaded = yield* instruction.resolve(ctx.messages, filepath, ctx.messageID) + const sample = yield* readSample(filepath, Number(stat.size), SAMPLE_BYTES) - const mime = sniffAttachmentMime( - yield* readSample(filepath, Number(stat.size), MIME_SAMPLE_BYTES), - AppFileSystem.mimeType(filepath), - ) - const kind = mediaKind(mime) - if (isImageAttachment(mime) || kind === "pdf") { + const mime = sniffAttachmentMime(sample, AppFileSystem.mimeType(filepath)) + if (isImageAttachment(mime) || isPdfAttachment(mime)) { const bytes = yield* fs.readFile(filepath) - const msg = `${kind === "image" ? "Image" : "PDF"} read successfully` + const msg = isPdfAttachment(mime) ? "PDF read successfully" : "Image read successfully" return { title, output: msg, @@ -228,7 +224,7 @@ export const ReadTool = Tool.define( } } - if (yield* isBinaryFile(filepath, Number(stat.size))) { + if (isBinaryFile(filepath, sample)) { return yield* Effect.fail(new Error(`Cannot read binary file: ${filepath}`)) } diff --git a/packages/opencode/src/util/media.ts b/packages/opencode/src/util/media.ts index 137c2a3229ed..0e98f53a529c 100644 --- a/packages/opencode/src/util/media.ts +++ b/packages/opencode/src/util/media.ts @@ -1,16 +1,15 @@ const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value) -export function mediaKind(mime: string) { - if (mime.startsWith("image/")) return "image" as const - if (mime === "application/pdf") return "pdf" as const +export function isPdfAttachment(mime: string) { + return mime === "application/pdf" } export function isMedia(mime: string) { - return mediaKind(mime) !== undefined + return mime.startsWith("image/") || isPdfAttachment(mime) } export function isImageAttachment(mime: string) { - return mediaKind(mime) === "image" && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet" + return mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet" } export function sniffAttachmentMime(bytes: Uint8Array, fallback: string) {