| author | |
| committer | |
| log | 2b178231ebfa597947efb4ec31bf60e0bb9e3128 |
| tree | 340c3855a90f7647b9304791338fb14d5194c2db |
| parent | 1ba471a37cd6b0f18820795f4505206f4723a0e3 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 477 | it("applies the remaining attachment slots to photos and videos together", async () => { |
| 478 | 478 | mocks.pickMedia.mockResolvedValue({ canceled: false, assets: [image, video] }); |
| 479 | 479 | |
| 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 | }); | |
| 481 | 484 | |
| 482 | 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 | 487 | expect(mocks.pickMedia).toHaveBeenCalledWith(expect.objectContaining({ selectionLimit: 1 })); |
| 485 | 488 | expect(mocks.copy).not.toHaveBeenCalled(); |
| 486 | 489 | }); |
| ... | ... | @@ -625,9 +628,9 @@ describe("composer file attachments", () => { |
| 625 | 628 | }); |
| 626 | 629 | |
| 627 | 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 | 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 | }); |
| 632 | 635 | |
| 633 | 636 | expect(mocks.pickFile).not.toHaveBeenCalled(); |
| ... | ... | @@ -838,7 +841,7 @@ describe("composer file attachments", () => { |
| 838 | 841 | ], |
| 839 | 842 | }); |
| 840 | 843 | |
| 841 | const result = await pickComposerFiles({ existingCount: 7, maxBytes: 1024 * 1024 }); | |
| 844 | const result = await pickComposerFiles({ existingCount: 99, maxBytes: 1024 * 1024 }); | |
| 842 | 845 | |
| 843 | 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 | 676 | return undefined; |
| 677 | 677 | }); |
| 678 | 678 | const key = "environment-1:replace-file"; |
| 679 | const files = Array.from({ length: 8 }, (_, index) => ({ | |
| 679 | const files = Array.from({ length: 100 }, (_, index) => ({ | |
| 680 | 680 | id: `file-${index}`, |
| 681 | 681 | type: "file" as const, |
| 682 | 682 | name: `notes-${index}.txt`, |
| ... | ... | @@ -687,8 +687,8 @@ describe("mobile composer drafts", () => { |
| 687 | 687 | appendComposerDraftAttachments(key, files, { appendReference: true }); |
| 688 | 688 | const firstLink = "[notes-0.txt](t3-context://v1/file/file-0)"; |
| 689 | 689 | 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); | |
| 692 | 692 | const replacement = { ...files[0]!, id: "replacement", fileUri: "file:///replacement.txt" }; |
| 693 | 693 | if (kind === "attachment") { |
| 694 | 694 | expect( |
| ... | ... | @@ -716,7 +716,7 @@ describe("mobile composer drafts", () => { |
| 716 | 716 | insertion, |
| 717 | 717 | ), |
| 718 | 718 | ).toBe(true); |
| 719 | expect(getComposerDraftSnapshot(key).attachments).toHaveLength(8); | |
| 719 | expect(getComposerDraftSnapshot(key).attachments).toHaveLength(100); | |
| 720 | 720 | expect(getComposerDraftSnapshot(key).context?.records).toContainEqual(record); |
| 721 | 721 | expect(getComposerDraftSnapshot(key).text).toBe( |
| 722 | 722 | `${formatComposerContextReference(record)}${insertion.text.slice(firstLink.length)}`, |
| ... | ... | @@ -730,7 +730,7 @@ describe("mobile composer drafts", () => { |
| 730 | 730 | } |
| 731 | 731 | const draft = getComposerDraftSnapshot(key); |
| 732 | 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 | 734 | expect(draft.context?.records.some((record) => record.contextId === "file-0")).toBe(false); |
| 735 | 735 | await cleanup.promise; |
| 736 | 736 | expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(files[0]!.fileUri); |
| ... | ... | @@ -746,7 +746,7 @@ describe("mobile composer drafts", () => { |
| 746 | 746 | return undefined; |
| 747 | 747 | }); |
| 748 | 748 | const key = "environment-1:concurrent-import"; |
| 749 | const files = Array.from({ length: 8 }, (_, index) => ({ | |
| 749 | const files = Array.from({ length: 100 }, (_, index) => ({ | |
| 750 | 750 | id: `existing-${index}`, |
| 751 | 751 | type: "file" as const, |
| 752 | 752 | name: `notes-${index}.txt`, |
| ... | ... | @@ -906,7 +906,7 @@ describe("mobile composer drafts", () => { |
| 906 | 906 | fileUri: `file:///documents/t3-composer-attachments/${id}.mov`, |
| 907 | 907 | }); |
| 908 | 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 | 910 | appAtomRegistry.set(composerDraftsAtom, { |
| 911 | 911 | [draftKey]: { text: "send this", attachments: existing }, |
| 912 | 912 | }); |
| ... | ... | @@ -918,7 +918,7 @@ describe("mobile composer drafts", () => { |
| 918 | 918 | |
| 919 | 919 | expect(rejected).toBe(1); |
| 920 | 920 | const draft = appAtomRegistry.get(composerDraftsAtom)[draftKey]; |
| 921 | expect(draft?.attachments).toHaveLength(8); | |
| 921 | expect(draft?.attachments).toHaveLength(100); | |
| 922 | 922 | expect(draft?.attachments.at(-1)?.id).toBe("incoming-1"); |
| 923 | 923 | await cleanup.promise; |
| 924 | 924 | expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledExactlyOnceWith( |
| ... | ... | @@ -932,7 +932,7 @@ describe("mobile composer drafts", () => { |
| 932 | 932 | { allowOverflow: true }, |
| 933 | 933 | ); |
| 934 | 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 | }); |
| 937 | 937 | |
| 938 | 938 | it("keeps shared attachment files until every draft releases them", async () => { |
| ... | ... | @@ -2270,7 +2270,7 @@ describe("mobile composer drafts", () => { |
| 2270 | 2270 | previewUri: "data:image/png;base64,YWJj", |
| 2271 | 2271 | }); |
| 2272 | 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}`)); | |
| 2274 | 2274 | |
| 2275 | 2275 | const merged = mergeComposerDraftContentState( |
| 2276 | 2276 | { [draftKey]: { text: "", attachments: [existingImage] } }, |
| ... | ... | @@ -2278,9 +2278,9 @@ describe("mobile composer drafts", () => { |
| 2278 | 2278 | { text: "", attachments: sharedImages }, |
| 2279 | 2279 | ); |
| 2280 | 2280 | |
| 2281 | expect(merged[draftKey]?.attachments).toHaveLength(8); | |
| 2281 | expect(merged[draftKey]?.attachments).toHaveLength(100); | |
| 2282 | 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 | }); |
| 2285 | 2285 | |
| 2286 | 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 | 13 | ThreadId, |
| 14 | 14 | } from "@t3tools/contracts"; |
| 15 | 15 | import * as Effect from "effect/Effect"; |
| 16 | import * as FileSystem from "effect/FileSystem"; | |
| 16 | 17 | import * as Layer from "effect/Layer"; |
| 17 | 18 | import * as Schema from "effect/Schema"; |
| 18 | 19 | |
| ... | ... | @@ -59,6 +60,53 @@ function turnStartCommand(input: { |
| 59 | 60 | } |
| 60 | 61 | |
| 61 | 62 | describe("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 | 110 | it.effect("rejects duplicate client ids before persisting attachments", () => |
| 63 | 111 | Effect.gen(function* () { |
| 64 | 112 | const error = yield* normalizeDispatchCommand( |
| ... | ... | @@ -464,25 +512,25 @@ describe("question attachments", () => { |
| 464 | 512 | answers: { first: "", second: "" }, |
| 465 | 513 | createdAt: "2026-08-01T00:00:00.000Z", |
| 466 | 514 | 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), | |
| 469 | 517 | }, |
| 470 | 518 | }; |
| 471 | 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 | 521 | expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([`${id}.txt`]); |
| 474 | 522 | const accepted = { |
| 475 | 523 | ...command, |
| 476 | 524 | attachmentsByQuestionId: { |
| 477 | 525 | ...command.attachmentsByQuestionId, |
| 478 | second: Array.from({ length: 4 }, () => attachment), | |
| 526 | second: Array.from({ length: 50 }, () => attachment), | |
| 479 | 527 | }, |
| 480 | 528 | }; |
| 481 | 529 | const normalized = yield* normalizeDispatchCommand(accepted); |
| 482 | 530 | if (normalized.type !== "thread.user-input.respond") throw new Error("Wrong command"); |
| 483 | 531 | 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); | |
| 486 | 534 | for (const item of attachments) { |
| 487 | 535 | expect(item.name).toBe(attachment.name); |
| 488 | 536 | expect( |
apps/server/src/orchestration/Normalizer.ts+12-9| ... | ... | @@ -5,7 +5,7 @@ import * as Path from "effect/Path"; |
| 5 | 5 | import { |
| 6 | 6 | type ClientOrchestrationCommand, |
| 7 | 7 | type UserInputAttachments, |
| 8 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | |
| 8 | getProviderAttachmentLimitError, | |
| 9 | 9 | type IsoDateTime, |
| 10 | 10 | type OrchestrationCommand, |
| 11 | 11 | OrchestrationDispatchCommandError, |
| ... | ... | @@ -142,13 +142,9 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => |
| 142 | 142 | canonicalCommand.type === "thread.turn.start" |
| 143 | 143 | ? canonicalCommand.message.attachments |
| 144 | 144 | : 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 }); | |
| 152 | 148 | } |
| 153 | 149 | if (canonicalCommand.type === "thread.turn.start") { |
| 154 | 150 | const clientAttachmentIds = new Set<string>(); |
| ... | ... | @@ -163,11 +159,12 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => |
| 163 | 159 | } |
| 164 | 160 | } |
| 165 | 161 | const claimedAttachmentPaths: string[] = []; |
| 162 | const attachmentsWithDecodedSizes = [...attachments]; | |
| 166 | 163 | // Context records bind to attachments by the id the client knew; they follow the rename. |
| 167 | 164 | const finalAttachmentIdByClientId = new Map<string, string>(); |
| 168 | 165 | const normalizedAttachments = yield* Effect.forEach( |
| 169 | 166 | attachments, |
| 170 | (attachment) => | |
| 167 | (attachment, index) => | |
| 171 | 168 | Effect.gen(function* () { |
| 172 | 169 | if (!("dataUrl" in attachment)) { |
| 173 | 170 | const claim = planAttachmentClaim({ |
| ... | ... | @@ -259,6 +256,11 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => |
| 259 | 256 | sizeBytes: bytes.byteLength, |
| 260 | 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 | } | |
| 262 | 264 | |
| 263 | 265 | const attachmentPath = resolveAttachmentPath({ |
| 264 | 266 | attachmentsDir: serverConfig.attachmentsDir, |
| ... | ... | @@ -286,6 +288,7 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => |
| 286 | 288 | }), |
| 287 | 289 | ), |
| 288 | 290 | ); |
| 291 | claimedAttachmentPaths.push(attachmentPath); | |
| 289 | 292 | if (attachment.id !== undefined) { |
| 290 | 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 | 14 | |
| 15 | 15 | ## Attach files |
| 16 | 16 | |
| 17 | Attach up to eight files per message. Images can be up to 10 MB; other files can | |
| 18 | be up to 50 MB, subject to the environment's upload support and limit. The agent | |
| 19 | receives them on the environment's machine. | |
| 17 | Attach up to 100 files per message. Each image can be up to 10 MiB, with at most | |
| 18 | 80 MiB of images in one message. Other files, including videos, can be up to | |
| 19 | 50 MiB each, subject to the environment's upload support and limit. The agent | |
| 20 | receives them on the environment's machine. Provider and model limits still | |
| 21 | apply, including images already in the conversation. A video attachment gives | |
| 22 | the agent a file path; it does not enable native video input. Antigravity does | |
| 23 | not accept video attachments. | |
| 20 | 24 | |
| 21 | 25 | Uploads begin when you add an attachment. All uploads must finish before the |
| 22 | 26 | message 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 | 6 | |
| 7 | 7 | 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. |
| 8 | 8 | |
| 9 | 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. | |
| 9 | 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. |
packages/contracts/src/orchestration.ts+21-1| ... | ... | @@ -163,8 +163,9 @@ export const ProviderUserInputAnswers = Schema.Record(Schema.String, Schema.Unkn |
| 163 | 163 | export type ProviderUserInputAnswers = typeof ProviderUserInputAnswers.Type; |
| 164 | 164 | |
| 165 | 165 | export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; |
| 166 | export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8; | |
| 166 | export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 100; | |
| 167 | 167 | export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024; |
| 168 | const PROVIDER_SEND_TURN_MAX_TOTAL_IMAGE_BYTES = 80 * 1024 * 1024; | |
| 168 | 169 | export const PROVIDER_SEND_TURN_MAX_FILE_BYTES = 50 * 1024 * 1024; |
| 169 | 170 | export const PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES = [ |
| 170 | 171 | "image/gif", |
| ... | ... | @@ -371,6 +372,25 @@ export const ChatAttachment = Schema.Union([ |
| 371 | 372 | ]); |
| 372 | 373 | export type ChatAttachment = typeof ChatAttachment.Type; |
| 373 | 374 | |
| 375 | export 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 | ||
| 374 | 394 | export const UserInputAttachments = Schema.Record( |
| 375 | 395 | Schema.String, |
| 376 | 396 | Schema.Array(Schema.Union([ChatImageAttachment, ChatFileAttachment])).pipe( |
packages/contracts/src/provider.test.ts+51| ... | ... | @@ -121,6 +121,57 @@ describe("ProviderSessionStartInput", () => { |
| 121 | 121 | }); |
| 122 | 122 | |
| 123 | 123 | describe("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 | 175 | it("accepts codex modelSelection", () => { |
| 125 | 176 | const parsed = decodeProviderSendTurnInput({ |
| 126 | 177 | threadId: "thread-1", |
packages/contracts/src/provider.ts+4-2| ... | ... | @@ -11,7 +11,7 @@ import { |
| 11 | 11 | import { |
| 12 | 12 | ChatAttachment, |
| 13 | 13 | ModelSelection, |
| 14 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | |
| 14 | getProviderAttachmentLimitError, | |
| 15 | 15 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, |
| 16 | 16 | ProviderApprovalDecision, |
| 17 | 17 | ProviderApprovalPolicy, |
| ... | ... | @@ -75,7 +75,9 @@ export const ProviderSendTurnInput = Schema.Struct({ |
| 75 | 75 | TrimmedNonEmptyString.check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)), |
| 76 | 76 | ), |
| 77 | 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 | 82 | modelSelection: Schema.optional(ModelSelection), |
| 81 | 83 | interactionMode: Schema.optional(ProviderInteractionMode), |