import { expect, test } from "@playwright/test"; import { canvasAgentOperationRequestSchema, parseCanvasAgentOperationRequestBody, } from "@/lib/canvas/agent-operation-request"; import { applyCanvasOperationPlanToCanvasData, CanvasAgentMutationError, type CanvasOperationResult, } from "@/lib/canvas/agent-mutations"; import type { CanvasAgentFeatureFlags } from "@/lib/canvas/agent-feature-flags"; import { handleCanvasAgentOperationRouteRequest } from "@/lib/canvas/agent-operation-route"; import type { CanvasOperationPlan } from "@/types/canvas/agent-protocol"; const projectId = "11111111-1111-4111-8111-111111111111"; const enabledRouteFeatures: CanvasAgentFeatureFlags = { operationProtocolEnabled: true, writebackMode: "operation_authoritative", slotEnabled: true, viewOperationEnabled: true, annotationEditEnabled: true, }; function buildMediaItem(index = 1) { return { mediaItemId: `media-${index}`, type: "image" as const, url: `https://example.com/generated-${index}.png`, assetId: "22222222-2222-4222-8222-222222222222", width: 1024, height: 768, title: `generated-${index}`, prompt: "make the product cleaner", sourceNodeIds: ["image-1"], metadata: { provider: "test", }, }; } function buildPlan(overrides: Record) { return { version: 1, operationId: "message-1:canvas-agent:test", projectId, baseCanvasRevision: "2026-06-22T00:00:00.000Z", baseCanvasUpdatedAt: "2026-06-22T00:00:00.000Z", ...overrides, }; } function parsePlan(overrides: Record) { return parseCanvasAgentOperationRequestBody({ operationPlan: buildPlan(overrides), }); } function issuePathsFor(overrides: Record) { const result = canvasAgentOperationRequestSchema.safeParse({ operationPlan: buildPlan(overrides), }); expect(result.success).toBe(false); return result.success ? [] : result.error.issues.map((issue) => issue.path.join(".")); } function buildImageNode(id = "image-1") { return { id, type: "image", position: { x: 100, y: 120 }, size: { width: 320, height: 240 }, data: { src: `https://cdn.example.com/${id}.png`, status: "completed", title: id, }, connections: [], createdAt: "2026-06-22T00:00:00.000Z", updatedAt: "2026-06-22T00:00:00.000Z", }; } function buildTextNode(id = "text-1") { return { id, type: "text", position: { x: 160, y: 180 }, size: { width: 280, height: 120 }, data: { content: "旧文案", fontSize: 18, color: "#111827", }, connections: [], createdAt: "2026-06-22T00:00:00.000Z", updatedAt: "2026-06-22T00:00:00.000Z", }; } function buildAgentSlotNode(id = "slot-1", slotType: "image" | "video" = "image") { return { id, type: "agent-slot", position: { x: 500, y: 120 }, size: { width: 300, height: 300 }, data: { slotType, status: "empty", fillPolicy: "replace-empty", filledNodeIds: [], }, connections: [], createdAt: "2026-06-22T00:00:00.000Z", updatedAt: "2026-06-22T00:00:00.000Z", }; } function buildMutationMediaItem( mediaItemId = "media-result-1", sourceNodeIds?: string[] ) { return { mediaItemId, type: "image" as const, url: `https://cdn.example.com/${mediaItemId}.png`, width: 1024, height: 768, title: mediaItemId, prompt: "make a clean revision", sourceNodeIds, }; } function getCanvasData(nodes: any[]) { return { nodes, edges: [], selectedNodeIds: nodes.length > 0 ? [nodes[0].id] : [], viewport: { x: 0, y: 0, zoom: 1 }, }; } function getResultNodes(canvasData: unknown) { const nodes = (canvasData as { nodes?: any[] }).nodes; return Array.isArray(nodes) ? nodes : []; } function getResultEdges(canvasData: unknown) { const edges = (canvasData as { edges?: any[] }).edges; return Array.isArray(edges) ? edges : []; } function buildRouteSuccessResult(): CanvasOperationResult { return { createdNodeIds: ["node-created-1"], createdNodeIdsByMediaItemId: { "media-1": "node-created-1" }, updatedNodeIds: [], createdEdgeIds: ["edge-1"], canvasData: getCanvasData([]), canvasRevision: "2026-06-22T00:01:00.000Z", canvasUpdatedAt: "2026-06-22T00:01:00.000Z", baseCanvasWasStale: false, }; } function buildRouteRequestBody(overrides: Record) { return { operationPlan: buildPlan(overrides), }; } test.describe("canvas agent operation request schema", () => { test("accepts slot fill requests with batch media and revision guards", () => { const body = parsePlan({ operation: "fill_slot", targetSlotId: "slot-1", placement: "viewport-center", preserveOriginal: true, mediaItems: [buildMediaItem(1), buildMediaItem(2)], }); expect(body.operationPlan).toMatchObject({ operation: "fill_slot", targetSlotId: "slot-1", baseCanvasRevision: "2026-06-22T00:00:00.000Z", mediaItems: [ { mediaItemId: "media-1", url: "https://example.com/generated-1.png" }, { mediaItemId: "media-2", url: "https://example.com/generated-2.png" }, ], }); }); test("accepts append revision requests with legacy media payloads", () => { const body = parsePlan({ operation: "append_revision", sourceNodeIds: ["image-1"], targetNodeIds: ["image-1"], media: { type: "image", url: "https://example.com/legacy.png", width: 1200, height: 900, title: "legacy-result", }, }); expect(body.operationPlan).toMatchObject({ operation: "append_revision", media: { url: "https://example.com/legacy.png", }, }); }); test("accepts slot-only, annotation-only, and text-only operations", () => { expect( parsePlan({ operation: "create_agent_slot", targetPosition: { x: 120, y: 160 }, slot: { slotType: "image", label: "主图结果槽", aspectRatio: "4:3", fillPolicy: "replace-empty", acceptedSources: ["text", "image", "annotation"], }, }).operationPlan.operation ).toBe("create_agent_slot"); expect( parsePlan({ operation: "add_annotation", targetNodeIds: ["image-1"], annotations: [ { id: "annotation-1", sourceNodeId: "image-1", type: "box", text: "tighten crop", bounds: { x: 0.1, y: 0.2, width: 0.3, height: 0.4 }, color: "#ff3366", }, ], }).operationPlan.operation ).toBe("add_annotation"); expect( parsePlan({ operation: "update_text", targetNodeIds: ["text-1"], textContent: "New canvas title", }).operationPlan.operation ).toBe("update_text"); expect( parsePlan({ operation: "create_text_node", targetPosition: { x: 120, y: 160 }, textContent: "New canvas copy", }).operationPlan.operation ).toBe("create_text_node"); }); test("caps batch media at twenty items", () => { const accepted = parsePlan({ operation: "insert_beside", sourceNodeIds: ["image-1"], placementStrategy: "grid", mediaItems: Array.from({ length: 20 }, (_, index) => buildMediaItem(index + 1)), }); expect(accepted.operationPlan.mediaItems).toHaveLength(20); expect( issuePathsFor({ operation: "insert_beside", sourceNodeIds: ["image-1"], mediaItems: Array.from({ length: 21 }, (_, index) => buildMediaItem(index + 1)), }) ).toContain("operationPlan.mediaItems"); }); test("rejects media operations without generated media", () => { expect( issuePathsFor({ operation: "insert_beside", sourceNodeIds: ["image-1"], placement: "right", }) ).toContain("operationPlan.mediaItems"); }); test("rejects annotation and text operations without required content", () => { expect( issuePathsFor({ operation: "add_annotation", targetNodeIds: ["image-1"], }) ).toContain("operationPlan.annotations"); expect( issuePathsFor({ operation: "update_text", targetNodeIds: ["text-1"], }) ).toContain("operationPlan.textContent"); expect( issuePathsFor({ operation: "rename_node", targetNodeIds: ["image-1"], }) ).toContain("operationPlan.textContent"); expect( issuePathsFor({ operation: "update_note", targetNodeIds: ["image-1"], }) ).toContain("operationPlan.textContent"); expect( issuePathsFor({ operation: "create_text_node", }) ).toContain("operationPlan.textContent"); }); test("rejects invalid media urls", () => { expect( issuePathsFor({ operation: "replace_node_media", targetNodeIds: ["image-1"], mediaItems: [ { ...buildMediaItem(), url: "not-a-url", }, ], }) ).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"); }); }); test.describe("canvas agent operation route handler", () => { test("returns 401 without reading the body when the user is not authenticated", async () => { let bodyRead = false; let operationApplied = false; const response = await handleCanvasAgentOperationRouteRequest({ userId: null, projectId, readBody: async () => { bodyRead = true; return {}; }, features: enabledRouteFeatures, applyOperation: async () => { operationApplied = true; return buildRouteSuccessResult(); }, }); expect(response).toMatchObject({ status: 401, body: { success: false, error: "未登录" }, }); expect(bodyRead).toBe(false); expect(operationApplied).toBe(false); }); test("returns rollout protection before parsing body when mutations are disabled", async () => { let bodyRead = false; let operationApplied = false; const response = await handleCanvasAgentOperationRouteRequest({ userId: "user-1", projectId, readBody: async () => { bodyRead = true; return {}; }, features: { ...enabledRouteFeatures, writebackMode: "operation_shadow", }, applyOperation: async () => { operationApplied = true; return buildRouteSuccessResult(); }, }); expect(response).toMatchObject({ status: 409, body: { success: false, code: "CANVAS_AGENT_WRITEBACK_DISABLED", writebackMode: "operation_shadow", }, }); expect(bodyRead).toBe(false); expect(operationApplied).toBe(false); }); test("applies valid requests with authenticated user and project id", async () => { const calls: Array<{ userId: string; projectId: string; planProjectId?: string; operationId: string; }> = []; const expectedResult = buildRouteSuccessResult(); const response = await handleCanvasAgentOperationRouteRequest({ userId: "user-1", projectId, rawBody: buildRouteRequestBody({ operation: "insert_beside", anchorNodeId: "image-1", mediaItems: [buildMediaItem(1)], }), features: enabledRouteFeatures, applyOperation: async (input) => { calls.push({ userId: input.userId, projectId: input.projectId, planProjectId: input.plan.projectId, operationId: input.plan.operationId, }); return expectedResult; }, }); expect(response.status).toBe(200); expect(response.body.success).toBe(true); if (response.body.success) { expect(response.body.data).toBe(expectedResult); } expect(calls).toEqual([ { 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; const response = await handleCanvasAgentOperationRouteRequest({ userId: "user-1", projectId, rawBody: buildRouteRequestBody({ projectId: "33333333-3333-4333-8333-333333333333", operation: "insert_beside", anchorNodeId: "image-1", mediaItems: [buildMediaItem(1)], }), features: enabledRouteFeatures, applyOperation: async () => { operationApplied = true; return buildRouteSuccessResult(); }, }); expect(response).toMatchObject({ status: 400, body: { success: false, code: "CANVAS_AGENT_PROJECT_MISMATCH", }, }); expect(operationApplied).toBe(false); }); test("maps invalid bodies and mutation errors to API responses", async () => { let operationApplied = false; const invalidResponse = await handleCanvasAgentOperationRouteRequest({ userId: "user-1", projectId, rawBody: { operationPlan: { operation: "insert_beside" } }, features: enabledRouteFeatures, applyOperation: async () => { operationApplied = true; return buildRouteSuccessResult(); }, }); expect(invalidResponse.status).toBe(400); expect(invalidResponse.body).toMatchObject({ success: false, error: "请求参数无效", }); expect(operationApplied).toBe(false); const invalidJsonResponse = await handleCanvasAgentOperationRouteRequest({ userId: "user-1", projectId, readBody: async () => { throw new SyntaxError("Unexpected end of JSON input"); }, features: enabledRouteFeatures, applyOperation: async () => { operationApplied = true; return buildRouteSuccessResult(); }, }); expect(invalidJsonResponse).toMatchObject({ status: 400, body: { success: false, error: "请求 JSON 无效" }, }); expect(operationApplied).toBe(false); const mutationResponse = await handleCanvasAgentOperationRouteRequest({ userId: "user-1", projectId, rawBody: buildRouteRequestBody({ operation: "insert_beside", anchorNodeId: "missing-anchor", mediaItems: [buildMediaItem(1)], }), features: enabledRouteFeatures, applyOperation: async () => { throw new CanvasAgentMutationError( "锚点节点不存在", 400, "CANVAS_AGENT_NODE_NOT_FOUND" ); }, }); expect(mutationResponse).toMatchObject({ status: 400, body: { success: false, error: "锚点节点不存在", code: "CANVAS_AGENT_NODE_NOT_FOUND", }, }); }); }); test.describe("canvas agent operation mutation contract", () => { test("rejects explicit anchor or source references that are not in canvas data", () => { expect(() => applyCanvasOperationPlanToCanvasData({ canvasData: getCanvasData([]), plan: { version: 1, operationId: "op-missing-anchor", operation: "insert_beside", projectId, anchorNodeId: "missing-anchor", mediaItems: [buildMutationMediaItem()], }, }) ).toThrow(/锚点节点不存在/); expect(() => applyCanvasOperationPlanToCanvasData({ canvasData: getCanvasData([buildImageNode()]), plan: { version: 1, operationId: "op-missing-source", operation: "append_revision", projectId, sourceNodeIds: ["missing-source"], mediaItems: [buildMutationMediaItem()], }, }) ).toThrow(/来源节点不存在/); expect(() => applyCanvasOperationPlanToCanvasData({ canvasData: getCanvasData([buildImageNode()]), plan: { version: 1, operationId: "op-missing-media-source", operation: "insert_beside", projectId, mediaItems: [ buildMutationMediaItem("media-result-1", ["missing-media-source"]), ], }, }) ).toThrow(/媒体来源节点不存在/); }); test("creates agent slot nodes with stable metadata and idempotency", () => { const canvasData = { ...getCanvasData([]), viewport: { x: -120, y: -80, zoom: 2, width: 1000, height: 700 }, }; const plan: CanvasOperationPlan = { version: 1 as const, operationId: "op-create-video-slot", operation: "create_agent_slot" as const, projectId, targetPosition: { x: 240, y: 180 }, slot: { slotType: "video" as const, label: "视频结果槽", aspectRatio: "16:9", fillPolicy: "append-version" as const, acceptedSources: ["text", "image", "annotation"], }, metadata: { messageId: "assistant-message-1", chatId: "chat-1", prompt: "创建一个视频槽位", }, }; const first = applyCanvasOperationPlanToCanvasData({ canvasData, plan, }); const createdSlotId = first.createdNodeIds[0]; const createdSlot = getResultNodes(first.canvasData).find( (node) => node.id === createdSlotId ); expect(createdSlot).toMatchObject({ type: "agent-slot", position: { x: 240, y: 180 }, size: { width: 360, height: 220 }, data: { slotType: "video", label: "视频结果槽", status: "empty", aspectRatio: "16:9", fillPolicy: "append-version", acceptedSources: ["text", "image", "annotation"], filledNodeIds: [], metadata: { createdByAgent: true, canvasAgentOperationId: "op-create-video-slot", canvasAgentOperation: "create_agent_slot", messageId: "assistant-message-1", chatId: "chat-1", prompt: "创建一个视频槽位", }, }, }); expect((first.canvasData as { selectedNodeIds?: string[] }).selectedNodeIds).toEqual([ createdSlotId, ]); const retry = applyCanvasOperationPlanToCanvasData({ canvasData: first.canvasData, plan, }); expect(retry.createdNodeIds).toEqual(first.createdNodeIds); expect(getResultNodes(retry.canvasData)).toHaveLength( getResultNodes(first.canvasData).length ); }); test("creates text nodes from agent plans with trace metadata and idempotency", () => { const canvasData = { ...getCanvasData([]), viewport: { x: -200, y: -100, zoom: 1, width: 1000, height: 700 }, }; const plan: CanvasOperationPlan = { version: 1, operationId: "op-create-text-node", operation: "create_text_node", projectId, textContent: "618 限时优惠\n新品主推", metadata: { messageId: "assistant-message-text", chatId: "chat-1", prompt: "在画布上添加一段促销文案", }, }; const first = applyCanvasOperationPlanToCanvasData({ canvasData, plan, }); const createdTextId = first.createdNodeIds[0]; const createdText = getResultNodes(first.canvasData).find( (node) => node.id === createdTextId ); expect(createdText).toMatchObject({ type: "text", position: { x: 570, y: 392 }, data: { content: "618 限时优惠\n新品主推", fontSize: 18, color: "#111827", metadata: { createdByAgent: true, canvasAgentOperationId: "op-create-text-node", canvasAgentOperation: "create_text_node", messageId: "assistant-message-text", chatId: "chat-1", prompt: "在画布上添加一段促销文案", }, }, }); expect((first.canvasData as { selectedNodeIds?: string[] }).selectedNodeIds).toEqual([ createdTextId, ]); const retry = applyCanvasOperationPlanToCanvasData({ canvasData: first.canvasData, plan, }); expect(retry.createdNodeIds).toEqual(first.createdNodeIds); expect(getResultNodes(retry.canvasData)).toHaveLength( getResultNodes(first.canvasData).length ); }); test("updates selected text nodes with trace metadata and idempotency", () => { const plan: CanvasOperationPlan = { version: 1, operationId: "op-update-text-node", operation: "update_text", projectId, targetNodeIds: ["text-update"], textContent: "轻盈强吸力,家务更省心", metadata: { messageId: "assistant-message-update-text", chatId: "chat-1", }, }; const first = applyCanvasOperationPlanToCanvasData({ canvasData: getCanvasData([buildTextNode("text-update")]), plan, }); const updatedText = getResultNodes(first.canvasData).find( (node) => node.id === "text-update" ); expect(first.createdNodeIds).toEqual([]); expect(first.updatedNodeIds).toEqual(["text-update"]); expect(updatedText).toMatchObject({ data: { content: "轻盈强吸力,家务更省心", metadata: { updatedByAgent: true, canvasAgentOperationId: "op-update-text-node", canvasAgentOperation: "update_text", previousContent: "旧文案", messageId: "assistant-message-update-text", chatId: "chat-1", }, }, }); const retry = applyCanvasOperationPlanToCanvasData({ canvasData: first.canvasData, plan, }); const retryText = getResultNodes(retry.canvasData).find( (node) => node.id === "text-update" ); expect(retry.updatedNodeIds).toEqual(["text-update"]); expect(retryText.data.metadata.previousContent).toBe("旧文案"); }); test("links selected canvas nodes without duplicating existing edges", () => { const source = buildImageNode("image-link-source"); const target = buildTextNode("text-link-target"); const plan: CanvasOperationPlan = { version: 1, operationId: "op-link-nodes", operation: "link_nodes", projectId, sourceNodeIds: ["image-link-source", "text-link-target"], edgeType: "reference", metadata: { messageId: "assistant-message-link", chatId: "chat-1", }, }; const first = applyCanvasOperationPlanToCanvasData({ canvasData: getCanvasData([source, target]), plan, }); expect(first.createdNodeIds).toEqual([]); expect(first.updatedNodeIds).toEqual([ "image-link-source", "text-link-target", ]); expect(first.createdEdgeIds).toHaveLength(1); expect(getResultEdges(first.canvasData)).toEqual([ expect.objectContaining({ source: "image-link-source", target: "text-link-target", type: "reference", }), ]); expect(getResultNodes(first.canvasData)).toEqual([ expect.objectContaining({ id: "image-link-source", connections: ["text-link-target"], }), expect.objectContaining({ id: "text-link-target", connections: ["image-link-source"], }), ]); const retry = applyCanvasOperationPlanToCanvasData({ canvasData: first.canvasData, plan, }); expect(retry.createdEdgeIds).toEqual([]); expect(getResultEdges(retry.canvasData)).toHaveLength(1); }); test("fills an agent slot and returns media item node mappings", () => { const slot = buildAgentSlotNode(); const result = applyCanvasOperationPlanToCanvasData({ canvasData: getCanvasData([slot]), plan: { version: 1, operationId: "op-fill-slot", operation: "fill_slot", projectId, targetSlotId: "slot-1", mediaItems: [buildMutationMediaItem("media-slot-result")], }, }); expect(result.createdNodeIds).toHaveLength(1); expect(result.createdNodeIdsByMediaItemId).toEqual({ "media-slot-result": result.createdNodeIds[0], }); expect(result.updatedNodeIds).toEqual(["slot-1"]); expect(getResultNodes(result.canvasData).find((node) => node.id === "slot-1")) .toMatchObject({ data: { status: "completed", filledNodeIds: [result.createdNodeIds[0]], }, }); expect(getResultEdges(result.canvasData)).toEqual([ expect.objectContaining({ source: "slot-1", target: result.createdNodeIds[0], }), ]); }); test("rejects fill-slot media when it does not match the slot type", () => { const videoSlot = buildAgentSlotNode("video-slot-1", "video"); expect(() => applyCanvasOperationPlanToCanvasData({ canvasData: getCanvasData([videoSlot]), plan: { version: 1, operationId: "op-fill-video-slot-with-image", operation: "fill_slot", projectId, targetSlotId: "video-slot-1", mediaItems: [buildMutationMediaItem("media-image-result")], }, }) ).toThrow("目标 slot 是视频槽位,不能填充图片结果"); const imageSlot = buildAgentSlotNode("image-slot-1", "image"); expect(() => applyCanvasOperationPlanToCanvasData({ canvasData: getCanvasData([imageSlot]), plan: { version: 1, operationId: "op-fill-image-slot-with-video", operation: "fill_slot", projectId, targetSlotId: "image-slot-1", mediaItems: [ { mediaItemId: "media-video-result", type: "video", url: "https://cdn.example.com/media-video-result.mp4", }, ], }, }) ).toThrow("目标 slot 是图片槽位,不能填充视频结果"); }); test("confirms replace-existing slot fills and keeps retries idempotent", () => { const slot = buildAgentSlotNode("slot-replace-existing") as any; slot.data = { ...slot.data, fillPolicy: "replace-existing", filledNodeIds: ["old-slot-result"], }; const oldResult = { ...buildImageNode("old-slot-result"), position: { x: 500, y: 120 }, }; const unrelated = buildImageNode("unrelated-image"); const canvasData = { ...getCanvasData([slot, oldResult, unrelated]), edges: [ { id: "edge-slot-old-result", source: "slot-replace-existing", target: "old-slot-result", type: "default", }, ], selectedNodeIds: ["slot-replace-existing"], }; const plan: CanvasOperationPlan = { version: 1, operationId: "op-replace-existing-slot-fill", operation: "fill_slot", projectId, targetSlotId: "slot-replace-existing", mediaItems: [buildMutationMediaItem("media-new-slot-result")], }; expect(() => applyCanvasOperationPlanToCanvasData({ canvasData, plan, }) ).toThrow(/需要用户确认/); let staleError: unknown; try { applyCanvasOperationPlanToCanvasData({ canvasData, baseCanvasWasStale: true, plan: { ...plan, confirmed: true, }, }); } catch (error) { staleError = error; } expect(staleError).toBeInstanceOf(CanvasAgentMutationError); expect((staleError as CanvasAgentMutationError).code).toBe( "STALE_CANVAS_REVISION" ); const replaced = applyCanvasOperationPlanToCanvasData({ canvasData, plan: { ...plan, confirmed: true, metadata: { confirmedByUser: true, }, }, }); const replacedNodes = getResultNodes(replaced.canvasData); const newNodeId = replaced.createdNodeIds[0]; expect(replaced.createdNodeIds).toHaveLength(1); expect(replaced.createdNodeIdsByMediaItemId).toEqual({ "media-new-slot-result": newNodeId, }); expect(replacedNodes.some((node) => node.id === "old-slot-result")).toBe(false); expect(replacedNodes.some((node) => node.id === "unrelated-image")).toBe(true); expect(replacedNodes.find((node) => node.id === "slot-replace-existing")) .toMatchObject({ data: { filledNodeIds: [newNodeId], metadata: { replacedSlotNodeIds: ["old-slot-result"], replacedByCanvasAgentOperationId: "op-replace-existing-slot-fill", }, }, }); expect(replacedNodes.find((node) => node.id === newNodeId)).toMatchObject({ data: { metadata: { replacedSlotNodeIds: ["old-slot-result"], replacedSlotId: "slot-replace-existing", }, }, }); expect(getResultEdges(replaced.canvasData)).toEqual([ expect.objectContaining({ source: "slot-replace-existing", target: newNodeId, }), ]); const retry = applyCanvasOperationPlanToCanvasData({ canvasData: replaced.canvasData, plan: { ...plan, confirmed: true, }, }); expect(retry.createdNodeIds).toEqual(replaced.createdNodeIds); expect(getResultNodes(retry.canvasData)).toHaveLength(replacedNodes.length); expect( getResultNodes(retry.canvasData).filter((node) => node.id === newNodeId) ).toHaveLength(1); }); test("creates append revision nodes without overwriting the source", () => { const source = buildImageNode("image-source"); const result = applyCanvasOperationPlanToCanvasData({ canvasData: getCanvasData([source]), plan: { version: 1, operationId: "op-append-revision", operation: "append_revision", projectId, sourceNodeIds: ["image-source"], anchorNodeId: "image-source", placement: "right", mediaItems: [ { ...buildMutationMediaItem("media-revision", ["image-source"]), prompt: "turn the background white", metadata: { model: "test-image-model", provider: "test-provider", }, }, ], metadata: { messageId: "assistant-message-1", chatId: "chat-1", model: "plan-level-model", }, }, }); const createdNode = getResultNodes(result.canvasData).find( (node) => node.id === result.createdNodeIds[0] ); expect(result.createdNodeIdsByMediaItemId["media-revision"]).toBe( result.createdNodeIds[0] ); expect(createdNode).toMatchObject({ type: "image", data: { src: "https://cdn.example.com/media-revision.png", prompt: "turn the background white", metadata: { generatedByAgent: true, canvasAgentOperationId: "op-append-revision", canvasAgentMediaItemId: "media-revision", revisionOf: "image-source", sourceNodeIds: ["image-source"], messageId: "assistant-message-1", chatId: "chat-1", model: "test-image-model", provider: "test-provider", }, }, }); expect( getResultNodes(result.canvasData).find((node) => node.id === "image-source") ).toMatchObject({ data: { src: "https://cdn.example.com/image-source.png" }, }); expect(getResultEdges(result.canvasData)).toEqual([ expect.objectContaining({ source: "image-source", target: result.createdNodeIds[0], type: "reference", }), ]); }); test("adds annotation metadata to media nodes without duplicating retries", () => { const target = buildImageNode("image-annotated"); const plan: CanvasOperationPlan = { version: 1, operationId: "op-add-annotation", operation: "add_annotation", projectId, targetNodeIds: ["image-annotated"], textContent: "背景换浅灰,主体保持不变", annotations: [ { type: "box", text: "处理这块背景", bounds: { x: 0.12, y: 0.2, width: 0.5, height: 0.32 }, color: "#f59e0b", }, ], metadata: { messageId: "assistant-message-annotation", chatId: "chat-1", }, }; const first = applyCanvasOperationPlanToCanvasData({ canvasData: getCanvasData([target]), plan, }); const annotatedNode = getResultNodes(first.canvasData).find( (node) => node.id === "image-annotated" ); expect(first.createdNodeIds).toEqual([]); expect(first.updatedNodeIds).toEqual(["image-annotated"]); expect(annotatedNode).toMatchObject({ data: { hasAnnotations: true, metadata: { annotations: [ expect.objectContaining({ id: "op-add-annotation:annotation-image-annotated-1", sourceNodeId: "image-annotated", type: "box", text: "处理这块背景", bounds: { x: 0.12, y: 0.2, width: 0.5, height: 0.32 }, createdByAgent: true, canvasAgentOperationId: "op-add-annotation", }), ], }, }, }); const retry = applyCanvasOperationPlanToCanvasData({ canvasData: first.canvasData, plan, }); const retryNode = getResultNodes(retry.canvasData).find( (node) => node.id === "image-annotated" ); expect(retry.updatedNodeIds).toEqual([]); expect(retryNode.data.metadata.annotations).toHaveLength(1); }); test("renames canvas nodes with trace metadata", () => { const target = buildImageNode("image-rename"); target.data.title = "旧主图"; const result = applyCanvasOperationPlanToCanvasData({ canvasData: getCanvasData([target]), plan: { version: 1, operationId: "op-rename-node", operation: "rename_node", projectId, targetNodeIds: ["image-rename"], textContent: "白底主图 A", metadata: { messageId: "assistant-message-rename", chatId: "chat-1", }, }, }); const renamedNode = getResultNodes(result.canvasData).find( (node) => node.id === "image-rename" ); expect(result.createdNodeIds).toEqual([]); expect(result.updatedNodeIds).toEqual(["image-rename"]); expect(renamedNode).toMatchObject({ data: { title: "白底主图 A", metadata: { renamedByAgent: true, canvasAgentOperationId: "op-rename-node", canvasAgentOperation: "rename_node", previousTitle: "旧主图", }, }, }); }); test("updates image notes and syncs note annotations with trace metadata", () => { const target = buildImageNode("image-note"); (target.data as any).note = "旧备注"; const result = applyCanvasOperationPlanToCanvasData({ canvasData: getCanvasData([target]), plan: { version: 1, operationId: "op-update-note", operation: "update_note", projectId, targetNodeIds: ["image-note"], textContent: "保留主体轮廓,背景可以更干净", metadata: { messageId: "assistant-message-note", chatId: "chat-1", }, }, }); const notedNode = getResultNodes(result.canvasData).find( (node) => node.id === "image-note" ); expect(result.createdNodeIds).toEqual([]); expect(result.updatedNodeIds).toEqual(["image-note"]); expect(notedNode).toMatchObject({ data: { note: "保留主体轮廓,背景可以更干净", hasAnnotations: true, metadata: { noteUpdatedByAgent: true, canvasAgentOperationId: "op-update-note", canvasAgentOperation: "update_note", previousNote: "旧备注", messageId: "assistant-message-note", annotations: [ expect.objectContaining({ id: "image-node-note:image-note", sourceNodeId: "image-note", type: "text-note", text: "保留主体轮廓,背景可以更干净", createdByAgent: true, updatedByAgent: true, canvasAgentOperationId: "op-update-note", }), ], }, }, }); }); test("keeps batch media writes idempotent by operation id", () => { const anchor = buildImageNode("image-anchor"); const plan = { version: 1 as const, operationId: "op-batch-idempotent", operation: "insert_beside" as const, projectId, anchorNodeId: "image-anchor", placementStrategy: "grid" as const, mediaItems: [1, 2, 3].map((index) => buildMutationMediaItem(`media-batch-${index}`) ), }; const first = applyCanvasOperationPlanToCanvasData({ canvasData: getCanvasData([anchor]), plan, }); const retry = applyCanvasOperationPlanToCanvasData({ canvasData: first.canvasData, plan, }); expect(first.createdNodeIds).toHaveLength(3); expect(Object.keys(first.createdNodeIdsByMediaItemId).sort()).toEqual([ "media-batch-1", "media-batch-2", "media-batch-3", ]); expect(retry.createdNodeIds).toEqual(first.createdNodeIds); expect(retry.createdNodeIdsByMediaItemId).toEqual( first.createdNodeIdsByMediaItemId ); expect(getResultNodes(retry.canvasData)).toHaveLength( getResultNodes(first.canvasData).length ); }); test("requires explicit confirmation for replace media and rejects stale confirmed replacements", () => { const target = buildImageNode("image-replace"); const replacePlan = { version: 1 as const, operationId: "op-replace-media", operation: "replace_node_media" as const, projectId, targetNodeIds: ["image-replace"], mediaItems: [buildMutationMediaItem("media-replacement")], }; expect(() => applyCanvasOperationPlanToCanvasData({ canvasData: getCanvasData([target]), plan: replacePlan, }) ).toThrow(/需要用户确认/); let caughtError: unknown; try { applyCanvasOperationPlanToCanvasData({ canvasData: getCanvasData([target]), baseCanvasWasStale: true, plan: { ...replacePlan, confirmed: true, }, }); } catch (error) { caughtError = error; } expect(caughtError).toBeInstanceOf(CanvasAgentMutationError); expect((caughtError as CanvasAgentMutationError).code).toBe( "STALE_CANVAS_REVISION" ); }); test("confirms move-node mutations and keeps retries from moving nodes again", () => { const first = buildImageNode("image-move-a"); first.position = { x: 300, y: 100 }; const second = buildImageNode("image-move-b"); second.position = { x: 100, y: 100 }; const unrelated = buildImageNode("image-unrelated"); unrelated.position = { x: 900, y: 500 }; const canvasData = getCanvasData([first, second, unrelated]); const plan: CanvasOperationPlan = { version: 1, operationId: "op-move-nodes", operation: "move_nodes", projectId, targetNodeIds: ["image-move-a", "image-move-b"], targetPosition: { x: 10, y: 20 }, layoutStrategy: "row", spacing: 30, metadata: { messageId: "assistant-message-move", chatId: "chat-1", }, }; expect(() => applyCanvasOperationPlanToCanvasData({ canvasData, plan, }) ).toThrow(/需要用户确认/); const moved = applyCanvasOperationPlanToCanvasData({ canvasData, plan: { ...plan, confirmed: true, metadata: { ...plan.metadata, confirmedByUser: true, }, }, }); const movedNodes = getResultNodes(moved.canvasData); const movedFirst = movedNodes.find((node) => node.id === "image-move-a"); const movedSecond = movedNodes.find((node) => node.id === "image-move-b"); const movedUnrelated = movedNodes.find((node) => node.id === "image-unrelated"); expect(moved.createdNodeIds).toEqual([]); expect(moved.updatedNodeIds).toEqual(["image-move-b", "image-move-a"]); expect(movedSecond).toMatchObject({ position: { x: 10, y: 20 }, data: { metadata: { movedByAgent: true, canvasAgentOperationId: "op-move-nodes", canvasAgentOperation: "move_nodes", canvasAgentLayoutStrategy: "row", previousPosition: { x: 100, y: 100 }, }, }, }); expect(movedFirst).toMatchObject({ position: { x: 360, y: 20 }, data: { metadata: { movedByAgent: true, previousPosition: { x: 300, y: 100 }, }, }, }); expect(movedUnrelated?.position).toEqual({ x: 900, y: 500 }); expect((moved.canvasData as { selectedNodeIds?: string[] }).selectedNodeIds).toEqual([ "image-move-b", "image-move-a", ]); const retry = applyCanvasOperationPlanToCanvasData({ canvasData: moved.canvasData, plan: { ...plan, confirmed: true, metadata: { ...plan.metadata, confirmedByUser: true, }, }, }); const retryNodes = getResultNodes(retry.canvasData); expect(new Set(retry.updatedNodeIds)).toEqual( new Set(["image-move-a", "image-move-b"]) ); expect(retryNodes.find((node) => node.id === "image-move-b")?.position).toEqual({ x: 10, y: 20, }); expect(retryNodes.find((node) => node.id === "image-move-a")?.position).toEqual({ x: 360, y: 20, }); }); test("rejects confirmed stale move-node mutations", () => { const target = buildImageNode("image-stale-move"); let caughtError: unknown; try { applyCanvasOperationPlanToCanvasData({ canvasData: getCanvasData([target]), baseCanvasWasStale: true, plan: { version: 1, operationId: "op-stale-move-node", operation: "move_nodes", projectId, targetNodeIds: ["image-stale-move"], targetPosition: { x: 10, y: 20 }, confirmed: true, metadata: { confirmedByUser: true, }, }, }); } catch (error) { caughtError = error; } expect(caughtError).toBeInstanceOf(CanvasAgentMutationError); expect((caughtError as CanvasAgentMutationError).code).toBe( "STALE_CANVAS_REVISION" ); }); test("confirms delete-node mutations, removes connected edges, and keeps retries idempotent", () => { const source = buildImageNode("image-source"); const target = buildImageNode("image-delete"); const unrelated = buildImageNode("image-unrelated"); const edge = { id: "edge-source-delete", source: "image-source", target: "image-delete", type: "reference", createdAt: "2026-06-22T00:00:00.000Z", }; const canvasData = { ...getCanvasData([source, target, unrelated]), edges: [edge], selectedNodeIds: ["image-delete"], }; const plan: CanvasOperationPlan = { version: 1, operationId: "op-delete-node", operation: "delete_nodes", projectId, targetNodeIds: ["image-delete"], metadata: { messageId: "assistant-message-delete", chatId: "chat-1", }, }; expect(() => applyCanvasOperationPlanToCanvasData({ canvasData, plan, }) ).toThrow(/需要用户确认/); const deleted = applyCanvasOperationPlanToCanvasData({ canvasData, plan: { ...plan, confirmed: true, metadata: { ...plan.metadata, confirmedByUser: true, }, }, }); const deletedCanvasData = deleted.canvasData as { nodes?: any[]; edges?: any[]; selectedNodeIds?: string[]; metadata?: Record; }; expect(deleted.createdNodeIds).toEqual([]); expect(deleted.updatedNodeIds).toEqual(["image-delete"]); expect(deletedCanvasData.nodes?.map((node) => node.id).sort()).toEqual([ "image-source", "image-unrelated", ]); expect(deletedCanvasData.edges).toEqual([]); expect(deletedCanvasData.selectedNodeIds).toEqual([]); expect( deletedCanvasData.nodes?.find((node) => node.id === "image-source")?.connections ).toEqual([]); expect(deletedCanvasData.metadata?.canvasAgentOperationAudit).toEqual([ expect.objectContaining({ operationId: "op-delete-node", operation: "delete_nodes", targetNodeIds: ["image-delete"], deletedNodeIds: ["image-delete"], deletedEdges: [edge], metadata: expect.objectContaining({ confirmedByUser: true, }), }), ]); const retry = applyCanvasOperationPlanToCanvasData({ canvasData: deleted.canvasData, plan: { ...plan, confirmed: true, metadata: { ...plan.metadata, confirmedByUser: true, }, }, }); expect(retry.updatedNodeIds).toEqual(["image-delete"]); expect(getResultNodes(retry.canvasData)).toHaveLength(2); expect(getResultEdges(retry.canvasData)).toEqual([]); }); test("rejects confirmed stale delete-node mutations", () => { const target = buildImageNode("image-stale-delete"); let caughtError: unknown; try { applyCanvasOperationPlanToCanvasData({ canvasData: getCanvasData([target]), baseCanvasWasStale: true, plan: { version: 1, operationId: "op-stale-delete-node", operation: "delete_nodes", projectId, targetNodeIds: ["image-stale-delete"], confirmed: true, metadata: { confirmedByUser: true, }, }, }); } catch (error) { caughtError = error; } expect(caughtError).toBeInstanceOf(CanvasAgentMutationError); expect((caughtError as CanvasAgentMutationError).code).toBe( "STALE_CANVAS_REVISION" ); }); });