diff --git a/apps/mobile/src/features/sharing/incoming-share-model.test.ts b/apps/mobile/src/features/sharing/incoming-share-model.test.ts index ce2011650e71..01beb7824a8b 100644 --- a/apps/mobile/src/features/sharing/incoming-share-model.test.ts +++ b/apps/mobile/src/features/sharing/incoming-share-model.test.ts @@ -176,7 +176,7 @@ describe("incoming native shares", () => { }); expect(result.attachments).toEqual([]); - expect(result.warnings).toEqual(["'huge.zip' exceeds the 50 MB attachment limit."]); + expect(result.warnings).toEqual(["'huge.zip' exceeds the 100 MB attachment limit."]); expect(persistFile).not.toHaveBeenCalled(); expect(removeOwnedFile).toHaveBeenCalledWith(file.value); }); diff --git a/apps/mobile/src/lib/composerFiles.test.ts b/apps/mobile/src/lib/composerFiles.test.ts index a99c2c6064e7..c29543bb54c9 100644 --- a/apps/mobile/src/lib/composerFiles.test.ts +++ b/apps/mobile/src/lib/composerFiles.test.ts @@ -446,10 +446,10 @@ describe("composer file attachments", () => { }, { reason: "server advertises more than the contract limit", - reported: 51 * 1024 * 1024, + reported: 100_000_001, stored: 42, - limit: 80 * 1024 * 1024, - error: "'clip.mov' exceeds the 50 MB attachment limit.", + limit: 200_000_000, + error: "'clip.mov' exceeds the 100 MB attachment limit.", }, ])( "rejects a video when $reason while retaining the selected photo", @@ -676,7 +676,7 @@ describe("composer file attachments", () => { expect(mocks.copy).not.toHaveBeenCalled(); }); - it("never accepts files above the 50 MB contract limit", async () => { + it("never accepts files above the 100 MB contract limit", async () => { mocks.pickFile.mockResolvedValue({ canceled: false, assets: [ @@ -684,16 +684,14 @@ describe("composer file attachments", () => { uri: "file:///downloads/archive.zip", name: "archive.zip", mimeType: "application/zip", - size: 51 * 1024 * 1024, + size: 100_000_001, }, ], }); - await expect( - pickComposerFiles({ existingCount: 0, maxBytes: 80 * 1024 * 1024 }), - ).resolves.toEqual({ + await expect(pickComposerFiles({ existingCount: 0, maxBytes: 200_000_000 })).resolves.toEqual({ files: [], - error: "'archive.zip' exceeds the 50 MB attachment limit.", + error: "'archive.zip' exceeds the 100 MB attachment limit.", }); }); diff --git a/apps/server/src/assets/AttachmentUpload.test.ts b/apps/server/src/assets/AttachmentUpload.test.ts index 6fffa1d1f9f2..7046e1937526 100644 --- a/apps/server/src/assets/AttachmentUpload.test.ts +++ b/apps/server/src/assets/AttachmentUpload.test.ts @@ -182,6 +182,33 @@ describe("AttachmentUpload", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("streams a 100 MB ZIP upload to disk", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const issued = yield* issueAttachmentUploadUrl({ + type: "file", + name: "archive.zip", + mimeType: "application/zip", + sizeBytes: 100_000_000, + }); + const token = issued.relativeUrl.slice(ATTACHMENT_UPLOAD_ROUTE_PREFIX.length + 1); + const claims = yield* validateAttachmentUploadToken(token); + if (!claims) throw new Error("Expected valid upload claims."); + const chunk = new Uint8Array(1_000_000); + expect( + yield* storeAttachmentUpload( + claims, + Stream.fromIterable(Array.from({ length: 100 }, () => chunk)), + ), + ).toEqual({ ok: true }); + expect( + NodeFS.statSync(NodePath.join(config.attachmentsDir, `${issued.attachmentId}.zip`)).size, + ).toBe(100_000_000); + yield* deletePendingAttachment(issued.attachmentId); + expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([]); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("removes partial streamed uploads that exceed their signed size", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index b4758e980065..83549e153d58 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -171,7 +171,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(second.capabilities.repositoryIdentity).toBe(true); expect(second.capabilities.connectionProbe).toBe(true); expect(second.capabilities.attachmentUploads).toBe(true); - expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 }); + expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 100_000_000 }); expect(second.capabilities.pullRequests).toBe(true); expect(second.capabilities.requiredWorktreeBootstrap).toBe(true); expect(second.capabilities.usagePriceOverrides).toBe(true); diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index 3f80e84e9a59..8f36f048648e 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -233,7 +233,7 @@ const PreviewRecordingStartTool = safeBrowserTool( const PreviewRecordingStopTool = safeBrowserTool( Tool.make("preview_recording_stop", { description: - "Stop recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted, and transfer the compressed recording once (up to 50 MiB) to an evidence file readable in this agent's environment. Returns its environment-local path after transfer succeeds.", + "Stop recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted, and transfer the compressed recording once (up to 100 MB) to an evidence file readable in this agent's environment. Returns its environment-local path after transfer succeeds.", parameters: PreviewAutomationTabTargetInput, success: Schema.Struct({ ...PreviewAutomationRecordingArtifact.fields, ...presentationFields }), failure: PreviewAutomationError, diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index b9997e1df312..6843ec2f97aa 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -986,6 +986,40 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance }).pipe(Effect.provide(NodeServices.layer)), ); +for (const name of ["codex", "claudeAgent", "cursor", "grok", "opencode", "antigravity"]) { + const driver = ProviderDriverKind.make(name); + const instanceId = ProviderInstanceId.make(name); + const adapter = makeFakeCodexAdapter(driver); + makeProviderServiceLayer({ + registry: makeStaticInstanceRegistry([[instanceId, adapter.adapter]]), + }).layer(`${name} file attachment limits`, (it) => { + it.effect("passes a 100 MB ZIP by path", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId(`zip-${name}`); + yield* provider.startSession(threadId, { + provider: driver, + providerInstanceId: instanceId, + threadId, + runtimeMode: "full-access", + }); + const attachment = { + type: "file" as const, + id: "thread-attach-12345678-1234-1234-1234-123456789abc-zip", + name: "archive.zip", + mimeType: "application/zip", + sizeBytes: 100_000_000, + }; + yield* provider.sendTurn({ threadId, attachments: [attachment] }); + const sent = adapter.sendTurn.mock.calls[0]?.[0]; + assert.include(sent?.input ?? "", '[Attached file "archive.zip" is saved at: '); + assert.include(sent?.input ?? "", `${attachment.id}.zip]`); + assert.deepEqual(sent?.attachments, [attachment]); + }), + ); + }); +} + const routing = makeProviderServiceLayer(); const customCompactionDriver = ProviderDriverKind.make("custom-compaction-provider"); diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts index aee10782a613..830cbe1095f7 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts @@ -2,7 +2,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import { ANTIGRAVITY_DEFAULT_MODEL, - PROVIDER_SEND_TURN_MAX_FILE_BYTES, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type ChatAttachment, type RuntimeMode, @@ -403,7 +402,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { it.effect.each([ { attachment: textAttachment, bytes: ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES + 1 }, { attachment: imageAttachment, bytes: PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1 }, - { attachment: pdfAttachment, bytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES + 1 }, + { attachment: pdfAttachment, bytes: 50 * 1024 * 1024 + 1 }, ])( "rejects oversized $attachment.name using file size instead of upload metadata", ({ attachment, bytes }) => @@ -435,8 +434,8 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { }; const first = yield* fixture.write(pdfAttachment, ""); const second = yield* fixture.write(secondAttachment, ""); - yield* fixture.fs.truncate(first.filePath, PROVIDER_SEND_TURN_MAX_FILE_BYTES / 2); - yield* fixture.fs.truncate(second.filePath, PROVIDER_SEND_TURN_MAX_FILE_BYTES / 2); + yield* fixture.fs.truncate(first.filePath, (50 * 1024 * 1024) / 2); + yield* fixture.fs.truncate(second.filePath, (50 * 1024 * 1024) / 2); const input = { input: undefined, attachments: [pdfAttachment, secondAttachment], @@ -448,7 +447,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { { type: "resource_link", uri: second.uri, name: "second.pdf", mimeType: "application/pdf" }, ]); - yield* fixture.fs.truncate(second.filePath, PROVIDER_SEND_TURN_MAX_FILE_BYTES / 2 + 1); + yield* fixture.fs.truncate(second.filePath, (50 * 1024 * 1024) / 2 + 1); const error = yield* buildAntigravityPrompt(input).pipe(Effect.flip); expect(error).toMatchObject({ _tag: "AcpRequestError", @@ -463,7 +462,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { const fixture = yield* makeAttachmentFixture(); const pdf = yield* fixture.write(pdfAttachment, ""); const text = yield* fixture.write(textAttachment, "a"); - yield* fixture.fs.truncate(pdf.filePath, PROVIDER_SEND_TURN_MAX_FILE_BYTES - 1); + yield* fixture.fs.truncate(pdf.filePath, 50 * 1024 * 1024 - 1); const error = yield* buildAntigravityPrompt({ input: undefined, attachments: [pdfAttachment, textAttachment], diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.ts index f2f370068181..98a06c4a28eb 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.ts @@ -1,7 +1,6 @@ import { ANTIGRAVITY_DEFAULT_MODEL, type AntigravityAuthMethod, - PROVIDER_SEND_TURN_MAX_FILE_BYTES, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type ProviderSendTurnInput, type RuntimeMode, @@ -244,7 +243,7 @@ const TEXT_FILE_EXTENSIONS = new Set([ ".conf", ]); export const ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES = 1024 * 1024; -const MAX_TOTAL_ATTACHMENT_BYTES = PROVIDER_SEND_TURN_MAX_FILE_BYTES; +const MAX_TOTAL_ATTACHMENT_BYTES = 50 * 1024 * 1024; /** Sends uploads as native ACP content instead of workspace path hints. */ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(function* (input: { @@ -317,7 +316,7 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi : audio ? ANTIGRAVITY_MAX_AUDIO_ATTACHMENT_BYTES : pdf - ? PROVIDER_SEND_TURN_MAX_FILE_BYTES + ? MAX_TOTAL_ATTACHMENT_BYTES : ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES; totalBytes += size; if (info.type !== "File" || size > limit || totalBytes > MAX_TOTAL_ATTACHMENT_BYTES) { diff --git a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts index c66a2f36a207..39df72f67c81 100644 --- a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts +++ b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts @@ -302,13 +302,14 @@ describe("toOpenCodeFileParts", () => { attachment("image/png"), // A ZIP file part makes OpenCode's Anthropic path throw before the // turn starts; it must ride only as the prompt's file path line. - attachment("application/zip"), + attachment("application/zip", 100_000_000), attachment("application/octet-stream"), // Image formats the model APIs reject stay on the fallback path too. attachment("image/bmp"), attachment("image/svg+xml"), // Over the direct-attachment limit: path fallback even for a PDF. attachment("application/pdf", 21 * 1024 * 1024), + attachment("application/pdf", 100_000_000), ], resolveAttachmentPath: () => "/tmp/attachment", }); diff --git a/docs/user/composer.md b/docs/user/composer.md index 6e699d0669c1..9e307020adc0 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -16,11 +16,11 @@ to keep a large paste editable in the composer instead. Attach up to 100 files per message. Each image can be up to 10 MiB, with at most 80 MiB of images in one message. Other files, including videos, can be up to -50 MiB each, subject to the environment's upload support and limit. The agent -receives them on the environment's machine. Provider and model limits still -apply, including images already in the conversation. A video attachment gives -the agent a file path; it does not enable native video input. Antigravity does -not accept video attachments. +100 MB (100,000,000 bytes) each, subject to the environment's upload support and +limit. The agent receives them on the environment's machine. Provider and model +limits still apply, including images already in the conversation. A video +attachment gives the agent a file path; it does not enable native video input. +Antigravity does not accept video attachments. Uploads begin when you add an attachment. All uploads must finish before the message can send. Retry or remove a failed upload. On web and desktop, reloading diff --git a/packages/client-runtime/src/state/attachments.test.ts b/packages/client-runtime/src/state/attachments.test.ts index f09c47362241..8a99082904f5 100644 --- a/packages/client-runtime/src/state/attachments.test.ts +++ b/packages/client-runtime/src/state/attachments.test.ts @@ -195,6 +195,9 @@ describe("file attachment limits", () => { expect(fileAttachmentTooLargeMessage("one.bin", 1024 * 1024)).toBe( "'one.bin' exceeds the 1 MB attachment limit.", ); + expect(fileAttachmentTooLargeMessage("archive.zip", 100_000_000)).toBe( + "'archive.zip' exceeds the 100 MB attachment limit.", + ); expect(fileAttachmentTooLargeMessage("big.zip", 50 * 1024 * 1024)).toBe( "'big.zip' exceeds the 50 MB attachment limit.", ); diff --git a/packages/client-runtime/src/state/attachments.ts b/packages/client-runtime/src/state/attachments.ts index 0dc457e2cade..d25ca26aa10a 100644 --- a/packages/client-runtime/src/state/attachments.ts +++ b/packages/client-runtime/src/state/attachments.ts @@ -227,10 +227,12 @@ export function formatAttachmentSize(sizeBytes: number): string { /** User-facing rejection for a file over the effective upload limit. */ export function fileAttachmentTooLargeMessage(name: string, maxUploadBytes: number): string { const maxUploadSize = - maxUploadBytes >= 1024 * 1024 && maxUploadBytes % (1024 * 1024) === 0 - ? `${maxUploadBytes / (1024 * 1024)} MB` - : maxUploadBytes >= 1024 && maxUploadBytes % 1024 === 0 - ? `${maxUploadBytes / 1024} KB` - : `${maxUploadBytes} ${maxUploadBytes === 1 ? "byte" : "bytes"}`; + maxUploadBytes >= 1_000_000 && maxUploadBytes % 1_000_000 === 0 + ? `${maxUploadBytes / 1_000_000} MB` + : maxUploadBytes >= 1024 * 1024 && maxUploadBytes % (1024 * 1024) === 0 + ? `${maxUploadBytes / (1024 * 1024)} MB` + : maxUploadBytes >= 1024 && maxUploadBytes % 1024 === 0 + ? `${maxUploadBytes / 1024} KB` + : `${maxUploadBytes} ${maxUploadBytes === 1 ? "byte" : "bytes"}`; return `'${name}' exceeds the ${maxUploadSize} attachment limit.`; } diff --git a/packages/contracts/src/assets.test.ts b/packages/contracts/src/assets.test.ts index c47c53b2a84e..623fb0b4e7a7 100644 --- a/packages/contracts/src/assets.test.ts +++ b/packages/contracts/src/assets.test.ts @@ -43,6 +43,20 @@ describe("AttachmentCreateUploadUrlInput", () => { ).toBe(true); }); + it.each([100_000_000, 100_000_001])( + "enforces the 100 MB file boundary at %s bytes", + (sizeBytes) => { + expect( + isUploadInput({ + type: "file", + name: "archive.zip", + mimeType: "application/zip", + sizeBytes, + }), + ).toBe(sizeBytes <= 100_000_000); + }, + ); + it("rejects empty and oversized uploads", () => { expect(isUploadInput({ ...uploadInput, sizeBytes: 0 })).toBe(false); expect( diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 3e323e4964d5..a8481d95b1c9 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -166,7 +166,7 @@ export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 100; export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024; const PROVIDER_SEND_TURN_MAX_TOTAL_IMAGE_BYTES = 80 * 1024 * 1024; -export const PROVIDER_SEND_TURN_MAX_FILE_BYTES = 50 * 1024 * 1024; +export const PROVIDER_SEND_TURN_MAX_FILE_BYTES = 100_000_000; export const PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES = [ "image/gif", "image/jpeg", diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index 431e83189d22..5f9c3d2a1696 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -910,7 +910,7 @@ export class PreviewAutomationRecordingTooLargeError extends Schema.TaggedError< { threadId: ThreadId, cause: Schema.optional(Schema.Defect()) }, ) { override get message(): string { - return "The recording exceeds 50 MiB. The saved copy remains on the desktop."; + return "The recording exceeds 100 MB. The saved copy remains on the desktop."; } }