Compare commits

...
Author SHA1 Message Date
LukeParkerDev 30fb0dc3fb fix(core): admit oversized local attachments as path references
A file:// attachment above the inline cap exists on the server, so it is admitted with empty data and rendered to the model as a bare 'Attached file: <path>' line instead of rejecting the prompt. The cap now only rejects data: URLs, which have no path to fall back to.
2026-09-18 16:26:06 +10:00
LukeParkerDev dca9dbc8b3 fix(app): deliver text and oversized attachments by path
Only media the selected model reads natively (images, PDFs) travels inline as a data URL, and only under the server's 20 MB attachment cap. Text files and anything larger reach the model as a path on the server instead of a base64 body that the server rejects. Attachment errors name the attachment rather than echoing the full data URI.
2026-09-18 16:15:08 +10:00
5 changed files with 137 additions and 20 deletions
@@ -52,14 +52,14 @@ export function createComposerAttachments(
// Uploads this composer started; they finish (or fail) even if the composer unmounts.
const [pending, setPending] = createStore<{ ids: string[] }>({ ids: [] })
// A file the model reads natively travels inline with the prompt, so its bytes live in the draft
// store. Anything else reaches the model as a path on the server and never enters the store:
// hashing and copying a large archive through it is what used to freeze the window.
// Media the model reads natively travels inline with the prompt, so its bytes live in the draft
// store. Everything else, including text, reaches the model as a path on the server that its
// tools open; those bytes never enter the store, and never get base64-encoded into the request.
const add = async (file: File, target = capture(), clipboard = false) => {
if (!target) return false
const mime = await attachmentMime(file)
const destination = input.destination()
if (native(mime, destination.input)) return addInline(file, mime, target, clipboard)
if (native(mime, destination.input) && file.size <= MAX_INLINE_BYTES) return addInline(file, mime, target, clipboard)
const sourcePath = input.getPathForFile?.(file) || undefined
if (destination.local && sourcePath) return addPath(target, { filename: file.name, mime, path: sourcePath })
void stage(file, mime, target, destination)
@@ -213,9 +213,11 @@ export function createComposerAttachments(
const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
// Mirrors the attachment kinds the server forwards to the model as message content.
// The server rejects inline attachments above this size, so larger media takes the path route.
const MAX_INLINE_BYTES = 20 * 1024 * 1024
// Mirrors the media the server forwards to the model as message content.
function native(mime: string, input: AttachmentDestination["input"]) {
if (mime === "text/plain") return true
if (imageMimes.has(mime)) return input.image
if (mime === "application/pdf") return input.pdf
return false
@@ -238,8 +240,8 @@ const textMimes = new Set([
"application/yaml",
])
// Text-like files normalize to text/plain so the server inlines their content; every other
// file keeps a binary type and is delivered to the model by path or as native media.
// Text-like files normalize to text/plain so the chip labels them as text; every other file keeps
// a binary type. Delivery is decided separately: native media inline, everything else by path.
async function attachmentMime(file: File) {
const type = file.type.split(";", 1)[0]?.trim().toLowerCase() ?? ""
if (imageMimes.has(type) || type === "application/pdf") return type
+22 -10
View File
@@ -93,9 +93,10 @@ export const prepare = Effect.fn("SessionPrompt.prepare")(function* (request: {
const materializeAttachment = Effect.fn("SessionPrompt.materializeAttachment")(function* (
input: PromptInput.FileAttachment,
) {
const label = attachmentLabel(input)
const resolved = input.uri.startsWith("data:")
? {
bytes: yield* decodeDataURL(input.uri),
bytes: yield* decodeDataURL(input.uri, label),
source: { type: "inline" as const },
start: undefined,
end: undefined,
@@ -105,8 +106,8 @@ const materializeAttachment = Effect.fn("SessionPrompt.materializeAttachment")(f
: yield* readFileAttachment(input.uri)
if (resolved.bytes.byteLength > MAX_ATTACHMENT_BYTES)
return yield* new AttachmentError({
uri: input.uri,
message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${input.uri}`,
uri: label,
message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${label}`,
})
const mime = resolved.mime ?? Mime.detect(resolved.bytes)
@@ -138,7 +139,7 @@ const normalizeImageAttachment = Effect.fn("SessionPrompt.normalizeImageAttachme
) {
if (!mime.startsWith("image/")) return { data: Base64.make(data), mime }
const image = yield* Image.Service
const label = input.name ?? (input.uri.startsWith("data:") ? "inline attachment" : input.uri)
const label = attachmentLabel(input)
const content = { uri: label, content: data, encoding: "base64" as const, mime }
const normalized = yield* image.normalize(label, content).pipe(
Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content)),
@@ -188,11 +189,17 @@ const readFileAttachment = Effect.fn("SessionPrompt.readFileAttachment")(functio
}
}
if (info.type !== "File") return yield* new AttachmentError({ uri, message: `Attachment is not a file: ${uri}` })
// The file exists on the server, so an oversized one is delivered as a bare reference the
// model reads with tools instead of rejecting the prompt.
if (Number(info.size) > MAX_ATTACHMENT_BYTES)
return yield* new AttachmentError({
uri,
message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${uri}`,
})
return {
bytes: Buffer.alloc(0),
source: { type: "uri" as const, uri },
start: undefined,
end: undefined,
name: path.basename(target),
mime: "application/octet-stream",
}
const bytes = yield* fs
.readFile(target)
.pipe(Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })))
@@ -201,7 +208,12 @@ const readFileAttachment = Effect.fn("SessionPrompt.readFileAttachment")(functio
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
function decodeDataURL(uri: string) {
// A data URL is the whole file; errors and logs must name the attachment, not echo its bytes.
function attachmentLabel(input: PromptInput.FileAttachment) {
return input.name ?? (input.uri.startsWith("data:") ? "inline attachment" : input.uri)
}
function decodeDataURL(uri: string, label: string) {
return Effect.try({
try: () => {
const comma = uri.indexOf(",")
@@ -214,7 +226,7 @@ function decodeDataURL(uri: string) {
if (bytes.toString("base64") !== payload) throw new Error("Non-canonical base64")
return bytes
},
catch: () => new AttachmentError({ uri, message: "Invalid attachment data URL" }),
catch: () => new AttachmentError({ uri: label, message: `Invalid attachment data URL: ${label}` }),
})
}
@@ -72,7 +72,27 @@ const directoryAttachment = (file: FileAttachment): ContentPart => ({
},
})
const referenceAttachment = (file: FileAttachment, location: string): ContentPart => ({
type: "text",
text: `\n\n${[
`Attached file: ${location}`,
file.description === undefined ? undefined : `Description: ${file.description}`,
]
.filter((line): line is string => line !== undefined)
.join("\n")}`,
metadata: {
attachment: {
source: file.source,
name: file.name,
description: file.description,
},
},
})
const attachmentContent = (file: FileAttachment): ContentPart[] => {
// Oversized files are admitted without their bytes; the model opens them by path.
const location = file.data.length === 0 && file.mime !== "application/x-directory" ? attachmentLocation(file) : undefined
if (location !== undefined) return [referenceAttachment(file, location)]
if (file.mime === "text/plain") return [textAttachment(file)]
if (file.mime === "application/x-directory") return [directoryAttachment(file)]
if (imageMimes.has(file.mime) || file.mime === "application/pdf") {
+51 -2
View File
@@ -431,6 +431,32 @@ describe("Session.prompt", () => {
}),
)
it.effect("admits oversized local files as bare references", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const directory = yield* tmpdirScoped("opencode-session-prompt-")
const source = path.join(directory.path, "server.log")
yield* Effect.promise(() => Bun.write(source, Buffer.alloc(20 * 1024 * 1024 + 1, 0x61)))
const message = yield* session.prompt({
sessionID,
text: "Inspect this log",
files: [{ uri: pathToFileURL(source).href }],
resume: false,
})
expect(message.payload.files).toEqual([
{
data: "",
mime: "application/octet-stream",
source: { type: "uri", uri: pathToFileURL(source).href },
name: "server.log",
},
])
}),
)
it.effect("normalizes large image content before validating persisted Base64", () =>
Effect.gen(function* () {
yield* setup
@@ -501,8 +527,31 @@ describe("Session.prompt", () => {
expect(error).toMatchObject({
_tag: "Session.AttachmentError",
uri,
message: "Invalid attachment data URL",
uri: "image.png",
message: "Invalid attachment data URL: image.png",
})
}),
)
it.effect("rejects oversized inline attachments without echoing their bytes", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const uri = `data:application/octet-stream;base64,${Buffer.alloc(20 * 1024 * 1024 + 1).toString("base64")}`
const error = yield* session
.prompt({
sessionID,
text: "Inspect this",
files: [{ uri }],
resume: false,
})
.pipe(Effect.flip)
expect(error).toMatchObject({
_tag: "Session.AttachmentError",
uri: "inline attachment",
message: "Attachment exceeds the 20971520 byte limit: inline attachment",
})
}),
)
@@ -405,6 +405,40 @@ Recent work
})
})
test("renders oversized reference files as a bare path", () => {
const location = path.resolve("/logs/server.log")
const file = FileAttachment.make({
data: Base64.make(""),
mime: "application/octet-stream",
source: { type: "uri", uri: pathToFileURL(location).href },
name: "server.log",
})
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-oversized"),
type: "user",
text: "Review this log",
files: [file],
time: { created },
}),
],
model,
)
expect(messages[0]).toMatchObject({
role: "user",
content: [
{ type: "text", text: "Review this log" },
{
type: "text",
text: `\n\nAttached file: ${location}`,
metadata: { attachment: { source: file.source, name: "server.log" } },
},
],
})
})
test("preserves attachment order after the prompt", () => {
const directory = path.resolve("/project/src")
const messages = toLLMMessages(