diff --git a/packages/app/src/composer/attachments/attachments.ts b/packages/app/src/composer/attachments/attachments.ts index 8350ea46c49..4fa2a7a3ab4 100644 --- a/packages/app/src/composer/attachments/attachments.ts +++ b/packages/app/src/composer/attachments/attachments.ts @@ -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 diff --git a/packages/core/src/session/prompt.ts b/packages/core/src/session/prompt.ts index bb552681e77..80878f354dd 100644 --- a/packages/core/src/session/prompt.ts +++ b/packages/core/src/session/prompt.ts @@ -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)), @@ -201,7 +202,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 +220,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}` }), }) } diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 16ca1e2c063..96a2cddcb56 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -501,8 +501,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", }) }), )