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", () => {
114114 const result = await manager.sendTurn({
115115 sessionId: "sess_1",
116116 input: "Inspect this image",
117 images: [
117 attachments: [
118118 {
119 type: "image",
119120 name: "error.png",
120121 mimeType: "image/png",
121122 sizeBytes: 1_024,
......@@ -158,8 +159,9 @@ describe("sendTurn", () => {
158159
159160 await manager.sendTurn({
160161 sessionId: "sess_1",
161 images: [
162 attachments: [
162163 {
164 type: "image",
163165 name: "diagram.png",
164166 mimeType: "image/png",
165167 sizeBytes: 256,
......@@ -186,6 +188,6 @@ describe("sendTurn", () => {
186188 manager.sendTurn({
187189 sessionId: "sess_1",
188190 }),
189 ).rejects.toThrow("Turn input must include text or image attachments.");
191 ).rejects.toThrow("Turn input must include text or attachments.");
190192 });
191193});
apps/server/src/codexAppServerManager.ts+8-6
......@@ -268,14 +268,16 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
268268 text_elements: [],
269269 });
270270 }
271 for (const image of input.images ?? []) {
272 turnInput.push({
273 type: "image",
274 url: image.dataUrl,
275 });
271 for (const attachment of input.attachments ?? []) {
272 if (attachment.type === "image") {
273 turnInput.push({
274 type: "image",
275 url: attachment.dataUrl,
276 });
277 }
276278 }
277279 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.");
279281 }
280282
281283 const turnStartParams: {
apps/web/src/components/ChatView.tsx+8-7
......@@ -1,8 +1,8 @@
11import {
22 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
3 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
34 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
4 PROVIDER_SEND_TURN_MAX_IMAGES,
5 type ProviderSendTurnImageInput,
5 type ProviderSendTurnAttachmentInput,
66 type ProviderApprovalDecision,
77 type ProviderEvent,
88} from "@t3tools/contracts";
......@@ -541,8 +541,8 @@ export default function ChatView() {
541541 error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`;
542542 continue;
543543 }
544 if (nextImages.length >= PROVIDER_SEND_TURN_MAX_IMAGES) {
545 error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_IMAGES} images per message.`;
544 if (nextImages.length >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
545 error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`;
546546 break;
547547 }
548548
......@@ -772,8 +772,9 @@ export default function ChatView() {
772772
773773 setIsSending(true);
774774 try {
775 const turnImages = await Promise.all(
776 composerImagesSnapshot.map(async (image): Promise<ProviderSendTurnImageInput> => ({
775 const turnAttachments = await Promise.all(
776 composerImagesSnapshot.map(async (image): Promise<ProviderSendTurnAttachmentInput> => ({
777 type: "image",
777778 name: image.name,
778779 mimeType: image.mimeType,
779780 sizeBytes: image.sizeBytes,
......@@ -794,7 +795,7 @@ export default function ChatView() {
794795 await api.providers.sendTurn({
795796 sessionId: sessionInfo.sessionId,
796797 ...(input ? { input } : {}),
797 ...(turnImages.length > 0 ? { images: turnImages } : {}),
798 ...(turnAttachments.length > 0 ? { attachments: turnAttachments } : {}),
798799 model: selectedModel || undefined,
799800 effort: selectedEffort || undefined,
800801 });
packages/contracts/src/provider.test.ts+11-8
......@@ -1,7 +1,7 @@
11import { describe, expect, it } from "vitest";
22
33import {
4 PROVIDER_SEND_TURN_MAX_IMAGES,
4 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
55 providerEventSchema,
66 providerRespondToRequestInputSchema,
77 providerSendTurnInputSchema,
......@@ -41,7 +41,7 @@ describe("providerSendTurnInputSchema", () => {
4141 effort: " high ",
4242 });
4343 expect(parsed.input).toBe("summarize this repo");
44 expect(parsed.images).toEqual([]);
44 expect(parsed.attachments).toEqual([]);
4545 expect(parsed.model).toBe("gpt-5.2-codex");
4646 expect(parsed.effort).toBe("high");
4747 });
......@@ -49,8 +49,9 @@ describe("providerSendTurnInputSchema", () => {
4949 it("accepts image-only turns", () => {
5050 const parsed = providerSendTurnInputSchema.parse({
5151 sessionId: "sess_1",
52 images: [
52 attachments: [
5353 {
54 type: "image",
5455 name: "diagram.png",
5556 mimeType: "image/png",
5657 sizeBytes: 1_024,
......@@ -59,10 +60,10 @@ describe("providerSendTurnInputSchema", () => {
5960 ],
6061 });
6162 expect(parsed.input).toBeUndefined();
62 expect(parsed.images).toHaveLength(1);
63 expect(parsed.attachments).toHaveLength(1);
6364 });
6465
65 it("rejects turns with neither text nor images", () => {
66 it("rejects turns with neither text nor attachments", () => {
6667 expect(() =>
6768 providerSendTurnInputSchema.parse({
6869 sessionId: "sess_1",
......@@ -74,8 +75,9 @@ describe("providerSendTurnInputSchema", () => {
7475 expect(() =>
7576 providerSendTurnInputSchema.parse({
7677 sessionId: "sess_1",
77 images: [
78 attachments: [
7879 {
80 type: "image",
7981 name: "not-image.txt",
8082 mimeType: "text/plain",
8183 sizeBytes: 25,
......@@ -86,11 +88,12 @@ describe("providerSendTurnInputSchema", () => {
8688 ).toThrow();
8789 });
8890
89 it("rejects more than the max image count", () => {
91 it("rejects more than the max attachment count", () => {
9092 expect(() =>
9193 providerSendTurnInputSchema.parse({
9294 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,
9497 name: `image-${index}.png`,
9598 mimeType: "image/png",
9699 sizeBytes: 1_024,
packages/contracts/src/provider.ts+44-27
......@@ -55,43 +55,58 @@ export const providerSessionStartInputSchema = z.object({
5555});
5656
5757export 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;
5959export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
6060export const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000;
6161
62export const providerSendTurnImageInputSchema = z
63 .object({
64 name: z.string().trim().min(1).max(255),
65 mimeType: z
66 .string()
67 .trim()
68 .min(1)
69 .max(100)
70 .regex(/^image\//i, "mimeType must be an image/* MIME type"),
71 sizeBytes: z.number().int().min(1).max(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES),
72 dataUrl: z.string().trim().min(1).max(PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS),
73 })
74 .superRefine((value, ctx) => {
75 if (!value.dataUrl.startsWith("data:image/")) {
76 ctx.addIssue({
77 code: z.ZodIssueCode.custom,
78 message: "Image dataUrl must start with data:image/",
79 path: ["dataUrl"],
80 });
81 }
82 });
62function validateImageDataUrl(value: { dataUrl: string }, ctx: z.RefinementCtx): void {
63 if (!value.dataUrl.startsWith("data:image/")) {
64 ctx.addIssue({
65 code: z.ZodIssueCode.custom,
66 message: "Image dataUrl must start with data:image/",
67 path: ["dataUrl"],
68 });
69 }
70}
71
72const providerSendTurnImageAttachmentBaseSchema = z.object({
73 type: z.literal("image"),
74 name: z.string().trim().min(1).max(255),
75 mimeType: z
76 .string()
77 .trim()
78 .min(1)
79 .max(100)
80 .regex(/^image\//i, "mimeType must be an image/* MIME type"),
81 sizeBytes: z.number().int().min(1).max(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES),
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
8496export const providerSendTurnInputSchema = z.object({
8597 sessionId: z.string().min(1),
8698 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([]),
88103 model: z.string().trim().min(1).optional(),
89104 effort: z.string().trim().min(1).optional(),
90105}).superRefine((value, ctx) => {
91 if (!value.input && value.images.length === 0) {
106 if (!value.input && value.attachments.length === 0) {
92107 ctx.addIssue({
93108 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",
95110 path: ["input"],
96111 });
97112 }
......@@ -144,8 +159,10 @@ export type ProviderApprovalDecision = z.infer<typeof providerApprovalDecisionSc
144159export type ProviderSessionStatus = z.infer<typeof providerSessionStatusSchema>;
145160export type ProviderSession = z.infer<typeof providerSessionSchema>;
146161export type ProviderSessionStartInput = z.input<typeof providerSessionStartInputSchema>;
147export type ProviderSendTurnImage = z.infer<typeof providerSendTurnImageInputSchema>;
148export type ProviderSendTurnImageInput = z.input<typeof providerSendTurnImageInputSchema>;
162export type ProviderSendTurnImageAttachment = z.infer<typeof providerSendTurnImageAttachmentInputSchema>;
163export type ProviderSendTurnImageAttachmentInput = z.input<typeof providerSendTurnImageAttachmentInputSchema>;
164export type ProviderSendTurnAttachment = z.infer<typeof providerSendTurnAttachmentInputSchema>;
165export type ProviderSendTurnAttachmentInput = z.input<typeof providerSendTurnAttachmentInputSchema>;
149166export type ProviderSendTurnInput = z.input<typeof providerSendTurnInputSchema>;
150167export type ProviderTurnStartResult = z.infer<typeof providerTurnStartResultSchema>;
151168export type ProviderInterruptTurnInput = z.input<typeof providerInterruptTurnInputSchema>;