Skip to content

Commit 9918f38

Browse files
authored
fix: detect attachment mime from file contents (#23291)
1 parent dd8c424 commit 9918f38

6 files changed

Lines changed: 198 additions & 77 deletions

File tree

packages/opencode/src/session/message-v2.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { MessageTable, PartTable, SessionTable } from "./session.sql"
1111
import { ProviderError } from "@/provider"
1212
import { iife } from "@/util/iife"
1313
import { errorMessage } from "@/util/error"
14+
import { isMedia } from "@/util/media"
1415
import type { SystemError } from "bun"
1516
import type { Provider } from "@/provider"
1617
import { ModelID, ProviderID } from "@/provider/schema"
@@ -25,10 +26,7 @@ interface FetchDecompressionError extends Error {
2526
}
2627

2728
export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached image(s) from tool result:"
28-
29-
export function isMedia(mime: string) {
30-
return mime.startsWith("image/") || mime === "application/pdf"
31-
}
29+
export { isMedia }
3230

3331
export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
3432
export const AbortedError = NamedError.create("MessageAbortedError", z.object({ message: z.string() }))

packages/opencode/src/tool/read.ts

Lines changed: 68 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import z from "zod"
2-
import { Effect, Scope } from "effect"
2+
import { Effect, Option, Scope } from "effect"
33
import { createReadStream } from "fs"
4-
import { open } from "fs/promises"
54
import * as path from "path"
65
import { createInterface } from "readline"
76
import * as Tool from "./tool"
@@ -11,12 +10,14 @@ import DESCRIPTION from "./read.txt"
1110
import { Instance } from "../project/instance"
1211
import { assertExternalDirectoryEffect } from "./external-directory"
1312
import { Instruction } from "../session/instruction"
13+
import { isImageAttachment, isPdfAttachment, sniffAttachmentMime } from "@/util/media"
1414

1515
const DEFAULT_READ_LIMIT = 2000
1616
const MAX_LINE_LENGTH = 2000
1717
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
1818
const MAX_BYTES = 50 * 1024
1919
const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`
20+
const SAMPLE_BYTES = 4096
2021

2122
const parameters = z.object({
2223
filePath: z.string().describe("The absolute path to the file or directory to read"),
@@ -77,6 +78,64 @@ export const ReadTool = Tool.define(
7778
yield* lsp.touchFile(filepath, false).pipe(Effect.ignore, Effect.forkIn(scope))
7879
})
7980

81+
const readSample = Effect.fn("ReadTool.readSample")(function* (filepath: string, fileSize: number, sampleSize: number) {
82+
if (fileSize === 0) return new Uint8Array()
83+
84+
return yield* Effect.scoped(
85+
Effect.gen(function* () {
86+
const file = yield* fs.open(filepath, { flag: "r" })
87+
return Option.getOrElse(yield* file.readAlloc(Math.min(sampleSize, fileSize)), () => new Uint8Array())
88+
}),
89+
)
90+
})
91+
92+
const isBinaryFile = (filepath: string, bytes: Uint8Array) => {
93+
const ext = path.extname(filepath).toLowerCase()
94+
switch (ext) {
95+
case ".zip":
96+
case ".tar":
97+
case ".gz":
98+
case ".exe":
99+
case ".dll":
100+
case ".so":
101+
case ".class":
102+
case ".jar":
103+
case ".war":
104+
case ".7z":
105+
case ".doc":
106+
case ".docx":
107+
case ".xls":
108+
case ".xlsx":
109+
case ".ppt":
110+
case ".pptx":
111+
case ".odt":
112+
case ".ods":
113+
case ".odp":
114+
case ".bin":
115+
case ".dat":
116+
case ".obj":
117+
case ".o":
118+
case ".a":
119+
case ".lib":
120+
case ".wasm":
121+
case ".pyc":
122+
case ".pyo":
123+
return true
124+
}
125+
126+
if (bytes.length === 0) return false
127+
128+
let nonPrintableCount = 0
129+
for (let i = 0; i < bytes.length; i++) {
130+
if (bytes[i] === 0) return true
131+
if (bytes[i] < 9 || (bytes[i] > 13 && bytes[i] < 32)) {
132+
nonPrintableCount++
133+
}
134+
}
135+
136+
return nonPrintableCount / bytes.length > 0.3
137+
}
138+
80139
const run = Effect.fn("ReadTool.execute")(function* (params: z.infer<typeof parameters>, ctx: Tool.Context) {
81140
if (params.offset !== undefined && params.offset < 1) {
82141
return yield* Effect.fail(new Error("offset must be greater than or equal to 1"))
@@ -141,12 +200,12 @@ export const ReadTool = Tool.define(
141200
}
142201

143202
const loaded = yield* instruction.resolve(ctx.messages, filepath, ctx.messageID)
203+
const sample = yield* readSample(filepath, Number(stat.size), SAMPLE_BYTES)
144204

145-
const mime = AppFileSystem.mimeType(filepath)
146-
const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
147-
const isPdf = mime === "application/pdf"
148-
if (isImage || isPdf) {
149-
const msg = `${isImage ? "Image" : "PDF"} read successfully`
205+
const mime = sniffAttachmentMime(sample, AppFileSystem.mimeType(filepath))
206+
if (isImageAttachment(mime) || isPdfAttachment(mime)) {
207+
const bytes = yield* fs.readFile(filepath)
208+
const msg = isPdfAttachment(mime) ? "PDF read successfully" : "Image read successfully"
150209
return {
151210
title,
152211
output: msg,
@@ -159,13 +218,13 @@ export const ReadTool = Tool.define(
159218
{
160219
type: "file" as const,
161220
mime,
162-
url: `data:${mime};base64,${Buffer.from(yield* fs.readFile(filepath)).toString("base64")}`,
221+
url: `data:${mime};base64,${Buffer.from(bytes).toString("base64")}`,
163222
},
164223
],
165224
}
166225
}
167226

168-
if (yield* Effect.promise(() => isBinaryFile(filepath, Number(stat.size)))) {
227+
if (isBinaryFile(filepath, sample)) {
169228
return yield* Effect.fail(new Error(`Cannot read binary file: ${filepath}`))
170229
}
171230

@@ -261,63 +320,3 @@ async function lines(filepath: string, opts: { limit: number; offset: number })
261320

262321
return { raw, count, cut, more, offset: opts.offset }
263322
}
264-
265-
async function isBinaryFile(filepath: string, fileSize: number): Promise<boolean> {
266-
const ext = path.extname(filepath).toLowerCase()
267-
// binary check for common non-text extensions
268-
switch (ext) {
269-
case ".zip":
270-
case ".tar":
271-
case ".gz":
272-
case ".exe":
273-
case ".dll":
274-
case ".so":
275-
case ".class":
276-
case ".jar":
277-
case ".war":
278-
case ".7z":
279-
case ".doc":
280-
case ".docx":
281-
case ".xls":
282-
case ".xlsx":
283-
case ".ppt":
284-
case ".pptx":
285-
case ".odt":
286-
case ".ods":
287-
case ".odp":
288-
case ".bin":
289-
case ".dat":
290-
case ".obj":
291-
case ".o":
292-
case ".a":
293-
case ".lib":
294-
case ".wasm":
295-
case ".pyc":
296-
case ".pyo":
297-
return true
298-
default:
299-
break
300-
}
301-
302-
if (fileSize === 0) return false
303-
304-
const fh = await open(filepath, "r")
305-
try {
306-
const sampleSize = Math.min(4096, fileSize)
307-
const bytes = Buffer.alloc(sampleSize)
308-
const result = await fh.read(bytes, 0, sampleSize, 0)
309-
if (result.bytesRead === 0) return false
310-
311-
let nonPrintableCount = 0
312-
for (let i = 0; i < result.bytesRead; i++) {
313-
if (bytes[i] === 0) return true
314-
if (bytes[i] < 9 || (bytes[i] > 13 && bytes[i] < 32)) {
315-
nonPrintableCount++
316-
}
317-
}
318-
// If >30% non-printable characters, consider it binary
319-
return nonPrintableCount / result.bytesRead > 0.3
320-
} finally {
321-
await fh.close()
322-
}
323-
}

packages/opencode/src/tool/webfetch.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { HttpClient, HttpClientRequest } from "effect/unstable/http"
44
import * as Tool from "./tool"
55
import TurndownService from "turndown"
66
import DESCRIPTION from "./webfetch.txt"
7+
import { isImageAttachment } from "@/util/media"
78

89
const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB
910
const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
@@ -104,10 +105,7 @@ export const WebFetchTool = Tool.define(
104105
const mime = contentType.split(";")[0]?.trim().toLowerCase() || ""
105106
const title = `${params.url} (${contentType})`
106107

107-
// Check if response is an image
108-
const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
109-
110-
if (isImage) {
108+
if (isImageAttachment(mime)) {
111109
const base64Content = Buffer.from(arrayBuffer).toString("base64")
112110
return {
113111
title,
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value)
2+
3+
export function isPdfAttachment(mime: string) {
4+
return mime === "application/pdf"
5+
}
6+
7+
export function isMedia(mime: string) {
8+
return mime.startsWith("image/") || isPdfAttachment(mime)
9+
}
10+
11+
export function isImageAttachment(mime: string) {
12+
return mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
13+
}
14+
15+
export function sniffAttachmentMime(bytes: Uint8Array, fallback: string) {
16+
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"
17+
if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg"
18+
if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif"
19+
if (startsWith(bytes, [0x42, 0x4d])) return "image/bmp"
20+
if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf"
21+
if (
22+
startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) &&
23+
startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50])
24+
) {
25+
return "image/webp"
26+
}
27+
28+
return fallback
29+
}

packages/opencode/test/session/message-v2.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, test } from "bun:test"
22
import { APICallError } from "ai"
33
import { MessageV2 } from "../../src/session/message-v2"
4+
import { ProviderTransform } from "../../src/provider"
45
import type { Provider } from "../../src/provider"
56
import { ModelID, ProviderID } from "../../src/provider/schema"
67
import { SessionID, MessageID, PartID } from "../../src/session/schema"
@@ -359,6 +360,89 @@ describe("session.message-v2.toModelMessage", () => {
359360
])
360361
})
361362

363+
test("preserves jpeg tool-result media for anthropic models", async () => {
364+
const anthropicModel: Provider.Model = {
365+
...model,
366+
id: ModelID.make("anthropic/claude-opus-4-7"),
367+
providerID: ProviderID.make("anthropic"),
368+
api: {
369+
id: "claude-opus-4-7-20250805",
370+
url: "https://api.anthropic.com",
371+
npm: "@ai-sdk/anthropic",
372+
},
373+
capabilities: {
374+
...model.capabilities,
375+
attachment: true,
376+
input: {
377+
...model.capabilities.input,
378+
image: true,
379+
pdf: true,
380+
},
381+
},
382+
}
383+
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01]).toString(
384+
"base64",
385+
)
386+
const userID = "m-user-anthropic"
387+
const assistantID = "m-assistant-anthropic"
388+
const input: MessageV2.WithParts[] = [
389+
{
390+
info: userInfo(userID),
391+
parts: [
392+
{
393+
...basePart(userID, "u1-anthropic"),
394+
type: "text",
395+
text: "run tool",
396+
},
397+
] as MessageV2.Part[],
398+
},
399+
{
400+
info: assistantInfo(assistantID, userID),
401+
parts: [
402+
{
403+
...basePart(assistantID, "a1-anthropic"),
404+
type: "tool",
405+
callID: "call-anthropic-1",
406+
tool: "read",
407+
state: {
408+
status: "completed",
409+
input: { filePath: "/tmp/rails-demo.png" },
410+
output: "Image read successfully",
411+
title: "Read",
412+
metadata: {},
413+
time: { start: 0, end: 1 },
414+
attachments: [
415+
{
416+
...basePart(assistantID, "file-anthropic-1"),
417+
type: "file",
418+
mime: "image/jpeg",
419+
filename: "rails-demo.png",
420+
url: `data:image/jpeg;base64,${jpeg}`,
421+
},
422+
],
423+
},
424+
},
425+
] as MessageV2.Part[],
426+
},
427+
]
428+
429+
const result = ProviderTransform.message(await MessageV2.toModelMessages(input, anthropicModel), anthropicModel, {})
430+
expect(result).toHaveLength(3)
431+
expect(result[2].role).toBe("tool")
432+
expect(result[2].content[0]).toMatchObject({
433+
type: "tool-result",
434+
toolCallId: "call-anthropic-1",
435+
toolName: "read",
436+
output: {
437+
type: "content",
438+
value: [
439+
{ type: "text", text: "Image read successfully" },
440+
{ type: "media", mediaType: "image/jpeg", data: jpeg },
441+
],
442+
},
443+
})
444+
})
445+
362446
test("omits provider metadata when assistant model differs", async () => {
363447
const userID = "m-user"
364448
const assistantID = "m-assistant"

packages/opencode/test/tool/read.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,19 @@ describe("tool.read truncation", () => {
394394
}),
395395
)
396396

397+
it.live("detects attachment media from file contents", () =>
398+
Effect.gen(function* () {
399+
const dir = yield* tmpdirScoped()
400+
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01])
401+
yield* put(path.join(dir, "image.bin"), jpeg)
402+
403+
const result = yield* exec(dir, { filePath: path.join(dir, "image.bin") })
404+
expect(result.output).toBe("Image read successfully")
405+
expect(result.attachments?.[0].mime).toBe("image/jpeg")
406+
expect(result.attachments?.[0].url.startsWith("data:image/jpeg;base64,")).toBe(true)
407+
}),
408+
)
409+
397410
it.live("large image files are properly attached without error", () =>
398411
Effect.gen(function* () {
399412
const result = yield* exec(FIXTURES_DIR, { filePath: path.join(FIXTURES_DIR, "large-image.png") })

0 commit comments

Comments
 (0)