From 760f15f2f94965e2fc653f11225741cd2b6fd870 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Feb 2026 15:33:33 -0800 Subject: [PATCH] Expand sendTurn attachment handling --- apps/server/src/codexAppServerManager.test.ts | 8 ++- apps/server/src/codexAppServerManager.ts | 14 ++-- apps/web/src/components/ChatView.tsx | 15 ++-- packages/contracts/src/provider.test.ts | 19 ++--- packages/contracts/src/provider.ts | 71 ++++++++++++------- 5 files changed, 76 insertions(+), 51 deletions(-) diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index f408970bdd205d9e1d4700dd6fff4db0c22ddc7f..e9d384594cddbd64a3461147e01c3b0dee309c3e 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -114,8 +114,9 @@ describe("sendTurn", () => { const result = await manager.sendTurn({ sessionId: "sess_1", input: "Inspect this image", - images: [ + attachments: [ { + type: "image", name: "error.png", mimeType: "image/png", sizeBytes: 1_024, @@ -158,8 +159,9 @@ describe("sendTurn", () => { await manager.sendTurn({ sessionId: "sess_1", - images: [ + attachments: [ { + type: "image", name: "diagram.png", mimeType: "image/png", sizeBytes: 256, @@ -186,6 +188,6 @@ describe("sendTurn", () => { manager.sendTurn({ sessionId: "sess_1", }), - ).rejects.toThrow("Turn input must include text or image attachments."); + ).rejects.toThrow("Turn input must include text or attachments."); }); }); diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 327484d07e71601d5a0eea42af71e16f30f089e0..0a1d61c098ae6fcf0d9af3e3804904797536c7a2 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -268,14 +268,16 @@ export class CodexAppServerManager extends EventEmitter= PROVIDER_SEND_TURN_MAX_IMAGES) { - error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_IMAGES} images per message.`; + if (nextImages.length >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { + error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`; break; } @@ -772,8 +772,9 @@ export default function ChatView() { setIsSending(true); try { - const turnImages = await Promise.all( - composerImagesSnapshot.map(async (image): Promise => ({ + const turnAttachments = await Promise.all( + composerImagesSnapshot.map(async (image): Promise => ({ + type: "image", name: image.name, mimeType: image.mimeType, sizeBytes: image.sizeBytes, @@ -794,7 +795,7 @@ export default function ChatView() { await api.providers.sendTurn({ sessionId: sessionInfo.sessionId, ...(input ? { input } : {}), - ...(turnImages.length > 0 ? { images: turnImages } : {}), + ...(turnAttachments.length > 0 ? { attachments: turnAttachments } : {}), model: selectedModel || undefined, effort: selectedEffort || undefined, }); diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts index b9af9633cf2460f4c1c042336af62456447658b9..e2ccd990be8c53361843f1bb61a4289692527a93 100644 --- a/packages/contracts/src/provider.test.ts +++ b/packages/contracts/src/provider.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { - PROVIDER_SEND_TURN_MAX_IMAGES, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, providerEventSchema, providerRespondToRequestInputSchema, providerSendTurnInputSchema, @@ -41,7 +41,7 @@ describe("providerSendTurnInputSchema", () => { effort: " high ", }); expect(parsed.input).toBe("summarize this repo"); - expect(parsed.images).toEqual([]); + expect(parsed.attachments).toEqual([]); expect(parsed.model).toBe("gpt-5.2-codex"); expect(parsed.effort).toBe("high"); }); @@ -49,8 +49,9 @@ describe("providerSendTurnInputSchema", () => { it("accepts image-only turns", () => { const parsed = providerSendTurnInputSchema.parse({ sessionId: "sess_1", - images: [ + attachments: [ { + type: "image", name: "diagram.png", mimeType: "image/png", sizeBytes: 1_024, @@ -59,10 +60,10 @@ describe("providerSendTurnInputSchema", () => { ], }); expect(parsed.input).toBeUndefined(); - expect(parsed.images).toHaveLength(1); + expect(parsed.attachments).toHaveLength(1); }); - it("rejects turns with neither text nor images", () => { + it("rejects turns with neither text nor attachments", () => { expect(() => providerSendTurnInputSchema.parse({ sessionId: "sess_1", @@ -74,8 +75,9 @@ describe("providerSendTurnInputSchema", () => { expect(() => providerSendTurnInputSchema.parse({ sessionId: "sess_1", - images: [ + attachments: [ { + type: "image", name: "not-image.txt", mimeType: "text/plain", sizeBytes: 25, @@ -86,11 +88,12 @@ describe("providerSendTurnInputSchema", () => { ).toThrow(); }); - it("rejects more than the max image count", () => { + it("rejects more than the max attachment count", () => { expect(() => providerSendTurnInputSchema.parse({ sessionId: "sess_1", - images: Array.from({ length: PROVIDER_SEND_TURN_MAX_IMAGES + 1 }, (_, index) => ({ + attachments: Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS + 1 }, (_, index) => ({ + type: "image" as const, name: `image-${index}.png`, mimeType: "image/png", sizeBytes: 1_024, diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 9604b9ddfbb9e449079bc1dbadd1153314befa3a..66dcaf18ce428d72349ed7919b67b6716300fc69 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -55,43 +55,58 @@ export const providerSessionStartInputSchema = z.object({ }); export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; -export const PROVIDER_SEND_TURN_MAX_IMAGES = 8; +export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8; export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024; export const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000; -export const providerSendTurnImageInputSchema = z - .object({ - name: z.string().trim().min(1).max(255), - mimeType: z - .string() - .trim() - .min(1) - .max(100) - .regex(/^image\//i, "mimeType must be an image/* MIME type"), - sizeBytes: z.number().int().min(1).max(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES), - dataUrl: z.string().trim().min(1).max(PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS), - }) - .superRefine((value, ctx) => { - if (!value.dataUrl.startsWith("data:image/")) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Image dataUrl must start with data:image/", - path: ["dataUrl"], - }); - } - }); +function validateImageDataUrl(value: { dataUrl: string }, ctx: z.RefinementCtx): void { + if (!value.dataUrl.startsWith("data:image/")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Image dataUrl must start with data:image/", + path: ["dataUrl"], + }); + } +} + +const providerSendTurnImageAttachmentBaseSchema = z.object({ + type: z.literal("image"), + name: z.string().trim().min(1).max(255), + mimeType: z + .string() + .trim() + .min(1) + .max(100) + .regex(/^image\//i, "mimeType must be an image/* MIME type"), + sizeBytes: z.number().int().min(1).max(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES), + dataUrl: z.string().trim().min(1).max(PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS), +}); + +export const providerSendTurnImageAttachmentInputSchema = + providerSendTurnImageAttachmentBaseSchema.superRefine(validateImageDataUrl); + +export const providerSendTurnAttachmentInputSchema = z.discriminatedUnion("type", [ + providerSendTurnImageAttachmentBaseSchema, +]).superRefine((value, ctx) => { + if (value.type === "image") { + validateImageDataUrl(value, ctx); + } +}); export const providerSendTurnInputSchema = z.object({ sessionId: z.string().min(1), input: z.string().trim().min(1).max(PROVIDER_SEND_TURN_MAX_INPUT_CHARS).optional(), - images: z.array(providerSendTurnImageInputSchema).max(PROVIDER_SEND_TURN_MAX_IMAGES).default([]), + attachments: z + .array(providerSendTurnAttachmentInputSchema) + .max(PROVIDER_SEND_TURN_MAX_ATTACHMENTS) + .default([]), model: z.string().trim().min(1).optional(), effort: z.string().trim().min(1).optional(), }).superRefine((value, ctx) => { - if (!value.input && value.images.length === 0) { + if (!value.input && value.attachments.length === 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "Either input text or at least one image is required", + message: "Either input text or at least one attachment is required", path: ["input"], }); } @@ -144,8 +159,10 @@ export type ProviderApprovalDecision = z.infer; export type ProviderSession = z.infer; export type ProviderSessionStartInput = z.input; -export type ProviderSendTurnImage = z.infer; -export type ProviderSendTurnImageInput = z.input; +export type ProviderSendTurnImageAttachment = z.infer; +export type ProviderSendTurnImageAttachmentInput = z.input; +export type ProviderSendTurnAttachment = z.infer; +export type ProviderSendTurnAttachmentInput = z.input; export type ProviderSendTurnInput = z.input; export type ProviderTurnStartResult = z.infer; export type ProviderInterruptTurnInput = z.input; -- 2.54.0