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", () => {...@@ -477,10 +477,13 @@ describe("composer file attachments", () => {
477 it("applies the remaining attachment slots to photos and videos together", async () => {477 it("applies the remaining attachment slots to photos and videos together", async () => {
478 mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [image, video] });478 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
482 expect(result.attachments).toEqual([expect.objectContaining({ type: "image" })]);485 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.");
484 expect(mocks.pickMedia).toHaveBeenCalledWith(expect.objectContaining({ selectionLimit: 1 }));487 expect(mocks.pickMedia).toHaveBeenCalledWith(expect.objectContaining({ selectionLimit: 1 }));
485 expect(mocks.copy).not.toHaveBeenCalled();488 expect(mocks.copy).not.toHaveBeenCalled();
486 });489 });
...@@ -625,9 +628,9 @@ describe("composer file attachments", () => {...@@ -625,9 +628,9 @@ describe("composer file attachments", () => {
625 });628 });
626629
627 it("does not open the picker when the draft has no remaining attachment slots", async () => {630 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({
629 files: [],632 files: [],
630 error: "You can attach up to 8 files per message.",633 error: "You can attach up to 100 files per message.",
631 });634 });
632635
633 expect(mocks.pickFile).not.toHaveBeenCalled();636 expect(mocks.pickFile).not.toHaveBeenCalled();
...@@ -838,7 +841,7 @@ describe("composer file attachments", () => {...@@ -838,7 +841,7 @@ describe("composer file attachments", () => {
838 ],841 ],
839 });842 });
840843
841 const result = await pickComposerFiles({ existingCount: 7, maxBytes: 1024 * 1024 });844 const result = await pickComposerFiles({ existingCount: 99, maxBytes: 1024 * 1024 });
842845
843 expect(result.files.map((file) => file.name)).toEqual(["report.pdf"]);846 expect(result.files.map((file) => file.name)).toEqual(["report.pdf"]);
844 });847 });
apps/mobile/src/state/use-composer-drafts.test.ts+12-12
...@@ -676,7 +676,7 @@ describe("mobile composer drafts", () => {...@@ -676,7 +676,7 @@ describe("mobile composer drafts", () => {
676 return undefined;676 return undefined;
677 });677 });
678 const key = "environment-1:replace-file";678 const key = "environment-1:replace-file";
679 const files = Array.from({ length: 8 }, (_, index) => ({679 const files = Array.from({ length: 100 }, (_, index) => ({
680 id: `file-${index}`,680 id: `file-${index}`,
681 type: "file" as const,681 type: "file" as const,
682 name: `notes-${index}.txt`,682 name: `notes-${index}.txt`,
...@@ -687,8 +687,8 @@ describe("mobile composer drafts", () => {...@@ -687,8 +687,8 @@ describe("mobile composer drafts", () => {
687 appendComposerDraftAttachments(key, files, { appendReference: true });687 appendComposerDraftAttachments(key, files, { appendReference: true });
688 const firstLink = "[notes-0.txt](t3-context://v1/file/file-0)";688 const firstLink = "[notes-0.txt](t3-context://v1/file/file-0)";
689 const insertion = captureComposerDraftInsertion(key, { start: 0, end: firstLink.length });689 const insertion = captureComposerDraftInsertion(key, { start: 0, end: firstLink.length });
690 expect(countComposerDraftAttachmentsAfterSelection(key, insertion)).toBe(7);690 expect(countComposerDraftAttachmentsAfterSelection(key, insertion)).toBe(99);
691 expect(getComposerDraftAfterSelection(key, insertion).context?.records).toHaveLength(7);691 expect(getComposerDraftAfterSelection(key, insertion).context?.records).toHaveLength(99);
692 const replacement = { ...files[0]!, id: "replacement", fileUri: "file:///replacement.txt" };692 const replacement = { ...files[0]!, id: "replacement", fileUri: "file:///replacement.txt" };
693 if (kind === "attachment") {693 if (kind === "attachment") {
694 expect(694 expect(
...@@ -716,7 +716,7 @@ describe("mobile composer drafts", () => {...@@ -716,7 +716,7 @@ describe("mobile composer drafts", () => {
716 insertion,716 insertion,
717 ),717 ),
718 ).toBe(true);718 ).toBe(true);
719 expect(getComposerDraftSnapshot(key).attachments).toHaveLength(8);719 expect(getComposerDraftSnapshot(key).attachments).toHaveLength(100);
720 expect(getComposerDraftSnapshot(key).context?.records).toContainEqual(record);720 expect(getComposerDraftSnapshot(key).context?.records).toContainEqual(record);
721 expect(getComposerDraftSnapshot(key).text).toBe(721 expect(getComposerDraftSnapshot(key).text).toBe(
722 `${formatComposerContextReference(record)}${insertion.text.slice(firstLink.length)}`,722 `${formatComposerContextReference(record)}${insertion.text.slice(firstLink.length)}`,
...@@ -730,7 +730,7 @@ describe("mobile composer drafts", () => {...@@ -730,7 +730,7 @@ describe("mobile composer drafts", () => {
730 }730 }
731 const draft = getComposerDraftSnapshot(key);731 const draft = getComposerDraftSnapshot(key);
732 expect(draft.attachments.map((file) => file.id)).not.toContain("file-0");732 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));
734 expect(draft.context?.records.some((record) => record.contextId === "file-0")).toBe(false);734 expect(draft.context?.records.some((record) => record.contextId === "file-0")).toBe(false);
735 await cleanup.promise;735 await cleanup.promise;
736 expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(files[0]!.fileUri);736 expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(files[0]!.fileUri);
...@@ -746,7 +746,7 @@ describe("mobile composer drafts", () => {...@@ -746,7 +746,7 @@ describe("mobile composer drafts", () => {
746 return undefined;746 return undefined;
747 });747 });
748 const key = "environment-1:concurrent-import";748 const key = "environment-1:concurrent-import";
749 const files = Array.from({ length: 8 }, (_, index) => ({749 const files = Array.from({ length: 100 }, (_, index) => ({
750 id: `existing-${index}`,750 id: `existing-${index}`,
751 type: "file" as const,751 type: "file" as const,
752 name: `notes-${index}.txt`,752 name: `notes-${index}.txt`,
...@@ -906,7 +906,7 @@ describe("mobile composer drafts", () => {...@@ -906,7 +906,7 @@ describe("mobile composer drafts", () => {
906 fileUri: `file:///documents/t3-composer-attachments/${id}.mov`,906 fileUri: `file:///documents/t3-composer-attachments/${id}.mov`,
907 });907 });
908 const draftKey = "new-task:environment-1:project-cap";908 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}`));
910 appAtomRegistry.set(composerDraftsAtom, {910 appAtomRegistry.set(composerDraftsAtom, {
911 [draftKey]: { text: "send this", attachments: existing },911 [draftKey]: { text: "send this", attachments: existing },
912 });912 });
...@@ -918,7 +918,7 @@ describe("mobile composer drafts", () => {...@@ -918,7 +918,7 @@ describe("mobile composer drafts", () => {
918918
919 expect(rejected).toBe(1);919 expect(rejected).toBe(1);
920 const draft = appAtomRegistry.get(composerDraftsAtom)[draftKey];920 const draft = appAtomRegistry.get(composerDraftsAtom)[draftKey];
921 expect(draft?.attachments).toHaveLength(8);921 expect(draft?.attachments).toHaveLength(100);
922 expect(draft?.attachments.at(-1)?.id).toBe("incoming-1");922 expect(draft?.attachments.at(-1)?.id).toBe("incoming-1");
923 await cleanup.promise;923 await cleanup.promise;
924 expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledExactlyOnceWith(924 expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledExactlyOnceWith(
...@@ -932,7 +932,7 @@ describe("mobile composer drafts", () => {...@@ -932,7 +932,7 @@ describe("mobile composer drafts", () => {
932 { allowOverflow: true },932 { allowOverflow: true },
933 );933 );
934 expect(overflowRejected).toBe(0);934 expect(overflowRejected).toBe(0);
935 expect(appAtomRegistry.get(composerDraftsAtom)[draftKey]?.attachments).toHaveLength(9);935 expect(appAtomRegistry.get(composerDraftsAtom)[draftKey]?.attachments).toHaveLength(101);
936 });936 });
937937
938 it("keeps shared attachment files until every draft releases them", async () => {938 it("keeps shared attachment files until every draft releases them", async () => {
...@@ -2270,7 +2270,7 @@ describe("mobile composer drafts", () => {...@@ -2270,7 +2270,7 @@ describe("mobile composer drafts", () => {
2270 previewUri: "data:image/png;base64,YWJj",2270 previewUri: "data:image/png;base64,YWJj",
2271 });2271 });
2272 const existingImage = image("existing");2272 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
2275 const merged = mergeComposerDraftContentState(2275 const merged = mergeComposerDraftContentState(
2276 { [draftKey]: { text: "", attachments: [existingImage] } },2276 { [draftKey]: { text: "", attachments: [existingImage] } },
...@@ -2278,9 +2278,9 @@ describe("mobile composer drafts", () => {...@@ -2278,9 +2278,9 @@ describe("mobile composer drafts", () => {
2278 { text: "", attachments: sharedImages },2278 { text: "", attachments: sharedImages },
2279 );2279 );
22802280
2281 expect(merged[draftKey]?.attachments).toHaveLength(8);2281 expect(merged[draftKey]?.attachments).toHaveLength(100);
2282 expect(merged[draftKey]?.attachments[0]).toEqual(existingImage);2282 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");
2284 });2284 });
22852285
2286 it("restores the exact draft captured before an interrupted share import", () => {2286 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 {...@@ -13,6 +13,7 @@ import {
13 ThreadId,13 ThreadId,
14} from "@t3tools/contracts";14} from "@t3tools/contracts";
15import * as Effect from "effect/Effect";15import * as Effect from "effect/Effect";
16import * as FileSystem from "effect/FileSystem";
16import * as Layer from "effect/Layer";17import * as Layer from "effect/Layer";
17import * as Schema from "effect/Schema";18import * as Schema from "effect/Schema";
1819
...@@ -59,6 +60,53 @@ function turnStartCommand(input: {...@@ -59,6 +60,53 @@ function turnStartCommand(input: {
59}60}
6061
61describe("normalizeDispatchCommand attachments", () => {62describe("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
62 it.effect("rejects duplicate client ids before persisting attachments", () =>110 it.effect("rejects duplicate client ids before persisting attachments", () =>
63 Effect.gen(function* () {111 Effect.gen(function* () {
64 const error = yield* normalizeDispatchCommand(112 const error = yield* normalizeDispatchCommand(
...@@ -464,25 +512,25 @@ describe("question attachments", () => {...@@ -464,25 +512,25 @@ describe("question attachments", () => {
464 answers: { first: "", second: "" },512 answers: { first: "", second: "" },
465 createdAt: "2026-08-01T00:00:00.000Z",513 createdAt: "2026-08-01T00:00:00.000Z",
466 attachmentsByQuestionId: {514 attachmentsByQuestionId: {
467 first: Array.from({ length: 4 }, () => attachment),515 first: Array.from({ length: 50 }, () => attachment),
468 second: Array.from({ length: 5 }, () => attachment),516 second: Array.from({ length: 51 }, () => attachment),
469 },517 },
470 };518 };
471 const failure = yield* normalizeDispatchCommand(command).pipe(Effect.flip);519 const failure = yield* normalizeDispatchCommand(command).pipe(Effect.flip);
472 expect(failure.message).toContain("up to 8");520 expect(failure.message).toContain("up to 100");
473 expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([`${id}.txt`]);521 expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([`${id}.txt`]);
474 const accepted = {522 const accepted = {
475 ...command,523 ...command,
476 attachmentsByQuestionId: {524 attachmentsByQuestionId: {
477 ...command.attachmentsByQuestionId,525 ...command.attachmentsByQuestionId,
478 second: Array.from({ length: 4 }, () => attachment),526 second: Array.from({ length: 50 }, () => attachment),
479 },527 },
480 };528 };
481 const normalized = yield* normalizeDispatchCommand(accepted);529 const normalized = yield* normalizeDispatchCommand(accepted);
482 if (normalized.type !== "thread.user-input.respond") throw new Error("Wrong command");530 if (normalized.type !== "thread.user-input.respond") throw new Error("Wrong command");
483 const attachments = Object.values(normalized.attachmentsByQuestionId!).flat();531 const attachments = Object.values(normalized.attachmentsByQuestionId!).flat();
484 expect(attachments).toHaveLength(8);532 expect(attachments).toHaveLength(100);
485 expect(new Set(attachments.map((item) => item.id)).size).toBe(8);533 expect(new Set(attachments.map((item) => item.id)).size).toBe(100);
486 for (const item of attachments) {534 for (const item of attachments) {
487 expect(item.name).toBe(attachment.name);535 expect(item.name).toBe(attachment.name);
488 expect(536 expect(
apps/server/src/orchestration/Normalizer.ts+12-9
...@@ -5,7 +5,7 @@ import * as Path from "effect/Path";...@@ -5,7 +5,7 @@ import * as Path from "effect/Path";
5import {5import {
6 type ClientOrchestrationCommand,6 type ClientOrchestrationCommand,
7 type UserInputAttachments,7 type UserInputAttachments,
8 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,8 getProviderAttachmentLimitError,
9 type IsoDateTime,9 type IsoDateTime,
10 type OrchestrationCommand,10 type OrchestrationCommand,
11 OrchestrationDispatchCommandError,11 OrchestrationDispatchCommandError,
...@@ -142,13 +142,9 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>...@@ -142,13 +142,9 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>
142 canonicalCommand.type === "thread.turn.start"142 canonicalCommand.type === "thread.turn.start"
143 ? canonicalCommand.message.attachments143 ? canonicalCommand.message.attachments
144 : Object.values(canonicalCommand.attachmentsByQuestionId ?? {}).flat();144 : Object.values(canonicalCommand.attachmentsByQuestionId ?? {}).flat();
145 if (145 const attachmentLimitError = getProviderAttachmentLimitError(attachments);
146 canonicalCommand.type === "thread.user-input.respond" &&146 if (attachmentLimitError) {
147 attachments.length > PROVIDER_SEND_TURN_MAX_ATTACHMENTS147 return yield* new OrchestrationDispatchCommandError({ message: attachmentLimitError });
148 ) {
149 return yield* new OrchestrationDispatchCommandError({
150 message: `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per question response.`,
151 });
152 }148 }
153 if (canonicalCommand.type === "thread.turn.start") {149 if (canonicalCommand.type === "thread.turn.start") {
154 const clientAttachmentIds = new Set<string>();150 const clientAttachmentIds = new Set<string>();
...@@ -163,11 +159,12 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>...@@ -163,11 +159,12 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>
163 }159 }
164 }160 }
165 const claimedAttachmentPaths: string[] = [];161 const claimedAttachmentPaths: string[] = [];
162 const attachmentsWithDecodedSizes = [...attachments];
166 // Context records bind to attachments by the id the client knew; they follow the rename.163 // Context records bind to attachments by the id the client knew; they follow the rename.
167 const finalAttachmentIdByClientId = new Map<string, string>();164 const finalAttachmentIdByClientId = new Map<string, string>();
168 const normalizedAttachments = yield* Effect.forEach(165 const normalizedAttachments = yield* Effect.forEach(
169 attachments,166 attachments,
170 (attachment) =>167 (attachment, index) =>
171 Effect.gen(function* () {168 Effect.gen(function* () {
172 if (!("dataUrl" in attachment)) {169 if (!("dataUrl" in attachment)) {
173 const claim = planAttachmentClaim({170 const claim = planAttachmentClaim({
...@@ -259,6 +256,11 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>...@@ -259,6 +256,11 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>
259 sizeBytes: bytes.byteLength,256 sizeBytes: bytes.byteLength,
260 ...(attachment.source ? { source: attachment.source } : {}),257 ...(attachment.source ? { source: attachment.source } : {}),
261 };258 };
259 attachmentsWithDecodedSizes[index] = persistedAttachment;
260 const decodedLimitError = getProviderAttachmentLimitError(attachmentsWithDecodedSizes);
261 if (decodedLimitError) {
262 return yield* new OrchestrationDispatchCommandError({ message: decodedLimitError });
263 }
262264
263 const attachmentPath = resolveAttachmentPath({265 const attachmentPath = resolveAttachmentPath({
264 attachmentsDir: serverConfig.attachmentsDir,266 attachmentsDir: serverConfig.attachmentsDir,
...@@ -286,6 +288,7 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>...@@ -286,6 +288,7 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>
286 }),288 }),
287 ),289 ),
288 );290 );
291 claimedAttachmentPaths.push(attachmentPath);
289 if (attachment.id !== undefined) {292 if (attachment.id !== undefined) {
290 finalAttachmentIdByClientId.set(attachment.id, attachmentId);293 finalAttachmentIdByClientId.set(attachment.id, attachmentId);
291 }294 }
docs/user/composer.md+7-3
...@@ -14,9 +14,13 @@ to keep a large paste editable in the composer instead....@@ -14,9 +14,13 @@ to keep a large paste editable in the composer instead.
1414
15## Attach files15## Attach files
1616
17Attach up to eight files per message. Images can be up to 10 MB; other files can17Attach up to 100 files per message. Each image can be up to 10 MiB, with at most
18be up to 50 MB, subject to the environment's upload support and limit. The agent1880 MiB of images in one message. Other files, including videos, can be up to
19receives them on the environment's machine.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
21Uploads begin when you add an attachment. All uploads must finish before the25Uploads begin when you add an attachment. All uploads must finish before the
22message can send. Retry or remove a failed upload. On web and desktop, reloading26message 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...@@ -6,4 +6,4 @@ Each question keeps its own attachments as you move between questions. Your norm
66
7Files 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.7Files 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...@@ -163,8 +163,9 @@ export const ProviderUserInputAnswers = Schema.Record(Schema.String, Schema.Unkn
163export type ProviderUserInputAnswers = typeof ProviderUserInputAnswers.Type;163export type ProviderUserInputAnswers = typeof ProviderUserInputAnswers.Type;
164164
165export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000;165export 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;
167export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024;167export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
168const PROVIDER_SEND_TURN_MAX_TOTAL_IMAGE_BYTES = 80 * 1024 * 1024;
168export const PROVIDER_SEND_TURN_MAX_FILE_BYTES = 50 * 1024 * 1024;169export const PROVIDER_SEND_TURN_MAX_FILE_BYTES = 50 * 1024 * 1024;
169export const PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES = [170export const PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES = [
170 "image/gif",171 "image/gif",
...@@ -371,6 +372,25 @@ export const ChatAttachment = Schema.Union([...@@ -371,6 +372,25 @@ export const ChatAttachment = Schema.Union([
371]);372]);
372export type ChatAttachment = typeof ChatAttachment.Type;373export 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
374export const UserInputAttachments = Schema.Record(394export const UserInputAttachments = Schema.Record(
375 Schema.String,395 Schema.String,
376 Schema.Array(Schema.Union([ChatImageAttachment, ChatFileAttachment])).pipe(396 Schema.Array(Schema.Union([ChatImageAttachment, ChatFileAttachment])).pipe(
packages/contracts/src/provider.test.ts+51
...@@ -121,6 +121,57 @@ describe("ProviderSessionStartInput", () => {...@@ -121,6 +121,57 @@ describe("ProviderSessionStartInput", () => {
121});121});
122122
123describe("ProviderSendTurnInput", () => {123describe("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
124 it("accepts codex modelSelection", () => {175 it("accepts codex modelSelection", () => {
125 const parsed = decodeProviderSendTurnInput({176 const parsed = decodeProviderSendTurnInput({
126 threadId: "thread-1",177 threadId: "thread-1",
packages/contracts/src/provider.ts+4-2
...@@ -11,7 +11,7 @@ import {...@@ -11,7 +11,7 @@ import {
11import {11import {
12 ChatAttachment,12 ChatAttachment,
13 ModelSelection,13 ModelSelection,
14 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,14 getProviderAttachmentLimitError,
15 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,15 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
16 ProviderApprovalDecision,16 ProviderApprovalDecision,
17 ProviderApprovalPolicy,17 ProviderApprovalPolicy,
...@@ -75,7 +75,9 @@ export const ProviderSendTurnInput = Schema.Struct({...@@ -75,7 +75,9 @@ export const ProviderSendTurnInput = Schema.Struct({
75 TrimmedNonEmptyString.check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)),75 TrimmedNonEmptyString.check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)),
76 ),76 ),
77 attachments: Schema.optional(77 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 ),
79 ),81 ),
80 modelSelection: Schema.optional(ModelSelection),82 modelSelection: Schema.optional(ModelSelection),
81 interactionMode: Schema.optional(ProviderInteractionMode),83 interactionMode: Schema.optional(ProviderInteractionMode),