Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 34 additions & 4 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,26 @@ const SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES = new Set([
"image/webp",
])

// Treat a file attachment as readable text rather than a binary blob. Besides the
// canonical `text/plain`, this covers text-like MIMEs (markdown, csv, json, xml, …)
// and the `application/octet-stream` fallback that `mime-types` returns for
// extension-less or unrecognized files. For `file:` attachments the Read tool
// still content-sniffs and fails closed on genuine binary; matching here just
// keeps text files off the base64 path that garbles their content.
function isTextMime(mime: string) {
return (
!mime ||
mime === "application/octet-stream" ||
mime.startsWith("text/") ||
mime === "application/json" ||
mime.endsWith("+json") ||
mime === "application/xml" ||
mime.endsWith("+xml") ||
mime === "application/javascript" ||
mime === "application/x-javascript"
)
}

const STRUCTURED_OUTPUT_DESCRIPTION = `Use this tool to return your final response in the requested structured format.

IMPORTANT:
Expand Down Expand Up @@ -785,7 +805,10 @@ const layer = Layer.effect(
const url = new URL(part.url)
switch (url.protocol) {
case "data:":
if (part.mime === "text/plain") {
// Inline data URLs have no file to content-sniff, so only decode as
// text for MIMEs that are unambiguously textual (not the
// `application/octet-stream` fallback, which may be real binary).
if (part.mime !== "application/octet-stream" && isTextMime(part.mime)) {
return [
{
messageID: info.id,
Expand All @@ -801,14 +824,17 @@ const layer = Layer.effect(
synthetic: true,
text: decodeDataUrl(part.url),
},
{ ...part, messageID: info.id, sessionID: input.sessionID },
// Normalized to text/plain so message-v2 drops it instead of
// re-sending the decoded content as a binary attachment.
{ ...part, mime: "text/plain", messageID: info.id, sessionID: input.sessionID },
]
}
break
case "file:": {
yield* Effect.logInfo("file", { mime: part.mime })
const filepath = fileURLToPath(part.url)
const mime = (yield* fsys.isDir(filepath)) ? "application/x-directory" : part.mime
const asText = isTextMime(mime)

const { read } = yield* registry.named()
const execRead = (args: Parameters<typeof read.execute>[0], extra?: Tool.Context["extra"]) => {
Expand All @@ -827,7 +853,7 @@ const layer = Layer.effect(
.pipe(Effect.onInterrupt(() => Effect.sync(() => controller.abort())))
}

if (mime === "text/plain") {
if (asText) {
let offset: number | undefined
let limit: number | undefined
const range = { start: url.searchParams.get("start"), end: url.searchParams.get("end") }
Expand Down Expand Up @@ -885,7 +911,11 @@ const layer = Layer.effect(
})),
)
} else {
pieces.push({ ...part, mime, messageID: info.id, sessionID: input.sessionID })
// The file was read into synthetic text above, so mark the
// trailing file part as text/plain: downstream (message-v2)
// drops text/plain file parts instead of re-sending the raw
// bytes as a binary attachment (which would garble text files).
pieces.push({ ...part, mime: "text/plain", messageID: info.id, sessionID: input.sessionID })
}
} else {
const error = Cause.squash(exit.cause)
Expand Down
97 changes: 97 additions & 0 deletions packages/opencode/test/session/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2060,6 +2060,103 @@ noLLMServer.instance(
30_000,
)

// Text file attachments with non-text/plain MIME (regression: garbled/binary output, issue #17301)

// A .txt attached with an octet-stream MIME (the mime-types fallback for
// extension-less / unrecognized files) previously skipped the Read-tool text
// path and was base64-blobbed as binary, producing garbled model input. It must
// now be read into clean synthetic text, and no raw binary file part should be
// forwarded to the model.
noLLMServer.instance(
"text file attached as application/octet-stream is read as clean text",
() =>
Effect.gen(function* () {
const { directory: dir } = yield* TestInstance
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({})

const testFile = path.join(dir, "notes")
yield* writeText(testFile, "test123")

const msg = yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [
{ type: "text", text: "read this" },
{
type: "file",
mime: "application/octet-stream",
url: `file://${testFile}`,
filename: "notes",
},
],
})

if (msg.info.role !== "user") throw new Error("expected user message")

const stored = yield* MessageV2.get({ sessionID: session.id, messageID: msg.info.id })

// File content arrives as clean synthetic text, not a garbled binary blob.
const textParts = stored.parts.filter((part) => part.type === "text")
expect(textParts.some((part) => part.text.includes("test123"))).toBe(true)
expect(textParts.some((part) => part.text.startsWith("Called the Read tool"))).toBe(true)

// The trailing file part must not still carry the binary MIME / base64 data
// URL that message-v2 would forward to the model as a garbled attachment.
const forwarded = stored.parts.filter(
(part) => part.type === "file" && part.mime !== "text/plain" && part.mime !== "application/x-directory",
)
expect(forwarded).toHaveLength(0)

yield* sessions.remove(session.id)
}),
{ config: cfg },
)

noLLMServer.instance(
"text file attached as text/markdown is read as clean text",
() =>
Effect.gen(function* () {
const { directory: dir } = yield* TestInstance
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({})

const testFile = path.join(dir, "readme.md")
yield* writeText(testFile, "# Heading\n\nhello markdown\n")

const msg = yield* prompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [
{
type: "file",
mime: "text/markdown",
url: `file://${testFile}`,
filename: "readme.md",
},
],
})

if (msg.info.role !== "user") throw new Error("expected user message")

const stored = yield* MessageV2.get({ sessionID: session.id, messageID: msg.info.id })
const textParts = stored.parts.filter((part) => part.type === "text")
expect(textParts.some((part) => part.text.includes("hello markdown"))).toBe(true)

const forwarded = stored.parts.filter(
(part) => part.type === "file" && part.mime !== "text/plain" && part.mime !== "application/x-directory",
)
expect(forwarded).toHaveLength(0)

yield* sessions.remove(session.id)
}),
{ config: cfg },
)

// Missing file handling

noLLMServer.instance(
Expand Down
Loading