diff --git a/tests/routes/canvas-agent-operations.test.ts b/tests/routes/canvas-agent-operations.test.ts index cf227d1a2..db00ff0e1 100644 --- a/tests/routes/canvas-agent-operations.test.ts +++ b/tests/routes/canvas-agent-operations.test.ts @@ -1003,4 +1003,128 @@ test.describe("canvas agent operation mutation contract", () => { "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" + ); + }); });