| 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,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] }); |
| 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 | 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 | }); |
| 626 | 629 | ||
| 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 | }); |
| 632 | 635 | ||
| 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 | }); |
| 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 | 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", () => { |
| 918 | 918 | ||
| 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 | }); |
| 937 | 937 | ||
| 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}`)); |
| 2274 | 2274 | ||
| 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 | ); |
| 2280 | 2280 | ||
| 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 | }); |
| 2285 | 2285 | ||
| 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"; |
| 15 | import * as Effect from "effect/Effect"; | 15 | import * as Effect from "effect/Effect"; |
| 16 | import * as FileSystem from "effect/FileSystem"; | ||
| 16 | import * as Layer from "effect/Layer"; | 17 | import * as Layer from "effect/Layer"; |
| 17 | import * as Schema from "effect/Schema"; | 18 | import * as Schema from "effect/Schema"; |
| 18 | 19 | ||
| ... | @@ -59,6 +60,53 @@ function turnStartCommand(input: { | ... | @@ -59,6 +60,53 @@ function turnStartCommand(input: { |
| 59 | } | 60 | } |
| 60 | 61 | ||
| 61 | describe("normalizeDispatchCommand attachments", () => { | 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 | 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"; |
| 5 | import { | 5 | import { |
| 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.attachments | 143 | ? 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_ATTACHMENTS | 147 | 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 | } | ||
| 262 | 264 | ||
| 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. |
| 14 | 14 | ||
| 15 | ## Attach files | 15 | ## Attach files |
| 16 | 16 | ||
| 17 | Attach up to eight files per message. Images can be up to 10 MB; other files can | 17 | Attach up to 100 files per message. Each image can be up to 10 MiB, with at most |
| 18 | be up to 50 MB, subject to the environment's upload support and limit. The agent | 18 | 80 MiB of images in one message. Other files, including videos, can be up to |
| 19 | receives them on the environment's machine. | 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 | Uploads begin when you add an attachment. All uploads must finish before the | 25 | Uploads begin when you add an attachment. All uploads must finish before the |
| 22 | message can send. Retry or remove a failed upload. On web and desktop, reloading | 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,4 +6,4 @@ Each question keeps its own attachments as you move between questions. Your norm |
| 6 | 6 | ||
| 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. | 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,8 +163,9 @@ export const ProviderUserInputAnswers = Schema.Record(Schema.String, Schema.Unkn |
| 163 | export type ProviderUserInputAnswers = typeof ProviderUserInputAnswers.Type; | 163 | export type ProviderUserInputAnswers = typeof ProviderUserInputAnswers.Type; |
| 164 | 164 | ||
| 165 | export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; | 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 | export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024; | 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 | export const PROVIDER_SEND_TURN_MAX_FILE_BYTES = 50 * 1024 * 1024; | 169 | export const PROVIDER_SEND_TURN_MAX_FILE_BYTES = 50 * 1024 * 1024; |
| 169 | export const PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES = [ | 170 | export 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 | ]); |
| 372 | export type ChatAttachment = typeof ChatAttachment.Type; | 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 | export const UserInputAttachments = Schema.Record( | 394 | export 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 | }); |
| 122 | 122 | ||
| 123 | describe("ProviderSendTurnInput", () => { | 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 | 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 { |
| 11 | import { | 11 | import { |
| 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), |