From 2b178231ebfa597947efb4ec31bf60e0bb9e3128 Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Mon, 21 Sep 2026 03:31:47 +0300 Subject: [PATCH] fix: allow more attachments without raising the image payload budget (#12620) --- apps/mobile/src/lib/composerFiles.test.ts | 13 ++-- .../src/state/use-composer-drafts.test.ts | 24 ++++---- .../Normalizer.attachments.test.ts | 60 +++++++++++++++++-- apps/server/src/orchestration/Normalizer.ts | 21 ++++--- docs/user/composer.md | 10 +++- docs/user/question-attachments.md | 2 +- packages/contracts/src/orchestration.ts | 22 ++++++- packages/contracts/src/provider.test.ts | 51 ++++++++++++++++ packages/contracts/src/provider.ts | 6 +- 9 files changed, 170 insertions(+), 39 deletions(-) diff --git a/apps/mobile/src/lib/composerFiles.test.ts b/apps/mobile/src/lib/composerFiles.test.ts index 962be35013f2d1ed60bdad4ded7f0877df71eb83..a99c2c6064e760c99165c511419e38a456c75c09 100644 --- a/apps/mobile/src/lib/composerFiles.test.ts +++ b/apps/mobile/src/lib/composerFiles.test.ts @@ -477,10 +477,13 @@ describe("composer file attachments", () => { it("applies the remaining attachment slots to photos and videos together", async () => { mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [image, video] }); - const result = await pickComposerMedia({ existingCount: 7, maxVideoBytes: 50 * 1024 * 1024 }); + const result = await pickComposerMedia({ + existingCount: 99, + maxVideoBytes: 50 * 1024 * 1024, + }); expect(result.attachments).toEqual([expect.objectContaining({ type: "image" })]); - expect(result.error).toBe("You can attach up to 8 attachments per message."); + expect(result.error).toBe("You can attach up to 100 attachments per message."); expect(mocks.pickMedia).toHaveBeenCalledWith(expect.objectContaining({ selectionLimit: 1 })); expect(mocks.copy).not.toHaveBeenCalled(); }); @@ -625,9 +628,9 @@ describe("composer file attachments", () => { }); it("does not open the picker when the draft has no remaining attachment slots", async () => { - await expect(pickComposerFiles({ existingCount: 8 })).resolves.toEqual({ + await expect(pickComposerFiles({ existingCount: 100 })).resolves.toEqual({ files: [], - error: "You can attach up to 8 files per message.", + error: "You can attach up to 100 files per message.", }); expect(mocks.pickFile).not.toHaveBeenCalled(); @@ -838,7 +841,7 @@ describe("composer file attachments", () => { ], }); - const result = await pickComposerFiles({ existingCount: 7, maxBytes: 1024 * 1024 }); + const result = await pickComposerFiles({ existingCount: 99, maxBytes: 1024 * 1024 }); expect(result.files.map((file) => file.name)).toEqual(["report.pdf"]); }); diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 6da72860009eae941b3b5efbf293f0632090c3ea..9f5dad4d560069ced396b1f58c7bcee941c7cb01 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -676,7 +676,7 @@ describe("mobile composer drafts", () => { return undefined; }); const key = "environment-1:replace-file"; - const files = Array.from({ length: 8 }, (_, index) => ({ + const files = Array.from({ length: 100 }, (_, index) => ({ id: `file-${index}`, type: "file" as const, name: `notes-${index}.txt`, @@ -687,8 +687,8 @@ describe("mobile composer drafts", () => { appendComposerDraftAttachments(key, files, { appendReference: true }); const firstLink = "[notes-0.txt](t3-context://v1/file/file-0)"; const insertion = captureComposerDraftInsertion(key, { start: 0, end: firstLink.length }); - expect(countComposerDraftAttachmentsAfterSelection(key, insertion)).toBe(7); - expect(getComposerDraftAfterSelection(key, insertion).context?.records).toHaveLength(7); + expect(countComposerDraftAttachmentsAfterSelection(key, insertion)).toBe(99); + expect(getComposerDraftAfterSelection(key, insertion).context?.records).toHaveLength(99); const replacement = { ...files[0]!, id: "replacement", fileUri: "file:///replacement.txt" }; if (kind === "attachment") { expect( @@ -716,7 +716,7 @@ describe("mobile composer drafts", () => { insertion, ), ).toBe(true); - expect(getComposerDraftSnapshot(key).attachments).toHaveLength(8); + expect(getComposerDraftSnapshot(key).attachments).toHaveLength(100); expect(getComposerDraftSnapshot(key).context?.records).toContainEqual(record); expect(getComposerDraftSnapshot(key).text).toBe( `${formatComposerContextReference(record)}${insertion.text.slice(firstLink.length)}`, @@ -730,7 +730,7 @@ describe("mobile composer drafts", () => { } const draft = getComposerDraftSnapshot(key); expect(draft.attachments.map((file) => file.id)).not.toContain("file-0"); - expect(draft.attachments.slice(0, 7)).toEqual(files.slice(1)); + expect(draft.attachments.slice(0, 99)).toEqual(files.slice(1)); expect(draft.context?.records.some((record) => record.contextId === "file-0")).toBe(false); await cleanup.promise; expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(files[0]!.fileUri); @@ -746,7 +746,7 @@ describe("mobile composer drafts", () => { return undefined; }); const key = "environment-1:concurrent-import"; - const files = Array.from({ length: 8 }, (_, index) => ({ + const files = Array.from({ length: 100 }, (_, index) => ({ id: `existing-${index}`, type: "file" as const, name: `notes-${index}.txt`, @@ -906,7 +906,7 @@ describe("mobile composer drafts", () => { fileUri: `file:///documents/t3-composer-attachments/${id}.mov`, }); const draftKey = "new-task:environment-1:project-cap"; - const existing = Array.from({ length: 7 }, (_, index) => makeAttachment(`held-${index}`)); + const existing = Array.from({ length: 99 }, (_, index) => makeAttachment(`held-${index}`)); appAtomRegistry.set(composerDraftsAtom, { [draftKey]: { text: "send this", attachments: existing }, }); @@ -918,7 +918,7 @@ describe("mobile composer drafts", () => { expect(rejected).toBe(1); const draft = appAtomRegistry.get(composerDraftsAtom)[draftKey]; - expect(draft?.attachments).toHaveLength(8); + expect(draft?.attachments).toHaveLength(100); expect(draft?.attachments.at(-1)?.id).toBe("incoming-1"); await cleanup.promise; expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledExactlyOnceWith( @@ -932,7 +932,7 @@ describe("mobile composer drafts", () => { { allowOverflow: true }, ); expect(overflowRejected).toBe(0); - expect(appAtomRegistry.get(composerDraftsAtom)[draftKey]?.attachments).toHaveLength(9); + expect(appAtomRegistry.get(composerDraftsAtom)[draftKey]?.attachments).toHaveLength(101); }); it("keeps shared attachment files until every draft releases them", async () => { @@ -2270,7 +2270,7 @@ describe("mobile composer drafts", () => { previewUri: "data:image/png;base64,YWJj", }); const existingImage = image("existing"); - const sharedImages = Array.from({ length: 8 }, (_, index) => image(`shared-${index}`)); + const sharedImages = Array.from({ length: 100 }, (_, index) => image(`shared-${index}`)); const merged = mergeComposerDraftContentState( { [draftKey]: { text: "", attachments: [existingImage] } }, @@ -2278,9 +2278,9 @@ describe("mobile composer drafts", () => { { text: "", attachments: sharedImages }, ); - expect(merged[draftKey]?.attachments).toHaveLength(8); + expect(merged[draftKey]?.attachments).toHaveLength(100); expect(merged[draftKey]?.attachments[0]).toEqual(existingImage); - expect(merged[draftKey]?.attachments.at(-1)?.id).toBe("shared-6"); + expect(merged[draftKey]?.attachments.at(-1)?.id).toBe("shared-98"); }); it("restores the exact draft captured before an interrupted share import", () => { diff --git a/apps/server/src/orchestration/Normalizer.attachments.test.ts b/apps/server/src/orchestration/Normalizer.attachments.test.ts index 51c70d7401ae0a3e24aaa7c5449dfdd166dacaf6..d44ec58e6a95106a6cbbfbeac3cd718d4ad8c8b0 100644 --- a/apps/server/src/orchestration/Normalizer.attachments.test.ts +++ b/apps/server/src/orchestration/Normalizer.attachments.test.ts @@ -13,6 +13,7 @@ import { ThreadId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; @@ -59,6 +60,53 @@ function turnStartCommand(input: { } describe("normalizeDispatchCommand attachments", () => { + it.effect("accepts 100 inline images and rejects 101 before writing files", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const attachments = Array.from({ length: 100 }, () => ({ + dataUrl: "data:image/png;base64,cGl4ZWxz", + sizeBytes: 6, + })); + const rejected = yield* normalizeDispatchCommand( + turnStartCommand({ attachments: [...attachments, attachments[0]!] }), + ).pipe(Effect.flip); + expect(rejected.message).toContain("up to 100"); + expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([]); + const accepted = yield* normalizeDispatchCommand(turnStartCommand({ attachments })); + if (accepted.type !== "thread.turn.start") throw new Error("Wrong command"); + expect(accepted.message.attachments).toHaveLength(100); + expect(NodeFS.readdirSync(config.attachmentsDir)).toHaveLength(100); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects decoded image overflow before writing it and removes earlier files", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + let writtenBytes = 0; + const dataUrl = `data:image/png;base64,${Buffer.alloc(10 * 1024 * 1024).toString("base64")}`; + const command = turnStartCommand({ + attachments: [ + ...Array.from({ length: 8 }, () => ({ dataUrl, sizeBytes: 1 })), + { dataUrl: "data:image/png;base64,YQ==", sizeBytes: 0 }, + ], + }); + const error = yield* normalizeDispatchCommand(command).pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fileSystem, + writeFile: (path, data, options) => { + writtenBytes += data.byteLength; + return fileSystem.writeFile(path, data, options); + }, + }), + Effect.flip, + ); + expect(error.message).toContain("80 MiB"); + expect(writtenBytes).toBe(80 * 1024 * 1024); + expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([]); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("rejects duplicate client ids before persisting attachments", () => Effect.gen(function* () { const error = yield* normalizeDispatchCommand( @@ -464,25 +512,25 @@ describe("question attachments", () => { answers: { first: "", second: "" }, createdAt: "2026-08-01T00:00:00.000Z", attachmentsByQuestionId: { - first: Array.from({ length: 4 }, () => attachment), - second: Array.from({ length: 5 }, () => attachment), + first: Array.from({ length: 50 }, () => attachment), + second: Array.from({ length: 51 }, () => attachment), }, }; const failure = yield* normalizeDispatchCommand(command).pipe(Effect.flip); - expect(failure.message).toContain("up to 8"); + expect(failure.message).toContain("up to 100"); expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([`${id}.txt`]); const accepted = { ...command, attachmentsByQuestionId: { ...command.attachmentsByQuestionId, - second: Array.from({ length: 4 }, () => attachment), + second: Array.from({ length: 50 }, () => attachment), }, }; const normalized = yield* normalizeDispatchCommand(accepted); if (normalized.type !== "thread.user-input.respond") throw new Error("Wrong command"); const attachments = Object.values(normalized.attachmentsByQuestionId!).flat(); - expect(attachments).toHaveLength(8); - expect(new Set(attachments.map((item) => item.id)).size).toBe(8); + expect(attachments).toHaveLength(100); + expect(new Set(attachments.map((item) => item.id)).size).toBe(100); for (const item of attachments) { expect(item.name).toBe(attachment.name); expect( diff --git a/apps/server/src/orchestration/Normalizer.ts b/apps/server/src/orchestration/Normalizer.ts index bef58adc0581bf5c7fec018fb373c2b51567eb7b..d4959f39af5913d65f589bb22376e0160b9a0792 100644 --- a/apps/server/src/orchestration/Normalizer.ts +++ b/apps/server/src/orchestration/Normalizer.ts @@ -5,7 +5,7 @@ import * as Path from "effect/Path"; import { type ClientOrchestrationCommand, type UserInputAttachments, - PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + getProviderAttachmentLimitError, type IsoDateTime, type OrchestrationCommand, OrchestrationDispatchCommandError, @@ -142,13 +142,9 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => canonicalCommand.type === "thread.turn.start" ? canonicalCommand.message.attachments : Object.values(canonicalCommand.attachmentsByQuestionId ?? {}).flat(); - if ( - canonicalCommand.type === "thread.user-input.respond" && - attachments.length > PROVIDER_SEND_TURN_MAX_ATTACHMENTS - ) { - return yield* new OrchestrationDispatchCommandError({ - message: `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per question response.`, - }); + const attachmentLimitError = getProviderAttachmentLimitError(attachments); + if (attachmentLimitError) { + return yield* new OrchestrationDispatchCommandError({ message: attachmentLimitError }); } if (canonicalCommand.type === "thread.turn.start") { const clientAttachmentIds = new Set(); @@ -163,11 +159,12 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => } } const claimedAttachmentPaths: string[] = []; + const attachmentsWithDecodedSizes = [...attachments]; // Context records bind to attachments by the id the client knew; they follow the rename. const finalAttachmentIdByClientId = new Map(); const normalizedAttachments = yield* Effect.forEach( attachments, - (attachment) => + (attachment, index) => Effect.gen(function* () { if (!("dataUrl" in attachment)) { const claim = planAttachmentClaim({ @@ -259,6 +256,11 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => sizeBytes: bytes.byteLength, ...(attachment.source ? { source: attachment.source } : {}), }; + attachmentsWithDecodedSizes[index] = persistedAttachment; + const decodedLimitError = getProviderAttachmentLimitError(attachmentsWithDecodedSizes); + if (decodedLimitError) { + return yield* new OrchestrationDispatchCommandError({ message: decodedLimitError }); + } const attachmentPath = resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, @@ -286,6 +288,7 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => }), ), ); + claimedAttachmentPaths.push(attachmentPath); if (attachment.id !== undefined) { finalAttachmentIdByClientId.set(attachment.id, attachmentId); } diff --git a/docs/user/composer.md b/docs/user/composer.md index fb51bd50754cea56c85e8c3dd4afafe5ead2f41f..6e699d0669c1c4e6c1fc8ef8b02583d1a1fad9ae 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -14,9 +14,13 @@ to keep a large paste editable in the composer instead. ## Attach files -Attach up to eight files per message. Images can be up to 10 MB; other files can -be up to 50 MB, subject to the environment's upload support and limit. The agent -receives them on the environment's machine. +Attach up to 100 files per message. Each image can be up to 10 MiB, with at most +80 MiB of images in one message. Other files, including videos, can be up to +50 MiB each, subject to the environment's upload support and limit. The agent +receives them on the environment's machine. Provider and model limits still +apply, including images already in the conversation. A video attachment gives +the agent a file path; it does not enable native video input. Antigravity does +not accept video attachments. Uploads begin when you add an attachment. All uploads must finish before the message can send. Retry or remove a failed upload. On web and desktop, reloading diff --git a/docs/user/question-attachments.md b/docs/user/question-attachments.md index e157cac46cfc446d149dca357bbcd7d6923e5b00..de05ee75c58e94069af09d5ce7ca611894b9ae4a 100644 --- a/docs/user/question-attachments.md +++ b/docs/user/question-attachments.md @@ -6,4 +6,4 @@ Each question keeps its own attachments as you move between questions. Your norm Files upload to the environment running the thread, including remote environments. The agent receives paths to those saved files with your answer and can open them with its available tools. Questions that only accept predefined choices do not offer attachments. Older servers need updating before this option appears. -Submitted attachments remain in the thread history. Reverting a thread removes attachments belonging to discarded answers; deleting the thread uses the same file cleanup as other attachments. You can attach up to eight files across one set of answers, using the same file size limits as the normal composer. +Submitted attachments remain in the thread history. Reverting a thread removes attachments belonging to discarded answers; deleting the thread uses the same file cleanup as other attachments. You can attach up to 100 files across one set of answers, using the same file and total image size limits as the normal composer. diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 6ca7e25ca4932f35752fb97d5fb82d774d3fe32c..9a9ed05d4e6d8d1dd46d8b19671cb172e58052d9 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -163,8 +163,9 @@ export const ProviderUserInputAnswers = Schema.Record(Schema.String, Schema.Unkn export type ProviderUserInputAnswers = typeof ProviderUserInputAnswers.Type; export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; -export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8; +export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 100; export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024; +const PROVIDER_SEND_TURN_MAX_TOTAL_IMAGE_BYTES = 80 * 1024 * 1024; export const PROVIDER_SEND_TURN_MAX_FILE_BYTES = 50 * 1024 * 1024; export const PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES = [ "image/gif", @@ -371,6 +372,25 @@ export const ChatAttachment = Schema.Union([ ]); export type ChatAttachment = typeof ChatAttachment.Type; +export function getProviderAttachmentLimitError( + attachments: ReadonlyArray>, +): string | undefined { + if (attachments.length > PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { + return `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message or question response.`; + } + const imageBytes = attachments.reduce( + (total, attachment) => + total + + (attachment.type === "image" || isProviderSendTurnSupportedImageMimeType(attachment.mimeType) + ? attachment.sizeBytes + : 0), + 0, + ); + if (imageBytes > PROVIDER_SEND_TURN_MAX_TOTAL_IMAGE_BYTES) { + return "Images can total up to 80 MiB per message or question response. Use smaller images or send fewer at once."; + } +} + export const UserInputAttachments = Schema.Record( Schema.String, Schema.Array(Schema.Union([ChatImageAttachment, ChatFileAttachment])).pipe( diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts index ba7ca63745b69e2e5e9c6555405574577305652e..2c25977b7ed4b79c56b0f7c472f5c1a2277d757a 100644 --- a/packages/contracts/src/provider.test.ts +++ b/packages/contracts/src/provider.test.ts @@ -121,6 +121,57 @@ describe("ProviderSessionStartInput", () => { }); describe("ProviderSendTurnInput", () => { + it("accepts 100 attachments and rejects 101", () => { + const attachments = Array.from({ length: 100 }, (_, index) => ({ + type: "image", + id: `image-${index}`, + name: "image.png", + mimeType: "image/png", + sizeBytes: 1, + })); + expect( + decodeProviderSendTurnInput({ threadId: "thread-1", attachments }).attachments, + ).toHaveLength(100); + expect(() => + decodeProviderSendTurnInput({ + threadId: "thread-1", + attachments: [...attachments, attachments[0]], + }), + ).toThrow(); + }); + + it.each(["image", "file"])( + "caps total image bytes for %s attachments without charging videos", + (type) => { + const image = { + type, + id: "image", + name: "image.png", + mimeType: "image/png", + sizeBytes: 10 * 1024 * 1024, + }; + const video = { + type: "file", + id: "video", + name: "video.mp4", + mimeType: "video/mp4", + sizeBytes: 50 * 1024 * 1024, + }; + expect( + decodeProviderSendTurnInput({ + threadId: "thread-1", + attachments: [...Array.from({ length: 8 }, () => image), video], + }).attachments, + ).toHaveLength(9); + expect(() => + decodeProviderSendTurnInput({ + threadId: "thread-1", + attachments: [...Array.from({ length: 8 }, () => image), { ...image, sizeBytes: 1 }], + }), + ).toThrow(/80 MiB/); + }, + ); + it("accepts codex modelSelection", () => { const parsed = decodeProviderSendTurnInput({ threadId: "thread-1", diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index df91839e8520110c23680e93e813677386d23fe9..8753ac2cf8e8f3b9cb9382c7c6f303dea6b789a8 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -11,7 +11,7 @@ import { import { ChatAttachment, ModelSelection, - PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + getProviderAttachmentLimitError, PROVIDER_SEND_TURN_MAX_INPUT_CHARS, ProviderApprovalDecision, ProviderApprovalPolicy, @@ -75,7 +75,9 @@ export const ProviderSendTurnInput = Schema.Struct({ TrimmedNonEmptyString.check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)), ), attachments: Schema.optional( - Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)), + Schema.Array(ChatAttachment).check( + Schema.makeFilter((attachments) => getProviderAttachmentLimitError(attachments) ?? true), + ), ), modelSelection: Schema.optional(ModelSelection), interactionMode: Schema.optional(ProviderInteractionMode), -- 2.54.0