| author | |
| committer | |
| log | 760f15f2f94965e2fc653f11225741cd2b6fd870 |
| tree | aeadd60db1d31b73ff6227f4520d14a586dc1ca4 |
| parent | 3cf58f80d10bb7208b4e9cbefbf26d69ca315608 |
5 files changed, 76 insertions(+), 51 deletions(-)
apps/server/src/codexAppServerManager.test.ts+5-3| ... | @@ -114,8 +114,9 @@ describe("sendTurn", () => { | ... | @@ -114,8 +114,9 @@ describe("sendTurn", () => { |
| 114 | const result = await manager.sendTurn({ | 114 | const result = await manager.sendTurn({ |
| 115 | sessionId: "sess_1", | 115 | sessionId: "sess_1", |
| 116 | input: "Inspect this image", | 116 | input: "Inspect this image", |
| 117 | images: [ | 117 | attachments: [ |
| 118 | { | 118 | { |
| 119 | type: "image", | ||
| 119 | name: "error.png", | 120 | name: "error.png", |
| 120 | mimeType: "image/png", | 121 | mimeType: "image/png", |
| 121 | sizeBytes: 1_024, | 122 | sizeBytes: 1_024, |
| ... | @@ -158,8 +159,9 @@ describe("sendTurn", () => { | ... | @@ -158,8 +159,9 @@ describe("sendTurn", () => { |
| 158 | 159 | ||
| 159 | await manager.sendTurn({ | 160 | await manager.sendTurn({ |
| 160 | sessionId: "sess_1", | 161 | sessionId: "sess_1", |
| 161 | images: [ | 162 | attachments: [ |
| 162 | { | 163 | { |
| 164 | type: "image", | ||
| 163 | name: "diagram.png", | 165 | name: "diagram.png", |
| 164 | mimeType: "image/png", | 166 | mimeType: "image/png", |
| 165 | sizeBytes: 256, | 167 | sizeBytes: 256, |
| ... | @@ -186,6 +188,6 @@ describe("sendTurn", () => { | ... | @@ -186,6 +188,6 @@ describe("sendTurn", () => { |
| 186 | manager.sendTurn({ | 188 | manager.sendTurn({ |
| 187 | sessionId: "sess_1", | 189 | sessionId: "sess_1", |
| 188 | }), | 190 | }), |
| 189 | ).rejects.toThrow("Turn input must include text or image attachments."); | 191 | ).rejects.toThrow("Turn input must include text or attachments."); |
| 190 | }); | 192 | }); |
| 191 | }); | 193 | }); |
apps/server/src/codexAppServerManager.ts+8-6| ... | @@ -268,14 +268,16 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -268,14 +268,16 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 268 | text_elements: [], | 268 | text_elements: [], |
| 269 | }); | 269 | }); |
| 270 | } | 270 | } |
| 271 | for (const image of input.images ?? []) { | 271 | for (const attachment of input.attachments ?? []) { |
| 272 | turnInput.push({ | 272 | if (attachment.type === "image") { |
| 273 | type: "image", | 273 | turnInput.push({ |
| 274 | url: image.dataUrl, | 274 | type: "image", |
| 275 | }); | 275 | url: attachment.dataUrl, |
| 276 | }); | ||
| 277 | } | ||
| 276 | } | 278 | } |
| 277 | if (turnInput.length === 0) { | 279 | if (turnInput.length === 0) { |
| 278 | throw new Error("Turn input must include text or image attachments."); | 280 | throw new Error("Turn input must include text or attachments."); |
| 279 | } | 281 | } |
| 280 | 282 | ||
| 281 | const turnStartParams: { | 283 | const turnStartParams: { |
apps/web/src/components/ChatView.tsx+8-7| ... | @@ -1,8 +1,8 @@ | ... | @@ -1,8 +1,8 @@ |
| 1 | import { | 1 | import { |
| 2 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, | 2 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, |
| 3 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | ||
| 3 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, | 4 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 4 | PROVIDER_SEND_TURN_MAX_IMAGES, | 5 | type ProviderSendTurnAttachmentInput, |
| 5 | type ProviderSendTurnImageInput, | ||
| 6 | type ProviderApprovalDecision, | 6 | type ProviderApprovalDecision, |
| 7 | type ProviderEvent, | 7 | type ProviderEvent, |
| 8 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| ... | @@ -541,8 +541,8 @@ export default function ChatView() { | ... | @@ -541,8 +541,8 @@ export default function ChatView() { |
| 541 | error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`; | 541 | error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`; |
| 542 | continue; | 542 | continue; |
| 543 | } | 543 | } |
| 544 | if (nextImages.length >= PROVIDER_SEND_TURN_MAX_IMAGES) { | 544 | if (nextImages.length >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { |
| 545 | error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_IMAGES} images per message.`; | 545 | error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`; |
| 546 | break; | 546 | break; |
| 547 | } | 547 | } |
| 548 | 548 | ||
| ... | @@ -772,8 +772,9 @@ export default function ChatView() { | ... | @@ -772,8 +772,9 @@ export default function ChatView() { |
| 772 | 772 | ||
| 773 | setIsSending(true); | 773 | setIsSending(true); |
| 774 | try { | 774 | try { |
| 775 | const turnImages = await Promise.all( | 775 | const turnAttachments = await Promise.all( |
| 776 | composerImagesSnapshot.map(async (image): Promise<ProviderSendTurnImageInput> => ({ | 776 | composerImagesSnapshot.map(async (image): Promise<ProviderSendTurnAttachmentInput> => ({ |
| 777 | type: "image", | ||
| 777 | name: image.name, | 778 | name: image.name, |
| 778 | mimeType: image.mimeType, | 779 | mimeType: image.mimeType, |
| 779 | sizeBytes: image.sizeBytes, | 780 | sizeBytes: image.sizeBytes, |
| ... | @@ -794,7 +795,7 @@ export default function ChatView() { | ... | @@ -794,7 +795,7 @@ export default function ChatView() { |
| 794 | await api.providers.sendTurn({ | 795 | await api.providers.sendTurn({ |
| 795 | sessionId: sessionInfo.sessionId, | 796 | sessionId: sessionInfo.sessionId, |
| 796 | ...(input ? { input } : {}), | 797 | ...(input ? { input } : {}), |
| 797 | ...(turnImages.length > 0 ? { images: turnImages } : {}), | 798 | ...(turnAttachments.length > 0 ? { attachments: turnAttachments } : {}), |
| 798 | model: selectedModel || undefined, | 799 | model: selectedModel || undefined, |
| 799 | effort: selectedEffort || undefined, | 800 | effort: selectedEffort || undefined, |
| 800 | }); | 801 | }); |
packages/contracts/src/provider.test.ts+11-8| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import { describe, expect, it } from "vitest"; | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 4 | PROVIDER_SEND_TURN_MAX_IMAGES, | 4 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 5 | providerEventSchema, | 5 | providerEventSchema, |
| 6 | providerRespondToRequestInputSchema, | 6 | providerRespondToRequestInputSchema, |
| 7 | providerSendTurnInputSchema, | 7 | providerSendTurnInputSchema, |
| ... | @@ -41,7 +41,7 @@ describe("providerSendTurnInputSchema", () => { | ... | @@ -41,7 +41,7 @@ describe("providerSendTurnInputSchema", () => { |
| 41 | effort: " high ", | 41 | effort: " high ", |
| 42 | }); | 42 | }); |
| 43 | expect(parsed.input).toBe("summarize this repo"); | 43 | expect(parsed.input).toBe("summarize this repo"); |
| 44 | expect(parsed.images).toEqual([]); | 44 | expect(parsed.attachments).toEqual([]); |
| 45 | expect(parsed.model).toBe("gpt-5.2-codex"); | 45 | expect(parsed.model).toBe("gpt-5.2-codex"); |
| 46 | expect(parsed.effort).toBe("high"); | 46 | expect(parsed.effort).toBe("high"); |
| 47 | }); | 47 | }); |
| ... | @@ -49,8 +49,9 @@ describe("providerSendTurnInputSchema", () => { | ... | @@ -49,8 +49,9 @@ describe("providerSendTurnInputSchema", () => { |
| 49 | it("accepts image-only turns", () => { | 49 | it("accepts image-only turns", () => { |
| 50 | const parsed = providerSendTurnInputSchema.parse({ | 50 | const parsed = providerSendTurnInputSchema.parse({ |
| 51 | sessionId: "sess_1", | 51 | sessionId: "sess_1", |
| 52 | images: [ | 52 | attachments: [ |
| 53 | { | 53 | { |
| 54 | type: "image", | ||
| 54 | name: "diagram.png", | 55 | name: "diagram.png", |
| 55 | mimeType: "image/png", | 56 | mimeType: "image/png", |
| 56 | sizeBytes: 1_024, | 57 | sizeBytes: 1_024, |
| ... | @@ -59,10 +60,10 @@ describe("providerSendTurnInputSchema", () => { | ... | @@ -59,10 +60,10 @@ describe("providerSendTurnInputSchema", () => { |
| 59 | ], | 60 | ], |
| 60 | }); | 61 | }); |
| 61 | expect(parsed.input).toBeUndefined(); | 62 | expect(parsed.input).toBeUndefined(); |
| 62 | expect(parsed.images).toHaveLength(1); | 63 | expect(parsed.attachments).toHaveLength(1); |
| 63 | }); | 64 | }); |
| 64 | 65 | ||
| 65 | it("rejects turns with neither text nor images", () => { | 66 | it("rejects turns with neither text nor attachments", () => { |
| 66 | expect(() => | 67 | expect(() => |
| 67 | providerSendTurnInputSchema.parse({ | 68 | providerSendTurnInputSchema.parse({ |
| 68 | sessionId: "sess_1", | 69 | sessionId: "sess_1", |
| ... | @@ -74,8 +75,9 @@ describe("providerSendTurnInputSchema", () => { | ... | @@ -74,8 +75,9 @@ describe("providerSendTurnInputSchema", () => { |
| 74 | expect(() => | 75 | expect(() => |
| 75 | providerSendTurnInputSchema.parse({ | 76 | providerSendTurnInputSchema.parse({ |
| 76 | sessionId: "sess_1", | 77 | sessionId: "sess_1", |
| 77 | images: [ | 78 | attachments: [ |
| 78 | { | 79 | { |
| 80 | type: "image", | ||
| 79 | name: "not-image.txt", | 81 | name: "not-image.txt", |
| 80 | mimeType: "text/plain", | 82 | mimeType: "text/plain", |
| 81 | sizeBytes: 25, | 83 | sizeBytes: 25, |
| ... | @@ -86,11 +88,12 @@ describe("providerSendTurnInputSchema", () => { | ... | @@ -86,11 +88,12 @@ describe("providerSendTurnInputSchema", () => { |
| 86 | ).toThrow(); | 88 | ).toThrow(); |
| 87 | }); | 89 | }); |
| 88 | 90 | ||
| 89 | it("rejects more than the max image count", () => { | 91 | it("rejects more than the max attachment count", () => { |
| 90 | expect(() => | 92 | expect(() => |
| 91 | providerSendTurnInputSchema.parse({ | 93 | providerSendTurnInputSchema.parse({ |
| 92 | sessionId: "sess_1", | 94 | sessionId: "sess_1", |
| 93 | images: Array.from({ length: PROVIDER_SEND_TURN_MAX_IMAGES + 1 }, (_, index) => ({ | 95 | attachments: Array.from({ length: PROVIDER_SEND_TURN_MAX_ATTACHMENTS + 1 }, (_, index) => ({ |
| 96 | type: "image" as const, | ||
| 94 | name: `image-${index}.png`, | 97 | name: `image-${index}.png`, |
| 95 | mimeType: "image/png", | 98 | mimeType: "image/png", |
| 96 | sizeBytes: 1_024, | 99 | sizeBytes: 1_024, |
packages/contracts/src/provider.ts+44-27| ... | @@ -55,43 +55,58 @@ export const providerSessionStartInputSchema = z.object({ | ... | @@ -55,43 +55,58 @@ export const providerSessionStartInputSchema = z.object({ |
| 55 | }); | 55 | }); |
| 56 | 56 | ||
| 57 | export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; | 57 | export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; |
| 58 | export const PROVIDER_SEND_TURN_MAX_IMAGES = 8; | 58 | export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8; |
| 59 | export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024; | 59 | export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024; |
| 60 | export const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000; | 60 | export const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000; |
| 61 | 61 | ||
| 62 | export const providerSendTurnImageInputSchema = z | 62 | function validateImageDataUrl(value: { dataUrl: string }, ctx: z.RefinementCtx): void { |
| 63 | .object({ | 63 | if (!value.dataUrl.startsWith("data:image/")) { |
| 64 | name: z.string().trim().min(1).max(255), | 64 | ctx.addIssue({ |
| 65 | mimeType: z | 65 | code: z.ZodIssueCode.custom, |
| 66 | .string() | 66 | message: "Image dataUrl must start with data:image/", |
| 67 | .trim() | 67 | path: ["dataUrl"], |
| 68 | .min(1) | 68 | }); |
| 69 | .max(100) | 69 | } |
| 70 | .regex(/^image\//i, "mimeType must be an image/* MIME type"), | 70 | } |
| 71 | sizeBytes: z.number().int().min(1).max(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES), | 71 | |
| 72 | dataUrl: z.string().trim().min(1).max(PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS), | 72 | const providerSendTurnImageAttachmentBaseSchema = z.object({ |
| 73 | }) | 73 | type: z.literal("image"), |
| 74 | .superRefine((value, ctx) => { | 74 | name: z.string().trim().min(1).max(255), |
| 75 | if (!value.dataUrl.startsWith("data:image/")) { | 75 | mimeType: z |
| 76 | ctx.addIssue({ | 76 | .string() |
| 77 | code: z.ZodIssueCode.custom, | 77 | .trim() |
| 78 | message: "Image dataUrl must start with data:image/", | 78 | .min(1) |
| 79 | path: ["dataUrl"], | 79 | .max(100) |
| 80 | }); | 80 | .regex(/^image\//i, "mimeType must be an image/* MIME type"), |
| 81 | } | 81 | sizeBytes: z.number().int().min(1).max(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES), |
| 82 | }); | 82 | dataUrl: z.string().trim().min(1).max(PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS), |
| 83 | }); | ||
| 84 | |||
| 85 | export const providerSendTurnImageAttachmentInputSchema = | ||
| 86 | providerSendTurnImageAttachmentBaseSchema.superRefine(validateImageDataUrl); | ||
| 87 | |||
| 88 | export const providerSendTurnAttachmentInputSchema = z.discriminatedUnion("type", [ | ||
| 89 | providerSendTurnImageAttachmentBaseSchema, | ||
| 90 | ]).superRefine((value, ctx) => { | ||
| 91 | if (value.type === "image") { | ||
| 92 | validateImageDataUrl(value, ctx); | ||
| 93 | } | ||
| 94 | }); | ||
| 83 | 95 | ||
| 84 | export const providerSendTurnInputSchema = z.object({ | 96 | export const providerSendTurnInputSchema = z.object({ |
| 85 | sessionId: z.string().min(1), | 97 | sessionId: z.string().min(1), |
| 86 | input: z.string().trim().min(1).max(PROVIDER_SEND_TURN_MAX_INPUT_CHARS).optional(), | 98 | input: z.string().trim().min(1).max(PROVIDER_SEND_TURN_MAX_INPUT_CHARS).optional(), |
| 87 | images: z.array(providerSendTurnImageInputSchema).max(PROVIDER_SEND_TURN_MAX_IMAGES).default([]), | 99 | attachments: z |
| 100 | .array(providerSendTurnAttachmentInputSchema) | ||
| 101 | .max(PROVIDER_SEND_TURN_MAX_ATTACHMENTS) | ||
| 102 | .default([]), | ||
| 88 | model: z.string().trim().min(1).optional(), | 103 | model: z.string().trim().min(1).optional(), |
| 89 | effort: z.string().trim().min(1).optional(), | 104 | effort: z.string().trim().min(1).optional(), |
| 90 | }).superRefine((value, ctx) => { | 105 | }).superRefine((value, ctx) => { |
| 91 | if (!value.input && value.images.length === 0) { | 106 | if (!value.input && value.attachments.length === 0) { |
| 92 | ctx.addIssue({ | 107 | ctx.addIssue({ |
| 93 | code: z.ZodIssueCode.custom, | 108 | code: z.ZodIssueCode.custom, |
| 94 | message: "Either input text or at least one image is required", | 109 | message: "Either input text or at least one attachment is required", |
| 95 | path: ["input"], | 110 | path: ["input"], |
| 96 | }); | 111 | }); |
| 97 | } | 112 | } |
| ... | @@ -144,8 +159,10 @@ export type ProviderApprovalDecision = z.infer<typeof providerApprovalDecisionSc | ... | @@ -144,8 +159,10 @@ export type ProviderApprovalDecision = z.infer<typeof providerApprovalDecisionSc |
| 144 | export type ProviderSessionStatus = z.infer<typeof providerSessionStatusSchema>; | 159 | export type ProviderSessionStatus = z.infer<typeof providerSessionStatusSchema>; |
| 145 | export type ProviderSession = z.infer<typeof providerSessionSchema>; | 160 | export type ProviderSession = z.infer<typeof providerSessionSchema>; |
| 146 | export type ProviderSessionStartInput = z.input<typeof providerSessionStartInputSchema>; | 161 | export type ProviderSessionStartInput = z.input<typeof providerSessionStartInputSchema>; |
| 147 | export type ProviderSendTurnImage = z.infer<typeof providerSendTurnImageInputSchema>; | 162 | export type ProviderSendTurnImageAttachment = z.infer<typeof providerSendTurnImageAttachmentInputSchema>; |
| 148 | export type ProviderSendTurnImageInput = z.input<typeof providerSendTurnImageInputSchema>; | 163 | export type ProviderSendTurnImageAttachmentInput = z.input<typeof providerSendTurnImageAttachmentInputSchema>; |
| 164 | export type ProviderSendTurnAttachment = z.infer<typeof providerSendTurnAttachmentInputSchema>; | ||
| 165 | export type ProviderSendTurnAttachmentInput = z.input<typeof providerSendTurnAttachmentInputSchema>; | ||
| 149 | export type ProviderSendTurnInput = z.input<typeof providerSendTurnInputSchema>; | 166 | export type ProviderSendTurnInput = z.input<typeof providerSendTurnInputSchema>; |
| 150 | export type ProviderTurnStartResult = z.infer<typeof providerTurnStartResultSchema>; | 167 | export type ProviderTurnStartResult = z.infer<typeof providerTurnStartResultSchema>; |
| 151 | export type ProviderInterruptTurnInput = z.input<typeof providerInterruptTurnInputSchema>; | 168 | export type ProviderInterruptTurnInput = z.input<typeof providerInterruptTurnInputSchema>; |