diff --git a/lib/canvas/agent-operation-request.ts b/lib/canvas/agent-operation-request.ts index 7fc8abd10..fc6fa5826 100644 --- a/lib/canvas/agent-operation-request.ts +++ b/lib/canvas/agent-operation-request.ts @@ -1,6 +1,13 @@ import { z } from "zod"; import type { CanvasOperationPlan } from "@/types/canvas/agent-protocol"; +const httpMediaUrlSchema = z + .string() + .url() + .refine((url) => /^https?:\/\//i.test(url), { + message: "仅支持 http/https 媒体 URL", + }); + export const canvasAgentOperationPlanSchema = z .object({ version: z.literal(1), @@ -89,7 +96,7 @@ export const canvasAgentOperationPlanSchema = z media: z .object({ type: z.enum(["image", "video"]), - url: z.string().url(), + url: httpMediaUrlSchema, assetId: z.string().uuid().optional(), width: z.number().positive().optional(), height: z.number().positive().optional(), @@ -104,7 +111,7 @@ export const canvasAgentOperationPlanSchema = z z.object({ mediaItemId: z.string().min(1).max(160), type: z.enum(["image", "video"]), - url: z.string().url(), + url: httpMediaUrlSchema, assetId: z.string().uuid().optional(), width: z.number().positive().optional(), height: z.number().positive().optional(), diff --git a/lib/canvas/agent-operation-route.ts b/lib/canvas/agent-operation-route.ts index 99edab38f..c2a011c7c 100644 --- a/lib/canvas/agent-operation-route.ts +++ b/lib/canvas/agent-operation-route.ts @@ -75,10 +75,14 @@ export async function handleCanvasAgentOperationRouteRequest(params: { }, }; } + const operationPlan: CanvasOperationPlan = { + ...body.operationPlan, + projectId: planProjectId || params.projectId, + }; const result = await params.applyOperation({ userId, projectId: params.projectId, - plan: body.operationPlan, + plan: operationPlan, }); return { diff --git a/tests/routes/canvas-agent-operations.test.ts b/tests/routes/canvas-agent-operations.test.ts index 5d6f23715..128c14fc6 100644 --- a/tests/routes/canvas-agent-operations.test.ts +++ b/tests/routes/canvas-agent-operations.test.ts @@ -324,6 +324,30 @@ test.describe("canvas agent operation request schema", () => { ], }) ).toContain("operationPlan.mediaItems.0.url"); + + expect( + issuePathsFor({ + operation: "insert_beside", + anchorNodeId: "image-1", + mediaItems: [ + { + ...buildMediaItem(), + url: "ftp://example.com/generated.png", + }, + ], + }) + ).toContain("operationPlan.mediaItems.0.url"); + + expect( + issuePathsFor({ + operation: "append_revision", + sourceNodeIds: ["image-1"], + media: { + type: "image", + url: "file:///tmp/generated.png", + }, + }) + ).toContain("operationPlan.media.url"); }); }); @@ -391,6 +415,7 @@ test.describe("canvas agent operation route handler", () => { const calls: Array<{ userId: string; projectId: string; + planProjectId?: string; operationId: string; }> = []; const expectedResult = buildRouteSuccessResult(); @@ -408,6 +433,7 @@ test.describe("canvas agent operation route handler", () => { calls.push({ userId: input.userId, projectId: input.projectId, + planProjectId: input.plan.projectId, operationId: input.plan.operationId, }); return expectedResult; @@ -423,11 +449,35 @@ test.describe("canvas agent operation route handler", () => { { userId: "user-1", projectId, + planProjectId: projectId, operationId: "message-1:canvas-agent:test", }, ]); }); + test("fills missing operation plan project id from route project id", async () => { + let appliedPlanProjectId = ""; + + const response = await handleCanvasAgentOperationRouteRequest({ + userId: "user-1", + projectId, + rawBody: buildRouteRequestBody({ + projectId: undefined, + operation: "insert_beside", + anchorNodeId: "image-1", + mediaItems: [buildMediaItem(1)], + }), + features: enabledRouteFeatures, + applyOperation: async (input) => { + appliedPlanProjectId = String(input.plan.projectId || ""); + return buildRouteSuccessResult(); + }, + }); + + expect(response.status).toBe(200); + expect(appliedPlanProjectId).toBe(projectId); + }); + test("rejects operation plans for a different project before applying", async () => { let operationApplied = false;