fix: enable image file reading in read tool (fixes #451)

The read tool was throwing an error for image files instead of reading them.
This fix implements proper image handling by converting images to base64 data URLs,
allowing opencode to read PNG, JPEG, and other image formats as documented.
This commit is contained in:
Morgan VanYperen 2025-06-30 17:24:10 -07:00
parent fea56d8de6
commit 1ed5a9e3dc
No known key found for this signature in database
GPG key ID: 1F1EFF9A133D867E
2 changed files with 162 additions and 4 deletions

View file

@ -63,10 +63,30 @@ export const ReadTool = Tool.define({
const limit = params.limit ?? DEFAULT_READ_LIMIT
const offset = params.offset || 0
const isImage = isImageFile(filePath)
if (isImage)
throw new Error(
`This is an image file of type: ${isImage}\nUse a different tool to process images`,
)
if (isImage) {
// Handle image files by returning them as base64 data URL
const buffer = await file.arrayBuffer()
const base64 = Buffer.from(buffer).toString("base64")
const mimeType = getMimeType(isImage)
const dataUrl = `data:${mimeType};base64,${base64}`
const output = `<image>\n${dataUrl}\n</image>`
const preview = `Image file: ${path.basename(filePath)} (${isImage})`
// just warms the lsp client
await LSP.touchFile(filePath, true)
FileTime.read(ctx.sessionID, filePath)
return {
output,
metadata: {
preview,
title: path.relative(App.info().path.root, filePath),
},
}
}
const lines = await file.text().then((text) => text.split("\n"))
const raw = lines.slice(offset, offset + limit).map((line) => {
return line.length > MAX_LINE_LENGTH
@ -122,3 +142,22 @@ function isImageFile(filePath: string): string | false {
return false
}
}
function getMimeType(imageType: string): string {
switch (imageType) {
case "JPEG":
return "image/jpeg"
case "PNG":
return "image/png"
case "GIF":
return "image/gif"
case "BMP":
return "image/bmp"
case "SVG":
return "image/svg+xml"
case "WebP":
return "image/webp"
default:
return "application/octet-stream"
}
}

View file

@ -0,0 +1,119 @@
import { describe, expect, test } from "bun:test"
import { App } from "../../src/app/app"
import { ReadTool } from "../../src/tool/read"
import * as fs from "fs"
import * as path from "path"
const ctx = {
sessionID: "test",
messageID: "",
abort: AbortSignal.any([]),
metadata: () => {},
}
describe("tool.read", () => {
test("read text file", async () => {
await App.provide({ cwd: process.cwd() }, async () => {
const result = await ReadTool.execute(
{
filePath: path.join(process.cwd(), "README.md"),
},
ctx,
)
expect(result.output).toContain("<file>")
expect(result.output).toContain("</file>")
expect(result.metadata.title).toBe("README.md")
})
})
test("read PNG file", async () => {
await App.provide({ cwd: process.cwd() }, async () => {
// Create a minimal PNG file for testing
const testPngPath = path.join(process.cwd(), "test-image.png")
// 1x1 red pixel PNG
const pngData = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xde, 0x00, 0x00, 0x00,
0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00,
0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d, 0xb4, 0x00, 0x00, 0x00,
0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
])
fs.writeFileSync(testPngPath, pngData)
try {
const result = await ReadTool.execute(
{
filePath: testPngPath,
},
ctx,
)
expect(result.output).toContain("<image>")
expect(result.output).toContain("data:image/png;base64,")
expect(result.output).toContain("</image>")
expect(result.metadata.preview).toContain(
"Image file: test-image.png (PNG)",
)
expect(result.metadata.title).toBe("test-image.png")
} finally {
// Clean up test file
fs.unlinkSync(testPngPath)
}
})
})
test("read JPEG file", async () => {
await App.provide({ cwd: process.cwd() }, async () => {
// Create a minimal JPEG file for testing
const testJpegPath = path.join(process.cwd(), "test-image.jpg")
// Minimal JPEG structure
const jpegData = Buffer.from([
0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01,
0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xff, 0xdb, 0x00, 0x43,
0x00, 0x08, 0x06, 0x06, 0x07, 0x06, 0x05, 0x08, 0x07, 0x07, 0x07, 0x09,
0x09, 0x08, 0x0a, 0x0c, 0x14, 0x0d, 0x0c, 0x0b, 0x0b, 0x0c, 0x19, 0x12,
0x13, 0x0f, 0x14, 0x1d, 0x1a, 0x1f, 0x1e, 0x1d, 0x1a, 0x1c, 0x1c, 0x20,
0x24, 0x2e, 0x27, 0x20, 0x22, 0x2c, 0x23, 0x1c, 0x1c, 0x28, 0x37, 0x29,
0x2c, 0x30, 0x31, 0x34, 0x34, 0x34, 0x1f, 0x27, 0x39, 0x3d, 0x38, 0x32,
0x3c, 0x2e, 0x33, 0x34, 0x32, 0xff, 0xd9,
])
fs.writeFileSync(testJpegPath, jpegData)
try {
const result = await ReadTool.execute(
{
filePath: testJpegPath,
},
ctx,
)
expect(result.output).toContain("<image>")
expect(result.output).toContain("data:image/jpeg;base64,")
expect(result.output).toContain("</image>")
expect(result.metadata.preview).toContain(
"Image file: test-image.jpg (JPEG)",
)
expect(result.metadata.title).toBe("test-image.jpg")
} finally {
// Clean up test file
fs.unlinkSync(testJpegPath)
}
})
})
test("file not found", async () => {
await App.provide({ cwd: process.cwd() }, async () => {
await expect(
ReadTool.execute(
{
filePath: "/tmp/nonexistent-file-that-does-not-exist.txt",
},
ctx,
),
).rejects.toThrow("File not found")
})
})
})