authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-09-21 03:31:47+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-20 17:31:47-07:00
log2b178231ebfa597947efb4ec31bf60e0bb9e3128
tree340c3855a90f7647b9304791338fb14d5194c2db
parent1ba471a37cd6b0f18820795f4505206f4723a0e3
signature Signed by PGP key B5690EEEBB952194

fix: allow more attachments without raising the image payload budget (#12620)


9 files changed, 170 insertions(+), 39 deletions(-)

apps/mobile/src/lib/composerFiles.test.ts+8-5
......@@ -477,10 +477,13 @@ describe("composer file attachments", () => {
477477 it("applies the remaining attachment slots to photos and videos together", async () => {
478478 mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [image, video] });
479479
480 const result = await pickComposerMedia({ existingCount: 7, maxVideoBytes: 50 * 1024 * 1024 });
480 const result = await pickComposerMedia({
481 existingCount: 99,
482 maxVideoBytes: 50 * 1024 * 1024,
483 });
481484
482485 expect(result.attachments).toEqual([expect.objectContaining({ type: "image" })]);
483 expect(result.error).toBe("You can attach up to 8 attachments per message.");
486 expect(result.error).toBe("You can attach up to 100 attachments per message.");
484487 expect(mocks.pickMedia).toHaveBeenCalledWith(expect.objectContaining({ selectionLimit: 1 }));
485488 expect(mocks.copy).not.toHaveBeenCalled();
486489 });
......@@ -625,9 +628,9 @@ describe("composer file attachments", () => {
625628 });
626629
627630 it("does not open the picker when the draft has no remaining attachment slots", async () => {
628 await expect(pickComposerFiles({ existingCount: 8 })).resolves.toEqual({
631 await expect(pickComposerFiles({ existingCount: 100 })).resolves.toEqual({
629632 files: [],
630 error: "You can attach up to 8 files per message.",
633 error: "You can attach up to 100 files per message.",
631634 });
632635
633636 expect(mocks.pickFile).not.toHaveBeenCalled();
......@@ -838,7 +841,7 @@ describe("composer file attachments", () => {
838841 ],
839842 });
840843
841 const result = await pickComposerFiles({ existingCount: 7, maxBytes: 1024 * 1024 });
844 const result = await pickComposerFiles({ existingCount: 99, maxBytes: 1024 * 1024 });
842845
843846 expect(result.files.map((file) => file.name)).toEqual(["report.pdf"]);
844847 });
apps/mobile/src/state/use-composer-drafts.test.ts+12-12
......@@ -676,7 +676,7 @@ describe("mobile composer drafts", () => {
676676 return undefined;
677677 });
678678 const key = "environment-1:replace-file";
679 const files = Array.from({ length: 8 }, (_, index) => ({
679 const files = Array.from({ length: 100 }, (_, index) => ({
680680 id: `file-${index}`,
681681 type: "file" as const,
682682 name: `notes-${index}.txt`,
......@@ -687,8 +687,8 @@ describe("mobile composer drafts", () => {
687687 appendComposerDraftAttachments(key, files, { appendReference: true });
688688 const firstLink = "[notes-0.txt](t3-context://v1/file/file-0)";
689689 const insertion = captureComposerDraftInsertion(key, { start: 0, end: firstLink.length });
690 expect(countComposerDraftAttachmentsAfterSelection(key, insertion)).toBe(7);
691 expect(getComposerDraftAfterSelection(key, insertion).context?.records).toHaveLength(7);
690 expect(countComposerDraftAttachmentsAfterSelection(key, insertion)).toBe(99);
691 expect(getComposerDraftAfterSelection(key, insertion).context?.records).toHaveLength(99);
692692 const replacement = { ...files[0]!, id: "replacement", fileUri: "file:///replacement.txt" };
693693 if (kind === "attachment") {
694694 expect(
......@@ -716,7 +716,7 @@ describe("mobile composer drafts", () => {
716716 insertion,
717717 ),
718718 ).toBe(true);
719 expect(getComposerDraftSnapshot(key).attachments).toHaveLength(8);
719 expect(getComposerDraftSnapshot(key).attachments).toHaveLength(100);
720720 expect(getComposerDraftSnapshot(key).context?.records).toContainEqual(record);
721721 expect(getComposerDraftSnapshot(key).text).toBe(
722722 `${formatComposerContextReference(record)}${insertion.text.slice(firstLink.length)}`,
......@@ -730,7 +730,7 @@ describe("mobile composer drafts", () => {
730730 }
731731 const draft = getComposerDraftSnapshot(key);
732732 expect(draft.attachments.map((file) => file.id)).not.toContain("file-0");
733 expect(draft.attachments.slice(0, 7)).toEqual(files.slice(1));
733 expect(draft.attachments.slice(0, 99)).toEqual(files.slice(1));
734734 expect(draft.context?.records.some((record) => record.contextId === "file-0")).toBe(false);
735735 await cleanup.promise;
736736 expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(files[0]!.fileUri);
......@@ -746,7 +746,7 @@ describe("mobile composer drafts", () => {
746746 return undefined;
747747 });
748748 const key = "environment-1:concurrent-import";
749 const files = Array.from({ length: 8 }, (_, index) => ({
749 const files = Array.from({ length: 100 }, (_, index) => ({
750750 id: `existing-${index}`,
751751 type: "file" as const,
752752 name: `notes-${index}.txt`,
......@@ -906,7 +906,7 @@ describe("mobile composer drafts", () => {
906906 fileUri: `file:///documents/t3-composer-attachments/${id}.mov`,
907907 });
908908 const draftKey = "new-task:environment-1:project-cap";
909 const existing = Array.from({ length: 7 }, (_, index) => makeAttachment(`held-${index}`));
909 const existing = Array.from({ length: 99 }, (_, index) => makeAttachment(`held-${index}`));
910910 appAtomRegistry.set(composerDraftsAtom, {
911911 [draftKey]: { text: "send this", attachments: existing },
912912 });
......@@ -918,7 +918,7 @@ describe("mobile composer drafts", () => {
918918
919919 expect(rejected).toBe(1);
920920 const draft = appAtomRegistry.get(composerDraftsAtom)[draftKey];
921 expect(draft?.attachments).toHaveLength(8);
921 expect(draft?.attachments).toHaveLength(100);
922922 expect(draft?.attachments.at(-1)?.id).toBe("incoming-1");
923923 await cleanup.promise;
924924 expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledExactlyOnceWith(
......@@ -932,7 +932,7 @@ describe("mobile composer drafts", () => {
932932 { allowOverflow: true },
933933 );
934934 expect(overflowRejected).toBe(0);
935 expect(appAtomRegistry.get(composerDraftsAtom)[draftKey]?.attachments).toHaveLength(9);
935 expect(appAtomRegistry.get(composerDraftsAtom)[draftKey]?.attachments).toHaveLength(101);
936936 });
937937
938938 it("keeps shared attachment files until every draft releases them", async () => {
......@@ -2270,7 +2270,7 @@ describe("mobile composer drafts", () => {
22702270 previewUri: "data:image/png;base64,YWJj",
22712271 });
22722272 const existingImage = image("existing");
2273 const sharedImages = Array.from({ length: 8 }, (_, index) => image(`shared-${index}`));
2273 const sharedImages = Array.from({ length: 100 }, (_, index) => image(`shared-${index}`));
22742274
22752275 const merged = mergeComposerDraftContentState(
22762276 { [draftKey]: { text: "", attachments: [existingImage] } },
......@@ -2278,9 +2278,9 @@ describe("mobile composer drafts", () => {
22782278 { text: "", attachments: sharedImages },
22792279 );
22802280
2281 expect(merged[draftKey]?.attachments).toHaveLength(8);
2281 expect(merged[draftKey]?.attachments).toHaveLength(100);
22822282 expect(merged[draftKey]?.attachments[0]).toEqual(existingImage);
2283 expect(merged[draftKey]?.attachments.at(-1)?.id).toBe("shared-6");
2283 expect(merged[draftKey]?.attachments.at(-1)?.id).toBe("shared-98");
22842284 });
22852285
22862286 it("restores the exact draft captured before an interrupted share import", () => {
apps/server/src/orchestration/Normalizer.attachments.test.ts+54-6
......@@ -13,6 +13,7 @@ import {
1313 ThreadId,
1414} from "@t3tools/contracts";
1515import * as Effect from "effect/Effect";
16import * as FileSystem from "effect/FileSystem";
1617import * as Layer from "effect/Layer";
1718import * as Schema from "effect/Schema";
1819
......@@ -59,6 +60,53 @@ function turnStartCommand(input: {
5960}
6061
6162describe("normalizeDispatchCommand attachments", () => {
63 it.effect("accepts 100 inline images and rejects 101 before writing files", () =>
64 Effect.gen(function* () {
65 const config = yield* ServerConfig.ServerConfig;
66 const attachments = Array.from({ length: 100 }, () => ({
67 dataUrl: "data:image/png;base64,cGl4ZWxz",
68 sizeBytes: 6,
69 }));
70 const rejected = yield* normalizeDispatchCommand(
71 turnStartCommand({ attachments: [...attachments, attachments[0]!] }),
72 ).pipe(Effect.flip);
73 expect(rejected.message).toContain("up to 100");
74 expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([]);
75 const accepted = yield* normalizeDispatchCommand(turnStartCommand({ attachments }));
76 if (accepted.type !== "thread.turn.start") throw new Error("Wrong command");
77 expect(accepted.message.attachments).toHaveLength(100);
78 expect(NodeFS.readdirSync(config.attachmentsDir)).toHaveLength(100);
79 }).pipe(Effect.provide(testLayer)),
80 );
81
82 it.effect("rejects decoded image overflow before writing it and removes earlier files", () =>
83 Effect.gen(function* () {
84 const config = yield* ServerConfig.ServerConfig;
85 const fileSystem = yield* FileSystem.FileSystem;
86 let writtenBytes = 0;
87 const dataUrl = `data:image/png;base64,${Buffer.alloc(10 * 1024 * 1024).toString("base64")}`;
88 const command = turnStartCommand({
89 attachments: [
90 ...Array.from({ length: 8 }, () => ({ dataUrl, sizeBytes: 1 })),
91 { dataUrl: "data:image/png;base64,YQ==", sizeBytes: 0 },
92 ],
93 });
94 const error = yield* normalizeDispatchCommand(command).pipe(
95 Effect.provideService(FileSystem.FileSystem, {
96 ...fileSystem,
97 writeFile: (path, data, options) => {
98 writtenBytes += data.byteLength;
99 return fileSystem.writeFile(path, data, options);
100 },
101 }),
102 Effect.flip,
103 );
104 expect(error.message).toContain("80 MiB");
105 expect(writtenBytes).toBe(80 * 1024 * 1024);
106 expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([]);
107 }).pipe(Effect.provide(testLayer)),
108 );
109
62110 it.effect("rejects duplicate client ids before persisting attachments", () =>
63111 Effect.gen(function* () {
64112 const error = yield* normalizeDispatchCommand(
......@@ -464,25 +512,25 @@ describe("question attachments", () => {
464512 answers: { first: "", second: "" },
465513 createdAt: "2026-08-01T00:00:00.000Z",
466514 attachmentsByQuestionId: {
467 first: Array.from({ length: 4 }, () => attachment),
468 second: Array.from({ length: 5 }, () => attachment),
515 first: Array.from({ length: 50 }, () => attachment),
516 second: Array.from({ length: 51 }, () => attachment),
469517 },
470518 };
471519 const failure = yield* normalizeDispatchCommand(command).pipe(Effect.flip);
472 expect(failure.message).toContain("up to 8");
520 expect(failure.message).toContain("up to 100");
473521 expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([`${id}.txt`]);
474522 const accepted = {
475523 ...command,
476524 attachmentsByQuestionId: {
477525 ...command.attachmentsByQuestionId,
478 second: Array.from({ length: 4 }, () => attachment),
526 second: Array.from({ length: 50 }, () => attachment),
479527 },
480528 };
481529 const normalized = yield* normalizeDispatchCommand(accepted);
482530 if (normalized.type !== "thread.user-input.respond") throw new Error("Wrong command");
483531 const attachments = Object.values(normalized.attachmentsByQuestionId!).flat();
484 expect(attachments).toHaveLength(8);
485 expect(new Set(attachments.map((item) => item.id)).size).toBe(8);
532 expect(attachments).toHaveLength(100);
533 expect(new Set(attachments.map((item) => item.id)).size).toBe(100);
486534 for (const item of attachments) {
487535 expect(item.name).toBe(attachment.name);
488536 expect(
apps/server/src/orchestration/Normalizer.ts+12-9
......@@ -5,7 +5,7 @@ import * as Path from "effect/Path";
55import {
66 type ClientOrchestrationCommand,
77 type UserInputAttachments,
8 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
8 getProviderAttachmentLimitError,
99 type IsoDateTime,
1010 type OrchestrationCommand,
1111 OrchestrationDispatchCommandError,
......@@ -142,13 +142,9 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>
142142 canonicalCommand.type === "thread.turn.start"
143143 ? canonicalCommand.message.attachments
144144 : Object.values(canonicalCommand.attachmentsByQuestionId ?? {}).flat();
145 if (
146 canonicalCommand.type === "thread.user-input.respond" &&
147 attachments.length > PROVIDER_SEND_TURN_MAX_ATTACHMENTS
148 ) {
149 return yield* new OrchestrationDispatchCommandError({
150 message: `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per question response.`,
151 });
145 const attachmentLimitError = getProviderAttachmentLimitError(attachments);
146 if (attachmentLimitError) {
147 return yield* new OrchestrationDispatchCommandError({ message: attachmentLimitError });
152148 }
153149 if (canonicalCommand.type === "thread.turn.start") {
154150 const clientAttachmentIds = new Set<string>();
......@@ -163,11 +159,12 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>
163159 }
164160 }
165161 const claimedAttachmentPaths: string[] = [];
162 const attachmentsWithDecodedSizes = [...attachments];
166163 // Context records bind to attachments by the id the client knew; they follow the rename.
167164 const finalAttachmentIdByClientId = new Map<string, string>();
168165 const normalizedAttachments = yield* Effect.forEach(
169166 attachments,
170 (attachment) =>
167 (attachment, index) =>
171168 Effect.gen(function* () {
172169 if (!("dataUrl" in attachment)) {
173170 const claim = planAttachmentClaim({
......@@ -259,6 +256,11 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>
259256 sizeBytes: bytes.byteLength,
260257 ...(attachment.source ? { source: attachment.source } : {}),
261258 };
259 attachmentsWithDecodedSizes[index] = persistedAttachment;
260 const decodedLimitError = getProviderAttachmentLimitError(attachmentsWithDecodedSizes);
261 if (decodedLimitError) {
262 return yield* new OrchestrationDispatchCommandError({ message: decodedLimitError });
263 }
262264
263265 const attachmentPath = resolveAttachmentPath({
264266 attachmentsDir: serverConfig.attachmentsDir,
......@@ -286,6 +288,7 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>
286288 }),
287289 ),
288290 );
291 claimedAttachmentPaths.push(attachmentPath);
289292 if (attachment.id !== undefined) {
290293 finalAttachmentIdByClientId.set(attachment.id, attachmentId);
291294 }
docs/user/composer.md+7-3
......@@ -14,9 +14,13 @@ to keep a large paste editable in the composer instead.
1414
1515## Attach files
1616
17Attach up to eight files per message. Images can be up to 10 MB; other files can
18be up to 50 MB, subject to the environment's upload support and limit. The agent
19receives them on the environment's machine.
17Attach up to 100 files per message. Each image can be up to 10 MiB, with at most
1880 MiB of images in one message. Other files, including videos, can be up to
1950 MiB each, subject to the environment's upload support and limit. The agent
20receives them on the environment's machine. Provider and model limits still
21apply, including images already in the conversation. A video attachment gives
22the agent a file path; it does not enable native video input. Antigravity does
23not accept video attachments.
2024
2125Uploads begin when you add an attachment. All uploads must finish before the
2226message can send. Retry or remove a failed upload. On web and desktop, reloading
docs/user/question-attachments.md+1-1
......@@ -6,4 +6,4 @@ Each question keeps its own attachments as you move between questions. Your norm
66
77Files 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.
88
9Submitted 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.
9Submitted 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.
packages/contracts/src/orchestration.ts+21-1
......@@ -163,8 +163,9 @@ export const ProviderUserInputAnswers = Schema.Record(Schema.String, Schema.Unkn
163163export type ProviderUserInputAnswers = typeof ProviderUserInputAnswers.Type;
164164
165165export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000;
166export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8;
166export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 100;
167167export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
168const PROVIDER_SEND_TURN_MAX_TOTAL_IMAGE_BYTES = 80 * 1024 * 1024;
168169export const PROVIDER_SEND_TURN_MAX_FILE_BYTES = 50 * 1024 * 1024;
169170export const PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES = [
170171 "image/gif",
......@@ -371,6 +372,25 @@ export const ChatAttachment = Schema.Union([
371372]);
372373export type ChatAttachment = typeof ChatAttachment.Type;
373374
375export function getProviderAttachmentLimitError(
376 attachments: ReadonlyArray<Pick<ChatAttachment, "type" | "mimeType" | "sizeBytes">>,
377): string | undefined {
378 if (attachments.length > PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
379 return `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message or question response.`;
380 }
381 const imageBytes = attachments.reduce(
382 (total, attachment) =>
383 total +
384 (attachment.type === "image" || isProviderSendTurnSupportedImageMimeType(attachment.mimeType)
385 ? attachment.sizeBytes
386 : 0),
387 0,
388 );
389 if (imageBytes > PROVIDER_SEND_TURN_MAX_TOTAL_IMAGE_BYTES) {
390 return "Images can total up to 80 MiB per message or question response. Use smaller images or send fewer at once.";
391 }
392}
393
374394export const UserInputAttachments = Schema.Record(
375395 Schema.String,
376396 Schema.Array(Schema.Union([ChatImageAttachment, ChatFileAttachment])).pipe(
packages/contracts/src/provider.test.ts+51
......@@ -121,6 +121,57 @@ describe("ProviderSessionStartInput", () => {
121121});
122122
123123describe("ProviderSendTurnInput", () => {
124 it("accepts 100 attachments and rejects 101", () => {
125 const attachments = Array.from({ length: 100 }, (_, index) => ({
126 type: "image",
127 id: `image-${index}`,
128 name: "image.png",
129 mimeType: "image/png",
130 sizeBytes: 1,
131 }));
132 expect(
133 decodeProviderSendTurnInput({ threadId: "thread-1", attachments }).attachments,
134 ).toHaveLength(100);
135 expect(() =>
136 decodeProviderSendTurnInput({
137 threadId: "thread-1",
138 attachments: [...attachments, attachments[0]],
139 }),
140 ).toThrow();
141 });
142
143 it.each(["image", "file"])(
144 "caps total image bytes for %s attachments without charging videos",
145 (type) => {
146 const image = {
147 type,
148 id: "image",
149 name: "image.png",
150 mimeType: "image/png",
151 sizeBytes: 10 * 1024 * 1024,
152 };
153 const video = {
154 type: "file",
155 id: "video",
156 name: "video.mp4",
157 mimeType: "video/mp4",
158 sizeBytes: 50 * 1024 * 1024,
159 };
160 expect(
161 decodeProviderSendTurnInput({
162 threadId: "thread-1",
163 attachments: [...Array.from({ length: 8 }, () => image), video],
164 }).attachments,
165 ).toHaveLength(9);
166 expect(() =>
167 decodeProviderSendTurnInput({
168 threadId: "thread-1",
169 attachments: [...Array.from({ length: 8 }, () => image), { ...image, sizeBytes: 1 }],
170 }),
171 ).toThrow(/80 MiB/);
172 },
173 );
174
124175 it("accepts codex modelSelection", () => {
125176 const parsed = decodeProviderSendTurnInput({
126177 threadId: "thread-1",
packages/contracts/src/provider.ts+4-2
......@@ -11,7 +11,7 @@ import {
1111import {
1212 ChatAttachment,
1313 ModelSelection,
14 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
14 getProviderAttachmentLimitError,
1515 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
1616 ProviderApprovalDecision,
1717 ProviderApprovalPolicy,
......@@ -75,7 +75,9 @@ export const ProviderSendTurnInput = Schema.Struct({
7575 TrimmedNonEmptyString.check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)),
7676 ),
7777 attachments: Schema.optional(
78 Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)),
78 Schema.Array(ChatAttachment).check(
79 Schema.makeFilter((attachments) => getProviderAttachmentLimitError(attachments) ?? true),
80 ),
7981 ),
8082 modelSelection: Schema.optional(ModelSelection),
8183 interactionMode: Schema.optional(ProviderInteractionMode),