authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-12 15:33:33-08:00
committergravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-12 15:33:33-08:00
log760f15f2f94965e2fc653f11225741cd2b6fd870
treeaeadd60db1d31b73ff6227f4520d14a586dc1ca4
parent3cf58f80d10bb7208b4e9cbefbf26d69ca315608

Expand sendTurn attachment handling


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", () => {
158159
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 }
280282
281 const turnStartParams: {283 const turnStartParams: {
apps/web/src/components/ChatView.tsx+8-7
...@@ -1,8 +1,8 @@...@@ -1,8 +1,8 @@
1import {1import {
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 }
548548
...@@ -772,8 +772,9 @@ export default function ChatView() {...@@ -772,8 +772,9 @@ export default function ChatView() {
772772
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 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
22
3import {3import {
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 });
6465
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 });
8890
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});
5656
57export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000;57export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000;
58export const PROVIDER_SEND_TURN_MAX_IMAGES = 8;58export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8;
59export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024;59export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
60export const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000;60export const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000;
6161
62export const providerSendTurnImageInputSchema = z62function 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: z65 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),72const 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
85export const providerSendTurnImageAttachmentInputSchema =
86 providerSendTurnImageAttachmentBaseSchema.superRefine(validateImageDataUrl);
87
88export const providerSendTurnAttachmentInputSchema = z.discriminatedUnion("type", [
89 providerSendTurnImageAttachmentBaseSchema,
90]).superRefine((value, ctx) => {
91 if (value.type === "image") {
92 validateImageDataUrl(value, ctx);
93 }
94});
8395
84export const providerSendTurnInputSchema = z.object({96export 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
144export type ProviderSessionStatus = z.infer<typeof providerSessionStatusSchema>;159export type ProviderSessionStatus = z.infer<typeof providerSessionStatusSchema>;
145export type ProviderSession = z.infer<typeof providerSessionSchema>;160export type ProviderSession = z.infer<typeof providerSessionSchema>;
146export type ProviderSessionStartInput = z.input<typeof providerSessionStartInputSchema>;161export type ProviderSessionStartInput = z.input<typeof providerSessionStartInputSchema>;
147export type ProviderSendTurnImage = z.infer<typeof providerSendTurnImageInputSchema>;162export type ProviderSendTurnImageAttachment = z.infer<typeof providerSendTurnImageAttachmentInputSchema>;
148export type ProviderSendTurnImageInput = z.input<typeof providerSendTurnImageInputSchema>;163export type ProviderSendTurnImageAttachmentInput = z.input<typeof providerSendTurnImageAttachmentInputSchema>;
164export type ProviderSendTurnAttachment = z.infer<typeof providerSendTurnAttachmentInputSchema>;
165export type ProviderSendTurnAttachmentInput = z.input<typeof providerSendTurnAttachmentInputSchema>;
149export type ProviderSendTurnInput = z.input<typeof providerSendTurnInputSchema>;166export type ProviderSendTurnInput = z.input<typeof providerSendTurnInputSchema>;
150export type ProviderTurnStartResult = z.infer<typeof providerTurnStartResultSchema>;167export type ProviderTurnStartResult = z.infer<typeof providerTurnStartResultSchema>;
151export type ProviderInterruptTurnInput = z.input<typeof providerInterruptTurnInputSchema>;168export type ProviderInterruptTurnInput = z.input<typeof providerInterruptTurnInputSchema>;