From c0550988e01a4ff4a93bfec708d0e2f9c6639d26 Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 23 Jun 2026 14:49:37 +0800 Subject: [PATCH] fix: keep canvas annotation operations idempotent --- lib/canvas/agent-mutations.ts | 7 +++ tests/routes/canvas-agent-operations.test.ts | 64 ++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/lib/canvas/agent-mutations.ts b/lib/canvas/agent-mutations.ts index 8b4764cdf..b72362333 100644 --- a/lib/canvas/agent-mutations.ts +++ b/lib/canvas/agent-mutations.ts @@ -786,6 +786,13 @@ function applyAddAnnotationOperation(params: { const existingAnnotations = Array.isArray(metadata.annotations) ? metadata.annotations.filter((item) => item && typeof item === "object") : []; + const hasExistingOperationAnnotation = existingAnnotations.some( + (item) => + (item as Record).canvasAgentOperationId === + params.plan.operationId + ); + if (hasExistingOperationAnnotation) return; + const nextAnnotations = annotations.map((annotation, index) => ({ ...annotation, id: diff --git a/tests/routes/canvas-agent-operations.test.ts b/tests/routes/canvas-agent-operations.test.ts index d4a1dcccd..702378994 100644 --- a/tests/routes/canvas-agent-operations.test.ts +++ b/tests/routes/canvas-agent-operations.test.ts @@ -688,6 +688,70 @@ test.describe("canvas agent operation mutation contract", () => { ]); }); + 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("keeps batch media writes idempotent by operation id", () => { const anchor = buildImageNode("image-anchor"); const plan = {